Implement PayeeAutocomplete2 based on react-aria-component's ComboBox

This commit is contained in:
Joel Jeremy Marquez
2025-10-06 15:36:23 -07:00
parent fed1cd7d30
commit 28db29ac5e
6 changed files with 675 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
import {
type ComponentProps,
useRef,
useContext,
type KeyboardEvent,
useEffect,
createContext,
type ReactNode,
} from 'react';
import {
ComboBox,
ListBox,
ListBoxItem,
ListBoxSection,
type ListBoxSectionProps,
type ComboBoxProps,
type ListBoxItemProps,
ComboBoxStateContext,
type Key,
} from 'react-aria-components';
import { type ComboBoxState } from 'react-stately';
import { Input } from '@actual-app/components/input';
import { Popover } from '@actual-app/components/popover';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
const popoverClassName = () =>
css({
...styles.darkScrollbar,
...styles.popover,
backgroundColor: theme.menuAutoCompleteBackground,
color: theme.menuAutoCompleteText,
padding: '5px 0',
borderRadius: 4,
});
const listBoxClassName = ({ width }: { width?: number }) =>
css({
width,
minWidth: 200,
maxHeight: 200,
overflow: 'auto',
'& [data-focused]': {
backgroundColor: theme.menuAutoCompleteBackgroundHover,
},
});
type Autocomplete2Props<T extends object> = Omit<
ComboBoxProps<T>,
'children'
> & {
inputPlaceholder?: string;
children: ComponentProps<typeof ListBox<T>>['children'];
};
export function Autocomplete2<T extends object>({
children,
...props
}: Autocomplete2Props<T>) {
const viewRef = useRef<HTMLDivElement | null>(null);
return (
<ComboBox<T>
allowsEmptyCollection
allowsCustomValue
menuTrigger="focus"
{...props}
>
<View ref={viewRef}>
<AutocompleteInput placeholder={props.inputPlaceholder} />
</View>
<Popover isNonModal className={popoverClassName()}>
<ListBox<T>
className={listBoxClassName({ width: viewRef.current?.clientWidth })}
>
{children}
</ListBox>
</Popover>
</ComboBox>
);
}
type AutocompleteInputContextValue = {
getFocusedKey?: (state: ComboBoxState<unknown>) => Key | null;
};
const AutocompleteInputContext =
createContext<AutocompleteInputContextValue | null>(null);
type AutocompleteInputProviderProps = {
children: ReactNode;
getFocusedKey?: (state: ComboBoxState<unknown>) => Key | null;
};
export function AutocompleteInputProvider({
children,
getFocusedKey,
}: AutocompleteInputProviderProps) {
return (
<AutocompleteInputContext.Provider value={{ getFocusedKey }}>
{children}
</AutocompleteInputContext.Provider>
);
}
type AutocompleteInputProps = ComponentProps<typeof Input>;
function AutocompleteInput({ onKeyUp, ...props }: AutocompleteInputProps) {
const state = useContext(ComboBoxStateContext);
const _onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
state.revert();
}
onKeyUp?.(e);
};
const autocompleteInputContext = useContext(AutocompleteInputContext);
useEffect(() => {
if (state && state.inputValue && !state.selectionManager.focusedKey) {
const focusedKey = autocompleteInputContext?.getFocusedKey
? autocompleteInputContext.getFocusedKey(state)
: defaultGetFocusedKey(state);
state.selectionManager.setFocusedKey(focusedKey);
}
}, [autocompleteInputContext, state, state?.inputValue]);
return <Input onKeyUp={_onKeyUp} {...props} />;
}
function defaultGetFocusedKey<T>(state: ComboBoxState<T>) {
// Focus on the first suggestion item when typing.
const keys = Array.from(state.collection.getKeys());
return keys
.map(key => state.collection.getItem(key))
.find(i => i.type === 'item')?.key;
}
const defaultAutocompleteSectionClassName = css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
'& header': {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 10,
color: theme.menuAutoCompleteTextHeader,
},
});
type AutocompleteSectionProps<T extends object> = ListBoxSectionProps<T>;
export function AutocompleteSection<T extends object>({
className,
...props
}: AutocompleteSectionProps<T>) {
return (
<ListBoxSection
className={cx(defaultAutocompleteSectionClassName, className)}
{...props}
/>
);
}
const defaultAutocompleteItemClassName = css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 20,
});
type AutocompleteItemProps = ListBoxItemProps;
export function AutocompleteItem({
className,
...props
}: AutocompleteItemProps) {
return (
<ListBoxItem
className={
typeof className === 'function'
? renderProps =>
cx(defaultAutocompleteItemClassName, className(renderProps))
: cx(defaultAutocompleteItemClassName, className)
}
{...props}
/>
);
}

