Compare commits

...

7 Commits

Author SHA1 Message Date
autofix-ci[bot]
25c5a59ca1 [autofix.ci] apply automated fixes 2026-01-09 13:53:10 -08:00
Joel Jeremy Marquez
de6f7cb59b Rename to ComboBox 2026-01-09 13:53:10 -08:00
Joel Jeremy Marquez
db8e994bd0 yarn install 2026-01-09 13:52:37 -08:00
Joel Jeremy Marquez
4567235f3e Fix typecheck error 2026-01-09 13:51:18 -08:00
Joel Jeremy Marquez
4406c2515f Fix lint and typecheck errors 2026-01-09 13:51:18 -08:00
Joel Jeremy Marquez
e8508310e2 Cleanup 2026-01-09 13:51:18 -08:00
Joel Jeremy Marquez
28db29ac5e Implement PayeeAutocomplete2 based on react-aria-component's ComboBox 2026-01-09 13:51:18 -08:00
6 changed files with 552 additions and 0 deletions

View File

@@ -14,6 +14,7 @@
"./block": "./src/Block.tsx", "./block": "./src/Block.tsx",
"./button": "./src/Button.tsx", "./button": "./src/Button.tsx",
"./card": "./src/Card.tsx", "./card": "./src/Card.tsx",
"./combo-box": "./src/ComboBox.tsx",
"./form-error": "./src/FormError.tsx", "./form-error": "./src/FormError.tsx",
"./initial-focus": "./src/InitialFocus.ts", "./initial-focus": "./src/InitialFocus.ts",
"./inline-field": "./src/InlineField.tsx", "./inline-field": "./src/InlineField.tsx",

View File

@@ -0,0 +1,198 @@
import {
type ComponentProps,
useRef,
useContext,
type KeyboardEvent,
useEffect,
createContext,
type ReactNode,
} from 'react';
import {
ComboBox as AriaComboBox,
ListBox,
ListBoxItem,
ListBoxSection,
type ListBoxSectionProps,
type ComboBoxProps as AriaComboBoxProps,
type ListBoxItemProps,
ComboBoxStateContext as AriaComboBoxStateContext,
type Key,
} from 'react-aria-components';
import { type ComboBoxState as AriaComboBoxState } from 'react-stately';
import { css, cx } from '@emotion/css';
import { Input } from './Input';
import { Popover } from './Popover';
import { styles } from './styles';
import { theme } from './theme';
import { View } from './View';
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 ComboBoxProps<T extends object> = Omit<
AriaComboBoxProps<T>,
'children'
> & {
inputPlaceholder?: string;
children: ComponentProps<typeof ListBox<T>>['children'];
};
export function ComboBox<T extends object>({
children,
...props
}: ComboBoxProps<T>) {
const viewRef = useRef<HTMLDivElement | null>(null);
return (
<AriaComboBox<T>
allowsEmptyCollection
allowsCustomValue
menuTrigger="focus"
{...props}
>
<View ref={viewRef}>
<ComboBoxInput placeholder={props.inputPlaceholder} />
</View>
<Popover isNonModal className={popoverClassName()}>
<ListBox<T>
className={listBoxClassName({ width: viewRef.current?.clientWidth })}
>
{children}
</ListBox>
</Popover>
</AriaComboBox>
);
}
type ComboBoxInputContextValue = {
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
};
const ComboBoxInputContext = createContext<ComboBoxInputContextValue | null>(
null,
);
type ComboBoxInputProviderProps = {
children: ReactNode;
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
};
export function ComboBoxInputProvider({
children,
getFocusedKey,
}: ComboBoxInputProviderProps) {
return (
<ComboBoxInputContext.Provider value={{ getFocusedKey }}>
{children}
</ComboBoxInputContext.Provider>
);
}
type ComboBoxInputProps = ComponentProps<typeof Input>;
function ComboBoxInput({ onKeyUp, ...props }: ComboBoxInputProps) {
const state = useContext(AriaComboBoxStateContext);
const _onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
state?.revert();
}
onKeyUp?.(e);
};
const comboBoxInputContext = useContext(ComboBoxInputContext);
useEffect(() => {
if (state && state.inputValue && !state.selectionManager.focusedKey) {
const focusedKey: Key | null =
(comboBoxInputContext?.getFocusedKey
? comboBoxInputContext.getFocusedKey(state)
: defaultGetFocusedKey(state)) ?? null;
state.selectionManager.setFocusedKey(focusedKey);
}
}, [comboBoxInputContext, state, state?.inputValue]);
return <Input onKeyUp={_onKeyUp} {...props} />;
}
function defaultGetFocusedKey<T>(state: AriaComboBoxState<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 && i.type === 'item')?.key ?? null
);
}
const defaultComboBoxSectionClassName = () =>
css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
'& header': {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 10,
color: theme.menuAutoCompleteTextHeader,
},
});
type ComboBoxSectionProps<T extends object> = ListBoxSectionProps<T>;
export function ComboBoxSection<T extends object>({
className,
...props
}: ComboBoxSectionProps<T>) {
return (
<ListBoxSection
className={cx(defaultComboBoxSectionClassName(), className)}
{...props}
/>
);
}
const defaultComboBoxItemClassName = () =>
css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 20,
});
type ComboBoxItemProps = ListBoxItemProps;
export function ComboBoxItem({ className, ...props }: ComboBoxItemProps) {
return (
<ListBoxItem
className={
typeof className === 'function'
? renderProps =>
cx(defaultComboBoxItemClassName(), className(renderProps))
: cx(defaultComboBoxItemClassName(), className)
}
{...props}
/>
);
}

