Skip to content

Commit 1da593e

Browse files
use DropdownMenu to delegate keyboard handlers
1 parent 88f3f0e commit 1da593e

File tree

1 file changed

+52
-253
lines changed

1 file changed

+52
-253
lines changed

src/scripts/Picklist.tsx

Lines changed: 52 additions & 253 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import React, {
77
Ref,
88
ReactNode,
99
useId,
10-
useState,
11-
useEffect,
12-
useCallback,
1310
} from 'react';
1411
import classnames from 'classnames';
1512
import { FormElement, FormElementProps } from './FormElement';
1613
import { Icon } from './Icon';
17-
import { DropdownMenuProps } from './DropdownMenu';
14+
import {
15+
DropdownMenu,
16+
DropdownMenuItem,
17+
DropdownMenuProps,
18+
} from './DropdownMenu';
1819
import { isElInChildren } from './util';
1920
import { ComponentSettingsContext } from './ComponentSettings';
2021
import { useControlledValue, useEventCallback, useMergeRefs } from './hooks';
@@ -155,124 +156,8 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
155156
opened_,
156157
defaultOpened ?? false
157158
);
158-
const [focusedValue, setFocusedValue] = useState<
159-
PicklistValue | undefined
160-
>();
161-
162159
const { getActiveElement } = useContext(ComponentSettingsContext);
163160

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-
276161
const elRef = useRef<HTMLDivElement | null>(null);
277162
const elementRef = useMergeRefs([elRef, elementRef_]);
278163
const comboboxElRef = useRef<HTMLDivElement | null>(null);
@@ -325,6 +210,20 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
325210
);
326211
});
327212

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+
328227
const onClick = useEventCallback(() => {
329228
if (!disabled) {
330229
setOpened((opened) => !opened);
@@ -346,81 +245,25 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
346245
}, 10);
347246
});
348247

349-
const onKeyDown = useEventCallback((e: React.KeyboardEvent) => {
248+
const onKeydown = useEventCallback((e: React.KeyboardEvent) => {
350249
if (e.keyCode === 40) {
351250
// down
352251
e.preventDefault();
353252
e.stopPropagation();
354253
if (!opened) {
355254
setOpened(true);
255+
setTimeout(() => {
256+
focusToTargetItemEl();
257+
}, 10);
356258
} else {
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-
}
259+
focusToTargetItemEl();
407260
}
408261
} else if (e.keyCode === 27) {
409262
// ESC
410263
e.preventDefault();
411264
e.stopPropagation();
412265
setOpened(false);
413266
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-
}
424267
}
425268
onKeyDown_?.(e);
426269
});
@@ -473,11 +316,6 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
473316
'slds-is-disabled': disabled,
474317
}
475318
);
476-
const dropdownClassNames = classnames(
477-
'slds-dropdown',
478-
'slds-dropdown_length-5',
479-
menuSize ? `slds-dropdown_${menuSize}` : 'slds-dropdown_fluid'
480-
);
481319

482320
const formElemProps = {
483321
id,
@@ -494,7 +332,6 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
494332
values,
495333
multiSelect,
496334
onSelect: onPicklistItemSelect,
497-
focusedValue,
498335
optionIdPrefix,
499336
};
500337

@@ -516,11 +353,8 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
516353
aria-expanded={opened}
517354
aria-haspopup='listbox'
518355
aria-disabled={disabled}
519-
aria-activedescendant={
520-
focusedValue ? `${optionIdPrefix}-${focusedValue}` : undefined
521-
}
522356
onClick={onClick}
523-
onKeyDown={onKeyDown}
357+
onKeyDown={onKeydown}
524358
onBlur={onBlur}
525359
{...rprops}
526360
>
@@ -534,25 +368,22 @@ export const Picklist: (<MultiSelect extends boolean | undefined>(
534368
</span>
535369
</div>
536370
{opened && (
537-
<div
538-
id={listboxId}
539-
className={dropdownClassNames}
540-
role='listbox'
541-
aria-label='Options'
542-
tabIndex={0}
543-
aria-busy={false}
544-
ref={dropdownRef}
545-
style={{ ...menuStyle, left: 0, transform: 'translate(0)' }}
371+
<DropdownMenu
372+
portalClassName={classnames(className, 'slds-picklist')}
373+
elementRef={dropdownRef}
374+
size={menuSize}
375+
style={menuStyle}
376+
onMenuSelect={onPicklistItemSelect}
377+
onMenuClose={() => {
378+
setOpened(false);
379+
onComplete?.();
380+
}}
381+
onBlur={onBlur}
546382
>
547-
<ul
548-
className='slds-listbox slds-listbox_vertical'
549-
role='presentation'
550-
>
551-
<PicklistContext.Provider value={contextValue}>
552-
{children}
553-
</PicklistContext.Provider>
554-
</ul>
555-
</div>
383+
<PicklistContext.Provider value={contextValue}>
384+
{children}
385+
</PicklistContext.Provider>
386+
</DropdownMenu>
556387
)}
557388
</div>
558389
</div>
@@ -582,54 +413,22 @@ export const PicklistItem: FC<PicklistItemProps> = ({
582413
value,
583414
disabled,
584415
children,
416+
...props
585417
}) => {
586-
const { values, multiSelect, onSelect, focusedValue, optionIdPrefix } =
587-
useContext(PicklistContext);
418+
const { values } = useContext(PicklistContext);
588419
const selected =
589420
selected_ ?? (value != null ? values.indexOf(value) >= 0 : false);
590-
const isFocused = focusedValue === value;
591-
592-
const onClick = useEventCallback(() => {
593-
if (!disabled && value != null) {
594-
onSelect(value);
595-
}
596-
});
597-
598-
const itemClassNames = classnames(
599-
'slds-media',
600-
'slds-listbox__option',
601-
'slds-listbox__option_plain',
602-
'slds-media_small',
603-
{
604-
'slds-is-selected': selected,
605-
'slds-has-focus': isFocused,
606-
}
607-
);
608421

609422
return (
610-
<li role='presentation' className='slds-listbox__item'>
611-
<div
612-
id={value ? `${optionIdPrefix}-${value}` : undefined}
613-
className={itemClassNames}
614-
role='option'
615-
aria-selected={selected}
616-
aria-checked={multiSelect ? selected : undefined}
617-
aria-disabled={disabled}
618-
onClick={onClick}
619-
>
620-
<span className='slds-media__figure slds-listbox__option-icon'>
621-
{selected && (
622-
<span className='slds-icon_container slds-icon-utility-check slds-current-color'>
623-
<Icon icon='check' className='slds-icon slds-icon_x-small' />
624-
</span>
625-
)}
626-
</span>
627-
<span className='slds-media__body'>
628-
<span className='slds-truncate' title={String(label || children)}>
629-
{label || children}
630-
</span>
631-
</span>
632-
</div>
633-
</li>
423+
<DropdownMenuItem
424+
icon={selected ? 'check' : 'none'}
425+
role='option'
426+
selected={selected}
427+
disabled={disabled}
428+
eventKey={value}
429+
{...props}
430+
>
431+
{label || children}
432+
</DropdownMenuItem>
634433
);
635434
};

0 commit comments

Comments
 (0)