Skip to content

Commit

Permalink
feat(Select): Typeahead example (patternfly#10207)
Browse files Browse the repository at this point in the history
* refactor(Select): rename shouldFocusFirstMenuItemOnOpen

* feat(SelectTypeahead example): better arrow up/down keys handling

- does not apply visual focus on the first menu option
- handles disabled options
- opens menu on pressing up/down arrow keys

* feat(SelectTypeahead example): don't close menu on clicking clear button when open

* refactor(SelectTypeahead example)

* refactor(SelectTypeahead example)

* fix(SelectTypeaheadCreatable example): changes based on SelectTypeahead

* fix(SelectMultiTypeahead example): changes based on SelectTypeahead

* fix(SelectTypeaheadCreatable example): don't show create option if that exact option exists

* fix(SelectMultiTypeaheadCreatable): changes based on SelectTypeahead

* fix(SelectMultiTypeaheadCheckbox): changes based on SelectTypeahead

* fix(SelectTypeaheadCreatable): close menu after creating option

* fix(SelectTypeahead template): rename prop back to shouldFocusFirstItemOnOpen
  • Loading branch information
adamviktora authored and kmcfaul committed Jun 27, 2024
1 parent bf104b2 commit ed62ec2
Show file tree
Hide file tree
Showing 7 changed files with 541 additions and 349 deletions.
8 changes: 4 additions & 4 deletions packages/react-core/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export interface SelectProps extends MenuProps, OUIAProps {
toggle: SelectToggleProps | ((toggleRef: React.RefObject<any>) => React.ReactNode);
/** Flag indicating the toggle should be focused after a selection. If this use case is too restrictive, the optional toggleRef property with a node toggle may be used to control focus. */
shouldFocusToggleOnSelect?: boolean;
/** Flag indicating the first menu item should be focused after opening the menu. */
shouldFocusFirstMenuItemOnOpen?: boolean;
/** @beta Flag indicating the first menu item should be focused after opening the menu. */
shouldFocusFirstItemOnOpen?: boolean;
/** Function callback when user selects an option. */
onSelect?: (event?: React.MouseEvent<Element, MouseEvent>, value?: string | number) => void;
/** Callback to allow the select component to change the open state of the menu.
Expand Down Expand Up @@ -88,7 +88,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
selected,
toggle,
shouldFocusToggleOnSelect = false,
shouldFocusFirstMenuItemOnOpen = true,
shouldFocusFirstItemOnOpen = true,
onOpenChange,
onOpenChangeKeys = ['Escape', 'Tab'],
isPlain,
Expand Down Expand Up @@ -128,7 +128,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({

const handleClick = (event: MouseEvent) => {
// toggle was opened, focus on first menu item
if (isOpen && shouldFocusFirstMenuItemOnOpen && toggleRef.current?.contains(event.target as Node)) {
if (isOpen && shouldFocusFirstItemOnOpen && toggleRef.current?.contains(event.target as Node)) {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
const [selected, setSelected] = React.useState<string[]>([]);
const [selectOptions, setSelectOptions] = React.useState<SelectOptionProps[]>(initialSelectOptions);
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
const [activeItem, setActiveItem] = React.useState<string | null>(null);
const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
const textInputRef = React.useRef<HTMLInputElement>();

const NO_RESULTS = 'no results';

React.useEffect(() => {
let newSelectOptions: SelectOptionProps[] = initialSelectOptions;

Expand All @@ -45,7 +47,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
// When no options are found after filtering, display 'No results found'
if (!newSelectOptions.length) {
newSelectOptions = [
{ isDisabled: false, children: `No results found for "${inputValue}"`, value: 'no results' }
{ isAriaDisabled: true, children: `No results found for "${inputValue}"`, value: NO_RESULTS }
];
}

Expand All @@ -56,56 +58,113 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
}

setSelectOptions(newSelectOptions);
setFocusedItemIndex(null);
setActiveItem(null);
}, [inputValue]);

const createItemId = (value: any) => `select-multi-typeahead-${value.replace(' ', '-')}`;

const setActiveAndFocusedItem = (itemIndex: number) => {
setFocusedItemIndex(itemIndex);
const focusedItem = selectOptions[itemIndex];
setActiveItemId(createItemId(focusedItem.value));
};

const resetActiveAndFocusedItem = () => {
setFocusedItemIndex(null);
setActiveItemId(null);
};

const closeMenu = () => {
setIsOpen(false);
resetActiveAndFocusedItem();
};

const onInputClick = () => {
if (!isOpen) {
setIsOpen(true);
} else if (!inputValue) {
closeMenu();
}
};

const onSelect = (value: string) => {
if (value && value !== NO_RESULTS) {
// eslint-disable-next-line no-console
console.log('selected', value);

setSelected(
selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value]
);
}

textInputRef.current?.focus();
};

const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
setInputValue(value);
resetActiveAndFocusedItem();
};

const handleMenuArrowKeys = (key: string) => {
let indexToFocus;
let indexToFocus = 0;

if (isOpen) {
if (key === 'ArrowUp') {
// When no index is set or at the first index, focus to the last, otherwise decrement focus index
if (focusedItemIndex === null || focusedItemIndex === 0) {
if (!isOpen) {
setIsOpen(true);
}

if (selectOptions.every((option) => option.isDisabled)) {
return;
}

if (key === 'ArrowUp') {
// When no index is set or at the first index, focus to the last, otherwise decrement focus index
if (focusedItemIndex === null || focusedItemIndex === 0) {
indexToFocus = selectOptions.length - 1;
} else {
indexToFocus = focusedItemIndex - 1;
}

// Skip disabled options
while (selectOptions[indexToFocus].isDisabled) {
indexToFocus--;
if (indexToFocus === -1) {
indexToFocus = selectOptions.length - 1;
} else {
indexToFocus = focusedItemIndex - 1;
}
}
}

if (key === 'ArrowDown') {
// When no index is set or at the last index, focus to the first, otherwise increment focus index
if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) {
if (key === 'ArrowDown') {
// When no index is set or at the last index, focus to the first, otherwise increment focus index
if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) {
indexToFocus = 0;
} else {
indexToFocus = focusedItemIndex + 1;
}

// Skip disabled options
while (selectOptions[indexToFocus].isDisabled) {
indexToFocus++;
if (indexToFocus === selectOptions.length) {
indexToFocus = 0;
} else {
indexToFocus = focusedItemIndex + 1;
}
}

setFocusedItemIndex(indexToFocus);
const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus];
setActiveItem(`select-multi-typeahead-${focusedItem.value.replace(' ', '-')}`);
}

setActiveAndFocusedItem(indexToFocus);
};

const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled);
const [firstMenuItem] = enabledMenuItems;
const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem;
const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null;

switch (event.key) {
// Select the first available option
case 'Enter':
if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) {
onSelect(focusedItem.value);
}

if (!isOpen) {
setIsOpen((prevIsOpen) => !prevIsOpen);
} else if (isOpen && focusedItem.value !== 'no results') {
onSelect(focusedItem.value as string);
setIsOpen(true);
}
break;
case 'Tab':
case 'Escape':
setIsOpen(false);
setActiveItem(null);

break;
case 'ArrowUp':
case 'ArrowDown':
Expand All @@ -117,24 +176,17 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {

const onToggleClick = () => {
setIsOpen(!isOpen);
textInputRef?.current?.focus();
};

const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
setInputValue(value);
const onClearButtonClick = () => {
setSelected([]);
setInputValue('');
resetActiveAndFocusedItem();
textInputRef?.current?.focus();
};

const onSelect = (value: string) => {
// eslint-disable-next-line no-console
console.log('selected', value);

if (value && value !== 'no results') {
setSelected(
selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value]
);
}

textInputRef.current?.focus();
};
const getChildren = (value: string) => initialSelectOptions.find((option) => option.value === value)?.children;

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
Expand All @@ -148,14 +200,14 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onClick={onInputClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="multi-typeahead-select-input"
autoComplete="off"
innerRef={textInputRef}
placeholder="Select a state"
{...(activeItem && { 'aria-activedescendant': activeItem })}
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-multi-typeahead-listbox"
Expand All @@ -169,25 +221,15 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
onSelect(selection);
}}
>
{selection}
{getChildren(selection)}
</Chip>
))}
</ChipGroup>
</TextInputGroupMain>
<TextInputGroupUtilities>
{selected.length > 0 && (
<Button
variant="plain"
onClick={() => {
setInputValue('');
setSelected([]);
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
<TextInputGroupUtilities {...(selected.length === 0 ? { style: { display: 'none' } } : {})}>
<Button variant="plain" onClick={onClearButtonClick} aria-label="Clear input value">
<TimesIcon aria-hidden />
</Button>
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
Expand All @@ -198,17 +240,20 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
id="multi-typeahead-select"
isOpen={isOpen}
selected={selected}
onSelect={(ev, selection) => onSelect(selection as string)}
onOpenChange={() => setIsOpen(false)}
onSelect={(_event, selection) => onSelect(selection as string)}
onOpenChange={(isOpen) => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
>
<SelectList isAriaMultiselectable id="select-multi-typeahead-listbox">
{selectOptions.map((option, index) => (
<SelectOption
key={option.value || option.children}
isFocused={focusedItemIndex === index}
className={option.className}
id={`select-multi-typeahead-${option.value.replace(' ', '-')}`}
id={createItemId(option.value)}
{...option}
ref={null}
/>
Expand Down
Loading

0 comments on commit ed62ec2

Please sign in to comment.