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
adamviktora authored and kmcfaul committed Jun 27, 2024
1 parent bf104b2 commit ed62ec2
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
@@ -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.
@@ -88,7 +88,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
selected,
toggle,
shouldFocusToggleOnSelect = false,
shouldFocusFirstMenuItemOnOpen = true,
shouldFocusFirstItemOnOpen = true,
onOpenChange,
onOpenChangeKeys = ['Escape', 'Tab'],
isPlain,
@@ -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();
Original file line number Diff line number Diff line change
@@ -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;

@@ -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 }
];
}

@@ -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':
@@ -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
@@ -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"
@@ -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>
@@ -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}
/>
Original file line number Diff line number Diff line change
@@ -28,10 +28,12 @@ export const SelectMultiTypeaheadCheckbox: 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 [placeholder, setPlaceholder] = React.useState('0 items selected');
const textInputRef = React.useRef<HTMLInputElement>();

const NO_RESULTS = 'no results';

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

@@ -45,9 +47,9 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
if (!newSelectOptions.length) {
newSelectOptions = [
{
isDisabled: false,
isAriaDisabled: true,
children: `No results found for "${inputValue}"`,
value: 'no results',
value: NO_RESULTS,
hasCheckbox: false
}
];
@@ -60,56 +62,99 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
}

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

