Skip to content

Commit

Permalink
core: refacto combo box
Browse files Browse the repository at this point in the history
Here are the main changes:
- the combo box takes now a value of type T and not a string anymore
- the combo box has a default behavior (filters on the labels) but a custom behaviour can be used
- the hook useOutsideClick replaces the previous handleParentDivBlur function
- a new story has been added to test the custom behavior

Signed-off-by: Clara Ni <[email protected]>
  • Loading branch information
clarani committed Jan 8, 2025
1 parent fed5e49 commit 3e679f7
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 178 deletions.
229 changes: 86 additions & 143 deletions ui-core/src/components/inputs/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,157 +7,100 @@ import React, {
useMemo,
useRef,
useState,
useCallback,
} from 'react';

import { ChevronDown, XCircle } from '@osrd-project/ui-icons';

Check warning on line 13 in ui-core/src/components/inputs/ComboBox/ComboBox.tsx

View workflow job for this annotation

GitHub Actions / build

Unable to resolve path to module '@osrd-project/ui-icons'

Check warning on line 13 in ui-core/src/components/inputs/ComboBox/ComboBox.tsx

View workflow job for this annotation

GitHub Actions / build

Unable to resolve path to module '@osrd-project/ui-icons'
import cx from 'classnames';

import { normalizeString } from './utils';
import useOutsideClick from '../../../hooks/useOutsideClick';
import Input, { type InputProps } from '../Input';

