Skip to content

Commit

Permalink
Showing 5 changed files with 593 additions and 327 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changes

- Refactored autocomplete logic to export it in a hook

### Added

### Fixed
13 changes: 9 additions & 4 deletions src/components/AutocompleteInput/AutocompleteInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// also exported from '@storybook/react' if you can deal with breaking changes in 6.1
import { Meta, Story } from '@storybook/react/types-6-0';
import React from 'react';
import { AutocompleteInput } from '.';
import { AutocompleteInput, AutocompleteInputProps } from '.';
import { MenuIcon } from '../Icons';

export default {
@@ -20,7 +20,7 @@ export default {
*
* @param props the story props
*/
const Template: Story = (props) => (
const Template: Story<AutocompleteInputProps> = (props) => (
<AutocompleteInput
{...props}
id="storybook-autocomplete"
@@ -35,12 +35,17 @@ const Template: Story = (props) => (

export const Autocomplete = Template.bind({});

export const SingleSelect = Template.bind({});
SingleSelect.args = {
isSingleSelect: true
};

/**
* A story that displays an Autocomplete example
*
* @param props the story props
*/
const WithOptionsTemplate: Story = (props) => (
const WithButtonsTemplate: Story = (props) => (
<AutocompleteInput
{...props}
id="storybook-autocomplete"
@@ -62,4 +67,4 @@ const WithOptionsTemplate: Story = (props) => (
/>
);

export const WithOptions = WithOptionsTemplate.bind({});
export const WithButtons = WithButtonsTemplate.bind({});
499 changes: 499 additions & 0 deletions src/components/AutocompleteInput/hook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,499 @@
import classnames from 'classnames';
import { useCombobox, useMultipleSelection } from 'downshift';
import { useCallback, useEffect, useMemo, useState } from 'react';

export type AutocompleteItemType = {
id: string | number;
label: string;
};

export type AutocompleteSettings<T extends AutocompleteItemType = AutocompleteItemType> = {
/**
* The autocomplete input id
*/
id: string;
/**
* The options that should be displayed in the dropdown
*/
options?: T[];
/**
* The list of selected items to initialize the selected array
*/
initialSelectedItems?: T[];
/**
* Whether or not the user is allowed to only select a single item or multiple items
*
* @default false
*/
isSingleSelect?: boolean;
/**
* Whether or not should always show the result list regardless if the user is searching or not.
*
* @default false
*/
displayRegardlessIfSearching?: boolean;
/**
* Whether or not should always show the result list regardless if the input is focused or not
* (Only relevant when using TAB to focus the input)
*
* @default true
*/
displayRegardlessIfFocused?: boolean;
/**
* A callback that is called every time the user selects or unselects an item
*/
onItemToggle?: (item: T, selected: boolean) => void;
/**
* Modifies the items being displayed, for example, to filter or sort them. It takes items as argument and expects them back in return.
*/
transformItems?: (items: T[]) => T[];
/**
* The input defaultValue property
*/
defaultValue?: string;
/**
* The input value property
*/
value?: string;
/**
* Whether or not the options should be filtered based on user's input
*/
ignoreFilter?: boolean;
/**
* The input onChange callback
*/
onChange?: (value?: string) => void;
/**
* Keep selected items in dropdown list regardless.
*/
displaySelected?: boolean;
};

/**
* The logic wrapper for the autocomplete
*
* @param settings The hook parameters
* @param settings.id The autocomplete input id
* @param settings.options The options that should be displayed in the dropdown
* @param settings.initialSelectedItems The list of selected items to initialize the selected array
* @param settings.isSingleSelect Whether or not the user is allowed to only select a single item or multiple items
* @param settings.displayRegardlessIfSearching Whether or not should always show the result list regardless if the user is searching or not.
* @param settings.displayRegardlessIfFocused Whether or not should always show the result list regardless if the input is focused or not
* @param settings.onItemToggle A callback that is called every time the user selects or unselects an item
* @param settings.transformItems Modifies the items being displayed, for example, to filter or sort them. It takes items as argument and expects them back in return.
* @param settings.defaultValue The input defaultValue property
* @param settings.value The input value property
* @param settings.ignoreFilter Whether or not the options should be filtered based on user's input
* @param settings.onChange The input onChange callback
* @param settings.displaySelected Keep selected items in dropdown list regardless.
*/
export const useAutocompleteInput = <T extends AutocompleteItemType = AutocompleteItemType>({
id,
options = [],
initialSelectedItems: selected,
isSingleSelect,
displayRegardlessIfSearching,
displayRegardlessIfFocused,
onItemToggle,
transformItems,
defaultValue = '',
value,
ignoreFilter,
onChange,
displaySelected
}: AutocompleteSettings<T>) => {
const [inputValue, setInputValue] = useState<string>(`${defaultValue || ''}`);

// Call the onChange if inputValue changes
useEffect(() => {
if (onChange) onChange(inputValue);
}, [inputValue, onChange]);

// Update the input value if the provided value changes
useEffect(() => {
if (value) setInputValue(value);
}, [value]);

// We disabled the memo exhaustive deps because we don't want this to be updated
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialSelectedItems = useMemo(() => selected || [], []);

// Makes a function that will be used to compare if two items are equal
const compareFunction = useCallback(
(resultItem: T, selectedItem?: T | null) => resultItem.id === selectedItem?.id,
[]
);

// Get the multiple selection helpers from downshift
// Ref: https://github.com/downshift-js/downshift/tree/master/src/hooks/useMultipleSelection
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
setSelectedItems
} = useMultipleSelection<T>({
initialSelectedItems,
/**
* Returns a string representation for an item
*
* @param item The item object
*/
itemToString: (item) => `${item.id}`,
/**
* Overrides the default reducer for the multiple selection
*
* @param _state The current reducer state
* @param action The current reducer dispatched action
* @param action.type The current reducer dispatched action type
* @param action.changes The current reducer changes up until now
* @param action.selectedItem The current selected item
* @param action.activeIndex The current active index
*/
stateReducer: (_state, { type, changes, selectedItem, activeIndex }) => {
switch (type) {
// Overrides the remove event. Because the original event uses a simple comparation.
// And that doesn't work, because we're working with objects and not strings. So we use our own compare function
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
const selectedItemsList: T[] = selectedItems || [];

// Find if the object is selected or not with our own compare function
// Check if the item we just selected is already selected or not
const selectedItemIndex = selectedItemsList.findIndex((x) => compareFunction(x, selectedItem));

// Same code as original reducer from here on
// Ref: https://github.com/downshift-js/downshift/blob/7972eb064352023fa9fafb69f85cdfa6477dd1a3/src/hooks/useMultipleSelection/reducer.js#L70
let newActiveIndex = activeIndex;

if (selectedItemsList.length === 1) {
newActiveIndex = -1;
} else if (selectedItemIndex === selectedItemsList.length - 1) {
newActiveIndex = selectedItemsList.length - 2;
}

return {
...changes,
selectedItems: [
...selectedItemsList.slice(0, selectedItemIndex),
...selectedItemsList.slice(selectedItemIndex + 1)
],
...{ activeIndex: newActiveIndex }
};
default:
return changes; // otherwise business as usual.
}
}
});

/**
* A method used to filter the items based on the user's input
*
* @param items The current list of items
*/
const getFilteredItems = (items: T[]) => {
// If we don't want to ignore the filter
if (!ignoreFilter) {
// Then filter

// If we shouldn't display the selected items in the dropdown result list
if (!displaySelected) {
// Return a filter function that also filters the selected items
return items.filter(
(item) =>
selectedItems.indexOf(item) < 0 && item.label.toLowerCase().includes((inputValue || '').toLowerCase())
);
} else {
// Return a filter function that does not filter the selected items, thus, keeping them
return items.filter((item) => item.label.toLowerCase().includes((inputValue || '').toLowerCase()));
}
} else {
// Don't filter
return items;
}
};

// Get filtered items, but before, transform them
const filteredOptions = getFilteredItems(transformItems ? transformItems(options) : options);

useEffect(() => {
if (selected) {
// Update the state of the selected list state
setSelectedItems(selected);
}
}, [selected, setSelectedItems]); // If the selected list outside of the component changes

const toggleSelectedItem = useCallback(
(itemToToggle: T) => {
// Check if the item exists
const index = selectedItems.findIndex((x) => compareFunction(x, itemToToggle));

if (index === -1) {
// If the item exists
if (onItemToggle) {
// Call the callback
onItemToggle(itemToToggle, true);
}

// If we should select only one. Remove the current if any.
if (isSingleSelect && selectedItems && selectedItems.length > 0) {
removeSelectedItem(selectedItems[0]);
}

// Add to the selected list
addSelectedItem(itemToToggle);
} else {
// If the item doesn't exist
if (onItemToggle) {
// Call the callbaxk
onItemToggle(itemToToggle, false);
}

// Remove from the list
removeSelectedItem(itemToToggle);
}
},
[addSelectedItem, isSingleSelect, onItemToggle, removeSelectedItem, selectedItems, compareFunction]
);

// Get the combobox selection helpers from downshift
const {
isOpen,
// getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
highlightedIndex,
getItemProps,
selectItem,
openMenu
} = useCombobox<T>({
/**
* Returns a string representation for an item
*
* @param item The item object
*/
itemToString: (item) => `${item?.id}`,
// If we should display regardless, we always give true, else, give undefined so the hook will control the state of isOpen
isOpen: displayRegardlessIfSearching !== undefined && displayRegardlessIfSearching === true ? true : undefined,
// The value of the input is the current refinement of the search
inputValue,
// The items list are the options provided by algolia
items: filteredOptions,
/**
* The callback for when something is selected
*
* @param changes The downshift combobox changes state
* @param changes.inputValue The current value of the html input
* @param changes.type The current change event type
* @param changes.selectedItem The item that was just selected, if any
*/
onStateChange: ({ inputValue, type, selectedItem }) => {
// If any kind of state within the input has changed
switch (type) {
// If the user is typing = InputChange
case useCombobox.stateChangeTypes.InputChange:
// Update the state
setInputValue(inputValue || '');
break;
// If the user has selected an item
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputBlur:
// If there's any selection
if (selectedItem) {
// Actually select the item, adding it to the array
toggleSelectedItem(selectedItem);

// Clear the input text
setInputValue('');

// Clear the pending selected item
selectItem((null as unknown) as T);
}
break;
default:
break;
}
},
/**
* The callback for when the input is updated
*
* @param changes The downshift combobox changes state
* @param changes.inputValue The current value of the html input
*/
onInputValueChange: ({ inputValue }) => {
if (onChange) onChange(inputValue?.trim() === 'undefined' ? '' : inputValue || '');
}
});

const getRootProps = useCallback(
(opts?: { className?: string }) => ({
className: classnames('autocomplete-root', opts?.className)
}),
[]
);

const getInputContainerProps = useCallback(
(opts?: { className?: string }) => ({
className: classnames('autocomplete-input-container', opts?.className)
}),
[]
);

const getSelectedListProps = useCallback(
(opts?: { className?: string }) => ({
className: classnames('autocomplete-selected-list', opts?.className)
}),
[]
);

return {
isOpen,
filteredOptions,
selectedItems,
getRootProps,
/**
* Get the required props for the label
*
* @param opts The method options
* @param opts.className The classname that should be appended
*/
getLabelProps: ({ className }: { className?: string }) => getLabelProps({ id, className }),
/**
* Get the required props for the combobox container
*
* @param opts The method options
* @param opts.className The classname that should be appended
*/
getComboboxProps: ({ className }: { className?: string }) =>
getComboboxProps({
className: classnames('autocomplete-combo', { open: isOpen }, className)
}),
getInputContainerProps,
/**
* Get the required props for the input
*
* @param opts The method options
* @param opts.className The classname that should be appended
*/
getInputProps: ({ className }: { className?: string }) =>
getInputProps(
getDropdownProps({
id,
className: classnames(
'autocomplete-input',
{
open: isOpen
},
className
),
/**
* The handler for when a key is pressed in the input
*
* @param event the input event of the button press
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onKeyDown: (event: any) => {
// If the user pressed these buttons, let's override the default downshift behavior
if (event.key === 'Home' || event.key === 'End' || event.key === 'Backspace') {
// Prevent the downshift event and use the default input event
// We do this or else "home" and "end" would be used to navigate the results instead of the input
event.nativeEvent.preventDownshiftDefault = true;
}
},
/**
* The handler for when the input gets focused
*/
onFocus: () => {
// If the menu is still not open, and we should not always display it
if (!isOpen && !displayRegardlessIfFocused) {
// Display the dropdown when focusing the input,
// Which shows the first results
openMenu();
}
},
/**
* The handler for when the user clicks in the input
*/
onClick: () => {
if (!isOpen) {
openMenu();
}
}
})
),
/**
* Get the required props for the result list container
*
* @param opts The method options
* @param opts.className The classname that should be appended
*/
getMenuProps: ({ className }: { className?: string }) =>
getMenuProps({
className: classnames(
'autocomplete-result-list',
{
open: isOpen
},
className
),
style: {
// Don't show when closed
display: isOpen ? 'block' : 'none'
}
}),
/**
* Get the required props for an item
*
* @param opts The method options
* @param opts.item the current item
* @param opts.index the item's index
* @param opts.className the classname that should be appended
*/
getItemProps: ({ item, index, className }: { item: T; index: number; className?: string }) => {
// Get if this item is selected or not to use for ui
const isSelected = selectedItems.findIndex((x) => x.id === item.id) > -1;
return {
...getItemProps({
item,
index,
className: classnames(
'autocomplete-result-item',
{
selected: isSelected,
highlighted: highlightedIndex === index
},
className
)
}),
isSelected
};
},
getSelectedListProps,
/**
* Get the required props for a selected item
*
* @param opts The method options
* @param opts.selectedItem the current selected item
* @param opts.index the item's index
* @param opts.className the classname that should be appended
*/
getSelectedItemProps: ({
selectedItem,
index,
className
}: {
selectedItem: T;
index: number;
className?: string;
}) =>
getSelectedItemProps({
className: classnames('autocomplete-selected-item', className),
selectedItem,
index
}),
toggleSelectedItem
};
};
400 changes: 77 additions & 323 deletions src/components/AutocompleteInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,29 @@
import React, { useEffect, useMemo, useCallback, useState } from 'react';
import { useCombobox, useMultipleSelection } from 'downshift';
import { AttentionIcon, CrossIcon } from '../Icons';
import classnames from 'classnames';
import React from 'react';
import { AttentionIcon, CrossIcon } from '../Icons';
import { AutocompleteItemType, AutocompleteSettings, useAutocompleteInput } from './hook';

type ItemType = {
id: string | number;
label: string;
};

type AutocompleteInputProps<T extends ItemType> = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
/**
* The options that should be displayed in the dropdown
*/
options?: T[];
/**
* The list of selected items to initialize the selected array
*/
initialSelectedItems?: T[];
/**
* Whether or not the user is allowed to only select a single item or multiple items
*
* @default false
*/
isSingleSelect?: boolean;
/**
* Whether or not should always show the result list regardless if the user is searching or not.
*
* @default false
*/
displayRegardlessIfSearching?: boolean;
/**
* Whether or not should always show the result list regardless if the input is focused or not
*
* @default true
*/
displayRegardlessIfFocused?: boolean;
/**
* A callback that is called every time the user selects or unselects an item
*/
onItemToggle?: (item: T, selected: boolean) => void;
/**
* The text that should be placed in the input's label
*/
label?: string;
/**
* A translation object to override existing translations
*/
translations?: {
explanation?: string;
blankState?: string;
export type AutocompleteInputProps<T extends AutocompleteItemType = AutocompleteItemType> = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange'
> &
AutocompleteSettings<T> & {
/**
* The text that should be placed in the input's label
*/
label?: string;
/**
* A translation object to override existing translations
*/
translations?: {
explanation?: string;
blankState?: string;
};
/**
* The render function used to render the option buttons
*/
renderButtons?: (item: T, isSelected: boolean) => React.ReactNode;
};
/**
* The render function used to render the option buttons
*/
renderButtons?: (item: T, isSelected: boolean) => React.ReactNode;
};

/**
* An autocomplete input
@@ -66,292 +35,77 @@ type AutocompleteInputProps<T extends ItemType> = Omit<React.InputHTMLAttributes
* @param props.displayRegardlessIfSearching Whether or not should always show the result list regardless if the user is searching or not.
* @param props.displayRegardlessIfFocused Whether or not should always show the result list regardless if the input is focused or not
* @param props.onItemToggle A callback that is called every time the user selects or unselects an item
* @param props.value The input value property
* @param props.transformItems Modifies the items being displayed, for example, to filter or sort them. It takes items as argument and expects them back in return.
* @param props.defaultValue The input defaultValue property
* @param props.value The input value property
* @param props.ignoreFilter Whether or not the options should be filtered based on user's input
* @param props.onChange The input onChange callback
* @param props.label The text that should be placed in the input's label
* @param props.translations A translation object to override existing translations
* @param props.renderButtons The render function used to render the option buttons
* @param props.displaySelected Keep selected items in dropdown list regardless.
*/
export const AutocompleteInput = <T extends ItemType>({
export const AutocompleteInput = <T extends AutocompleteItemType = AutocompleteItemType>({
// Hook settings
options = [],
initialSelectedItems: selected,
initialSelectedItems,
isSingleSelect,
displayRegardlessIfSearching,
displayRegardlessIfFocused,
onItemToggle,
transformItems,
defaultValue = '',
value,
ignoreFilter,
onChange,
displaySelected,
// Component props
label,
translations,
renderButtons,
// Input props
...inputProps
}: AutocompleteInputProps<T>) => {
const [inputValue, setInputValue] = useState<string>(`${defaultValue || ''}`);

// We disabled the memo exhaustive deps because we don't want this to be updated
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialSelectedItems = useMemo(() => selected || [], []);

// Makes a function that will be used to compare if two items are equal
const compareFunction = useCallback(
(resultItem: T, selectedItem?: T | null) => resultItem.id === selectedItem?.id,
[]
);

// Get the multiple selection helpers from downshift
// Ref: https://github.com/downshift-js/downshift/tree/master/src/hooks/useMultipleSelection
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
setSelectedItems
} = useMultipleSelection<T>({
initialSelectedItems,
/**
* Returns a string representation for an item
*
* @param item The item object
*/
itemToString: (item) => `${item.id}`,
/**
* Overrides the default reducer for the multiple selection
*
* @param _state The current reducer state
* @param action The current reducer dispatched action
* @param action.type The current reducer dispatched action type
* @param action.changes The current reducer changes up until now
* @param action.selectedItem The current selected item
* @param action.activeIndex The current active index
*/
stateReducer: (_state, { type, changes, selectedItem, activeIndex }) => {
switch (type) {
// Overrides the remove event. Because the original event uses a simple comparation.
// And that doesn't work, because we're working with objects and not strings. So we use our own compare function
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
const selectedItemsList: T[] = selectedItems || [];

// Find if the object is selected or not with our own compare function
// Check if the item we just selected is already selected or not
const selectedItemIndex = selectedItemsList.findIndex((x) => compareFunction(x, selectedItem));

// Same code as original reducer from here on
// Ref: https://github.com/downshift-js/downshift/blob/7972eb064352023fa9fafb69f85cdfa6477dd1a3/src/hooks/useMultipleSelection/reducer.js#L70
let newActiveIndex = activeIndex;

if (selectedItemsList.length === 1) {
newActiveIndex = -1;
} else if (selectedItemIndex === selectedItemsList.length - 1) {
newActiveIndex = selectedItemsList.length - 2;
}

return {
...changes,
selectedItems: [
...selectedItemsList.slice(0, selectedItemIndex),
...selectedItemsList.slice(selectedItemIndex + 1)
],
...{ activeIndex: newActiveIndex }
};
default:
return changes; // otherwise business as usual.
}
}
});

/**
* A method used to filter the items based on the user's input
*
* @param items The current list of items
*/
const getFilteredItems = useCallback(
(items: T[]) =>
items.filter(
(item) => selectedItems.indexOf(item) < 0 && item.label.toLowerCase().includes((inputValue || '').toLowerCase())
),
[inputValue, selectedItems]
);

const filteredOptions = useMemo(() => getFilteredItems(options), [getFilteredItems, options]);

useEffect(() => {
if (selected) {
// Update the state of the selected list state
setSelectedItems(selected);
}
}, [selected, setSelectedItems]); // If the selected list outside of the component changes

const toggleSelectedItem = useCallback(
(itemToToggle: T) => {
// Check if the item exists
const index = selectedItems.findIndex((x) => compareFunction(x, itemToToggle));

if (index === -1) {
// If the item exists
if (onItemToggle) {
// Call the callback
onItemToggle(itemToToggle, true);
}

// If we should select only one. Remove the current if any.
if (isSingleSelect && selectedItems && selectedItems.length > 0) {
removeSelectedItem(selectedItems[0]);
}

// Add to the selected list
addSelectedItem(itemToToggle);
} else {
// If the item doesn't exist
if (onItemToggle) {
// Call the callbaxk
onItemToggle(itemToToggle, false);
}

// Remove from the list
removeSelectedItem(itemToToggle);
}
},
[addSelectedItem, isSingleSelect, onItemToggle, removeSelectedItem, selectedItems, compareFunction]
);

// Get the combobox selection helpers from downshift
const {
isOpen,
// getToggleButtonProps,
filteredOptions,
selectedItems,
getRootProps,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
highlightedIndex,
getInputProps,
getMenuProps,
getItemProps,
selectItem,
openMenu
} = useCombobox<T>({
/**
* Returns a string representation for an item
*
* @param item The item object
*/
itemToString: (item) => `${item?.id}`,
// If we should display regardless, we always give true, else, give undefined so the hook will control the state of isOpen
isOpen: displayRegardlessIfSearching !== undefined && displayRegardlessIfSearching === true ? true : undefined,
// The value of the input is the current refinement of the search
inputValue,
// The items list are the options provided by algolia
items: filteredOptions,
/**
* The callback for when something is selected
*
* @param changes The downshift combobox changes state
* @param changes.inputValue The current value of the html input
* @param changes.type The current change event type
* @param changes.selectedItem The item that was just selected, if any
*/
onStateChange: ({ inputValue, type, selectedItem }) => {
// If any kind of state within the input has changed
switch (type) {
// If the user is typing = InputChange
case useCombobox.stateChangeTypes.InputChange:
// Update the state
setInputValue(inputValue || '');
break;
// If the user has selected an item
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputBlur:
// If there's any selection
if (selectedItem) {
// Actually select the item, adding it to the array
toggleSelectedItem(selectedItem);

// Clear the input text
setInputValue('');

// Clear the pending selected item
selectItem((null as unknown) as T);
}
break;
default:
break;
}
}
/**
* The callback for when the input is updated
*
* @param changes The downshift combobox changes state
* @param changes.inputValue The current value of the html input
*/
// onInputValueChange: ({ inputValue }) => {
//
// setInputValue(inputValue || '');
// }
getSelectedListProps,
getSelectedItemProps,
toggleSelectedItem
} = useAutocompleteInput({
id: inputProps?.id,
options,
initialSelectedItems,
isSingleSelect,
displayRegardlessIfSearching,
displayRegardlessIfFocused,
onItemToggle,
transformItems,
defaultValue,
value,
ignoreFilter,
onChange,
displaySelected
});

return (
<div className="autocomplete-root">
{label && (
<label className="pui-label-input" {...getLabelProps({ id: inputProps.id })}>
{label}
</label>
)}
<div {...getRootProps()}>
{label && <label {...getLabelProps({ className: 'pui-label-input' })}>{label}</label>}
{/* Render the combo container for the input and the results */}
<div
className={classnames('autocomplete-combo pui-dropdown-input-container', { open: isOpen })}
// The parent must be relative so the child(result list) position absolute works
{...getComboboxProps()}
// The parent must be relative so the child(result list/menu) position absolute works
{...getComboboxProps({ className: 'pui-dropdown-input-container' })}
>
<input
className="autocomplete-input pui-text-input"
{...inputProps}
{...getInputProps(
getDropdownProps({
id: inputProps.id,
/**
* The handler for when a key is pressed in the input
*
* @param event the input event of the button press
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onKeyDown: (event: any) => {
// If the user pressed these buttons, let's override the default downshift behavior
if (event.key === 'Home' || event.key === 'End' || event.key === 'Backspace') {
// Prevent the downshift event and use the default input event
// We do this or else "home" and "end" would be used to navigate the results instead of the input
event.nativeEvent.preventDownshiftDefault = true;
}
},
/**
* The handler for when the input gets focused
*/
onFocus: () => {
// If the menu is still not open, and we should not always display it
if (!isOpen && !displayRegardlessIfFocused) {
// Display the dropdown when focusing the input,
// Which shows the first results
openMenu();
}
},
/**
* The handler for when the user clicks in the input
*/
onClick: () => {
if (!isOpen) {
openMenu();
}
}
})
)}
/>
<input {...inputProps} {...getInputProps({ className: classnames('pui-text-input', inputProps?.className) })} />
{/* The container for the results must be in the DOM at all times for accessibility reasons */}
<ul
className="pui-dropdown-input-options"
{...getMenuProps({
style: {
// Don't show when closed
display: isOpen ? 'block' : 'none'
}
})}
>
<ul {...getMenuProps({ className: 'pui-dropdown-input-options' })}>
{/* Only render this if open */}
{isOpen && (
<>
@@ -363,16 +117,13 @@ export const AutocompleteInput = <T extends ItemType>({
filteredOptions && filteredOptions.length > 0 ? (
// Display the results
filteredOptions.map((item, index) => {
// Get if this item is selected or not to use for ui
const isSelected = selectedItems.findIndex((x) => x.id === item.id) > -1;

// Get the complete classes
const className = classnames(
'autocomplete-result-item pui-dropdown-input-item flex items-center justify-between',
{ selected: isSelected, highlighted: highlightedIndex === index }
);
// Get the required props passed by downshift
const { onMouseMove, ...optionProps } = getItemProps({ item, index });
const { onMouseMove, className, isSelected, ...optionProps } = getItemProps({
item,
index,
className: 'pui-dropdown-input-item flex items-center'
});

// If it wants to render the buttons
return renderButtons ? (
@@ -418,13 +169,16 @@ export const AutocompleteInput = <T extends ItemType>({
// If there's at least one selected item
selectedItems && selectedItems.length > 0 && (
// Render the selected items container
<ul className="autocomplete-selected-list pui-dropdown-input-selected-options">
<ul {...getSelectedListProps({ className: 'pui-dropdown-input-selected-options' })}>
{/* For each selected item */}
{selectedItems.map((selectedItem, index) => (
<li
key={`selected-${selectedItem.id}`}
className="autocomplete-selected-item pui-dropdown-input-selected-item-base pui-animate-scaleHover focus:outline-none"
{...getSelectedItemProps({ selectedItem, index })}
{...getSelectedItemProps({
selectedItem,
index,
className: 'pui-dropdown-input-selected-item-base pui-animate-scaleHover focus:outline-none'
})}
>
<button
type="button"
6 changes: 6 additions & 0 deletions src/components/DropdownInput/index.css
Original file line number Diff line number Diff line change
@@ -93,6 +93,12 @@
/* Highlights the background */
@apply text-pui-secondary font-semibold bg-gray-100;
}

&.selected {
/* Changes the text color */
/* Changes the font */
@apply text-pui-primary font-semibold;
}
}

/* The display of selected options */

0 comments on commit 9f11610

Please sign in to comment.