Skip to content

Commit 4d2a839

Browse files
authored
Select compound pattern (#105)
* #96 Select component * #96 Select examples * #96 docs * #96 display text for selected value * #96 left-side check * #80 SelectComponent examples * #96 tests * #96 stories
1 parent 243225d commit 4d2a839

7 files changed

Lines changed: 889 additions & 61 deletions

File tree

Lines changed: 263 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,117 @@
1-
import { InputHTMLAttributes, PropsWithChildren } from 'react';
1+
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react';
22
import { Control, FieldValues, Path, useController } from 'react-hook-form';
3+
import noop from 'lodash/noop';
34

45
import { cn } from 'common/utils/css';
5-
import { PropsWithTestId } from 'common/utils/types';
6+
import { BaseComponentProps } from 'common/utils/types';
67
import Label from './Label';
78
import FieldError from './FieldError';
89
import HelpText from '../Text/HelpText';
10+
import FAIcon, { FAIconProps } from '../Icon/FAIcon';
11+
import Backdrop from '../Backdrop/Backdrop';
12+
import Divider, { DividerProps } from '../Divider/Divider';
13+
14+
/**
15+
* The type of value for the select form control.
16+
*/
17+
type SelectValue = boolean | number | string;
18+
19+
/**
20+
* Defines the attributes of the SelectContext value.
21+
*/
22+
type SelectContextValue = {
23+
isDisabled: boolean;
24+
isError: boolean;
25+
isOpen: boolean;
26+
setIsOpen: (isOpen: boolean) => void;
27+
text?: string;
28+
setText: (text?: string) => void;
29+
value?: SelectValue;
30+
setValue: (val: SelectValue) => void;
31+
};
32+
33+
/**
34+
* The SelectContext instance.
35+
*/
36+
const SelectContext = createContext<SelectContextValue>({
37+
isDisabled: false,
38+
isError: false,
39+
isOpen: false,
40+
setIsOpen: noop,
41+
text: undefined,
42+
setText: noop,
43+
value: undefined,
44+
setValue: noop,
45+
});
946

1047
/**
1148
* Properties for the `Select` component.
1249
* @param {Control} control - Object containing methods for registering components
1350
* into React Hook Form.
51+
* @param {boolean} [disabled] - Optional. Indicates if the control is disabled.
52+
* Defaults to `false`.
1453
* @param {string} [label] - Optional. The text to display. If omitted, the
1554
* `value` is displayed.
1655
* @param {string} name - Name of the form control.
56+
* @param {boolean} [required] - Optional. Indicates if a value is required.
57+
* Defaults to `false`.
1758
* @param {string} [supportingText] - Optional. Help text or instructions.
18-
* @see {@link BaseComponentProps}
19-
* @see {@link PropsWithChildren}
20-
* @see {@link InputHTMLAttributes}
2159
*/
22-
export interface SelectProps<T extends FieldValues>
23-
extends PropsWithTestId,
24-
PropsWithChildren,
25-
InputHTMLAttributes<HTMLSelectElement> {
60+
export interface SelectProps<T extends FieldValues> extends BaseComponentProps, PropsWithChildren {
2661
control: Control<T>;
62+
disabled?: boolean;
2763
label?: string;
2864
name: string;
65+
required?: boolean;
2966
supportingText?: string;
3067
}
3168

3269
/**
33-
* The `Select` component renders a HTML `select` element. It is used to capture
34-
* one or more values from a curated set of options.
35-
*
36-
* The `children` must contain one or more `option` or `optgroup` elements.
37-
* @param {SelectProps} props - Component properties.
38-
* @returns JSX
70+
* The `Select` component renders a list of options from which a user may select.
3971
*/
4072
const Select = <T extends FieldValues>({
4173
children,
4274
className,
4375
control,
76+
disabled = false,
4477
label,
4578
name,
79+
required = false,
4680
supportingText,
4781
testId = 'select',
48-
...props
4982
}: SelectProps<T>): JSX.Element => {
83+
const [isOpen, setIsOpen] = useState(false);
84+
const [text, setText] = useState<string>();
5085
const { field, fieldState } = useController({ control, name: name as Path<T> });
51-
const isDisabled = props.disabled || props.readOnly;
86+
87+
/** Reset "text" when field value is changed to empty */
88+
useEffect(() => {
89+
if (!field.value) {
90+
setText(undefined);
91+
}
92+
}, [field.value]);
5293

5394
return (
54-
<div className={className} data-testid={testId}>
95+
<div className={cn('relative', className)} data-testid={testId}>
5596
{!!label && (
56-
<Label htmlFor={name} required={props.required} testId={`${testId}-label`}>
97+
<Label htmlFor={name} required={required} testId={`${testId}-label`}>
5798
{label}
5899
</Label>
59100
)}
60-
<select
61-
id={props.id || name}
62-
{...props}
63-
{...field}
64-
className={cn(
65-
'mb-1 block w-full border-b border-neutral-500/50 bg-transparent py-0.5 focus:border-blue-600',
66-
{
67-
'border-red-600!': fieldState.error,
68-
},
69-
{
70-
'opacity-50': isDisabled,
71-
},
72-
)}
73-
data-testid={`${testId}-select`}
101+
<SelectContext.Provider
102+
value={{
103+
isDisabled: disabled,
104+
isError: !!fieldState.error,
105+
isOpen,
106+
setIsOpen,
107+
text,
108+
setText,
109+
value: field.value,
110+
setValue: field.onChange,
111+
}}
74112
>
75113
{children}
76-
</select>
114+
</SelectContext.Provider>
77115
<FieldError message={fieldState.error?.message} testId={`${testId}-error`} />
78116
{!!supportingText && (
79117
<HelpText testId={`${testId}-supporting-text`}>{supportingText}</HelpText>
@@ -82,4 +120,195 @@ const Select = <T extends FieldValues>({
82120
);
83121
};
84122

123+
/**
124+
* The `Trigger` component wraps the element used to open the Select Options.
125+
* There should be 1 Trigger within a Select.
126+
*/
127+
const Trigger = ({
128+
children,
129+
className,
130+
testId = 'select-trigger',
131+
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
132+
const { isDisabled, isError, isOpen, setIsOpen } = useContext(SelectContext);
133+
134+
const handleClick = () => {
135+
if (!isDisabled) {
136+
setIsOpen(!isOpen);
137+
}
138+
};
139+
140+
return (
141+
<button
142+
className={cn(
143+
'flex w-full items-center gap-2 border-b py-0.5',
144+
{ 'border-neutral-500/50 focus:border-blue-600': !isError },
145+
{ 'border-red-600': isError },
146+
{ 'opacity-50': isDisabled },
147+
{ 'cursor-pointer': !isDisabled },
148+
className,
149+
)}
150+
onClick={handleClick}
151+
aria-haspopup={true}
152+
aria-expanded={isOpen}
153+
data-testid={testId}
154+
>
155+
{children}
156+
</button>
157+
);
158+
};
159+
Select.Trigger = Trigger;
160+
161+
/**
162+
* Properties for the Value component.
163+
*/
164+
interface ValueProps extends BaseComponentProps {
165+
placeholder?: string;
166+
}
167+
168+
/**
169+
* The `Value` component displays either the current Select value. If there is no value
170+
* and "placeholder" is supplied, the placeholder is displayed.
171+
*
172+
* The Value component is a child of the Select Trigger.
173+
*/
174+
const Value = ({ className, placeholder, testId = 'select-value' }: ValueProps): JSX.Element => {
175+
const { text } = useContext(SelectContext);
176+
177+
return (
178+
<div className={cn('grow truncate text-left', className)} data-testid={testId}>
179+
{text}
180+
{!text && <span className="opacity-75">{placeholder}</span>}
181+
</div>
182+
);
183+
};
184+
Select.Value = Value;
185+
186+
/**
187+
* The `Icon` component displays the icon within a Select Trigger. By default,
188+
* the chevron down icon is displayed; however, the icon may be overridden.
189+
*/
190+
const Icon = ({
191+
icon = 'chevronDown',
192+
testId = 'select-icon',
193+
...props
194+
}: Omit<FAIconProps, 'icon'> & Partial<Pick<FAIconProps, 'icon'>>): JSX.Element => {
195+
return <FAIcon icon={icon} testId={testId} {...props} />;
196+
};
197+
Select.Icon = Icon;
198+
199+
/**
200+
* The `Options` component wraps the individual Select Option, Header,
201+
* and Separator components. There should be 1 Options within a Select.
202+
*/
203+
const Options = ({
204+
children,
205+
className,
206+
testId = 'select-options',
207+
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
208+
const { isOpen, setIsOpen } = useContext(SelectContext);
209+
210+
return (
211+
<>
212+
<Backdrop
213+
className={cn('bg-transparent', { hidden: !isOpen })}
214+
onClick={() => setIsOpen(!isOpen)}
215+
testId={`${testId}-backdrop`}
216+
/>
217+
<ul
218+
className={cn(
219+
'absolute right-0 z-1001 mt-1 max-h-60 w-full overflow-y-auto rounded-md border border-neutral-500 bg-white p-1 dark:bg-neutral-800',
220+
{ hidden: !isOpen },
221+
className,
222+
)}
223+
role="listbox"
224+
data-testid={testId}
225+
>
226+
{children}
227+
</ul>
228+
</>
229+
);
230+
};
231+
Select.Options = Options;
232+
233+
/**
234+
* Properties for the Option component.
235+
*/
236+
interface OptionProps extends BaseComponentProps, PropsWithChildren {
237+
value: SelectValue;
238+
}
239+
240+
/**
241+
* The `Option` component renders an individual Option. The "value" property
242+
* specifies the value which will be updated in the form context when this Option
243+
* is selected.
244+
*/
245+
const Option = ({
246+
children,
247+
className,
248+
testId = 'select-option',
249+
value,
250+
}: OptionProps): JSX.Element => {
251+
const { setIsOpen, value: currentValue, setValue, setText } = useContext(SelectContext);
252+
const isSelected = value === currentValue;
253+
254+
/* Set the "text" value for the selected item. */
255+
useEffect(() => {
256+
if (isSelected) {
257+
setText(children?.toString());
258+
}
259+
}, [isSelected]);
260+
261+
const handleClick = () => {
262+
setValue(value);
263+
setIsOpen(false);
264+
};
265+
266+
return (
267+
<li
268+
className={cn(
269+
'flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm not-last:mb-1',
270+
{ 'bg-neutral-500/25': isSelected },
271+
{ 'cursor-pointer hover:bg-neutral-500/25': !isSelected },
272+
className,
273+
)}
274+
onClick={handleClick}
275+
aria-selected={isSelected}
276+
role="option"
277+
data-testid={testId}
278+
>
279+
<div className="flex w-4 items-center justify-around">
280+
{isSelected && <FAIcon icon="check" size="sm" testId={`${testId}-selected`} />}
281+
</div>
282+
{children}
283+
</li>
284+
);
285+
};
286+
Select.Option = Option;
287+
288+
/**
289+
* The `Heading` component renders a heading within the Options list. A Heading
290+
* is useful for categorizing a group of related Option components.
291+
*/
292+
const Heading = ({
293+
children,
294+
className,
295+
testId = 'select-heading',
296+
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
297+
return (
298+
<h5 className={cn('px-2 py-1.5 text-sm font-bold', className)} data-testid={testId}>
299+
{children}
300+
</h5>
301+
);
302+
};
303+
Select.Heading = Heading;
304+
305+
/**
306+
* The `Separator` component renders a horizontal divider within Select Options.
307+
* This is useful to organize and separate groups of related options.
308+
*/
309+
const Separator = ({ className, testId = 'select-separator' }: DividerProps): JSX.Element => {
310+
return <Divider className={cn('-mx-1 my-1', className)} testId={testId} />;
311+
};
312+
Select.Separator = Separator;
313+
85314
export default Select;

0 commit comments

Comments
 (0)