View File

@@ -0,0 +1,346 @@
import { type ComponentProps, useMemo, useState } from 'react';
import { Header, type Key } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { SvgAdd } from '@actual-app/components/icons/v1';
import { theme } from '@actual-app/components/theme';
import {
normalisedEquals,
normalisedIncludes,
} from 'loot-core/shared/normalisation';
import { type AccountEntity, type PayeeEntity } from 'loot-core/types/models';
import {
Autocomplete2,
AutocompleteInputProvider,
AutocompleteItem,
AutocompleteSection,
} from './Autocomplete2';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees';
import { usePrevious } from '@desktop-client/hooks/usePrevious';
import {
createPayee,
getActivePayees,
} from '@desktop-client/payees/payeesSlice';
import { useDispatch } from '@desktop-client/redux';
type PayeeAutocompleteItemType = {
type: 'account' | 'payee' | 'suggested';
};
type PayeeAutocompleteItem = PayeeEntity & PayeeAutocompleteItemType;
type PayeeAutocomplete2Props = Omit<
ComponentProps<typeof Autocomplete2>,
'children'
> & {
showInactive?: boolean;
};
export function PayeeAutocomplete2({
showInactive,
selectedKey,
onOpenChange,
onSelectionChange,
...props
}: PayeeAutocomplete2Props) {
const { t } = useTranslation();
const payees = usePayees();
const commonPayees = useCommonPayees();
const accounts = useAccounts();
const [focusTransferPayees, setFocusedTransferPayees] = useState(false);
const allPayeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(commonPayees, payees);
let filteredSuggestions: PayeeAutocompleteItem[] = [...suggestions];
if (!showInactive) {
filteredSuggestions = filterActivePayees(filteredSuggestions, accounts);
}
if (focusTransferPayees) {
filteredSuggestions = filterTransferPayees(filteredSuggestions);
}
return filteredSuggestions;
}, [commonPayees, payees, showInactive, focusTransferPayees, accounts]);
const [inputValue, setInputValue] = useState(
getPayeeName(allPayeeSuggestions, selectedKey),
);
const [_selectedKey, setSelectedKey] = useState<Key | null>(
selectedKey || null,
);
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const previousIsAutocompeleteOpen = usePrevious(isAutocompleteOpen);
const isInitialAutocompleteOpen =
!previousIsAutocompeleteOpen && isAutocompleteOpen;
const filter = (textValue: string, inputValue: string) => {
return (
isInitialAutocompleteOpen || normalisedIncludes(textValue, inputValue)
);
};
const filteredPayeeSuggestions = allPayeeSuggestions.filter(p =>
filter(p.name, inputValue),
);
const suggestedPayees = filteredPayeeSuggestions.filter(
p => p.type === 'suggested',
);
const regularPayees = filteredPayeeSuggestions.filter(
p => !p.favorite && p.type === 'payee',
);
const accountPayees = filteredPayeeSuggestions.filter(
p => p.type === 'account',
);
const findExactMatchPayee = () =>
filteredPayeeSuggestions.find(
p => p.id !== 'new' && normalisedEquals(p.name, inputValue),
);
const exactMatchPayee = findExactMatchPayee();
const dispatch = useDispatch();
const onCreatePayee = () => {
return dispatch(createPayee({ name: inputValue })).unwrap();
};
const _onSelectionChange = async (id: Key | null) => {
if (id === 'new') {
const newPayeeId = await onCreatePayee?.();
setSelectedKey(newPayeeId);
onSelectionChange?.(newPayeeId);
return;
}
setSelectedKey(id);
setInputValue(getPayeeName(filteredPayeeSuggestions, id));
onSelectionChange?.(id);
};
const _onOpenChange = (isOpen: boolean) => {
setIsAutocompleteOpen(isOpen);
onOpenChange?.(isOpen);
};
const getFocusedKey: ComponentProps<
typeof AutocompleteInputProvider
>['getFocusedKey'] = state => {
const keys = Array.from(state.collection.getKeys());
const found = keys
.map(key => state.collection.getItem(key))
.find(i => i.type === 'item' && i.key !== 'new');
// Focus on the first suggestion item when typing.
// Otherwise, if there are no results, focus on the "new" item to allow creating a new entry.
return found?.key || 'new';
};
return (
<AutocompleteInputProvider getFocusedKey={getFocusedKey}>
<Autocomplete2
aria-label={t('Payee autocomplete')}
inputPlaceholder="nothing"
inputValue={inputValue}
onInputChange={setInputValue}
selectedKey={_selectedKey || selectedKey}
onSelectionChange={_onSelectionChange}
onOpenChange={_onOpenChange}
{...props}
>
<PayeeList
showCreatePayee={!!inputValue && !exactMatchPayee}
inputValue={inputValue}
suggestedPayees={suggestedPayees}
regularPayees={regularPayees}
accountPayees={accountPayees}
/>
{/* <AutocompleteSection className={css({ position: 'sticky', bottom: 0, })}>
<Button variant="menu" slot={null}>
<Trans>Make transfer</Trans>
</Button>
<Button variant="menu" slot={null}>
<Trans>Manage payees</Trans>
</Button>
</AutocompleteSection> */}
</Autocomplete2>
</AutocompleteInputProvider>
);
}
type PayeeListProps = {
showCreatePayee: boolean;
inputValue: string;
suggestedPayees: PayeeAutocompleteItem[];
regularPayees: PayeeAutocompleteItem[];
accountPayees: PayeeAutocompleteItem[];
};
function PayeeList({
showCreatePayee,
inputValue,
suggestedPayees,
regularPayees,
accountPayees,
}: PayeeListProps) {
// useAutocompleteFocusOnInput({
// getFocusedKey: state => {
// const keys = Array.from(state.collection.getKeys());
// return keys
// .map(key => state.collection.getItem(key))
// .find(i => i.type === 'item' && i.key !== 'new')?.key || 'new';
// },
// });
return (
<>
{showCreatePayee && (
<AutocompleteItem
key="new"
id="new"
textValue={inputValue}
style={{ paddingLeft: 10, color: theme.noticeText }}
>
<SvgAdd width={8} height={8} style={{ marginRight: 5 }} />
Create payee: {inputValue}
</AutocompleteItem>
)}
{suggestedPayees.length > 0 && (
<AutocompleteSection>
<Header>
<Trans>Suggested Payees</Trans>
</Header>
{suggestedPayees.map(payee => (
<AutocompleteItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</AutocompleteItem>
))}
</AutocompleteSection>
)}
{regularPayees.length > 0 && (
<AutocompleteSection>
<Header>
<Trans>Payees</Trans>
</Header>
{regularPayees.map(payee => (
<AutocompleteItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</AutocompleteItem>
))}
</AutocompleteSection>
)}
{accountPayees.length > 0 && (
<AutocompleteSection>
<Header>
<Trans>Transfer To/From</Trans>
</Header>
{accountPayees.map(payee => (
<AutocompleteItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</AutocompleteItem>
))}
</AutocompleteSection>
)}
</>
);
}
const MAX_AUTO_SUGGESTIONS = 5;
function getPayeeSuggestions(
commonPayees: PayeeEntity[],
payees: PayeeEntity[],
): PayeeAutocompleteItem[] {
const favoritePayees = payees
.filter(p => p.favorite)
.map(p => {
return { ...p, type: determineType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
if (commonPayees?.length > 0) {
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
additionalCommonPayees = commonPayees
.filter(
p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)),
)
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length)
.map(p => {
return { ...p, type: determineType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));
}
}
if (favoritePayees.length + additionalCommonPayees.length) {
const filteredPayees: PayeeAutocompleteItem[] = payees
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
.map<PayeeAutocompleteItem>(p => {
return { ...p, type: determineType(p, false) };
});
return favoritePayees.concat(additionalCommonPayees).concat(filteredPayees);
}
return payees.map(p => {
return { ...p, type: determineType(p, false) };
});
}
function filterActivePayees<T extends PayeeEntity>(
payees: T[],
accounts: AccountEntity[],
): T[] {
return accounts ? (getActivePayees(payees, accounts) as T[]) : payees;
}
function filterTransferPayees(payees: PayeeAutocompleteItem[]) {
return payees.filter(payee => !!payee.transfer_acct);
}
function determineType(
payee: PayeeEntity,
isCommon: boolean,
): PayeeAutocompleteItem['type'] {
if (payee.transfer_acct) {
return 'account';
}
if (isCommon) {
return 'suggested';
} else {
return 'payee';
}
}
function getPayeeName<T extends PayeeEntity>(items: T[], id: Key | null) {
return items.find(p => p.id === id)?.name || '';
}