React.useEffect(() => {
setPlaceholder(`${selected.length} item${selected.length !== 1 ? 's' : ''} selected`);
}, [selected]);

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 handleMenuArrowKeys = (key: string) => {
let indexToFocus;
let indexToFocus = 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;
}

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) {
// 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) {
indexToFocus = 0;
} 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) {
// 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-checkbox-${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':
@@ -121,17 +166,19 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {

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

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

const onSelect = (value: string) => {
// eslint-disable-next-line no-console
console.log('selected', value);
if (value && value !== NO_RESULTS) {
// 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]
);
@@ -140,9 +187,12 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
textInputRef.current?.focus();
};

React.useEffect(() => {
setPlaceholder(`${selected.length} items selected`);
}, [selected]);
const onClearButtonClick = () => {
setSelected([]);
setInputValue('');
resetActiveAndFocusedItem();
textInputRef?.current?.focus();
};

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
@@ -156,32 +206,22 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onClick={onInputClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="multi-typeahead-select-checkbox-input"
autoComplete="off"
innerRef={textInputRef}
placeholder={placeholder}
{...(activeItem && { 'aria-activedescendant': activeItem })}
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-multi-typeahead-checkbox-listbox"
/>
<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>
@@ -193,19 +233,22 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
id="multi-typeahead-checkbox-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 id="select-multi-typeahead-checkbox-listbox">
<SelectList isAriaMultiselectable id="select-multi-typeahead-checkbox-listbox">
{selectOptions.map((option, index) => (
<SelectOption
{...(!option.isDisabled && { hasCheckbox: true })}
{...(!option.isDisabled && !option.isAriaDisabled && { hasCheckbox: true })}
isSelected={selected.includes(option.value)}
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}
/>
Original file line number Diff line number Diff line change
@@ -30,10 +30,12 @@ export const SelectMultiTypeaheadCreatable: 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 [onCreation, setOnCreation] = React.useState<boolean>(false); // Boolean to refresh filter state after new option is created
const textInputRef = React.useRef<HTMLInputElement>();

const CREATE_NEW = 'create';

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

@@ -43,9 +45,9 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
String(menuItem.children).toLowerCase().includes(inputValue.toLowerCase())
);

// When no options are found after filtering, display creation option
if (!newSelectOptions.length) {
newSelectOptions = [{ isDisabled: false, children: `Create new option "${inputValue}"`, value: 'create' }];
// If no option matches the filter exactly, display creation option
if (!initialSelectOptions.some((option) => option.value === inputValue)) {
newSelectOptions = [...newSelectOptions, { children: `Create new option "${inputValue}"`, value: CREATE_NEW }];
}

// Open the menu when the input value changes and the new value is not empty
@@ -55,56 +57,125 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
}

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

const createItemId = (value: any) => `select-multi-create-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) {
if (value === CREATE_NEW) {
if (!initialSelectOptions.some((item) => item.value === inputValue)) {
initialSelectOptions = [...initialSelectOptions, { value: inputValue, children: inputValue }];
}
setSelected(
selected.includes(inputValue)
? selected.filter((selection) => selection !== inputValue)
: [...selected, inputValue]
);
setOnCreation(!onCreation);
resetActiveAndFocusedItem();
} else {
// 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) {
setIsOpen(true);
}

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 (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-create-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) {
setIsOpen((prevIsOpen) => !prevIsOpen);
} else if (isOpen && focusedItem.value !== 'no results') {
if (isOpen && focusedItem && !focusedItem.isAriaDisabled) {
onSelect(focusedItem.value as string);
}
break;
case 'Tab':
case 'Escape':
setIsOpen(false);
setActiveItem(null);

if (!isOpen) {
setIsOpen(true);
}

break;
case 'ArrowUp':
case 'ArrowDown':
@@ -116,35 +187,17 @@ export const SelectMultiTypeaheadCreatable: 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) => {
if (value) {
if (value === 'create') {
if (!initialSelectOptions.some((item) => item.value === inputValue)) {
initialSelectOptions = [...initialSelectOptions, { value: inputValue, children: inputValue }];
}
setSelected(
selected.includes(inputValue)
? selected.filter((selection) => selection !== inputValue)
: [...selected, inputValue]
);
setOnCreation(!onCreation);
} else {
// 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 getChildren = (value: string) => initialSelectOptions.find((option) => option.value === value)?.children;

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
@@ -158,14 +211,14 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onClick={onInputClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="multi-create-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-create-typeahead-listbox"
@@ -179,25 +232,15 @@ export const SelectMultiTypeaheadCreatable: 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>
@@ -208,17 +251,20 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
id="multi-create-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-create-typeahead-listbox">
{selectOptions.map((option, index) => (
<SelectOption
key={option.value || option.children}
isFocused={focusedItemIndex === index}
className={option.className}
id={`select-multi-create-typeahead-${option.value.replace(' ', '-')}`}
id={createItemId(option.value)}
{...option}
ref={null}
/>
146 changes: 82 additions & 64 deletions packages/react-core/src/components/Select/examples/SelectTypeahead.tsx
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ export const SelectTypeahead: React.FunctionComponent = () => {
const [filterValue, setFilterValue] = 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';
@@ -48,7 +48,6 @@ export const SelectTypeahead: React.FunctionComponent = () => {
newSelectOptions = [
{ 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
@@ -60,21 +59,17 @@ export const SelectTypeahead: React.FunctionComponent = () => {
setSelectOptions(newSelectOptions);
}, [filterValue]);

React.useEffect(() => {
if (isOpen && selectOptions.length && selectOptions[0].value !== NO_RESULTS) {
setActiveAndFocusedItem(0);
}
}, [isOpen, filterValue]);
const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`;

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

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

const closeMenu = () => {
@@ -90,69 +85,95 @@ export const SelectTypeahead: React.FunctionComponent = () => {
}
};

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

setInputValue(String(content));
setFilterValue('');
setSelected(String(value));

closeMenu();
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
if (value && value !== NO_RESULTS) {
setInputValue(value as string);
setFilterValue('');
setSelected(value as string);
const optionText = selectOptions.find((option) => option.value === value)?.children;
selectOption(value, optionText as string);
}
closeMenu();
};

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

resetActiveAndFocusedItem();

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

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

if (!isOpen) {
setIsOpen(true);
}

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 (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) {
indexToFocus = 0;
} 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) {
// Skip disabled options
while (selectOptions[indexToFocus].isDisabled) {
indexToFocus++;
if (indexToFocus === selectOptions.length) {
indexToFocus = 0;
} else {
indexToFocus = focusedItemIndex + 1;
}
}

setActiveAndFocusedItem(indexToFocus);
}

setActiveAndFocusedItem(indexToFocus);
};

const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const enabledMenuItems = selectOptions.filter((option) => !option.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.value !== NO_RESULTS) {
setInputValue(String(focusedItem.children));
setFilterValue('');
setSelected(String(focusedItem.children));
if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) {
selectOption(focusedItem.value, focusedItem.children as string);
}

setIsOpen((prevIsOpen) => !prevIsOpen);
resetActiveAndFocusedItem();
if (!isOpen) {
setIsOpen(true);
}

break;
case 'ArrowUp':
@@ -163,15 +184,25 @@ export const SelectTypeahead: React.FunctionComponent = () => {
}
};

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

