Skip to content

Commit

Permalink
OPHJOD-896: Add Textarea component
Browse files Browse the repository at this point in the history
  • Loading branch information
sauanto committed Nov 12, 2024
1 parent 3e50629 commit b62edc1
Show file tree
Hide file tree
Showing 8 changed files with 369 additions and 14 deletions.
9 changes: 7 additions & 2 deletions lib/components/InputField/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface BaseInputFieldProps {
placeholder?: string;
/** The help text to display below the input field */
help?: string;
/** Additional classes to add to the input field */
className?: string;
}

interface HideLabelProps extends BaseInputFieldProps {
Expand All @@ -37,7 +39,7 @@ export type InputFieldProps = ShowLabelProps | HideLabelProps;

/** Input fields are text boxes that allow users to input custom text entries with a keyboard. Various options can be shown with the field to communicate the input requirements. */
export const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
{ name, value, onBlur, onChange, placeholder, label, hideLabel = false, help }: InputFieldProps,
{ name, value, onBlur, onChange, placeholder, label, hideLabel = false, help, className = '' }: InputFieldProps,
ref,
) {
const inputId = React.useId();
Expand All @@ -64,7 +66,10 @@ export const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(fu
placeholder={placeholder}
autoComplete="off"
aria-describedby={help ? helpId : undefined}
className="ds-block ds-w-full ds-rounded ds-border ds-border-border-gray ds-bg-white ds-p-5 ds-text-black ds-outline-none placeholder:ds-text-secondary-gray ds-font-arial ds-text-body-md"
className={tc([
'ds-block ds-w-full ds-rounded ds-border ds-border-border-gray ds-bg-white ds-p-5 ds-text-black ds-outline-none placeholder:ds-text-secondary-gray ds-font-arial ds-text-body-md',
className,
])}
/>
{help && (
<div id={helpId} className="ds-mt-2 ds-block ds-text-help ds-text-secondary-gray ds-font-arial">
Expand Down
3 changes: 3 additions & 0 deletions lib/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface PaginationProps {
translations: PaginationRootProps['translations'];
onPageChange: (details: PageChangeDetails) => void;
type?: 'button' | 'link';
ariaLabel?: string;
}

/** Pagination component for navigating through a list of items. */
Expand All @@ -36,6 +37,7 @@ export const Pagination = ({
currentPage,
translations,
type,
ariaLabel,
onPageChange,
}: PaginationProps) => {
const lastPage = Math.ceil(totalItems / pageSize);
Expand All @@ -51,6 +53,7 @@ export const Pagination = ({
translations={translations}
onPageChange={onPageChange}
type={type}
aria-label={ariaLabel}
className="ds-inline-flex ds-list-none ds-items-center ds-justify-center ds-gap-3"
>
<ArkPagination.PrevTrigger className={getClassName({ disabled: isFirstPage })} disabled={isFirstPage}>
Expand Down
50 changes: 38 additions & 12 deletions lib/components/Slider/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '@floating-ui/react';
import React from 'react';

import { cx } from 'cva';
import { tidyClasses as tc } from '../../utils';

const ARROW_HEIGHT = 12;
Expand All @@ -33,18 +34,18 @@ export interface SliderProps {
onValueChange: (value: number) => void;
/** Current value of the slider */
value: number;
/** Disabled state */
disabled?: boolean;
}

const Marker = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" viewBox="0 0 5 5" fill="none">
<circle cx="2.5" cy="2.5" r="1.5" fill="#71A9CB" />
</svg>
);
};
const Marker = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" width={5} height={5}>
<circle cx="2.5" cy="2.5" r="1.5" fill="currentColor" />
</svg>
);

/** Sliders allow users to quickly select a value within a range. They should be used when the upper and lower bounds to the range are invariable. */
export const Slider = ({ label, onValueChange, value, rightLabel }: SliderProps) => {
export const Slider = ({ label, onValueChange, value, rightLabel, disabled }: SliderProps) => {
const inputId = React.useId();
const [focused, setFocused] = React.useState(false);
const arrowRef = React.useRef(null);
Expand Down Expand Up @@ -86,7 +87,14 @@ export const Slider = ({ label, onValueChange, value, rightLabel }: SliderProps)
};

return (
<div className="ds-flex ds-h-[40px] ds-rounded-[20px] ds-bg-white ds-justify-between ds:min-w-full sm:ds-min-w-[414px]">
<div
className={cx(
'ds-flex ds-h-[40px] ds-rounded-[20px] ds-bg-white ds-justify-between ds:min-w-full sm:ds-min-w-[414px]',
{
'ds-text-inactive-gray ds-cursor-not-allowed': disabled,
},
)}
>
<span className="ds-ml-6 ds-mr-5 ds-flex ds-items-center ds-text-[12px]">{label}</span>
<ArkSlider.Root
id={inputId}
Expand All @@ -96,8 +104,14 @@ export const Slider = ({ label, onValueChange, value, rightLabel }: SliderProps)
onFocusChange={onFocusChangeHandler}
value={[value]}
step={25}
disabled={disabled}
>
<ArkSlider.MarkerGroup className="ds-z-10">
<ArkSlider.MarkerGroup
className={cx('ds-z-10 ds-bg-todo', {
'ds-text-[#71A9CB]': !disabled,
'ds-text-inactive-gray': disabled,
})}
>
<ArkSlider.Marker value={0}>
<Marker />
</ArkSlider.Marker>
Expand All @@ -116,13 +130,25 @@ export const Slider = ({ label, onValueChange, value, rightLabel }: SliderProps)
</ArkSlider.MarkerGroup>
<ArkSlider.Control className="ds-flex">
<ArkSlider.Track className="ds-flex ds-h-[5px] ds-grow ds-bg-bg-gray-2 ds-rounded-[4px]">
<ArkSlider.Range className="ds-bg-accent ds-h-[5px] ds-rounded-[8px]" />
<ArkSlider.Range
className={cx('ds-h-[5px] ds-rounded-[8px]', {
'ds-bg-accent': !disabled,
'ds-bg-inactive-gray': disabled,
})}
/>
</ArkSlider.Track>
<ArkSlider.Thumb
ref={refs.setReference}
{...getReferenceProps()}
aria-label={rightLabel ? `${label} - ${rightLabel}` : label}
index={0}
className="ds-absolute -ds-top-[6px] ds-flex ds-size-[17px] ds-justify-center ds-rounded-full ds-bg-accent ds-z-20"
className={cx(
'ds-absolute -ds-top-[6px] ds-flex ds-size-[17px] ds-justify-center ds-rounded-full ds-z-20',
{
'ds-bg-accent': !disabled,
'ds-bg-inactive-gray': disabled,
},
)}
/>
</ArkSlider.Control>
</ArkSlider.Root>
Expand Down
55 changes: 55 additions & 0 deletions lib/components/Textarea/Textarea.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import React from 'react';

import { Textarea } from './Textarea';

const meta = {
title: 'Forms/Textarea',
component: Textarea,
tags: ['autodocs'],
} satisfies Meta<typeof Textarea>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: (args: Story['args']) => {
const { value, onChange, ...rest } = args;
const [textareaValue, setTextareaValue] = React.useState(value);
return (
<Textarea
value={textareaValue}
onChange={(event) => {
setTextareaValue(event.target.value);
onChange(event);
}}
{...rest}
/>
);
},
decorators: [
(Story) => (
<div className="ds-max-w-[480px]">
<Story />
</div>
),
],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/6M2LrpSCcB0thlFDaQAI2J/cx_jod_client?node-id=5805-3164',
},
docs: {
description: {
story: 'This is a input field component for displaying and editing a value with a label text.',
},
},
},
args: {
value: 'Lorem ipsum dolor sit amet',
onChange: fn(),
label: 'Label text',
},
};
66 changes: 66 additions & 0 deletions lib/components/Textarea/Textarea.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Textarea } from './Textarea';

describe('Textarea', () => {
const placeholder = 'Enter text';

it('renders without crashing', () => {
const onChange = vi.fn();
const { container } = render(<Textarea onChange={onChange} placeholder={placeholder} />);
const textarea = screen.getByPlaceholderText(placeholder);
expect(textarea).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('displays the label when hideLabel is false', () => {
const onChange = vi.fn();
const { container } = render(<Textarea onChange={onChange} label="Test Label" placeholder="Enter text" />);
const label = screen.getByText('Test Label');
expect(label).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('hides the label when hideLabel is true', () => {
const onChange = vi.fn();
const { container } = render(<Textarea onChange={onChange} hideLabel={true} placeholder="Enter text" />);
const label = screen.queryByText('Test Label');
expect(label).not.toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('calls onChange when the value changes', () => {
const onChange = vi.fn();
const { container } = render(<Textarea onChange={onChange} placeholder="Enter text" />);
const textarea = screen.getByPlaceholderText(placeholder);
fireEvent.change(textarea, { target: { value: 'New value' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(container).toMatchSnapshot();
});

it('calls onBlur when the textarea loses focus', () => {
const onChange = vi.fn();
const onBlur = vi.fn();
const { container } = render(<Textarea onChange={onChange} onBlur={onBlur} placeholder="Enter text" />);
const textarea = screen.getByPlaceholderText(placeholder);
fireEvent.blur(textarea);
expect(onBlur).toHaveBeenCalledTimes(1);
expect(container).toMatchSnapshot();
});

it('displays help text when provided', () => {
const onChange = vi.fn();
const { container } = render(<Textarea onChange={onChange} help="Help text" placeholder="Enter text" />);
const helpText = screen.getByText('Help text');
expect(helpText).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('sets the correct number of rows', () => {
const onChange = vi.fn();
const { container } = render(<Textarea onChange={onChange} rows={5} placeholder="Enter text" />);
const textarea = screen.getByPlaceholderText(placeholder);
expect(textarea).toHaveAttribute('rows', '5');
expect(container).toMatchSnapshot();
});
});
83 changes: 83 additions & 0 deletions lib/components/Textarea/Textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import { tidyClasses as tc } from '../../utils';

interface BaseTextareaProps {
/** The name of the textarea */
name?: string;
/** The value of the textarea */
value?: string;
/** The function to call when the textarea loses focus */
onBlur?: (event: React.FocusEvent<HTMLTextAreaElement>) => void;
/** The function to call when the value of the textarea changes */
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
/** The placeholder text to display in the textarea */
placeholder?: string;
/** The help text to display below the textarea */
help?: string;
/** The number of rows to display in the textarea */
rows?: number;
/** Additional classes to add to the textarea */
className?: string;
}

interface HideLabelProps extends BaseTextareaProps {
/** The label text is not shown when hideLabel is true */
label?: never;
/** Hide label */
hideLabel?: true;
/** The placeholder text to display in the textarea is required when hideLabel is true */
placeholder: string;
}
interface ShowLabelProps extends BaseTextareaProps {
/** The label text to display above the textarea */
label: string;
/** Hide label */
hideLabel?: false;
/** The placeholder text to display in the textarea */
placeholder?: string;
}

export type TextareaProps = ShowLabelProps | HideLabelProps;

/** Textareas are multi-line text boxes that allow users to input custom text entries with a keyboard. Various options can be shown with the field to communicate the input requirements. */
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ name, value, onBlur, onChange, placeholder, label, hideLabel = false, help, rows, className = '' }: TextareaProps,
ref,
) {
const inputId = React.useId();
const helpId = React.useId();
return (
<>
<label
htmlFor={inputId}
className={tc([
hideLabel ? 'ds-hidden' : '',
'ds-mb-4 ds-inline-block ds-align-top ds-text-form-label ds-font-arial ds-text-black',
])}
>
{label}
</label>
<textarea
ref={ref}
id={inputId}
name={name}
value={value}
onBlur={onBlur}
onChange={onChange}
rows={rows}
placeholder={placeholder}
autoComplete="off"
aria-describedby={help ? helpId : undefined}
className={tc([
'ds-block ds-w-full ds-rounded ds-border ds-border-border-gray ds-bg-white ds-p-5 ds-text-black ds-outline-none placeholder:ds-text-secondary-gray ds-font-arial ds-text-body-md',
className,
])}
/>
{help && (
<div id={helpId} className="ds-mt-2 ds-block ds-text-help ds-text-secondary-gray ds-font-arial">
{help}
</div>
)}
</>
);
});
Loading

0 comments on commit b62edc1

Please sign in to comment.