Skip to content

Commit

Permalink
feat(Select): Typeahead template (#10235)
Browse files Browse the repository at this point in the history
* fix(SelectTypeahead example): make "no results" option aria-disabled

* fix(SelectTypeahead example): don't close the menu on input click when there is text

* fix(SelectTypeahead example): remove visual focus on item after closing the menu

Prevents situation where we open the menu via focusing on the toggle arrow and clicking enter -- then two items can have focus styling, which is not ideal.

* fix(SelectTypeahead example): remove check icon from the selected option when input text changes

* fix(SelectTypeahead example): rename example

* feat(Select): add prop to opt out of focusing first menu item on open

Flag prop shouldFocusFirstMenuItemOnOpen has been added, because of typeahead select, which should keep focus on the input.

* refactor(SelectTypeahead example): adaption on first menu item focused

* feat(MenuToggle): make typeahead toggle button not focusable

* fix(SelectTypeahead example): focus input after toggle button click

* feat(SelectTypeahead example): change the focused item on hover

* fix(SelectTypeahead example): don't focus on first item after tabbing

* feat(Select): add typeahead select template

* fix(SelectTypeahead): address PR review

- new changes were done also based on SelectTypeahead example updates (#10207)

* fix(SelectTypeahead template): call onToggle every time menu opens/closes

* refactor(SelectTypeahead template)
  • Loading branch information
adamviktora authored May 2, 2024
1 parent 9ee0d01 commit c7d2e9f
Show file tree
Hide file tree
Showing 7 changed files with 408 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class MenuToggleBase extends React.Component<MenuToggleProps> {
aria-expanded={isExpanded}
onClick={onClick}
aria-label={ariaLabel || 'Menu toggle'}
tabIndex={-1}
>
{toggleControls}
</button>
Expand Down
5 changes: 4 additions & 1 deletion packages/react-core/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +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;
/** 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 @@ -86,6 +88,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
selected,
toggle,
shouldFocusToggleOnSelect = false,
shouldFocusFirstMenuItemOnOpen = true,
onOpenChange,
onOpenChangeKeys = ['Escape', 'Tab'],
isPlain,
Expand Down Expand Up @@ -125,7 +128,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({

const handleClick = (event: MouseEvent) => {
// toggle was opened, focus on first menu item
if (isOpen && toggleRef.current?.contains(event.target as Node)) {
if (isOpen && shouldFocusFirstMenuItemOnOpen && 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 @@ -22,7 +22,7 @@ const initialSelectOptions: SelectOptionProps[] = [
{ value: 'North Carolina', children: 'North Carolina' }
];

export const SelectBasic: React.FunctionComponent = () => {
export const SelectTypeahead: React.FunctionComponent = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [selected, setSelected] = React.useState<string>('');
const [inputValue, setInputValue] = React.useState<string>('');
Expand All @@ -32,6 +32,8 @@ export const SelectBasic: React.FunctionComponent = () => {
const [activeItem, setActiveItem] = React.useState<string | null>(null);
const textInputRef = React.useRef<HTMLInputElement>();

const NO_RESULTS = 'no results';

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

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

// Open the menu when the input value changes and the new value is not empty
Expand All @@ -55,31 +58,57 @@ export const SelectBasic: React.FunctionComponent = () => {
}

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

const onToggleClick = () => {
setIsOpen(!isOpen);
React.useEffect(() => {
if (isOpen && selectOptions.length && selectOptions[0].value !== NO_RESULTS) {
setActiveAndFocusedItem(0);
}
}, [isOpen, filterValue]);

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

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

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

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

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
// eslint-disable-next-line no-console
console.log('selected', value);

if (value && value !== 'no results') {
if (value && value !== NO_RESULTS) {
setInputValue(value as string);
setFilterValue('');
setSelected(value as string);
}
setIsOpen(false);
setFocusedItemIndex(null);
setActiveItem(null);
closeMenu();
};

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

if (value !== selected) {
setSelected('');
}
};

const handleMenuArrowKeys = (key: string) => {
Expand All @@ -104,9 +133,7 @@ export const SelectBasic: React.FunctionComponent = () => {
}
}

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

Expand All @@ -118,21 +145,15 @@ export const SelectBasic: React.FunctionComponent = () => {
switch (event.key) {
// Select the first available option
case 'Enter':
if (isOpen && focusedItem.value !== 'no results') {
if (isOpen && focusedItem.value !== NO_RESULTS) {
setInputValue(String(focusedItem.children));
setFilterValue('');
setSelected(String(focusedItem.children));
}

setIsOpen((prevIsOpen) => !prevIsOpen);
setFocusedItemIndex(null);
setActiveItem(null);
resetActiveAndFocusedItem();

break;
case 'Tab':
case 'Escape':
setIsOpen(false);
setActiveItem(null);
break;
case 'ArrowUp':
case 'ArrowDown':
Expand All @@ -147,14 +168,17 @@ export const SelectBasic: React.FunctionComponent = () => {
ref={toggleRef}
variant="typeahead"
aria-label="Typeahead menu toggle"
onClick={onToggleClick}
onClick={() => {
setIsOpen(!isOpen);
textInputRef?.current?.focus();
}}
isExpanded={isOpen}
isFullWidth
>
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onClick={onInputClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="typeahead-select-input"
Expand Down Expand Up @@ -193,17 +217,19 @@ export const SelectBasic: React.FunctionComponent = () => {
isOpen={isOpen}
selected={selected}
onSelect={onSelect}
onOpenChange={() => {
setIsOpen(false);
onOpenChange={(isOpen) => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstMenuItemOnOpen={false}
>
<SelectList id="select-typeahead-listbox">
{selectOptions.map((option, index) => (
<SelectOption
key={option.value || option.children}
isFocused={focusedItemIndex === index}
className={option.className}
onMouseEnter={() => setActiveAndFocusedItem(index)}
onClick={() => setSelected(option.value)}
id={`select-typeahead-${option.value.replace(' ', '-')}`}
{...option}
Expand Down
Loading

0 comments on commit c7d2e9f

Please sign in to comment.