View File

@@ -54,6 +54,7 @@ import {
import { FormulaActionEditor } from './FormulaActionEditor';
import { PayeeAutocomplete2 } from '@desktop-client/components/autocomplete/PayeeAutocomplete2';
import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge';
import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable';
import { BetweenAmountInput } from '@desktop-client/components/util/AmountInput';
@@ -319,6 +320,13 @@ function ConditionEditor({
onChange={v => onChange('value', v)}
/>
);
} else if (field === 'payee') {
valueEditor = (
<PayeeAutocomplete2
selectedKey={value}
onSelectionChange={id => onChange('value', id)}
/>
);
} else {
valueEditor = (
<GenericInput

View File

@@ -4,3 +4,11 @@ export function getNormalisedString(value: string) {
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '');
}
export function normalisedEquals(a: string, b: string) {
return getNormalisedString(a) === getNormalisedString(b);
}
export function normalisedIncludes(a: string, b: string) {
return getNormalisedString(a).includes(getNormalisedString(b));
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Implement PayeeAutocomplete2 based on react-aria-component's ComboBox

113
yarn.lock
View File

@@ -4099,6 +4099,15 @@ __metadata:
languageName: node
linkType: hard
"@internationalized/string@npm:^3.2.7":
version: 3.2.7
resolution: "@internationalized/string@npm:3.2.7"
dependencies:
"@swc/helpers": "npm:^0.5.0"
checksum: 10/38b54817cf125ba88d1136a6bca4fb57c46672d26d21490f838efe928049546800df6d9c8048411696455fc8caacb8ac23c2de2a1b61f2258b1302c1c97cc128
languageName: node
linkType: hard
"@isaacs/balanced-match@npm:^4.0.1":
version: 4.0.1
resolution: "@isaacs/balanced-match@npm:4.0.1"
@@ -6376,6 +6385,21 @@ __metadata:
languageName: node
linkType: hard
"@react-aria/visually-hidden@npm:^3.8.27":
version: 3.8.27
resolution: "@react-aria/visually-hidden@npm:3.8.27"
dependencies:
"@react-aria/interactions": "npm:^3.25.5"
"@react-aria/utils": "npm:^3.30.1"
"@react-types/shared": "npm:^3.32.0"
"@swc/helpers": "npm:^0.5.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
checksum: 10/6c0f27ecdf7aa7eda4e5f6ab9b88dc1d024cc489b54d96ddc692c673b09dabb8ac2fc9017b4d0160f82384bcae698774e53031e5a701d59197e37e5c852231cd
languageName: node
linkType: hard
"@react-dnd/asap@npm:^5.0.1":
version: 5.0.2
resolution: "@react-dnd/asap@npm:5.0.2"
@@ -22933,6 +22957,59 @@ __metadata:
languageName: node
linkType: hard
"react-aria@npm:^3.43.2":
version: 3.43.2
resolution: "react-aria@npm:3.43.2"
dependencies:
"@internationalized/string": "npm:^3.2.7"
"@react-aria/breadcrumbs": "npm:^3.5.28"
"@react-aria/button": "npm:^3.14.1"
"@react-aria/calendar": "npm:^3.9.1"
"@react-aria/checkbox": "npm:^3.16.1"
"@react-aria/color": "npm:^3.1.1"
"@react-aria/combobox": "npm:^3.13.2"
"@react-aria/datepicker": "npm:^3.15.1"
"@react-aria/dialog": "npm:^3.5.30"
"@react-aria/disclosure": "npm:^3.0.8"
"@react-aria/dnd": "npm:^3.11.2"
"@react-aria/focus": "npm:^3.21.1"
"@react-aria/gridlist": "npm:^3.14.0"
"@react-aria/i18n": "npm:^3.12.12"
"@react-aria/interactions": "npm:^3.25.5"
"@react-aria/label": "npm:^3.7.21"
"@react-aria/landmark": "npm:^3.0.6"
"@react-aria/link": "npm:^3.8.5"
"@react-aria/listbox": "npm:^3.14.8"
"@react-aria/menu": "npm:^3.19.2"
"@react-aria/meter": "npm:^3.4.26"
"@react-aria/numberfield": "npm:^3.12.1"
"@react-aria/overlays": "npm:^3.29.1"
"@react-aria/progress": "npm:^3.4.26"
"@react-aria/radio": "npm:^3.12.1"
"@react-aria/searchfield": "npm:^3.8.8"
"@react-aria/select": "npm:^3.16.2"
"@react-aria/selection": "npm:^3.25.1"
"@react-aria/separator": "npm:^3.4.12"
"@react-aria/slider": "npm:^3.8.1"
"@react-aria/ssr": "npm:^3.9.10"
"@react-aria/switch": "npm:^3.7.7"
"@react-aria/table": "npm:^3.17.7"
"@react-aria/tabs": "npm:^3.10.7"
"@react-aria/tag": "npm:^3.7.1"
"@react-aria/textfield": "npm:^3.18.1"
"@react-aria/toast": "npm:^3.0.7"
"@react-aria/tooltip": "npm:^3.8.7"
"@react-aria/tree": "npm:^3.1.3"
"@react-aria/utils": "npm:^3.30.1"
"@react-aria/visually-hidden": "npm:^3.8.27"
"@react-types/shared": "npm:^3.32.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
checksum: 10/f8981d63202df94faebe3b548cb2aeabf0a9043ced7dfd90820a09a4b644ac2b1b894b78f88cc5910679f8cb2c42b40a314bf5c5cf359990b695e3043e939d16
languageName: node
linkType: hard
"react-dnd-html5-backend@npm:^16.0.1":
version: 16.0.1
resolution: "react-dnd-html5-backend@npm:16.0.1"
@@ -23378,6 +23455,42 @@ __metadata:
languageName: node
linkType: hard
"react-stately@npm:^3.41.0":
version: 3.41.0
resolution: "react-stately@npm:3.41.0"
dependencies:
"@react-stately/calendar": "npm:^3.8.4"
"@react-stately/checkbox": "npm:^3.7.1"
"@react-stately/collections": "npm:^3.12.7"
"@react-stately/color": "npm:^3.9.1"
"@react-stately/combobox": "npm:^3.11.1"
"@react-stately/data": "npm:^3.14.0"
"@react-stately/datepicker": "npm:^3.15.1"
"@react-stately/disclosure": "npm:^3.0.7"
"@react-stately/dnd": "npm:^3.7.0"
"@react-stately/form": "npm:^3.2.1"
"@react-stately/list": "npm:^3.13.0"
"@react-stately/menu": "npm:^3.9.7"
"@react-stately/numberfield": "npm:^3.10.1"
"@react-stately/overlays": "npm:^3.6.19"
"@react-stately/radio": "npm:^3.11.1"
"@react-stately/searchfield": "npm:^3.5.15"
"@react-stately/select": "npm:^3.7.1"
"@react-stately/selection": "npm:^3.20.5"
"@react-stately/slider": "npm:^3.7.1"
"@react-stately/table": "npm:^3.15.0"
"@react-stately/tabs": "npm:^3.8.5"
"@react-stately/toast": "npm:^3.1.2"
"@react-stately/toggle": "npm:^3.9.1"
"@react-stately/tooltip": "npm:^3.5.7"
"@react-stately/tree": "npm:^3.9.2"
"@react-types/shared": "npm:^3.32.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
checksum: 10/0ac4703c6a7a71e737894138fdfdb06412af76855ffeba83491897e311a0a44880366a6aa23c19f33ba7e93e233fd05d250f447cc1983595d2fee3e6362e5b27
languageName: node
linkType: hard
"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3":
version: 2.2.3
resolution: "react-style-singleton@npm:2.2.3"