Skip to content

Commit

Permalink
ui-core: refactor InputWithSuggestions to ComboBox and improve compon…
Browse files Browse the repository at this point in the history
…ent logic

Signed-off-by: Achraf Mohyeddine <a.mohyeddine@gmail.com>

Signed-off-by: Achraf Mohyeddine <a.mohyeddine@gmail.com>
  • Loading branch information
achrafmohye committed Sep 18, 2024
1 parent d8983fc commit 5251ba8
Show file tree
Hide file tree
Showing 9 changed files with 78 additions and 46 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, {
useState,
useRef,
useEffect,
type ChangeEventHandler,
type FocusEventHandler,
type KeyboardEventHandler,
type ReactNode,
type FocusEventHandler,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

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

Check warning on line 12 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 12 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 12 in ui-core/src/components/inputs/ComboBox/ComboBox.tsx

View workflow job for this annotation

GitHub Actions / publish

Unable to resolve path to module '@osrd-project/ui-icons'
Expand All @@ -15,17 +15,17 @@ import cx from 'classnames';
import { normalizeString } from './utils';
import Input, { type InputProps } from '../Input';

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

const InputWithSuggestions = <T,>({
const ComboBox = <T,>({
suggestions,
onChange,
getSuggestionLabel,
Expand All @@ -34,29 +34,33 @@ const InputWithSuggestions = <T,>({
exactSearch = false,
value,
small,
onSelectSuggestion,
...inputProps
}: InputWithSuggestionsProps<T>) => {
}: ComboBoxProps<T>) => {
const [filteredSuggestions, setFilteredSuggestions] = useState<T[]>([]);
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
const [inputValue, setInputValue] = useState(value ? getSuggestionLabel(value) : '');
const [selectedOption, setSelectedOption] = useState<T | null>(null);

const inputRef = useRef<HTMLInputElement>(null);

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

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

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

const clearInput = () => {
setInputValue('');
setSelectedOption(null);
inputRef.current?.focus();
};

const icons = [
...(inputValue
...(selectedOption
? [
{
icon: <X size={small ? 'sm' : 'lg'} />,
Expand All @@ -70,7 +74,9 @@ const InputWithSuggestions = <T,>({
{
icon: <ChevronDown size={small ? 'sm' : 'lg'} />,
action: focusInput,
className: 'chevron-icon',
className: cx('chevron-icon', {
disabled: inputProps.disabled,
}),
},
]
: []), // Conditionally include the chevron icon only when suggestions are not empty
Expand All @@ -82,9 +88,14 @@ const InputWithSuggestions = <T,>({
}
}, [value, getSuggestionLabel]);

useEffect(() => {
setFilteredSuggestions(sortedSuggestions);
}, [sortedSuggestions]);

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

if (userInput.trim() === '') {
setFilteredSuggestions([]);
Expand All @@ -102,8 +113,10 @@ const InputWithSuggestions = <T,>({

const selectSuggestion = (index: number) => {
const selectedSuggestion = filteredSuggestions[index];
setInputValue(getSuggestionLabel(selectedSuggestion));
onChange(selectedSuggestion);
const suggestionLabel = getSuggestionLabel(selectedSuggestion);
setInputValue(suggestionLabel);
setSelectedOption(selectedSuggestion);
onSelectSuggestion?.(selectedSuggestion);
setFilteredSuggestions([]);
setActiveSuggestionIndex(-1);
};
Expand All @@ -128,11 +141,21 @@ const InputWithSuggestions = <T,>({
};

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

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

if (filteredSuggestions.length === 1) {
selectSuggestion(0);
} else {
setFilteredSuggestions([]);
} else if (!isInputInSuggestions) {
setInputValue('');
setSelectedOption(null);
}

setFilteredSuggestions([]);
};

const handleSuggestionClick = (index: number) => {
Expand All @@ -141,7 +164,7 @@ const InputWithSuggestions = <T,>({

return (
<div
className="input-with-suggestions"
className="combo-box"
style={{ '--number-of-suggestions': numberOfSuggestionsToShow } as React.CSSProperties}
onBlur={handleParentDivOnBlur}
>
Expand All @@ -156,7 +179,7 @@ const InputWithSuggestions = <T,>({
withIcons={icons}
small={small}
/>
{showSuggestions && filteredSuggestions.length > 0 && (
{showSuggestions && (
<ul className="suggestions-list">
{filteredSuggestions.map((suggestion, index) => (
<li
Expand All @@ -166,7 +189,7 @@ const InputWithSuggestions = <T,>({
small,
})}
onClick={() => handleSuggestionClick(index)}
onMouseDown={(e) => e.preventDefault()} // Prevents the div parent (.input-with-suggestions) from losing focus
onMouseDown={(e) => e.preventDefault()} // Prevents the div parent (.combo-box) from losing focus
>
{getSuggestionLabel(suggestion)}
</li>
Expand All @@ -177,4 +200,4 @@ const InputWithSuggestions = <T,>({
);
};

export default InputWithSuggestions;
export default ComboBox;
1 change: 1 addition & 0 deletions ui-core/src/components/inputs/ComboBox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ComboBox, type ComboBoxProps } from './ComboBox';
4 changes: 0 additions & 4 deletions ui-core/src/components/inputs/InputWithSuggestions/index.ts

This file was deleted.

27 changes: 12 additions & 15 deletions ui-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,31 @@
import './styles/main.css';

export { default as Button, ButtonProps } from './components/buttons/Button';
export { ComboBox, type ComboBoxProps } from './components/inputs/ComboBox';
export {
CheckboxList,
CheckboxesTree,
Checkbox,
CheckboxList,
CheckboxListProps,
CheckboxProps,
CheckboxesTree,
CheckboxesTreeProps,
} from './components/inputs/Checkbox';
export {
DatePicker,
type CalendarSlot,
type DatePickerProps,
type RangeDatePickerProps,
type SingleDatePickerProps,
} from './components/inputs/datePicker';
export { default as Input, InputProps } from './components/inputs/Input';
export { default as TokenInput, TokenInputProps } from './components/inputs/TokenInput';
export { default as PasswordInput, PasswordInputProps } from './components/inputs/PasswordInput';
export { default as TextArea, TextAreaProps } from './components/inputs/TextArea';
export { default as RadioButton, RadioButtonProps } from './components/inputs/RadioButton';
export { default as RadioGroup, RadioGroupProps } from './components/inputs/RadioGroup';
export { default as Select, SelectProps } from './components/Select';
export { default as TextArea, TextAreaProps } from './components/inputs/TextArea';
export { default as TimePicker } from './components/inputs/TimePicker';
export {
DatePicker,
type RangeDatePickerProps,
type SingleDatePickerProps,
type DatePickerProps,
type CalendarSlot,
} from './components/inputs/datePicker';
export {
default as TolerancePicker,
type TolerancePickerProps,
} from './components/inputs/tolerancePicker/TolerancePicker';
export {
InputWithSuggestions,
type InputWithSuggestionsProps,
} from './components/inputs/InputWithSuggestions';
export { default as TokenInput, TokenInputProps } from './components/inputs/TokenInput';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import '@osrd-project/ui-core/dist/theme.css';

import { InputWithSuggestions } from '../components/inputs/InputWithSuggestions';
import { ComboBox } from '../components/inputs/ComboBox';

type Suggestion = { id: string; label: string };

Expand All @@ -13,13 +13,14 @@ const suggestions = [
{ id: '3', label: 'Manuella' },
] as Suggestion[];

const meta: Meta<typeof InputWithSuggestions> = {
component: InputWithSuggestions,
const meta: Meta<typeof ComboBox> = {
component: ComboBox,
args: {
small: false,
disabled: false,
readOnly: false,
onChange: () => {},
onSelectSuggestion: () => {},
getSuggestionLabel: (option) => (option as Suggestion).label,
suggestions: suggestions,
},
Expand All @@ -30,12 +31,12 @@ const meta: Meta<typeof InputWithSuggestions> = {
</div>
),
],
title: 'core/InputWithSuggestions',
title: 'core/ComboBox',
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof InputWithSuggestions>;
type Story = StoryObj<typeof ComboBox>;

export const Default: Story = {
args: {
Expand All @@ -49,7 +50,7 @@ export const WithDefaultValue: Story = {
args: {
label: 'Your name',
type: 'text',
value: { id: '1', label: 'Manuel' },
value: suggestions[0], // Use a suggestion from the suggestions array
},
};

Expand Down Expand Up @@ -96,3 +97,11 @@ export const SmallInput: Story = {
small: true,
},
};

export const WithoutSuggestions: Story = {
args: {
label: 'Your name',
type: 'text',
suggestions: [],
},
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.input-with-suggestions {
.combo-box {
position: relative;
width: 100%;
@apply text-grey-80;
Expand Down
2 changes: 1 addition & 1 deletion ui-core/src/styles/inputs/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
@import './timePicker.css';
@import './tokenInput.css';
@import './tolerancePicker.css';
@import './inputWithSuggestions.css';
@import './comboBox.css';
6 changes: 6 additions & 0 deletions ui-core/src/styles/inputs/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
@apply text-primary-20;
cursor: pointer;
}

.chevron-icon {
&.disabled {
@apply text-grey-30;
}
}
}

.leading-content-wrapper {
Expand Down

0 comments on commit 5251ba8

Please sign in to comment.