mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-11 22:49:59 +00:00
[AI] Add press-and-hold calculator backspace (#9083)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user