Skip to content

core: refacto combo box #779

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

Merged
merged 1 commit into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
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 @@
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'
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 @@
} 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 @@
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
Loading