-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: moved autocomplete logic to its own hook
- v11.0.2
- v11.0.1
- v11.0.0
- v10.5.1
- v10.5.0
- v10.4.0
- v10.3.0
- v10.2.0
- v10.1.0
- v10.0.2
- v10.0.1
- v10.0.0
- v9.5.0
- v9.4.0
- v9.3.2
- v9.3.1
- v9.3.0
- v9.2.5
- v9.2.4
- v9.2.3
- v9.2.2
- v9.2.1
- v9.2.0
- v9.1.0
- v9.0.0
- v8.0.1
- v8.0.0
- v7.9.2
- v7.9.1
- v7.9.0
- v7.8.0
- v7.7.2
- v7.7.1
- v7.7.0
- v7.6.0
- v7.5.0
- v7.4.0
- v7.3.1
- v7.3.0
- v7.2.1
- v7.2.0
- v7.1.1
- v7.1.0
- v7.0.1
- v7.0.0
- v6.0.1
- v6.0.0
- v5.2.3
- v5.2.2
- v5.2.1
- v5.2.0
- v5.1.0
- v5.0.0
- v4.1.1
- v4.1.0
- v4.0.1
- v4.0.0
- v3.4.10
- v3.4.9
- v3.4.8
- v3.4.7
- v3.4.6
- v3.4.5
- v3.4.4
- v3.4.3
- v3.4.2
- v3.4.1
- v3.4.0
- v3.3.0
- v3.2.0
- v3.1.4
- v3.1.3
- v3.1.2
- v3.1.1
- v3.1.0
- v3.0.8
- v3.0.7
- v3.0.6
- v3.0.5
- v3.0.4
- v3.0.3
- v3.0.2
- v3.0.1
- v3.0.0
- v2.1.4
- v2.1.3
- v2.1.2
- v2.1.1
- v2.1.0
- v2.0.0
- v1.4.4
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.0
- v1.1.11
- v1.1.10
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
1 parent
ead8e6c
commit 9f11610
Showing
5 changed files
with
593 additions
and
327 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters