From 8bf2b46c48db4f52e2c45fcc1500a3679cd779a1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 17 Jun 2026 21:23:06 -0300 Subject: [PATCH] refactor(fuselage): rewrite AutoComplete with derived selection state Replace duplicated local selection state with a value-derived useMemo, eliminating drift between value/options and the rendered chips. Extract keyboard navigation into a dedicated event.key-based hook scoped to AutoComplete, leaving the shared useCursor untouched. Same visual and public API; snapshots unchanged. --- .changeset/autocomplete-rewrite.md | 5 + .../components/AutoComplete/AutoComplete.tsx | 113 +++++++---------- .../AutoComplete/useAutoCompleteCursor.ts | 118 ++++++++++++++++++ 3 files changed, 170 insertions(+), 66 deletions(-) create mode 100644 .changeset/autocomplete-rewrite.md create mode 100644 packages/fuselage/src/components/AutoComplete/useAutoCompleteCursor.ts diff --git a/.changeset/autocomplete-rewrite.md b/.changeset/autocomplete-rewrite.md new file mode 100644 index 0000000000..97d9ad1a7a --- /dev/null +++ b/.changeset/autocomplete-rewrite.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/fuselage': patch +--- + +Refactor `AutoComplete` to derive selection from the controlled `value` instead of duplicating it in local state, fixing selection drift. Keyboard navigation moved to a dedicated, `event.key`-based hook. No visual or API changes. diff --git a/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx b/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx index 6fb7dbc763..9456e03678 100644 --- a/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx +++ b/packages/fuselage/src/components/AutoComplete/AutoComplete.tsx @@ -7,7 +7,7 @@ import type { MouseEvent, ReactNode, } from 'react'; -import { useEffect, useRef, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { AnimatedVisibility } from '../AnimatedVisibility'; import { Box } from '../Box'; @@ -15,8 +15,9 @@ import { Chip } from '../Chip'; import { Icon } from '../Icon'; import { Input } from '../InputBox'; import { Margins } from '../Margins'; -import { useCursor, Options, type OptionType } from '../Options'; +import { Options, type OptionType } from '../Options'; import { PositionAnimated } from '../PositionAnimated'; +import { useAutoCompleteCursor } from './useAutoCompleteCursor'; type AutoCompleteOption = { value: string; @@ -52,30 +53,23 @@ export type AutoCompleteProps = Omit< value?: string | string[]; }; +/** + * Resolves the currently selected options from the controlled `value`. + * The selection is always derived — never duplicated in local state — so it + * cannot drift from `value`/`options`. + */ const getSelected = ( value: string | string[] | undefined, options: AutoCompleteOption[], -) => { +): AutoCompleteOption[] => { if (!value) { return []; } return typeof value === 'string' ? options.filter((option) => option.value === value) - : options?.filter((option) => value.includes(option.value)); + : options.filter((option) => value.includes(option.value)); }; -const isSelectedValid = - (value: string | string[] | undefined) => - (selected: AutoCompleteOption) => { - if (!value) { - return false; - } - - return typeof value === 'string' - ? selected.value === value - : value.includes(selected.value); - }; - /** * An input for selection of options. */ @@ -92,24 +86,29 @@ function AutoComplete({ error, disabled, multiple, - onBlur: onBlurAction = () => {}, + onBlur: onBlurAction = () => undefined, ...props }: AutoCompleteProps) { const ref = useRef(null); const { ref: containerRef, borderBoxSize } = useResizeObserver(); - const [selected, setSelected] = useState( - () => getSelected(value, options) || [], + const selected = useMemo( + () => getSelected(value, options), + [value, options], + ); + + const memoizedOptions = useMemo( + () => + options.map( + ({ value, label }): OptionType => [value, label], + ), + [options], ); - useEffect(() => { - // Validates if selected items are still valid after value changes - setSelected((selected) => { - return !selected.every(isSelectedValid(value)) - ? selected.filter(isSelectedValid(value)) - : selected; - }); - }, [value]); + const firstSelectedIndex = useMemo( + () => options.findIndex((option) => selected[0]?.value === option.value), + [options, selected], + ); const handleSelect = useEffectEvent( ([newValue]: OptionType) => { @@ -119,11 +118,10 @@ function AutoComplete({ } if (multiple) { - setSelected([...selected, ...getSelected(newValue as string, options)]); - onChange([...(value || []), newValue as string]); + const current = Array.isArray(value) ? value : []; + onChange([...current, newValue]); } else { - setSelected(getSelected(newValue as string, options)); - onChange(newValue as string); + onChange(newValue); } setFilter?.(''); @@ -136,49 +134,38 @@ function AutoComplete({ event.stopPropagation(); event.preventDefault(); - const filtered = selected.filter( - (item) => item.value !== event.currentTarget.value, - ); + const removed = event.currentTarget.value; - const filteredValue = + onChange( multiple && Array.isArray(value) - ? value?.filter((item) => item !== event.currentTarget.value) || [] - : ''; - - setSelected(filtered); - onChange(filteredValue); + ? value.filter((item) => item !== removed) + : '', + ); hide(); }, ); - const memoizedOptions = useMemo( - () => - options.map( - ({ value, label }): OptionType => [value, label], - ), - [options], - ); - - const firstSelectedIndex = useMemo( - () => options.findIndex((option) => selected[0]?.value === option.value), - [options, selected], - ); - - const [cursor, handleKeyDown, , reset, [optionsAreVisible, hide, show]] = - useCursor(firstSelectedIndex, memoizedOptions, handleSelect); + const { cursor, visible, show, hide, reset, handleKeyDown } = + useAutoCompleteCursor(firstSelectedIndex, memoizedOptions, handleSelect); const handleOnBlur = useEffectEvent((event: FocusEvent) => { hide(); onBlurAction(event); }); + const handleOnChange = useEffectEvent((e: ChangeEvent) => + setFilter?.(e.currentTarget.value), + ); + + const handleClick = useEffectEvent(() => ref.current?.focus()); + useEffect(reset, [filter, reset]); return ( ref.current?.focus())} + onClick={handleClick} flexGrow={1} className={useMemo( () => [error && 'invalid', disabled && 'disabled'], @@ -196,14 +183,12 @@ function AutoComplete({ ) => - setFilter?.(e.currentTarget.value), - )} + onChange={handleOnChange} onBlur={handleOnBlur} onFocus={show} onKeyDown={handleKeyDown} placeholder={ - optionsAreVisible === AnimatedVisibility.HIDDEN || !value + visible === AnimatedVisibility.HIDDEN || !value ? placeholder : undefined } @@ -233,16 +218,12 @@ function AutoComplete({ - + ([, , , , type]: OptionType< + TValue, + TLabel +>) => !type || type === 'option'; + +const findIndex = ( + options: OptionType[], + from: number, + step: 1 | -1, +) => { + for (let i = from; i >= 0 && i < options.length; i += step) { + if (isSelectable(options[i])) { + return i; + } + } + return -1; +}; + +const firstIndex = (options: OptionType[]) => + findIndex(options, 0, 1); + +const lastIndex = (options: OptionType[]) => + findIndex(options, options.length - 1, -1); + +/** + * Keyboard navigation + visibility state for the AutoComplete listbox. + * + * Replaces the shared `useCursor` god-hook with a focused, `event.key`-based + * implementation. Visibility is delegated to the shared `useVisible`. + */ +export const useAutoCompleteCursor = ( + initial: number, + options: OptionType[], + onSelect: (option: OptionType) => void, +) => { + const [cursor, setCursor] = useState(initial); + const [visible, hide, show] = useVisible(); + + const reset = useEffectEvent(() => setCursor(0)); + + const handleKeyDown = useEffectEvent((e: KeyboardEvent) => { + const { key } = e; + + if ( + visible === AnimatedVisibility.HIDDEN && + key !== 'Escape' && + key !== 'Tab' + ) { + show(); + } + + switch (key) { + case 'Home': + e.preventDefault(); + return setCursor(firstIndex(options)); + + case 'End': + e.preventDefault(); + return setCursor(lastIndex(options)); + + case 'ArrowUp': + e.preventDefault(); + return setCursor((current) => + current < 1 ? lastIndex(options) : findIndex(options, current - 1, -1), + ); + + case 'ArrowDown': + e.preventDefault(); + return setCursor((current) => + current === lastIndex(options) + ? firstIndex(options) + : findIndex(options, current + 1, 1), + ); + + case 'Enter': + e.preventDefault(); + if (visible === AnimatedVisibility.VISIBLE) { + e.nativeEvent.stopImmediatePropagation(); + e.stopPropagation(); + } + hide(); + return onSelect(options[cursor]); + + case 'Escape': + e.preventDefault(); + reset(); + hide(); + if (visible === AnimatedVisibility.VISIBLE) { + e.nativeEvent.stopImmediatePropagation(); + e.stopPropagation(); + } + return; + + default: + if (key.match(/^[\d\w]$/i)) { + const index = options.findIndex( + (option) => + isSelectable(option) && + typeof option[1] === 'string' && + option[1][0]?.toLowerCase() === key.toLowerCase(), + ); + if (index > -1) { + setCursor(index); + } + } + } + }); + + return { cursor, visible, show, hide, reset, handleKeyDown }; +};