View File

@@ -0,0 +1,331 @@
import { type ComponentProps, useMemo, useState } from 'react';
import { Header, type Key } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import {
ComboBox,
ComboBoxInputProvider,
ComboBoxItem,
ComboBoxSection,
} from '@actual-app/components/combo-box';
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 { 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 PayeeComboBoxItem = PayeeEntity & {
type: 'account' | 'payee' | 'suggested';
};
type PayeeComboBoxProps = Omit<ComponentProps<typeof ComboBox>, 'children'> & {
showInactive?: boolean;
};
export function PayeeComboBox({
showInactive,
selectedKey,
onOpenChange,
onSelectionChange,
...props
}: PayeeComboBoxProps) {
const { t } = useTranslation();
const payees = usePayees();
const commonPayees = useCommonPayees();
const accounts = useAccounts();
const [focusTransferPayees] = useState(false);
const allPayeeSuggestions: PayeeComboBoxItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(commonPayees, payees);
let filteredSuggestions: PayeeComboBoxItem[] = [...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 || null),
);
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 ComboBoxInputProvider
>['getFocusedKey'] = state => {
const keys = Array.from(state.collection.getKeys());
const found = keys
.map(key => state.collection.getItem(key))
.find(i => 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 (
<ComboBoxInputProvider getFocusedKey={getFocusedKey}>
<ComboBox
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}
/>
{/* <ComboBoxSection 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>
</ComboBoxSection> */}
</ComboBox>
</ComboBoxInputProvider>
);
}
type PayeeListProps = {
showCreatePayee: boolean;
inputValue: string;
suggestedPayees: PayeeComboBoxItem[];
regularPayees: PayeeComboBoxItem[];
accountPayees: PayeeComboBoxItem[];
};
function PayeeList({
showCreatePayee,
inputValue,
suggestedPayees,
regularPayees,
accountPayees,
}: PayeeListProps) {
return (
<>
{showCreatePayee && (
<ComboBoxItem
key="new"
id="new"
textValue={inputValue}
style={{ paddingLeft: 10, color: theme.noticeText }}
>
<SvgAdd width={8} height={8} style={{ marginRight: 5 }} />
Create payee: {inputValue}
</ComboBoxItem>
)}
{suggestedPayees.length > 0 && (
<ComboBoxSection>
<Header>
<Trans>Suggested Payees</Trans>
</Header>
{suggestedPayees.map(payee => (
<ComboBoxItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</ComboBoxItem>
))}
</ComboBoxSection>
)}
{regularPayees.length > 0 && (
<ComboBoxSection>
<Header>
<Trans>Payees</Trans>
</Header>
{regularPayees.map(payee => (
<ComboBoxItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</ComboBoxItem>
))}
</ComboBoxSection>
)}
{accountPayees.length > 0 && (
<ComboBoxSection>
<Header>
<Trans>Transfer To/From</Trans>
</Header>
{accountPayees.map(payee => (
<ComboBoxItem
key={payee.id}
id={payee.id}
textValue={payee.name}
value={payee}
>
{payee.name}
</ComboBoxItem>
))}
</ComboBoxSection>
)}
</>
);
}
const MAX_AUTO_SUGGESTIONS = 5;
function getPayeeSuggestions(
commonPayees: PayeeEntity[],
payees: PayeeEntity[],
): PayeeComboBoxItem[] {
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: PayeeComboBoxItem[] = [];
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: PayeeComboBoxItem[] = payees
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
.map<PayeeComboBoxItem>(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: PayeeComboBoxItem[]) {
return payees.filter(payee => !!payee.transfer_acct);
}
function determineType(
payee: PayeeEntity,
isCommon: boolean,
): PayeeComboBoxItem['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 { FormulaActionEditor } from './FormulaActionEditor';
import { PayeeComboBox } from '@desktop-client/components/autocomplete/PayeeComboBox';
import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge'; import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge';
import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable'; import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable';
import { BetweenAmountInput } from '@desktop-client/components/util/AmountInput'; import { BetweenAmountInput } from '@desktop-client/components/util/AmountInput';
@@ -319,6 +320,13 @@ function ConditionEditor({
onChange={v => onChange('value', v)} onChange={v => onChange('value', v)}
/> />
); );
} else if (field === 'payee') {
valueEditor = (
<PayeeComboBox
selectedKey={value}
onSelectionChange={id => onChange('value', id)}
/>
);
} else { } else {
valueEditor = ( valueEditor = (
<GenericInput <GenericInput

View File

@@ -4,3 +4,11 @@ export function getNormalisedString(value: string) {
.normalize('NFD') .normalize('NFD')
.replace(/\p{Diacritic}/gu, ''); .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