Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): Typeahead example #10207

Merged
merged 12 commits into from
May 8, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(SelectTypeaheadCreatable example): changes based on SelectTypeahead
adamviktora committed May 6, 2024
commit 87fbdece7e11ed8329f6d085c131783e24362665
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,10 +43,8 @@ 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' }];
}
// Display creation option
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
if (!isOpen) {
@@ -54,96 +53,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) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When selecting a creatable option for a single select, the new option is created + selected, but the menu stays open. I think I'd expect the menu to close after selection, similar to selecting an already existing option.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I know, it was intentional. I thought it might be better for user to see the option was actually created, so I kept the menu open. But I was hesitating on this one. I'll change that so it is consistent with selecting other options.

It is not that much important here, but for the templates, we might want to have this behavior customizable with props.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah gotcha! With that context if we did want to keep the menu open, it'd be worth adding some verbiage in the example description just to state that. Something like "Unlike other typeahead examples, this example keeps the select menu open after selecting a creatable option in order to show that the item was created." Bascially just so consumers don't think we're recommending that this be the behavior they implement or something (though if they need that behavior they of course can keep it).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, good idea. Anyway, I won't change that back anymore, the closing after creation seems more convenient.

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('');
resetActiveAndFocusedItem();
} 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 (selectOptions.every((option) => option.isDisabled)) {
return;
}

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 (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-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 +189,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 +214,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 +242,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}
/>