Compare commits

...

3 Commits

Author SHA1 Message Date
Julian Dominguez-Schatz
95a931a014 Empty commit to bump ci 2025-08-25 00:39:00 -04:00
Julian Dominguez-Schatz
8f9c27b447 Merge branch 'master' into jfdoming/08-21-auto-focus-on-navigate-in-all-browsers 2025-08-23 14:13:15 -04:00
Julian Dominguez-Schatz
ff7fb9544b Auto-focus on navigate in all browsers 2025-08-21 10:09:13 -04:00
9 changed files with 187 additions and 25 deletions

View File

@@ -6,6 +6,7 @@ export class MobileTransactionEntryPage {
readonly page: Page;
readonly header: Locator;
readonly amountField: Locator;
readonly globalAmountField: Locator;
readonly transactionForm: Locator;
readonly footer: Locator;
readonly addTransactionButton: Locator;
@@ -15,6 +16,7 @@ export class MobileTransactionEntryPage {
this.header = page.getByRole('heading');
this.transactionForm = page.getByTestId('transaction-form');
this.amountField = this.transactionForm.getByTestId('amount-input');
this.globalAmountField = page.getByTestId('navigable-focus-input');
this.footer = page.getByTestId('transaction-form-footer');
this.addTransactionButton = this.footer.getByRole('button', {
name: 'Add transaction',

View File

@@ -32,7 +32,8 @@ test.describe('Mobile Transactions', () => {
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await transactionEntryPage.amountField.fill('12.34');
await expect(transactionEntryPage.globalAmountField).toBeFocused();
await transactionEntryPage.globalAmountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -62,7 +63,8 @@ test.describe('Mobile Transactions', () => {
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await expect(page).toMatchThemeScreenshots();
await transactionEntryPage.amountField.fill('12.34');
await expect(transactionEntryPage.globalAmountField).toBeFocused();
await transactionEntryPage.globalAmountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -84,7 +86,9 @@ test.describe('Mobile Transactions', () => {
test('creates an uncategorized transaction from `/categories/uncategorized` page', async () => {
// Create uncategorized transaction
let transactionEntryPage = await navigation.goToTransactionEntryPage();
await transactionEntryPage.amountField.fill('12.35');
await expect(transactionEntryPage.globalAmountField).toBeFocused();
await transactionEntryPage.globalAmountField.fill('12.35');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -117,7 +121,9 @@ test.describe('Mobile Transactions', () => {
test('creates a categorized transaction from `/categories/uncategorized` page', async () => {
// Create uncategorized transaction
let transactionEntryPage = await navigation.goToTransactionEntryPage();
await transactionEntryPage.amountField.fill('12.35');
await expect(transactionEntryPage.globalAmountField).toBeFocused();
await transactionEntryPage.globalAmountField.fill('12.35');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(

View File

@@ -0,0 +1,93 @@
import {
createContext,
useCallback,
useContext,
useRef,
useState,
type ReactNode,
type InputHTMLAttributes,
type RefObject,
} from 'react';
type InputMode = InputHTMLAttributes<HTMLInputElement>['inputMode'];
type FocusOptions = {
inputmode: InputMode;
};
type FocusInput = (opts: FocusOptions) => void;
const NavigableFocusFunctionContext = createContext<FocusInput | null>(null);
const NavigableFocusValueContext =
createContext<RefObject<HTMLInputElement | null> | null>(null);
type ProviderProps = { children: ReactNode };
export function NavigableFocusProvider({ children }: ProviderProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const inputModeRef = useRef<InputMode>('text');
const [focusedValue, setFocusedValue] = useState('');
const focusInput = useCallback((opts: FocusOptions) => {
const el = inputRef.current;
if (!el) {
return;
}
if (opts?.inputmode) {
inputModeRef.current = opts.inputmode;
el.setAttribute('inputmode', opts.inputmode);
}
el.value = ''; // Reset previous state
el.setSelectionRange(0, 0); // Move cursor to start
el.focus();
}, []);
return (
<NavigableFocusFunctionContext.Provider value={focusInput}>
<NavigableFocusValueContext.Provider value={inputRef}>
<input
data-testid="navigable-focus-input"
type="text"
ref={inputRef}
value={focusedValue}
inputMode={inputModeRef.current}
onChange={e => setFocusedValue(e.target.value)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: 1,
height: 1,
opacity: 0,
pointerEvents: 'none',
// Don't zoom in on iOS
fontSize: '16px',
}}
aria-hidden="true"
/>
{children}
</NavigableFocusValueContext.Provider>
</NavigableFocusFunctionContext.Provider>
);
}
export function useNavigableFocusFunction() {
const focusInput = useContext(NavigableFocusFunctionContext);
if (focusInput == null) {
throw new Error(
'useNavigableFocusFunction must be used within NavigableFocusFunctionProvider',
);
}
return focusInput;
}
export function useNavigableFocusRef() {
const ref = useContext(NavigableFocusValueContext);
if (ref == null) {
throw new Error(
'useNavigableFocusRef must be used within NavigableFocusRefProvider',
);
}
return ref;
}

View File

@@ -25,6 +25,7 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { useDrag } from '@use-gesture/react';
import { useNavigableFocusFunction } from '@desktop-client/components/NavigableFocusProvider';
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
const COLUMN_COUNT = 3;
@@ -67,6 +68,8 @@ export function MobileNavTabs() {
[api, OPEN_FULL_Y],
);
const focusInput = useNavigableFocusFunction();
const openDefault = useCallback(
(velocity = 0) => {
setNavbarState('default');
@@ -103,6 +106,7 @@ export function MobileNavTabs() {
path: '/transactions/new',
style: navTabStyle,
Icon: SvgAdd,
focus: { inputmode: 'decimal' as const },
},
{
name: t('Accounts'),
@@ -141,7 +145,16 @@ export function MobileNavTabs() {
Icon: SvgCog,
},
].map(tab => (
<NavTab key={tab.path} onClick={() => openDefault()} {...tab} />
<NavTab
key={tab.path}
onClick={() => {
if (tab.focus) {
focusInput(tab.focus);
}
openDefault();
}}
{...tab}
/>
));
const bufferTabsCount = COLUMN_COUNT - (navTabs.length % COLUMN_COUNT);

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAdd } from '@actual-app/components/icons/v1';
import { useNavigableFocusFunction } from '@desktop-client/components/NavigableFocusProvider';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
type AddTransactionButtonProps = {
@@ -18,13 +19,16 @@ export function AddTransactionButton({
categoryId,
}: AddTransactionButtonProps) {
const { t } = useTranslation();
const focusInput = useNavigableFocusFunction();
const navigate = useNavigate();
return (
<Button
variant="bare"
aria-label={t('Add transaction')}
style={{ margin: 10 }}
onPress={() => {
focusInput({ inputmode: 'decimal' });
navigate(to, { state: { accountId, categoryId } });
}}
>

View File

@@ -7,6 +7,7 @@ import React, {
useRef,
useState,
type CSSProperties,
type ChangeEvent,
} from 'react';
import { Button } from '@actual-app/components/button';
@@ -14,6 +15,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { useEventCallback } from 'usehooks-ts';
import {
amountToCurrency,
@@ -23,6 +25,7 @@ import {
} from 'loot-core/shared/util';
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
import { useNavigableFocusRef } from '@desktop-client/components/NavigableFocusProvider';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
@@ -59,11 +62,17 @@ const AmountInput = memo(function AmountInput({
const initialValue = Math.abs(props.value);
const globalFocusedRef = useNavigableFocusRef();
useEffect(() => {
if (focused) {
inputRef.current?.focus();
// Focus requested
if (globalFocusedRef.current !== document.activeElement) {
// Global focusable element not focused, attempt to focus input
inputRef.current?.focus();
}
}
}, [focused]);
}, [focused, globalFocusedRef]);
useEffect(() => {
setEditing(false);
@@ -71,21 +80,24 @@ const AmountInput = memo(function AmountInput({
setValue(initialValue);
}, [initialValue]);
const onKeyUp: HTMLProps<HTMLInputElement>['onKeyUp'] = e => {
if (e.key === 'Backspace' && text === '') {
setEditing(true);
} else if (e.key === 'Enter') {
props.onEnter?.(e);
if (!e.defaultPrevented) {
onUpdate(e.currentTarget.value);
const onKeyUp: HTMLProps<HTMLInputElement>['onKeyUp'] = useEventCallback(
e => {
if (e.key === 'Backspace' && text === '') {
setEditing(true);
} else if (e.key === 'Enter') {
props.onEnter?.(e);
if (!e.defaultPrevented) {
onUpdate(e.currentTarget.value);
}
}
}
};
},
);
const applyText = () => {
const parsed = currencyToAmount(text) || 0;
const newValue = editing ? parsed : value;
globalFocusedRef.current?.blur();
setValue(Math.abs(newValue));
setEditing(false);
setText('');
@@ -106,20 +118,44 @@ const AmountInput = memo(function AmountInput({
}
};
const onBlur: HTMLProps<HTMLInputElement>['onBlur'] = e => {
const onBlur: HTMLProps<HTMLInputElement>['onBlur'] = useEventCallback(e => {
props.onBlur?.(e);
if (!e.defaultPrevented) {
onUpdate(e.target.value);
}
};
});
const onChangeText = (text: string) => {
const onChangeText = useEventCallback((e: ChangeEvent<HTMLInputElement>) => {
let text = e.target.value;
text = reapplyThousandSeparators(text);
text = appendDecimals(text, String(hideFraction) === 'true');
setEditing(true);
setText(text);
props.onChangeValue?.(text);
};
});
useEffect(() => {
const el: HTMLInputElement | null = globalFocusedRef.current;
if (el === document.activeElement) {
// @ts-expect-error addEventListener missing types
el.addEventListener('blur', onBlur);
// @ts-expect-error addEventListener missing types
el.addEventListener('input', onChangeText);
// @ts-expect-error addEventListener missing types
el.addEventListener('keyup', onKeyUp);
}
return () => {
if (el) {
// @ts-expect-error addEventListener missing types
el.removeEventListener('blur', onBlur);
// @ts-expect-error addEventListener missing types
el.removeEventListener('input', onChangeText);
// @ts-expect-error addEventListener missing types
el.removeEventListener('keyup', onKeyUp);
}
};
}, [onBlur, onChangeText, onKeyUp, globalFocusedRef]);
const input = (
<input
@@ -128,7 +164,7 @@ const AmountInput = memo(function AmountInput({
value={text}
inputMode="decimal"
autoCapitalize="none"
onChange={e => onChangeText(e.target.value)}
onChange={onChangeText}
onFocus={onFocus}
onBlur={onBlur}
onKeyUp={onKeyUp}

View File

@@ -32,7 +32,6 @@ import {
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import * as Platform from 'loot-core/shared/platform';
import { q } from 'loot-core/shared/query';
import { getStatusLabel } from 'loot-core/shared/schedules';
import {
@@ -510,7 +509,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
const [totalAmountFocused, setTotalAmountFocused] = useState(
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
!Platform.isIOSAgent,
true,
);
const childTransactionElementRefMap = useRef({});
const hasAccountChanged = useRef(false);
@@ -528,7 +527,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
const isInitialMount = useInitialMount();
useEffect(() => {
if (isInitialMount && isAdding && !Platform.isIOSAgent) {
if (isInitialMount && isAdding) {
onTotalAmountEdit();
}
}, [isAdding, isInitialMount, onTotalAmountEdit]);

View File

@@ -25,6 +25,7 @@ import * as budgetfilesSlice from './budgetfiles/budgetfilesSlice';
// focus outline appear from keyboard events.
import 'focus-visible';
import { App } from './components/App';
import { NavigableFocusProvider } from './components/NavigableFocusProvider';
import { ServerProvider } from './components/ServerContext';
import * as modalsSlice from './modals/modalsSlice';
import * as notificationsSlice from './notifications/notificationsSlice';
@@ -94,7 +95,9 @@ root.render(
<Provider store={store}>
<ServerProvider>
<AuthProvider>
<App />
<NavigableFocusProvider>
<App />
</NavigableFocusProvider>
</AuthProvider>
</ServerProvider>
</Provider>,

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jfdoming]
---
Auto-focus on navigate in all browsers