@@ -7,15 +7,14 @@ import React, {
77 Ref ,
88 ReactNode ,
99 useId ,
10+ useState ,
11+ useEffect ,
12+ useCallback ,
1013} from 'react' ;
1114import classnames from 'classnames' ;
1215import { FormElement , FormElementProps } from './FormElement' ;
1316import { Icon } from './Icon' ;
14- import {
15- DropdownMenu ,
16- DropdownMenuItem ,
17- DropdownMenuProps ,
18- } from './DropdownMenu' ;
17+ import { DropdownMenuProps } from './DropdownMenu' ;
1918import { isElInChildren } from './util' ;
2019import { ComponentSettingsContext } from './ComponentSettings' ;
2120import { useControlledValue , useEventCallback , useMergeRefs } from './hooks' ;
@@ -156,8 +155,124 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
156155 opened_ ,
157156 defaultOpened ?? false
158157 ) ;
158+ const [ focusedValue , setFocusedValue ] = useState <
159+ PicklistValue | undefined
160+ > ( ) ;
161+
159162 const { getActiveElement } = useContext ( ComponentSettingsContext ) ;
160163
164+ // Get option values from children
165+ const getOptionValues = useCallback ( ( ) => {
166+ const optionValues : PicklistValue [ ] = [ ] ;
167+ React . Children . forEach ( children , ( child ) => {
168+ if ( ! React . isValidElement ( child ) ) {
169+ return ;
170+ }
171+
172+ const props : unknown = child . props ;
173+ const isPropsObject = typeof props === 'object' && props !== null ;
174+
175+ if (
176+ isPropsObject &&
177+ 'value' in props &&
178+ ( typeof props . value === 'string' || typeof props . value === 'number' )
179+ ) {
180+ optionValues . push ( props . value ) ;
181+ }
182+ } ) ;
183+ return optionValues ;
184+ } , [ children ] ) ;
185+
186+ // Get next option value for keyboard navigation
187+ const getNextValue = useCallback (
188+ ( currentValue ?: PicklistValue ) => {
189+ const optionValues = getOptionValues ( ) ;
190+ if ( optionValues . length === 0 ) return undefined ;
191+
192+ if ( ! currentValue ) return optionValues [ 0 ] ;
193+
194+ const currentIndex = optionValues . indexOf ( currentValue ) ;
195+ return optionValues [
196+ Math . min ( currentIndex + 1 , optionValues . length - 1 )
197+ ] ; // not wrap around
198+ } ,
199+ [ getOptionValues ]
200+ ) ;
201+
202+ // Get previous option value for keyboard navigation
203+ const getPrevValue = useCallback (
204+ ( currentValue ?: PicklistValue ) => {
205+ const optionValues = getOptionValues ( ) ;
206+ if ( optionValues . length === 0 ) return undefined ;
207+
208+ if ( ! currentValue ) return optionValues [ optionValues . length - 1 ] ;
209+
210+ const currentIndex = optionValues . indexOf ( currentValue ) ;
211+ return optionValues [ Math . max ( currentIndex - 1 , 0 ) ] ; // not wrap around
212+ } ,
213+ [ getOptionValues ]
214+ ) ;
215+
216+ // Scroll focused element into view
217+ const scrollFocusedElementIntoView = useEventCallback (
218+ ( nextFocusedValue : PicklistValue | undefined ) => {
219+ if ( ! nextFocusedValue || ! dropdownElRef . current ) {
220+ return ;
221+ }
222+
223+ const dropdownContainer = dropdownElRef . current ;
224+ const targetElement = dropdownContainer . querySelector (
225+ `#${ CSS . escape ( optionIdPrefix ) } -${ nextFocusedValue } `
226+ ) ;
227+
228+ if ( ! ( targetElement instanceof HTMLElement ) ) {
229+ return ;
230+ }
231+
232+ // Calculate element position within container
233+ const elementTopPosition = targetElement . offsetTop ;
234+ const elementBottomPosition =
235+ elementTopPosition + targetElement . offsetHeight ;
236+
237+ // Calculate currently visible area
238+ const currentScrollPosition = dropdownContainer . scrollTop ;
239+ const visibleAreaHeight = dropdownContainer . clientHeight ;
240+ const visibleAreaTop = currentScrollPosition ;
241+ const visibleAreaBottom = currentScrollPosition + visibleAreaHeight ;
242+
243+ // Check if element is outside the visible area
244+ const isAbove = elementTopPosition < visibleAreaTop ;
245+ const isBelow = elementBottomPosition > visibleAreaBottom ;
246+
247+ // Scroll only if element is not currently visible
248+ if ( isAbove || isBelow ) {
249+ targetElement . scrollIntoView ( {
250+ block : 'center' ,
251+ } ) ;
252+ }
253+ }
254+ ) ;
255+
256+ // Set initial focus when dropdown opens
257+ useEffect ( ( ) => {
258+ if ( opened && ! focusedValue ) {
259+ // Focus on first selected value or first option
260+ const initialFocus =
261+ values . length > 0 ? values [ 0 ] : getOptionValues ( ) [ 0 ] ;
262+ setFocusedValue ( initialFocus ) ;
263+ scrollFocusedElementIntoView ( initialFocus ) ;
264+ } else if ( ! opened ) {
265+ // Reset focus when dropdown closes
266+ setFocusedValue ( undefined ) ;
267+ }
268+ } , [
269+ opened ,
270+ values ,
271+ getOptionValues ,
272+ focusedValue ,
273+ scrollFocusedElementIntoView ,
274+ ] ) ;
275+
161276 const elRef = useRef < HTMLDivElement | null > ( null ) ;
162277 const elementRef = useMergeRefs ( [ elRef , elementRef_ ] ) ;
163278 const comboboxElRef = useRef < HTMLDivElement | null > ( null ) ;
@@ -210,20 +325,6 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
210325 ) ;
211326 } ) ;
212327
213- const focusToTargetItemEl = useEventCallback ( ( ) => {
214- const dropdownEl = dropdownElRef . current ;
215- if ( ! dropdownEl ) {
216- return ;
217- }
218- const firstItemEl : HTMLAnchorElement | null =
219- dropdownEl . querySelector (
220- '.slds-is-selected > .react-slds-menuitem[tabIndex]'
221- ) || dropdownEl . querySelector ( '.react-slds-menuitem[tabIndex]' ) ;
222- if ( firstItemEl ) {
223- firstItemEl . focus ( ) ;
224- }
225- } ) ;
226-
227328 const onClick = useEventCallback ( ( ) => {
228329 if ( ! disabled ) {
229330 setOpened ( ( opened ) => ! opened ) ;
@@ -245,25 +346,81 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
245346 } , 10 ) ;
246347 } ) ;
247348
248- const onKeydown = useEventCallback ( ( e : React . KeyboardEvent ) => {
349+ const onKeyDown = useEventCallback ( ( e : React . KeyboardEvent ) => {
249350 if ( e . keyCode === 40 ) {
250351 // down
251352 e . preventDefault ( ) ;
252353 e . stopPropagation ( ) ;
253354 if ( ! opened ) {
254355 setOpened ( true ) ;
255- setTimeout ( ( ) => {
256- focusToTargetItemEl ( ) ;
257- } , 10 ) ;
258356 } else {
259- focusToTargetItemEl ( ) ;
357+ // Navigate to next option
358+ const nextValue = getNextValue ( focusedValue ) ;
359+ setFocusedValue ( nextValue ) ;
360+ scrollFocusedElementIntoView ( nextValue ) ;
361+ }
362+ } else if ( e . keyCode === 38 ) {
363+ // up
364+ e . preventDefault ( ) ;
365+ e . stopPropagation ( ) ;
366+ if ( ! opened ) {
367+ setOpened ( true ) ;
368+ } else {
369+ // Navigate to previous option
370+ const prevValue = getPrevValue ( focusedValue ) ;
371+ setFocusedValue ( prevValue ) ;
372+ scrollFocusedElementIntoView ( prevValue ) ;
373+ }
374+ } else if ( e . keyCode === 9 ) {
375+ // Tab or Shift+Tab
376+ if ( opened ) {
377+ e . preventDefault ( ) ;
378+ e . stopPropagation ( ) ;
379+ const optionValues = getOptionValues ( ) ;
380+ const currentIndex = focusedValue
381+ ? optionValues . indexOf ( focusedValue )
382+ : - 1 ;
383+
384+ if ( e . shiftKey ) {
385+ // Shift+Tab - Navigate to previous option or close if at first
386+ if ( currentIndex <= 0 ) {
387+ // At first option or no focus, close the picklist
388+ setOpened ( false ) ;
389+ onComplete ?.( ) ;
390+ } else {
391+ const prevValue = getPrevValue ( focusedValue ) ;
392+ setFocusedValue ( prevValue ) ;
393+ scrollFocusedElementIntoView ( prevValue ) ;
394+ }
395+ } else {
396+ // Tab - Navigate to next option or close if at last
397+ if ( currentIndex >= optionValues . length - 1 ) {
398+ // At last option, close the picklist
399+ setOpened ( false ) ;
400+ onComplete ?.( ) ;
401+ } else {
402+ const nextValue = getNextValue ( focusedValue ) ;
403+ setFocusedValue ( nextValue ) ;
404+ scrollFocusedElementIntoView ( nextValue ) ;
405+ }
406+ }
260407 }
261408 } else if ( e . keyCode === 27 ) {
262409 // ESC
263410 e . preventDefault ( ) ;
264411 e . stopPropagation ( ) ;
265412 setOpened ( false ) ;
266413 onComplete ?.( ) ;
414+ } else if ( e . keyCode === 13 || e . keyCode === 32 ) {
415+ // Enter or Space
416+ e . preventDefault ( ) ;
417+ e . stopPropagation ( ) ;
418+ if ( opened && focusedValue != null ) {
419+ // Select focused option
420+ onPicklistItemSelect ( focusedValue ) ;
421+ } else {
422+ setOpened ( ( opened ) => ! opened ) ;
423+ }
267424 }
268425 onKeyDown_ ?.( e ) ;
269426 } ) ;
@@ -316,6 +473,11 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
316473 'slds-is-disabled' : disabled ,
317474 }
318475 ) ;
476+ const dropdownClassNames = classnames (
477+ 'slds-dropdown' ,
478+ 'slds-dropdown_length-5' ,
479+ menuSize ? `slds-dropdown_${ menuSize } ` : 'slds-dropdown_fluid'
480+ ) ;
319481
320482 const formElemProps = {
321483 id,
@@ -332,6 +494,7 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
332494 values,
333495 multiSelect,
334496 onSelect : onPicklistItemSelect ,
497+ focusedValue,
335498 optionIdPrefix,
336499 } ;
337500
@@ -352,8 +515,11 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
352515 aria-expanded = { opened }
353516 aria-haspopup = 'listbox'
354517 aria-disabled = { disabled }
518+ aria-activedescendant = {
519+ focusedValue ? `${ optionIdPrefix } -${ focusedValue } ` : undefined
520+ }
355521 onClick = { onClick }
356- onKeyDown = { onKeydown }
522+ onKeyDown = { onKeyDown }
357523 onBlur = { onBlur }
358524 { ...rprops }
359525 >
@@ -367,22 +533,25 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
367533 </ span >
368534 </ div >
369535 { opened && (
370- < DropdownMenu
371- portalClassName = { classnames ( className , 'slds-picklist' ) }
372- elementRef = { dropdownRef }
373- size = { menuSize }
374- style = { menuStyle }
375- onMenuSelect = { onPicklistItemSelect }
376- onMenuClose = { ( ) => {
377- setOpened ( false ) ;
378- onComplete ?.( ) ;
379- } }
380- onBlur = { onBlur }
536+ < div
537+ id = { listboxId }
538+ className = { dropdownClassNames }
539+ role = 'listbox'
540+ aria-label = 'Options'
541+ tabIndex = { 0 }
542+ aria-busy = { false }
543+ ref = { dropdownRef }
544+ style = { { ...menuStyle , left : 0 , transform : 'translate(0)' } }
381545 >
382- < PicklistContext . Provider value = { contextValue } >
383- { children }
384- </ PicklistContext . Provider >
385- </ DropdownMenu >
546+ < ul
547+ className = 'slds-listbox slds-listbox_vertical'
548+ role = 'presentation'
549+ >
550+ < PicklistContext . Provider value = { contextValue } >
551+ { children }
552+ </ PicklistContext . Provider >
553+ </ ul >
554+ </ div >
386555 ) }
387556 </ div >
388557 </ div >
@@ -412,22 +581,54 @@ export const PicklistItem: FC<PicklistItemProps> = ({
412581 value,
413582 disabled,
414583 children,
415- ...props
416584} ) => {
417- const { values } = useContext ( PicklistContext ) ;
585+ const { values, multiSelect, onSelect, focusedValue, optionIdPrefix } =
586+ useContext ( PicklistContext ) ;
418587 const selected =
419588 selected_ ?? ( value != null ? values . indexOf ( value ) >= 0 : false ) ;
589+ const isFocused = focusedValue === value ;
590+
591+ const onClick = useEventCallback ( ( ) => {
592+ if ( ! disabled && value != null ) {
593+ onSelect ( value ) ;
594+ }
595+ } ) ;
596+
597+ const itemClassNames = classnames (
598+ 'slds-media' ,
599+ 'slds-listbox__option' ,
600+ 'slds-listbox__option_plain' ,
601+ 'slds-media_small' ,
602+ {
603+ 'slds-is-selected' : selected ,
604+ 'slds-has-focus' : isFocused ,
605+ }
606+ ) ;
420607
421608 return (
422- < DropdownMenuItem
423- icon = { selected ? 'check' : 'none' }
424- role = 'option'
425- selected = { selected }
426- disabled = { disabled }
427- eventKey = { value }
428- { ...props }
429- >
430- { label || children }
431- </ DropdownMenuItem >
609+ < li role = 'presentation' className = 'slds-listbox__item' >
610+ < div
611+ id = { value ? `${ optionIdPrefix } -${ value } ` : undefined }
612+ className = { itemClassNames }
613+ role = 'option'
614+ aria-selected = { selected }
615+ aria-checked = { multiSelect ? selected : undefined }
616+ aria-disabled = { disabled }
617+ onClick = { onClick }
618+ >
619+ < span className = 'slds-media__figure slds-listbox__option-icon' >
620+ { selected && (
621+ < span className = 'slds-icon_container slds-icon-utility-check slds-current-color' >
622+ < Icon icon = 'check' className = 'slds-icon slds-icon_x-small' />
623+ </ span >
624+ ) }
625+ </ span >
626+ < span className = 'slds-media__body' >
627+ < span className = 'slds-truncate' title = { String ( label || children ) } >
628+ { label || children }
629+ </ span >
630+ </ span >
631+ </ div >
632+ </ li >
432633 ) ;
433634} ;
0 commit comments