[AI] Add press-and-hold calculator backspace (#9083)

This commit is contained in:
Hrushikesh Ramilla
2026-10-04 11:47:18 +00:00
committed by GitHub
parent a264f74038
commit 3c60bf675e
4 changed files with 303 additions and 3 deletions
@@ -0,0 +1,85 @@
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { MobileNavigation } from './page-models/mobile-navigation';
test('calculator backspace supports tap, hold and touch cancellation', async ({
browser,
}, testInfo) => {
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
recordVideo: { dir: testInfo.outputPath('video') },
});
try {
await page.goto('/');
await new ConfigurationPage(page).createDemoFile();
const navigation = new MobileNavigation(page);
const settings = await navigation.goToSettingsPage();
await settings.enableExperimentalFeature('Mobile calculator');
const transaction = await navigation.goToTransactionEntryPage();
await transaction.amountField.click();
await transaction.amountField.fill('123456789');
await transaction.amountField.press('End');
const backspace = page.getByRole('button', { name: '\u232B' });
await expect(backspace).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('calculator-before.png'),
});
const bounds = await backspace.boundingBox();
if (!bounds) {
throw new Error('Expected backspace to have a bounding box');
}
const touchPoints = [
{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
];
const session = await page.context().newCDPSession(page);
await page.clock.install();
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints,
});
await page.clock.runFor(100);
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await expect(transaction.amountField).toHaveValue('12345678');
await transaction.amountField.fill('123456789');
await transaction.amountField.press('End');
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints,
});
await page.clock.runFor(600);
await expect(transaction.amountField).toHaveValue('123456');
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await page.clock.runFor(1000);
await expect(transaction.amountField).toHaveValue('123456');
await expect(transaction.amountField).toBeFocused();
await page.screenshot({
path: testInfo.outputPath('calculator-after.png'),
});
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints,
});
await page.clock.runFor(400);
await expect(transaction.amountField).toHaveValue('12345');
await session.send('Input.dispatchTouchEvent', {
type: 'touchCancel',
touchPoints: [],
});
await page.clock.runFor(1000);
await expect(transaction.amountField).toHaveValue('12345');
await session.detach();
} finally {
await page.close();
}
});
@@ -1,7 +1,13 @@
import React, { useRef, useState } from 'react';
import { evalArithmetic } from '@actual-app/core/shared/arithmetic';
import { fireEvent, render, screen } from '@testing-library/react';
import {
act,
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { CalculatorButtons } from './CalculatorButtons';
@@ -31,6 +37,161 @@ function TestCalculator({ initialValue = '' }: { initialValue?: string }) {
}
describe('CalculatorButtons', () => {
describe('holding backspace', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
const setup = () => {
const result = render(<TestCalculator initialValue="123456789" />);
const input = screen.getByLabelText('Expression');
if (!(input instanceof HTMLInputElement)) {
throw new Error('Expected expression to be an input');
}
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
return {
...result,
input,
backspace: screen.getByRole('button', { name: '\u232B' }),
};
};
it('deletes once on a short press', async () => {
const { input, backspace } = setup();
fireEvent.pointerDown(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
width: 10,
height: 10,
});
await act(() => vi.advanceTimersByTime(300));
expect(input).toHaveValue('123456789');
fireEvent.pointerUp(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
});
fireEvent.click(backspace);
expect(input).toHaveValue('12345678');
await act(() => vi.advanceTimersByTime(1000));
expect(input).toHaveValue('12345678');
});
it('repeats during a hold and stops without an extra deletion on release', async () => {
const { input, backspace } = setup();
fireEvent.pointerDown(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
width: 10,
height: 10,
});
await act(() => vi.advanceTimersByTime(600));
expect(input).toHaveValue('123456');
expect(input).toHaveFocus();
expect(input.selectionStart).toBe(6);
fireEvent.pointerUp(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
});
fireEvent.click(backspace);
await act(() => vi.advanceTimersByTime(1000));
expect(input).toHaveValue('123456');
});
it('deletes the selection first, then continues at the caret', async () => {
const { input, backspace } = setup();
input.setSelectionRange(3, 6);
fireEvent.pointerDown(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
width: 10,
height: 10,
});
await act(() => vi.advanceTimersByTime(400));
expect(input).toHaveValue('123789');
await act(() => vi.advanceTimersByTime(100));
expect(input).toHaveValue('12789');
fireEvent.pointerUp(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
});
fireEvent.click(backspace);
});
it('stops when the pointer leaves the button', async () => {
const { input, backspace } = setup();
fireEvent.pointerDown(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
width: 10,
height: 10,
});
await act(() => vi.advanceTimersByTime(400));
fireEvent.pointerLeave(backspace, { pointerId: 1, pointerType: 'mouse' });
await act(() => vi.advanceTimersByTime(1000));
expect(input).toHaveValue('12345678');
fireEvent.pointerUp(input, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
});
});
it('stops on window blur and clears the timer when unmounted', async () => {
const { input, backspace, unmount } = setup();
fireEvent.pointerDown(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
width: 10,
height: 10,
});
await act(() => vi.advanceTimersByTime(400));
fireEvent.blur(window);
await act(() => vi.advanceTimersByTime(1000));
expect(input).toHaveValue('12345678');
fireEvent.pointerUp(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
});
fireEvent.click(backspace);
fireEvent.pointerDown(backspace, {
pointerId: 1,
pointerType: 'mouse',
button: 0,
width: 10,
height: 10,
});
unmount();
await act(() => vi.advanceTimersByTime(1000));
expect(input).toHaveValue('12345678');
});
it('keeps keyboard activation as a single deletion', async () => {
const { input, backspace } = setup();
await act(async () => {
backspace.focus();
});
fireEvent.keyDown(backspace, { key: ' ' });
await act(() => vi.advanceTimersByTime(1000));
expect(input).toHaveValue('123456789');
fireEvent.keyUp(backspace, { key: ' ' });
expect(input).toHaveValue('12345678');
});
});
it('updates a controlled external input from keypad actions', () => {
render(<TestCalculator />);
@@ -1,4 +1,4 @@
import React from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { ReactNode, RefObject } from 'react';
import { Button } from '@actual-app/components/button';
@@ -226,6 +226,7 @@ export function CalculatorButtons({
</CalculatorButton>
<CalculatorButton
onInteractionStart={onInteractionStart}
repeatOnHold
onPress={() => {
backspace();
}}
@@ -267,12 +268,35 @@ function CalculatorButton({
onInteractionStart,
children,
size = 20,
repeatOnHold = false,
}: {
onPress: () => void;
onInteractionStart?: () => void;
children: ReactNode;
size?: number;
repeatOnHold?: boolean;
}) {
const repeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasRepeated = useRef(false);
const stopRepeating = useCallback(() => {
if (repeatTimer.current !== null) {
clearTimeout(repeatTimer.current);
repeatTimer.current = null;
}
}, []);
useEffect(() => {
if (!repeatOnHold) {
return;
}
window.addEventListener('blur', stopRepeating);
return () => {
stopRepeating();
window.removeEventListener('blur', stopRepeating);
};
}, [repeatOnHold, stopRepeating]);
return (
<Button
variant="normal"
@@ -283,7 +307,31 @@ function CalculatorButton({
navigator.vibrate(3);
}
}}
onPress={onPress}
onPressStart={e => {
stopRepeating();
hasRepeated.current = false;
if (
!repeatOnHold ||
e.pointerType === 'keyboard' ||
e.pointerType === 'virtual'
) {
return;
}
const repeat = () => {
hasRepeated.current = true;
onPress();
repeatTimer.current = setTimeout(repeat, 100);
};
repeatTimer.current = setTimeout(repeat, 400);
}}
onPressEnd={stopRepeating}
onPress={() => {
if (!hasRepeated.current) {
onPress();
}
}}
onContextMenu={repeatOnHold ? e => e.preventDefault() : undefined}
style={{
background: theme.pillBackground,
border: 0,
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [Hrushikesh-ramilla]
---
Hold the calculator's backspace button to delete continuously.