const onClearButtonClick = () => {
setSelected('');
setInputValue('');
setFilterValue('');
resetActiveAndFocusedItem();
textInputRef?.current?.focus();
};

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
variant="typeahead"
aria-label="Typeahead menu toggle"
onClick={() => {
setIsOpen(!isOpen);
textInputRef?.current?.focus();
}}
onClick={onToggleClick}
isExpanded={isOpen}
isFullWidth
>
@@ -185,27 +216,16 @@ export const SelectTypeahead: React.FunctionComponent = () => {
autoComplete="off"
innerRef={textInputRef}
placeholder="Select a state"
{...(activeItem && { 'aria-activedescendant': activeItem })}
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-typeahead-listbox"
/>

<TextInputGroupUtilities>
{!!inputValue && (
<Button
variant="plain"
onClick={() => {
setSelected('');
setInputValue('');
setFilterValue('');
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
<TextInputGroupUtilities {...(!inputValue ? { style: { display: 'none' } } : {})}>
<Button variant="plain" onClick={onClearButtonClick} aria-label="Clear input value">
<TimesIcon aria-hidden />
</Button>
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
@@ -221,17 +241,15 @@ export const SelectTypeahead: React.FunctionComponent = () => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstMenuItemOnOpen={false}
shouldFocusFirstItemOnOpen={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(' ', '-')}`}
id={createItemId(option.value)}
{...option}
ref={null}
/>
Original file line number Diff line number Diff line change
@@ -29,10 +29,11 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
const [filterValue, setFilterValue] = 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 [onCreation, setOnCreation] = React.useState<boolean>(false); // Boolean to refresh filter state after new option is created
const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
const textInputRef = React.useRef<HTMLInputElement>();

const CREATE_NEW = 'create';

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

@@ -42,9 +43,9 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase())
);

// When no options are found after filtering, display creation option
if (!newSelectOptions.length) {
newSelectOptions = [{ isDisabled: false, children: `Create new option "${filterValue}"`, value: 'create' }];
// If no option matches the filter exactly, display creation option
if (!initialSelectOptions.some((option) => option.value === filterValue)) {
newSelectOptions = [...newSelectOptions, { children: `Create new option "${filterValue}"`, value: CREATE_NEW }];
}

// Open the menu when the input value changes and the new value is not empty
@@ -54,96 +55,133 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
}

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

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

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

const resetActiveAndFocusedItem = () => {
setFocusedItemIndex(null);
}, [filterValue, onCreation]);
setActiveItemId(null);
};

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

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
const onInputClick = () => {
if (!isOpen) {
setIsOpen(true);
} else if (!inputValue) {
closeMenu();
}
};

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

setInputValue(String(content));
setFilterValue('');
setSelected(String(value));

closeMenu();
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
if (value) {
if (value === 'create') {
if (!initialSelectOptions.some((item) => item.value === filterValue)) {
if (value === CREATE_NEW) {
if (!initialSelectOptions.some((item) => item.children === filterValue)) {
initialSelectOptions = [...initialSelectOptions, { value: filterValue, children: filterValue }];
}
setSelected(filterValue);
setOnCreation(!onCreation);
setFilterValue('');
closeMenu();
} else {
// eslint-disable-next-line no-console
console.log('selected', value);
setInputValue(value as string);
setFilterValue('');
setSelected(value as string);
const optionText = selectOptions.find((option) => option.value === value)?.children;
selectOption(value, optionText as string);
}
}

setIsOpen(false);
setFocusedItemIndex(null);
setActiveItem(null);
};

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

resetActiveAndFocusedItem();

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

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

if (!isOpen) {
setIsOpen(true);
}

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 (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) {
indexToFocus = 0;
} 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) {
// 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-create-typeahead-${focusedItem.value.replace(' ', '-')}`);
}

setActiveAndFocusedItem(indexToFocus);
};

const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const enabledMenuItems = selectOptions.filter((option) => !option.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) {
if (isOpen && focusedItem && !focusedItem.isAriaDisabled) {
onSelect(undefined, focusedItem.value as string);
setIsOpen((prevIsOpen) => !prevIsOpen);
setFocusedItemIndex(null);
setActiveItem(null);
}

setIsOpen((prevIsOpen) => !prevIsOpen);
setFocusedItemIndex(null);
setActiveItem(null);
if (!isOpen) {
setIsOpen(true);
}

break;
case 'Tab':
case 'Escape':
setIsOpen(false);
setActiveItem(null);
break;
case 'ArrowUp':
case 'ArrowDown':
@@ -153,6 +191,19 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
}
};

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

const onClearButtonClick = () => {
setSelected('');
setInputValue('');
setFilterValue('');
resetActiveAndFocusedItem();
textInputRef?.current?.focus();
};

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
@@ -165,34 +216,23 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onClick={onInputClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="create-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-create-typeahead-listbox"
/>

<TextInputGroupUtilities>
{!!inputValue && (
<Button
variant="plain"
onClick={() => {
setSelected('');
setInputValue('');
setFilterValue('');
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
<TextInputGroupUtilities {...(!inputValue ? { style: { display: 'none' } } : {})}>
<Button variant="plain" onClick={onClearButtonClick} aria-label="Clear input value">
<TimesIcon aria-hidden />
</Button>
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
@@ -204,19 +244,19 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
isOpen={isOpen}
selected={selected}
onSelect={onSelect}
onOpenChange={() => {
setIsOpen(false);
onOpenChange={(isOpen) => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
>
<SelectList id="select-create-typeahead-listbox">
{selectOptions.map((option, index) => (
<SelectOption
key={option.value || option.children}
isFocused={focusedItemIndex === index}
className={option.className}
onClick={() => setSelected(option.value)}
id={`select-typeahead-${option.value.replace(' ', '-')}`}
id={createItemId(option.value)}
{...option}
ref={null}
/>
Original file line number Diff line number Diff line change
@@ -288,7 +288,7 @@ export const SelectTypeaheadBase: React.FunctionComponent<SelectTypeaheadProps>
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstMenuItemOnOpen={false}
shouldFocusFirstItemOnOpen={false}
ref={innerRef}
{...props}
>

0 comments on commit ed62ec2

Please sign in to comment.