[AI] Keep focus on parent payment when enabling split (#8894)

When turning a new transaction into a split via keyboard, stay on the
parent amount field so the user can enter the total before split lines.
After split is enabled, Tab from the parent cleared field moves to the
first child row instead of Cancel.

Fixes actualbudget/actual#8522.

Co-authored-by: Bennett Payoyo <[email protected]>
This commit is contained in:
Bennett Payoyo
2026-10-05 15:49:10 +00:00
committed by GitHub
co-authored by Bennett Payoyo
parent 9732a4463a
commit c72ce699b3
3 changed files with 137 additions and 16 deletions
@@ -1139,6 +1139,109 @@ describe('Transactions', () => {
expect(getTransactions()[2].amount).toBe(-1000);
});
test('selecting split with the keyboard keeps focus on the parent payment field', async () => {
const { container, updateProps } = renderTransactions();
updateProps({ isAdding: true });
const input = await editNewField(container, 'category');
expect(screen.getByTestId('autocomplete')).toBeTruthy();
expect(screen.getByTestId('split-transaction-button')).toBeTruthy();
// Nothing is highlighted on open; ArrowDown highlights Split (index 0).
await userEvent.keyboard('[ArrowDown]');
await userEvent.type(input, '[Enter]');
await waitForAutocomplete();
await waitForAutocomplete();
await waitFor(() => {
expect(
container.querySelectorAll(
'[data-testid="new-transaction"] [data-testid="debit"]',
).length,
).toBeGreaterThan(1);
});
expectToBeEditingField(container, 'debit', 0, true);
});
test('selecting split after visiting payment keeps focus on the parent payment field', async () => {
const appliedTransactions: TransactionEntity[] = [];
const { container, updateProps } = renderTransactions({
onApplyRules: async transaction => {
appliedTransactions.push(transaction);
return transaction;
},
});
updateProps({ isAdding: true });
// Tabbing through payment saves debit 0 as -0 (negated), so amount is no longer null.
const debitInput = await editNewField(container, 'debit');
await userEvent.type(debitInput, '[Tab]');
await waitFor(() => {
const appliedAmount = appliedTransactions.at(-1)?.amount;
expect(appliedAmount == null).toBe(false);
expect(appliedAmount === 0).toBe(true);
});
const input = await editNewField(container, 'category');
await userEvent.keyboard('[ArrowDown]');
await userEvent.type(input, '[Enter]');
await waitForAutocomplete();
await waitForAutocomplete();
await waitFor(() => {
expect(
container.querySelectorAll(
'[data-testid="new-transaction"] [data-testid="debit"]',
).length,
).toBeGreaterThan(1);
});
expectToBeEditingField(container, 'debit', 0, true);
});
test('tab from the parent cleared field focuses the first split child', async () => {
const { container, updateProps } = renderTransactions();
updateProps({ isAdding: true });
const input = await editNewField(container, 'category');
await userEvent.keyboard('[ArrowDown]');
await userEvent.type(input, '[Enter]');
await waitForAutocomplete();
await waitForAutocomplete();
await waitFor(() => {
expect(
container.querySelectorAll(
'[data-testid="new-transaction"] [data-testid="debit"]',
).length,
).toBeGreaterThan(1);
});
// Enabling split still leaves the parent payment field focused.
let field = expectToBeEditingField(container, 'debit', 0, true);
await userEvent.type(field, '[Tab]');
field = expectToBeEditingField(container, 'credit', 0, true);
await userEvent.type(field, '[Tab]');
field = expectToBeEditingField(container, 'cleared', 0, true);
await userEvent.type(field, '[Tab]');
expectToBeEditingField(container, 'payee', 1, true);
const cancelButton = container.querySelector(
'[data-testid="new-transaction"] [data-testid="cancel-button"]',
)!;
expect(cancelButton.contains(container.ownerDocument.activeElement)).toBe(
false,
);
// Cancel follows the last split line.
const lastCleared = await editNewField(container, 'cleared', 2);
await userEvent.type(lastCleared, '[Tab]');
expect(cancelButton.contains(container.ownerDocument.activeElement)).toBe(
true,
);
});
test('escape closes the new transaction rows', async () => {
const { container, updateProps } = renderTransactions({
onCloseAddTransaction: () => {
@@ -3474,17 +3474,34 @@ export const TransactionTable = forwardRef(
}
function getFieldsNewTransaction(item?: TransactionEntity) {
const rows = newTransactions ?? [];
// Action buttons render under the split lines, after the last child.
const hasSplitChildren =
item?.is_parent === true && rows.some(row => row.parent_id === item.id);
const fields = [
'select',
...getFocusableFields(),
'cancel',
'schedule',
'add',
...(hasSplitChildren ? [] : ['cancel', 'schedule', 'add']),
];
return getFields(item, fields).filter(
const resolved = getFields(item, fields).filter(
f => f !== 'schedule' || (item ? isFutureTransaction(item) : false),
);
const isLastSplitChild =
item?.is_child === true && rows[rows.length - 1]?.id === item.id;
if (!isLastSplitChild || !item) {
return resolved;
}
const parent = rows.find(row => row.id === item.parent_id);
const showSchedule = isFutureTransaction(parent ?? item);
return [
...resolved,
'cancel',
...(showSchedule ? ['schedule'] : []),
'add',
];
}
function getFieldsTableTransaction(item?: TransactionEntity) {
@@ -3772,24 +3789,19 @@ export const TransactionTable = forwardRef(
if (isTemporaryId(id)) {
const { newNavigator } = latestState.current;
const newTrans = latestState.current.newTransactions;
const { data, diff } = splitTransaction(
const { data } = splitTransaction(
newTrans,
id,
makeEmptySplitSubtransactions,
);
setNewTransactions(data);
// Jump next to "debit" field if it is empty
// Otherwise jump to the same field as before, but downwards
// to the added split transaction
if (newTrans[0].amount === null) {
newNavigator.onEdit(newTrans[0].id, 'debit');
} else {
newNavigator.onEdit(
diff.added[0].id,
latestState.current.newNavigator.focusedField,
);
}
// Stay on the parent amount field when split is first enabled
// so the user can enter the parent amount before the split lines.
newNavigator.onEdit(
newTrans[0].id,
newTrans[0].amount > 0 ? 'credit' : 'debit',
);
} else {
const trans = latestState.current.transactions.find(t => t.id === id);
const newId = onSplitProp(id);
@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [Yahiro025]
---
Keep keyboard focus on the parent payment field when splitting a new transaction, and move to the first split line when tabbing past the parent row