mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
Compare commits
3 Commits
v26.2.1
...
jfdoming/0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a931a014 | ||
|
|
8f9c27b447 | ||
|
|
ff7fb9544b |
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
6
upcoming-release-notes/5595.md
Normal file
6
upcoming-release-notes/5595.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Auto-focus on navigate in all browsers
|
||||
Reference in New Issue
Block a user