Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/autocomplete-rewrite.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 47 additions & 66 deletions packages/fuselage/src/components/AutoComplete/AutoComplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ 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';
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<TLabel> = {
value: string;
Expand Down Expand Up @@ -52,30 +53,23 @@ export type AutoCompleteProps<TLabel> = 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 = <TLabel,>(
value: string | string[] | undefined,
options: AutoCompleteOption<TLabel>[],
) => {
): AutoCompleteOption<TLabel>[] => {
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 =
<TLabel,>(value: string | string[] | undefined) =>
(selected: AutoCompleteOption<TLabel>) => {
if (!value) {
return false;
}

return typeof value === 'string'
? selected.value === value
: value.includes(selected.value);
};

/**
* An input for selection of options.
*/
Expand All @@ -92,24 +86,29 @@ function AutoComplete<TLabel = ReactNode>({
error,
disabled,
multiple,
onBlur: onBlurAction = () => {},
onBlur: onBlurAction = () => undefined,
...props
}: AutoCompleteProps<TLabel>) {
const ref = useRef<HTMLInputElement>(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<string, TLabel> => [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<string, TLabel>) => {
Expand All @@ -119,11 +118,10 @@ function AutoComplete<TLabel = ReactNode>({
}

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?.('');
Expand All @@ -136,49 +134,38 @@ function AutoComplete<TLabel = ReactNode>({
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<string, TLabel> => [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<HTMLInputElement>) => {
hide();
onBlurAction(event);
});

const handleOnChange = useEffectEvent((e: ChangeEvent<HTMLInputElement>) =>
setFilter?.(e.currentTarget.value),
);

const handleClick = useEffectEvent(() => ref.current?.focus());

useEffect(reset, [filter, reset]);

return (
<Box
rcx-autocomplete
ref={containerRef}
onClick={useEffectEvent(() => ref.current?.focus())}
onClick={handleClick}
flexGrow={1}
className={useMemo(
() => [error && 'invalid', disabled && 'disabled'],
Expand All @@ -196,14 +183,12 @@ function AutoComplete<TLabel = ReactNode>({
<Margins all='x4'>
<Input
ref={ref}
onChange={useEffectEvent((e: ChangeEvent<HTMLInputElement>) =>
setFilter?.(e.currentTarget.value),
)}
onChange={handleOnChange}
onBlur={handleOnBlur}
onFocus={show}
onKeyDown={handleKeyDown}
placeholder={
optionsAreVisible === AnimatedVisibility.HIDDEN || !value
visible === AnimatedVisibility.HIDDEN || !value
? placeholder
: undefined
}
Expand Down Expand Up @@ -233,16 +218,12 @@ function AutoComplete<TLabel = ReactNode>({
</Box>
<Box rcx-autocomplete__addon>
<Icon
name={
optionsAreVisible === AnimatedVisibility.VISIBLE
? 'cross'
: 'magnifier'
}
name={visible === AnimatedVisibility.VISIBLE ? 'cross' : 'magnifier'}
size='x20'
color='default'
/>
</Box>
<PositionAnimated visible={optionsAreVisible} anchor={containerRef}>
<PositionAnimated visible={visible} anchor={containerRef}>
<Options
width={borderBoxSize.inlineSize}
onSelect={handleSelect}
Expand Down
118 changes: 118 additions & 0 deletions packages/fuselage/src/components/AutoComplete/useAutoCompleteCursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import type { KeyboardEvent } from 'react';
import { useState } from 'react';

import { AnimatedVisibility } from '../AnimatedVisibility';
import type { OptionType } from '../Options';
import { useVisible } from '../Options/useVisible';

const isSelectable = <TValue, TLabel>([, , , , type]: OptionType<
TValue,
TLabel
>) => !type || type === 'option';

const findIndex = <TValue, TLabel>(
options: OptionType<TValue, TLabel>[],
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 = <TValue, TLabel>(options: OptionType<TValue, TLabel>[]) =>
findIndex(options, 0, 1);

const lastIndex = <TValue, TLabel>(options: OptionType<TValue, TLabel>[]) =>
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 = <TValue = string, TLabel = unknown>(
initial: number,
options: OptionType<TValue, TLabel>[],
onSelect: (option: OptionType<TValue, TLabel>) => 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 };
};
Loading