export type ComboBoxProps<T> = InputProps & {
export type ComboBoxProps<T> = Omit<InputProps, 'value'> & {
value?: T;
suggestions: Array<T>;
getSuggestionLabel: (option: T) => string;
customLabel?: ReactNode;
numberOfSuggestionsToShow?: number;
exactSearch?: boolean;
value?: string;
onSelectSuggestion?: (option: T | undefined) => void;
disableDefaultFilter?: boolean;
getSuggestionLabel: (option: T) => string;
onSelectSuggestion: (option: T | undefined) => void;
resetSuggestions: () => void;
};

/**
* ComboBox component
*
* You can use the hook useDefaultComboBox to get the default behavior.
* See the stories.
*/
const ComboBox = <T,>({
suggestions,
onChange,
getSuggestionLabel,
customLabel,
numberOfSuggestionsToShow = 5,
exactSearch = false,
value = '',
value,
small,
getSuggestionLabel,
onChange,
onSelectSuggestion,
disableDefaultFilter = false,
resetSuggestions,
...inputProps
}: ComboBoxProps<T>) => {
const [filteredSuggestions, setFilteredSuggestions] = useState<T[]>([]);
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
const [inputValue, setInputValue] = useState(value);
const [selectedOption, setSelectedOption] = useState<T | null>(null);
const [inputValue, setInputValue] = useState('');
const [isInputFocused, setIsInputFocused] = useState(false);
const suggestionRefs = useRef<(HTMLLIElement | null)[]>([]);

const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLInputElement>(null);

const sortedSuggestions = useMemo(
() =>
!disableDefaultFilter
? [...suggestions].sort((a, b) =>
getSuggestionLabel(a).localeCompare(getSuggestionLabel(b))
)
: suggestions,
[suggestions, getSuggestionLabel, disableDefaultFilter]
);

const showSuggestions = isInputFocused && filteredSuggestions.length > 0 && !inputProps.disabled;

const focusInput = () => inputRef.current?.focus();

const clearInput = () => {
setInputValue('');
// Immediately clear the input's value in the DOM to prevent inconsistencies with handleInputFocus
if (inputRef.current) {
inputRef.current.value = '';
}
setSelectedOption(null);
onSelectSuggestion?.(undefined);
const syntheticEvent = {
target: {
value: '',
},
currentTarget: {
value: '',
},
} as React.ChangeEvent<HTMLInputElement>;
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, [inputRef]);

// Call the parent's onChange handler with the synthetic event
onChange?.(syntheticEvent);
focusInput();
const removeFocus = () => {
setIsInputFocused(false);
setActiveSuggestionIndex(-1);
setTimeout(() => {
inputRef.current?.blur();
}, 0);
resetSuggestions();
};

const icons = [
...(selectedOption || suggestions.some((suggestion) => getSuggestionLabel(suggestion) === value)
? [
{
icon: <XCircle variant="fill" />,
action: clearInput,
className: 'clear-icon',
},
]
: []), // Conditionally include the clear icon only when inputValue is not empty
...(sortedSuggestions.length > 0
? [
{
icon: <ChevronDown size={small ? 'sm' : 'lg'} />,
action: focusInput,
className: cx('chevron-icon', {
disabled: inputProps.disabled,
}),
},
]
: []), // Conditionally include the chevron icon only when suggestions are not empty
];

/* eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
if (value) {
setInputValue(value);
setInputValue(getSuggestionLabel(value));
} else {
setInputValue('');
setSelectedOption(null);
}
}, [value]);

useEffect(() => {
setFilteredSuggestions(sortedSuggestions);
}, [sortedSuggestions]);
const showSuggestions = useMemo(
() => isInputFocused && suggestions.length > 0 && !inputProps.disabled,
[isInputFocused, suggestions.length, inputProps.disabled]
);

// behavior
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const userInput = normalizeString(e.currentTarget.value).trim();
setInputValue(e.currentTarget.value);
onChange?.(e);

if (userInput.trim() === '') {
setFilteredSuggestions([]);
setSelectedOption(null);
return;
}

const filtered = sortedSuggestions.filter((suggestion) => {
const suggestionLabel = normalizeString(getSuggestionLabel(suggestion).toLowerCase());
return exactSearch
? suggestionLabel.startsWith(userInput.toLowerCase())
: suggestionLabel.includes(userInput.toLowerCase());
});
setFilteredSuggestions(filtered);
setInputValue(e.currentTarget.value);
};

const selectSuggestion = (index: number) => {
const selectedSuggestion = filteredSuggestions[index];
const suggestionLabel = getSuggestionLabel(selectedSuggestion);
setInputValue(suggestionLabel);
setSelectedOption(selectedSuggestion);
onSelectSuggestion?.(selectedSuggestion);
setFilteredSuggestions([]);
setActiveSuggestionIndex(-1);
setTimeout(() => {
inputRef.current?.blur();
}, 0);
const selectedSuggestion = suggestions.at(index)!;
onSelectSuggestion(selectedSuggestion);
setInputValue(getSuggestionLabel(selectedSuggestion));
removeFocus();
};

const closeSuggestions = () => {
setInputValue(value ? getSuggestionLabel(value) : '');
removeFocus();
};

const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'ArrowDown') {
setActiveSuggestionIndex((prev) => {
const newIndex = prev < filteredSuggestions.length - 1 ? prev + 1 : prev;
const newIndex = prev < suggestions.length - 1 ? prev + 1 : prev;
if (suggestionRefs.current[newIndex]) {
(suggestionRefs.current[newIndex] as HTMLLIElement).scrollIntoView({
block: 'nearest',
Expand All @@ -178,57 +121,53 @@ const ComboBox = <T,>({
} else if ((e.key === 'Enter' || e.key === 'Tab') && activeSuggestionIndex >= 0) {
selectSuggestion(activeSuggestionIndex);
} else if (e.key === 'Escape') {
setFilteredSuggestions([]);
closeSuggestions();
}
};

const handleInputFocus: FocusEventHandler<HTMLInputElement> = (e) => {
e.stopPropagation();
setIsInputFocused(true);

const normalizedInput = normalizeString(e.currentTarget.value.trim().toLowerCase());
if (normalizedInput) {
const filtered = sortedSuggestions.filter((suggestion) => {
const suggestionLabel = normalizeString(getSuggestionLabel(suggestion).toLowerCase());
return exactSearch
? suggestionLabel.startsWith(normalizedInput)
: suggestionLabel.includes(normalizedInput);
});
setFilteredSuggestions(filtered);
} else {
setFilteredSuggestions(sortedSuggestions);
}
};

const handleParentDivOnBlur: FocusEventHandler<HTMLInputElement> = () => {
setIsInputFocused(false);
const normalizedInput = normalizeString(inputValue.trim().toLowerCase());

const isInputInSuggestions = suggestions.some(
(suggestion) =>
normalizeString(getSuggestionLabel(suggestion).toLowerCase()) === normalizedInput
);

if (filteredSuggestions.length === 1) {
selectSuggestion(0);
} else if (!isInputInSuggestions && selectedOption) {
setInputValue(getSuggestionLabel(selectedOption));
} else if (!isInputInSuggestions) {
setInputValue('');
setSelectedOption(null);
}

setFilteredSuggestions([]);
const clearInput = () => {
setInputValue('');
onSelectSuggestion(undefined);
resetSuggestions();
focusInput();
};

const handleSuggestionClick = (index: number) => {
selectSuggestion(index);
};
useOutsideClick(wrapperRef, closeSuggestions);

const inputIcons = useMemo(
() => [
// Conditionally include the clear icon only when input is not empty
...(value
? [
{
icon: <XCircle variant="fill" />,
action: clearInput,
className: 'clear-icon',
},
]
: []),
// chevron of the select
{
icon: <ChevronDown size={small ? 'sm' : 'lg'} />,
action: focusInput,
className: cx('chevron-icon', {
disabled: inputProps.disabled,
}),
},
],
[inputValue]
);

return (
<div
className="combo-box"
style={{ '--number-of-suggestions': numberOfSuggestionsToShow } as React.CSSProperties}
onBlur={handleParentDivOnBlur}
ref={wrapperRef}
>
{customLabel && <label htmlFor={inputProps.id}>{customLabel}</label>}
<Input
Expand All @@ -238,21 +177,25 @@ const ComboBox = <T,>({
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
withIcons={icons}
withIcons={inputIcons}
small={small}
/>
{showSuggestions && (
<ul className="suggestions-list">
{filteredSuggestions.map((suggestion, index) => (
{suggestions.map((suggestion, index) => (
<li
ref={(el) => (suggestionRefs.current[index] = el)}
key={`${getSuggestionLabel(suggestion)}-${index}`}
className={cx('suggestion-item', {
active: index === activeSuggestionIndex,
selected: value === suggestion,
small,
})}
onClick={() => handleSuggestionClick(index)}
onClick={() => selectSuggestion(index)}
onMouseDown={(e) => e.preventDefault()} // Prevents the div parent (.combo-box) from losing focus
onMouseEnter={() => {
setActiveSuggestionIndex(index);
}}
>
{getSuggestionLabel(suggestion)}
</li>
Expand Down
4 changes: 3 additions & 1 deletion ui-core/src/components/inputs/ComboBox/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { default as ComboBox, type ComboBoxProps } from './ComboBox';
export { default, type ComboBoxProps } from './ComboBox';

export { default as useDefaultComboBox } from './useDefaultComboBox';
56 changes: 56 additions & 0 deletions ui-core/src/components/inputs/ComboBox/useDefaultComboBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMemo, useState } from 'react';

const normalizeString = (str: string) => str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');

const defaultFilterSuggestions = <T>(
getSuggestionLabel: (suggestion: T) => string,
suggestions: T[],
query: string
) => {
const input = normalizeString(query).trim().toLowerCase();
if (!input) {
return suggestions;
}

const getSuggestionScore = (suggestion: T) => {
const suggestionLabel = normalizeString(getSuggestionLabel(suggestion).toLowerCase());
if (suggestionLabel.startsWith(input)) {
return 2;
}
if (suggestionLabel.includes(input)) {
return 1;
}
return 0;
};

return suggestions
.map((suggestion) => ({
suggestion,
score: getSuggestionScore(suggestion),
}))
.filter(({ score }) => score > 0)
.sort(({ score: scoreA }, { score: scoreB }) => scoreB - scoreA)
.map(({ suggestion }) => suggestion);
};

const useDefaultComboBox = <T>(suggestions: T[], getSuggestionLabel: (suggestion: T) => string) => {
const [query, setQuery] = useState('');

const filteredSuggestions = useMemo(
() => defaultFilterSuggestions(getSuggestionLabel, suggestions, query),
// eslint-disable-next-line react-hooks/exhaustive-deps
[suggestions, query]
);

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};

const resetSuggestions = () => {
setQuery('');
};

return { suggestions: filteredSuggestions, onChange, resetSuggestions };
};

export default useDefaultComboBox;
2 changes: 0 additions & 2 deletions ui-core/src/components/inputs/ComboBox/utils.ts

This file was deleted.

Loading

0 comments on commit 3e679f7

Please sign in to comment.