1- import { InputHTMLAttributes , PropsWithChildren } from 'react' ;
1+ import { createContext , PropsWithChildren , useContext , useEffect , useState } from 'react' ;
22import { Control , FieldValues , Path , useController } from 'react-hook-form' ;
3+ import noop from 'lodash/noop' ;
34
45import { cn } from 'common/utils/css' ;
5- import { PropsWithTestId } from 'common/utils/types' ;
6+ import { BaseComponentProps } from 'common/utils/types' ;
67import Label from './Label' ;
78import FieldError from './FieldError' ;
89import 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 */
4072const 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+
85314export default Select ;
0 commit comments