Skip to content

Commit dfe1d79

Browse files
Revert "use DropdownMenu to delegate keyboard handlers"
This reverts commit 1da593e.
1 parent df4398d commit dfe1d79

File tree

1 file changed

+253
-52
lines changed

1 file changed

+253
-52
lines changed

src/scripts/Picklist.tsx

Lines changed: 253 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ import React, {
77
Ref,
88
ReactNode,
99
useId,
10+
useState,
11+
useEffect,
12+
useCallback,
1013
} from 'react';
1114
import classnames from 'classnames';
1215
import { FormElement, FormElementProps } from './FormElement';
1316
import { Icon } from './Icon';
14-
import {
15-
DropdownMenu,
16-
DropdownMenuItem,
17-
DropdownMenuProps,
18-
} from './DropdownMenu';
17+
import { DropdownMenuProps } from './DropdownMenu';
1918
import { isElInChildren } from './util';
2019
import { ComponentSettingsContext } from './ComponentSettings';
2120
import { 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

Comments
 (0)