Compare commits

...

7 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
d2bf2e9cc9 Fix InputWithContent 2024-11-06 09:19:36 -08:00
Joel Jeremy Marquez
7e1cc49478 Fix import 2024-11-04 15:50:42 -08:00
Joel Jeremy Marquez
e6a49b1d99 Rename className var 2024-11-04 15:48:59 -08:00
Joel Jeremy Marquez
db7d890e79 Fix styling issues 2024-11-04 15:48:32 -08:00
Joel Jeremy Marquez
46977b59ca Delay Input autoSelect a bit 2024-11-04 15:45:28 -08:00
Joel Jeremy Marquez
b3d0348493 Remove FocusScope contain 2024-11-04 15:45:28 -08:00
Joel Jeremy Marquez
b78f1fd575 Use react-aria-component input as base of Actual's Input component 2024-11-04 15:45:28 -08:00
59 changed files with 2286 additions and 2174 deletions

View File

@@ -4,7 +4,6 @@ import { SvgPencil1 } from '../icons/v2';
import { theme } from '../style';
import { Button } from './common/Button2';
import { InitialFocus } from './common/InitialFocus';
import { Input } from './common/Input';
import { View } from './common/View';
@@ -33,24 +32,24 @@ export function EditablePageHeaderTitle({
if (isEditing) {
return (
<InitialFocus>
<Input
defaultValue={title}
onEnter={e => onSaveValue(e.currentTarget.value)}
onBlur={e => onSaveValue(e.target.value)}
onEscape={() => setIsEditing(false)}
style={{
fontSize: 25,
fontWeight: 500,
marginTop: -3,
marginBottom: -3,
marginLeft: -6,
paddingTop: 2,
paddingBottom: 2,
width: Math.max(20, title.length) + 'ch',
}}
/>
</InitialFocus>
<Input
autoFocus
autoSelect
defaultValue={title}
onEnter={e => onSaveValue(e.currentTarget.value)}
onBlur={e => onSaveValue(e.target.value)}
onEscape={() => setIsEditing(false)}
style={{
fontSize: 25,
fontWeight: 500,
marginTop: -3,
marginBottom: -3,
marginLeft: -6,
paddingTop: 2,
paddingBottom: 2,
width: Math.max(20, title.length) + 'ch',
}}
/>
);
}

View File

@@ -294,7 +294,7 @@ export function ManageRules({
<Search
placeholder="Filter rules..."
value={filter}
onChange={onSearchChange}
onChangeValue={onSearchChange}
/>
</View>
<View style={{ flex: 1 }}>

View File

@@ -30,7 +30,6 @@ import {
import { theme, styles } from '../../style';
import { AnimatedRefresh } from '../AnimatedRefresh';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Menu } from '../common/Menu';
import { MenuButton } from '../common/MenuButton';
@@ -345,8 +344,8 @@ export function AccountHeader({
<Search
placeholder={t('Search')}
value={search}
onChange={onSearch}
inputRef={searchInput}
onChangeValue={onSearch}
ref={searchInput}
/>
{workingHard ? (
<View>
@@ -574,24 +573,24 @@ function AccountNameField({
if (editingName) {
return (
<Fragment>
<InitialFocus>
<Input
defaultValue={accountName}
onEnter={e => onSaveName(e.currentTarget.value)}
onBlur={e => onSaveName(e.target.value)}
onEscape={() => onExposeName(false)}
style={{
fontSize: 25,
fontWeight: 500,
marginTop: -3,
marginBottom: -4,
marginLeft: -6,
paddingTop: 2,
paddingBottom: 2,
width: Math.max(20, accountName.length) + 'ch',
}}
/>
</InitialFocus>
<Input
autoFocus
autoSelect
defaultValue={accountName}
onEnter={e => onSaveName(e.target.value)}
onBlur={e => onSaveName(e.target.value)}
onEscape={() => onExposeName(false)}
style={{
fontSize: 25,
fontWeight: 500,
marginTop: -3,
marginBottom: -4,
marginLeft: -6,
paddingTop: 2,
paddingBottom: 2,
width: Math.max(20, accountName.length) + 'ch',
}}
/>
{saveNameError && (
<View style={{ color: theme.warningText }}>{saveNameError}</View>
)}

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useRef, useState } from 'react';
import { Form } from 'react-aria-components';
import { Trans } from 'react-i18next';
import * as queries from 'loot-core/src/client/queries';
@@ -9,7 +10,6 @@ import { type AccountEntity } from 'loot-core/types/models';
import { SvgCheckCircle1 } from '../../icons/v2';
import { styles, theme } from '../../style';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -124,43 +124,49 @@ export function ReconcileMenu({
});
const format = useFormat();
const [inputValue, setInputValue] = useState<string | null>(null);
const [inputFocused, setInputFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function onSubmit() {
if (inputValue === '') {
setInputFocused(true);
return;
}
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const amount =
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
if (inputValue === '') {
inputRef.current?.focus();
return;
}
onReconcile(amount);
onClose();
}
const amount =
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
onReconcile(amount);
onClose();
},
[clearedBalance, inputValue, onClose, onReconcile],
);
return (
<View style={{ padding: '5px 8px' }}>
<Text>
<Trans>
Enter the current balance of your bank account that you want to
reconcile with:
</Trans>
</Text>
{clearedBalance != null && (
<InitialFocus>
<Form onSubmit={onSubmit}>
<View style={{ padding: '5px 8px' }}>
<Text>
<Trans>
Enter the current balance of your bank account that you want to
reconcile with:
</Trans>
</Text>
{clearedBalance != null && (
<Input
ref={inputRef}
defaultValue={format(clearedBalance, 'financial')}
onChangeValue={setInputValue}
style={{ margin: '7px 0' }}
focused={inputFocused}
onEnter={onSubmit}
autoFocus
autoSelect
/>
</InitialFocus>
)}
<Button variant="primary" onPress={onSubmit}>
<Trans>Reconcile</Trans>
</Button>
</View>
)}
<Button variant="primary" type="submit">
<Trans>Reconcile</Trans>
</Button>
</View>
</Form>
);
}

View File

@@ -170,7 +170,7 @@ type AccountItemProps = {
function AccountItem({
item,
className,
className = '',
highlighted,
embedded,
...props

View File

@@ -4,11 +4,12 @@ import React, {
useRef,
useEffect,
useMemo,
type ComponentProps,
type HTMLProps,
type ReactNode,
type KeyboardEvent,
type ChangeEvent,
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
} from 'react';
import { css, cx } from '@emotion/css';
@@ -25,18 +26,16 @@ import { View } from '../common/View';
import { useResponsive } from '../responsive/ResponsiveProvider';
type CommonAutocompleteProps<T extends Item> = {
focused?: boolean;
autoFocus?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
inputProps?: ComponentPropsWithRef<typeof Input>;
suggestions?: T[];
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderInput?: (props: ComponentPropsWithRef<typeof Input>) => ReactNode;
renderItems?: (
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
idx: number,
value?: string,
) => ReactNode;
@@ -138,14 +137,14 @@ function fireUpdate<T extends Item>(
onUpdate?.(selected, value);
}
function defaultRenderInput(props: ComponentProps<typeof Input>) {
function defaultRenderInput(props: ComponentPropsWithRef<typeof Input>) {
// data-1p-ignore disables 1Password autofill behaviour
return <Input data-1p-ignore {...props} />;
}
function defaultRenderItems<T extends Item>(
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
highlightedIndex: number,
) {
return (
@@ -210,7 +209,7 @@ type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
};
function SingleAutocomplete<T extends Item>({
focused,
autoFocus,
embedded = false,
containerProps,
labelProps = {},
@@ -450,8 +449,8 @@ function SingleAutocomplete<T extends Item>({
<View ref={triggerRef} style={{ flexShrink: 0 }}>
{renderInput(
getInputProps({
focused,
...inputProps,
autoFocus,
onFocus: e => {
inputProps.onFocus?.(e);
@@ -550,8 +549,9 @@ function SingleAutocomplete<T extends Item>({
}
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const { onChange } = inputProps || {};
onChange?.(e.target.value);
const { onChangeValue, onChange } = inputProps || {};
onChangeValue?.(e.target.value);
onChange?.(e);
},
}),
)}
@@ -641,7 +641,6 @@ function MultiAutocomplete<T extends Item>({
clearOnBlur = true,
...props
}: MultiAutocompleteProps<T>) {
const [focused, setFocused] = useState(false);
const selectedItemIds = selectedItems.map(getItemId);
function onRemoveItem(id: T['id']) {
@@ -658,7 +657,7 @@ function MultiAutocomplete<T extends Item>({
function onKeyDown(
e: KeyboardEvent<HTMLInputElement>,
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
prevOnKeyDown?: ComponentPropsWithoutRef<typeof Input>['onKeyDown'],
) {
if (e.key === 'Backspace' && e.currentTarget.value === '') {
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
@@ -682,7 +681,7 @@ function MultiAutocomplete<T extends Item>({
strict={strict}
renderInput={inputProps => (
<View
style={{
className={`${css({
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
@@ -690,11 +689,11 @@ function MultiAutocomplete<T extends Item>({
backgroundColor: theme.tableBackground,
borderRadius: 4,
border: '1px solid ' + theme.formInputBorder,
...(focused && {
'&:focus-within': {
border: '1px solid ' + theme.formInputBorderSelected,
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
}),
}}
},
})} ${inputProps.className || ''}`}
>
{selectedItems.map((item, idx) => {
item = findItem(strict, suggestions, item);
@@ -711,21 +710,14 @@ function MultiAutocomplete<T extends Item>({
<Input
{...inputProps}
onKeyDown={e => onKeyDown(e, inputProps.onKeyDown)}
onFocus={e => {
setFocused(true);
inputProps.onFocus(e);
}}
onBlur={e => {
setFocused(false);
inputProps.onBlur(e);
}}
style={{
flex: 1,
minWidth: 30,
border: 0,
':focus': { border: 0, boxShadow: 'none' },
...inputProps.style,
}}
className={String(
css({
flex: 1,
minWidth: 30,
border: 0,
'&[data-focused]': { border: 0, boxShadow: 'none' },
}),
)}
/>
</View>
)}
@@ -761,8 +753,8 @@ export function AutocompleteFooter({
}
type AutocompleteProps<T extends Item> =
| ComponentProps<typeof SingleAutocomplete<T>>
| ComponentProps<typeof MultiAutocomplete<T>>;
| ComponentPropsWithoutRef<typeof SingleAutocomplete<T>>
| ComponentPropsWithoutRef<typeof MultiAutocomplete<T>>;
export function Autocomplete<T extends Item>({
...props

View File

@@ -365,7 +365,7 @@ type CategoryItemProps = {
function CategoryItem({
item,
className,
className = '',
style,
highlighted,
embedded,

View File

@@ -341,8 +341,6 @@ export function PayeeAutocomplete({
}
}
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
return (
<Autocomplete
key={focusTransferPayees ? 'transfers' : 'all'}
@@ -360,16 +358,11 @@ export function PayeeAutocomplete({
}
return item.name;
}}
focused={payeeFieldFocused}
inputProps={{
...inputProps,
autoCapitalize: 'words',
onBlur: () => {
setRawPayee('');
setPayeeFieldFocused(false);
},
onFocus: () => setPayeeFieldFocused(true),
onChange: setRawPayee,
onBlur: () => setRawPayee(''),
onChangeValue: setRawPayee,
}}
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect}
@@ -556,7 +549,7 @@ type PayeeItemProps = {
function PayeeItem({
item,
className,
className = '',
highlighted,
embedded,
...props

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
import { Form } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { type CategoryEntity } from 'loot-core/src/types/models';
@@ -6,7 +7,6 @@ import { type CategoryEntity } from 'loot-core/src/types/models';
import { useCategories } from '../../../hooks/useCategories';
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
import { Button } from '../../common/Button2';
import { InitialFocus } from '../../common/InitialFocus';
import { View } from '../../common/View';
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
@@ -39,52 +39,55 @@ export function CoverMenu({
: categoryGroups;
}, [categoryId, showToBeBudgeted, originalCategoryGroups]);
function submit() {
if (fromCategoryId) {
onSubmit(fromCategoryId);
}
onClose();
}
const onSubmitInner = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (fromCategoryId) {
onSubmit(fromCategoryId);
}
onClose();
},
[fromCategoryId, onSubmit, onClose],
);
return (
<View style={{ padding: 10 }}>
<View style={{ marginBottom: 5 }}>
<Trans>Cover from category:</Trans>
</View>
<Form onSubmit={onSubmitInner}>
<View style={{ padding: 10 }}>
<View style={{ marginBottom: 5 }}>
<Trans>Cover from category:</Trans>
</View>
<InitialFocus>
{node => (
<CategoryAutocomplete
categoryGroups={filteredCategoryGroups}
value={null}
openOnFocus={true}
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
inputProps={{
inputRef: node,
onEnter: event => !event.defaultPrevented && submit(),
placeholder: t('(none)'),
}}
showHiddenCategories={false}
/>
)}
</InitialFocus>
<View
style={{
alignItems: 'flex-end',
marginTop: 10,
}}
>
<Button
variant="primary"
style={{
fontSize: 12,
paddingTop: 3,
<CategoryAutocomplete
categoryGroups={filteredCategoryGroups}
value={null}
openOnFocus={true}
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
inputProps={{
placeholder: t('(none)'),
}}
showHiddenCategories={false}
autoFocus
/>
<View
style={{
alignItems: 'flex-end',
marginTop: 10,
}}
onPress={submit}
>
<Trans>Transfer</Trans>
</Button>
<Button
variant="primary"
type="submit"
style={{
fontSize: 12,
paddingTop: 3,
}}
>
<Trans>Transfer</Trans>
</Button>
</View>
</View>
</View>
</Form>
);
}

View File

@@ -22,7 +22,11 @@ import { Button } from '../../common/Button2';
import { Popover } from '../../common/Popover';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
import { type Binding, type SheetFields } from '../../spreadsheet';
import {
type SheetResult,
type Binding,
type SheetFields,
} from '../../spreadsheet';
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
import { useSheetName } from '../../spreadsheet/useSheetName';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
@@ -41,8 +45,11 @@ export function useEnvelopeSheetName<
export function useEnvelopeSheetValue<
FieldName extends SheetFields<'envelope-budget'>,
>(binding: Binding<'envelope-budget', FieldName>) {
return useSheetValue(binding);
>(
binding: Binding<'envelope-budget', FieldName>,
onChange?: (result: SheetResult<'envelope-budget', FieldName>) => void,
) {
return useSheetValue(binding, onChange);
}
export const EnvelopeCellValue = <

View File

@@ -1,45 +1,48 @@
import React, {
useState,
useContext,
useEffect,
type ChangeEvent,
} from 'react';
import React, { useState, useCallback, type FormEvent, useRef } from 'react';
import { Form } from 'react-aria-components';
import { Trans } from 'react-i18next';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import { envelopeBudget } from 'loot-core/client/queries';
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
import { Button } from '../../common/Button2';
import { InitialFocus } from '../../common/InitialFocus';
import { Input } from '../../common/Input';
import { View } from '../../common/View';
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
type HoldMenuProps = {
onSubmit: (amount: number) => void;
onClose: () => void;
};
export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
const spreadsheet = useSpreadsheet();
const sheetName = useContext(NamespaceContext);
const [amount, setAmount] = useState<string>(
integerToCurrency(
useEnvelopeSheetValue(envelopeBudget.toBudget, result => {
setAmount(integerToCurrency(result?.value ?? 0));
}) ?? 0,
),
);
const inputRef = useRef<HTMLInputElement>(null);
const [amount, setAmount] = useState<string | null>(null);
const onSubmitInner = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
useEffect(() => {
(async () => {
const node = await spreadsheet.get(sheetName, 'to-budget');
setAmount(integerToCurrency(Math.max(node.value as number, 0)));
})();
}, []);
if (amount === '') {
inputRef.current?.focus();
return;
}
function submit(newAmount: string) {
const parsedAmount = evalArithmetic(newAmount);
if (parsedAmount) {
onSubmit(amountToInteger(parsedAmount));
}
onClose();
}
const parsedAmount = evalArithmetic(amount);
if (parsedAmount) {
onSubmit(amountToInteger(parsedAmount));
}
onClose();
},
[amount, onSubmit, onClose],
);
if (amount === null) {
// See `TransferMenu` for more info about this
@@ -47,39 +50,37 @@ export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
}
return (
<View style={{ padding: 10 }}>
<View style={{ marginBottom: 5 }}>
<Trans>Hold this amount:</Trans>
</View>
<View>
<InitialFocus>
<Input
value={amount}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setAmount(e.target.value)
}
onEnter={() => submit(amount)}
/>
</InitialFocus>
</View>
<View
style={{
alignItems: 'flex-end',
marginTop: 10,
}}
>
<Button
variant="primary"
<Form onSubmit={onSubmitInner}>
<View style={{ padding: 10 }}>
<View style={{ marginBottom: 5 }}>
<Trans>Hold this amount:</Trans>
</View>
<Input
ref={inputRef}
value={amount}
onChangeValue={(value: string) => setAmount(value)}
autoFocus
autoSelect
/>
<View
style={{
fontSize: 12,
paddingTop: 3,
paddingBottom: 3,
alignItems: 'flex-end',
marginTop: 10,
}}
onPress={() => submit(amount)}
>
<Trans>Hold</Trans>
</Button>
<Button
variant="primary"
type="submit"
style={{
fontSize: 12,
paddingTop: 3,
paddingBottom: 3,
}}
>
<Trans>Hold</Trans>
</Button>
</View>
</View>
</View>
</Form>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
import { Form } from 'react-aria-components';
import { Trans } from 'react-i18next';
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
@@ -8,7 +9,6 @@ import { type CategoryEntity } from 'loot-core/types/models';
import { useCategories } from '../../../hooks/useCategories';
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
import { Button } from '../../common/Button2';
import { InitialFocus } from '../../common/InitialFocus';
import { Input } from '../../common/Input';
import { View } from '../../common/View';
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
@@ -42,65 +42,67 @@ export function TransferMenu({
}, [originalCategoryGroups, categoryId, showToBeBudgeted]);
const _initialAmount = integerToCurrency(Math.max(initialAmount, 0));
const [amount, setAmount] = useState<string | null>(null);
const [amount, setAmount] = useState<string>(_initialAmount);
const [toCategoryId, setToCategoryId] = useState<string | null>(null);
const _onSubmit = (newAmount: string | null, categoryId: string | null) => {
const parsedAmount = evalArithmetic(newAmount || '');
if (parsedAmount && categoryId) {
onSubmit?.(amountToInteger(parsedAmount), categoryId);
}
const _onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onClose();
};
const parsedAmount = evalArithmetic(amount || '');
if (parsedAmount && toCategoryId) {
onSubmit?.(amountToInteger(parsedAmount), toCategoryId);
}
onClose();
},
[amount, toCategoryId, onSubmit, onClose],
);
return (
<View style={{ padding: 10 }}>
<View style={{ marginBottom: 5 }}>
<Trans>Transfer this amount:</Trans>
</View>
<View>
<InitialFocus>
<Input
defaultValue={_initialAmount}
onUpdate={value => setAmount(value)}
onEnter={() => _onSubmit(amount, toCategoryId)}
/>
</InitialFocus>
</View>
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
<Form onSubmit={_onSubmit}>
<View style={{ padding: 10 }}>
<View style={{ marginBottom: 5 }}>
<Trans>Transfer this amount:</Trans>
</View>
<Input
value={amount}
onChangeValue={value => setAmount(value)}
autoFocus
autoSelect
/>
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
<CategoryAutocomplete
categoryGroups={filteredCategoryGroups}
value={null}
openOnFocus={true}
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
inputProps={{
onEnter: event =>
!event.defaultPrevented && _onSubmit(amount, toCategoryId),
placeholder: '(none)',
}}
showHiddenCategories={true}
/>
<View
style={{
alignItems: 'flex-end',
marginTop: 10,
}}
>
<Button
variant="primary"
style={{
fontSize: 12,
paddingTop: 3,
paddingBottom: 3,
<CategoryAutocomplete
categoryGroups={filteredCategoryGroups}
value={null}
openOnFocus={true}
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
inputProps={{
placeholder: '(none)',
}}
showHiddenCategories={true}
/>
<View
style={{
alignItems: 'flex-end',
marginTop: 10,
}}
onPress={() => _onSubmit(amount, toCategoryId)}
>
<Trans>Transfer</Trans>
</Button>
<Button
variant="primary"
type="submit"
style={{
fontSize: 12,
paddingTop: 3,
paddingBottom: 3,
}}
>
<Trans>Transfer</Trans>
</Button>
</View>
</View>
</View>
</Form>
);
}

View File

@@ -22,7 +22,11 @@ import { Button } from '../../common/Button2';
import { Popover } from '../../common/Popover';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
import { type Binding, type SheetFields } from '../../spreadsheet';
import {
type SheetResult,
type Binding,
type SheetFields,
} from '../../spreadsheet';
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { Field, SheetCell, type SheetCellProps } from '../../table';
@@ -36,8 +40,9 @@ export const useTrackingSheetValue = <
FieldName extends SheetFields<'tracking-budget'>,
>(
binding: Binding<'tracking-budget', FieldName>,
onChange?: (result: SheetResult<'tracking-budget', FieldName>) => void,
) => {
return useSheetValue(binding);
return useSheetValue(binding, onChange);
};
const TrackingCellValue = <FieldName extends SheetFields<'tracking-budget'>>(

View File

@@ -177,7 +177,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
className={
typeof className === 'function'
? renderProps =>
`${defaultButtonClassName} ${className(renderProps)}`
`${defaultButtonClassName} ${className(renderProps) || ''}`
: `${defaultButtonClassName} ${className || ''}`
}
>

View File

@@ -1,36 +0,0 @@
import {
type ReactElement,
type Ref,
cloneElement,
useEffect,
useRef,
} from 'react';
type InitialFocusProps = {
children:
| ReactElement<{ inputRef: Ref<HTMLInputElement> }>
| ((node: Ref<HTMLInputElement>) => ReactElement);
};
export function InitialFocus({ children }: InitialFocusProps) {
const node = useRef<HTMLInputElement>(null);
useEffect(() => {
if (node.current) {
// This is needed to avoid a strange interaction with
// `ScopeTab`, which doesn't allow it to be focused at first for
// some reason. Need to look into it.
setTimeout(() => {
if (node.current) {
node.current.focus();
node.current.setSelectionRange(0, 10000);
}
}, 0);
}
}, []);
if (typeof children === 'function') {
return children(node);
}
return cloneElement(children, { inputRef: node });
}

View File

@@ -1,110 +1,138 @@
import React, {
type InputHTMLAttributes,
type KeyboardEvent,
type Ref,
type CSSProperties,
type ComponentPropsWithRef,
forwardRef,
useEffect,
useRef,
useMemo,
} from 'react';
import { Input as ReactAriaInput } from 'react-aria-components';
import { css, cx } from '@emotion/css';
import { useMergedRefs } from '../../hooks/useMergedRefs';
import { useProperFocus } from '../../hooks/useProperFocus';
import { styles, theme } from '../../style';
export const defaultInputStyle = {
outline: 0,
backgroundColor: theme.tableBackground,
color: theme.formInputText,
margin: 0,
padding: 5,
borderRadius: 4,
border: '1px solid ' + theme.formInputBorder,
};
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
style?: CSSProperties;
inputRef?: Ref<HTMLInputElement>;
type InputProps = ComponentPropsWithRef<typeof ReactAriaInput> & {
autoSelect?: boolean;
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
onEscape?: (event: KeyboardEvent<HTMLInputElement>) => void;
onChangeValue?: (newValue: string) => void;
onUpdate?: (newValue: string) => void;
focused?: boolean;
};
export function Input({
style,
inputRef,
onEnter,
onEscape,
onChangeValue,
onUpdate,
focused,
className,
...nativeProps
}: InputProps) {
const ref = useRef<HTMLInputElement>(null);
useProperFocus(ref, focused);
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
autoSelect,
className = '',
onEnter,
onEscape,
onChangeValue,
onUpdate,
...props
},
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
const mergedRef = useMergedRefs<HTMLInputElement>(ref, inputRef);
useEffect(() => {
if (autoSelect) {
// Select on mount does not work properly for inputs that are inside a dialog.
// See https://github.com/facebook/react/issues/23301#issuecomment-1656908450
// for the reason why we need to use setTimeout here.
setTimeout(() => inputRef.current?.select());
}
}, [autoSelect]);
return (
<input
ref={mergedRef}
className={cx(
const defaultInputClassName = useMemo(
() =>
css(
defaultInputStyle,
{
outline: 0,
backgroundColor: theme.tableBackground,
color: theme.formInputText,
margin: 0,
padding: 5,
borderRadius: 4,
border: '1px solid ' + theme.formInputBorder,
whiteSpace: 'nowrap',
overflow: 'hidden',
flexShrink: 0,
':focus': {
'&[data-focused]': {
border: '1px solid ' + theme.formInputBorderSelected,
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
},
'::placeholder': { color: theme.formInputTextPlaceholder },
'&::placeholder': { color: theme.formInputTextPlaceholder },
},
styles.smallText,
style,
),
className,
)}
{...nativeProps}
onKeyDown={e => {
nativeProps.onKeyDown?.(e);
[],
);
if (e.key === 'Enter' && onEnter) {
onEnter(e);
return (
<ReactAriaInput
ref={mergedRef}
{...props}
className={
typeof className === 'function'
? renderProps => cx(defaultInputClassName, className(renderProps))
: cx(defaultInputClassName, className)
}
onKeyDown={e => {
props.onKeyDown?.(e);
if (e.key === 'Escape' && onEscape) {
onEscape(e);
if (e.key === 'Enter' && onEnter) {
onEnter(e);
}
if (e.key === 'Escape' && onEscape) {
onEscape(e);
}
}}
onBlur={e => {
onUpdate?.(e.target.value);
props.onBlur?.(e);
}}
onChange={e => {
onChangeValue?.(e.target.value);
props.onChange?.(e);
}}
/>
);
},
);
Input.displayName = 'Input';
type BigInputProps = InputProps;
export const BigInput = forwardRef<HTMLInputElement, BigInputProps>(
({ className, ...props }, ref) => {
const defaultClassName = useMemo(
() =>
String(
css({
padding: 10,
fontSize: 15,
'&, &[data-focused]': { border: 'none', ...styles.shadow },
}),
),
[],
);
return (
<Input
ref={ref}
{...props}
className={renderProps =>
typeof className === 'function'
? cx(defaultClassName, className(renderProps))
: cx(defaultClassName, className)
}
}}
onBlur={e => {
onUpdate?.(e.target.value);
nativeProps.onBlur?.(e);
}}
onChange={e => {
onChangeValue?.(e.target.value);
nativeProps.onChange?.(e);
}}
/>
);
}
/>
);
},
);
export function BigInput(props: InputProps) {
return (
<Input
{...props}
style={{
padding: 10,
fontSize: 15,
border: 'none',
...styles.shadow,
':focus': { border: 'none', ...styles.shadow },
...props.style,
}}
/>
);
}
BigInput.displayName = 'BigInput';

View File

@@ -1,74 +1,48 @@
import {
useState,
type ComponentProps,
type ReactNode,
type CSSProperties,
} from 'react';
import { type ComponentPropsWithRef, type ReactNode, forwardRef } from 'react';
import { css, cx } from '@emotion/css';
import { theme } from '../../style';
import { Input, defaultInputStyle } from './Input';
import { Input } from './Input';
import { View } from './View';
type InputWithContentProps = ComponentProps<typeof Input> & {
type InputWithContentProps = ComponentPropsWithRef<typeof Input> & {
leftContent?: ReactNode;
rightContent?: ReactNode;
inputStyle?: CSSProperties;
focusStyle?: CSSProperties;
style?: CSSProperties;
getStyle?: (focused: boolean) => CSSProperties;
containerClassName?: string;
};
export function InputWithContent({
leftContent,
rightContent,
inputStyle,
focusStyle,
style,
getStyle,
...props
}: InputWithContentProps) {
const [focused, setFocused] = useState(props.focused ?? false);
export const InputWithContent = forwardRef<
HTMLInputElement,
InputWithContentProps
>(({ leftContent, rightContent, containerClassName, ...props }, ref) => {
return (
<View
style={{
...defaultInputStyle,
padding: 0,
flexDirection: 'row',
alignItems: 'center',
...style,
...(focused &&
(focusStyle ?? {
className={cx(
css({
backgroundColor: theme.tableBackground,
color: theme.formInputText,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 4,
'&:focus-within': {
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
})),
...getStyle?.(focused),
}}
>
{leftContent}
<Input
{...props}
focused={focused}
style={{
width: '100%',
...inputStyle,
flex: 1,
'&, &:focus, &:hover': {
},
'& input, input[data-focused], input[data-hovered]': {
border: 0,
backgroundColor: 'transparent',
boxShadow: 'none',
color: 'inherit',
},
}}
onFocus={e => {
setFocused(true);
props.onFocus?.(e);
}}
onBlur={e => {
setFocused(false);
props.onBlur?.(e);
}}
/>
}),
containerClassName,
)}
>
{leftContent}
<Input ref={ref} {...props} />
{rightContent}
</View>
);
}
});
InputWithContent.displayName = 'InputWithContent';

View File

@@ -8,6 +8,7 @@ import React, {
type ComponentPropsWithRef,
type CSSProperties,
} from 'react';
import { FocusScope } from 'react-aria';
import {
ModalOverlay as ReactAriaModalOverlay,
Modal as ReactAriaModal,
@@ -22,9 +23,9 @@ import { useModalState } from '../../hooks/useModalState';
import { AnimatedLoading } from '../../icons/AnimatedLoading';
import { SvgLogo } from '../../icons/logo';
import { SvgDelete } from '../../icons/v0';
import { useResponsive } from '../../ResponsiveProvider';
import { styles, theme } from '../../style';
import { tokens } from '../../tokens';
import { useResponsive } from '../responsive/ResponsiveProvider';
import { Button } from './Button2';
import { Input } from './Input';
@@ -132,9 +133,11 @@ export const Modal = ({
}}
>
<View style={{ paddingTop: 0, flex: 1, flexShrink: 0 }}>
{typeof children === 'function'
? children(modalProps)
: children}
<FocusScope autoFocus restoreFocus>
{typeof children === 'function'
? children(modalProps)
: children}
</FocusScope>
</View>
{isLoading && (
<View
@@ -402,14 +405,15 @@ export function ModalTitle({
return isEditing ? (
<Input
inputRef={inputRef}
ref={inputRef}
style={{
fontSize: 25,
fontWeight: 700,
textAlign: 'center',
...style,
}}
focused={isEditing}
autoFocus={isEditing}
autoSelect={isEditing}
defaultValue={title}
onUpdate={_onTitleUpdate}
onKeyDown={e => {

View File

@@ -1,92 +1,89 @@
import { type Ref } from 'react';
import { type ComponentPropsWithRef, forwardRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from '@emotion/css';
import { SvgRemove, SvgSearchAlternate } from '../../icons/v2';
import { theme } from '../../style';
import { Button } from './Button2';
import { type Input } from './Input';
import { InputWithContent } from './InputWithContent';
import { View } from './View';
type SearchProps = {
inputRef?: Ref<HTMLInputElement>;
value: string;
onChange: (value: string) => void;
placeholder: string;
type SearchProps = ComponentPropsWithRef<typeof Input> & {
isInModal?: boolean;
width?: number;
};
export function Search({
inputRef,
value,
onChange,
placeholder,
isInModal = false,
width = 250,
}: SearchProps) {
const { t } = useTranslation();
return (
<InputWithContent
inputRef={inputRef}
style={{
width,
flex: '',
borderColor: isInModal ? undefined : 'transparent',
backgroundColor: isInModal ? undefined : theme.formInputBackground,
}}
focusStyle={
isInModal
? undefined
: {
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
backgroundColor: theme.formInputBackgroundSelected,
}
}
leftContent={
<SvgSearchAlternate
style={{
width: 13,
height: 13,
flexShrink: 0,
color: value ? theme.menuItemTextSelected : 'inherit',
margin: 5,
marginRight: 0,
}}
/>
}
rightContent={
value && (
<View title={t('Clear search term')}>
<Button
variant="bare"
style={{ padding: 8 }}
onPress={() => onChange('')}
>
<SvgRemove style={{ width: 8, height: 8 }} />
</Button>
</View>
)
}
inputStyle={{
'::placeholder': {
color: theme.formInputTextPlaceholder,
transition: 'color .25s',
},
':focus': isInModal
? {}
: {
'::placeholder': {
color: theme.formInputTextPlaceholderSelected,
export const Search = forwardRef<HTMLInputElement, SearchProps>(
({ value, onChangeValue, isInModal = false, width = 250, ...props }, ref) => {
const { t } = useTranslation();
const defaultClassName = useMemo(
() =>
css({
width,
// flex: '',
borderColor: isInModal ? undefined : 'transparent',
backgroundColor: isInModal ? undefined : theme.formInputBackground,
'&:focus-within': isInModal
? {}
: {
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
backgroundColor: theme.formInputBackgroundSelected,
},
'& input': {
flex: 1,
'::placeholder': {
color: theme.formInputTextPlaceholder,
transition: 'color .25s',
},
}}
value={value}
placeholder={placeholder}
onKeyDown={e => {
if (e.key === 'Escape') onChange('');
}}
onChangeValue={value => onChange(value)}
/>
);
}
'[data-focused]': isInModal
? {}
: {
'::placeholder': {
color: theme.formInputTextPlaceholderSelected,
},
},
},
}),
[isInModal, width],
);
return (
<InputWithContent
ref={ref}
containerClassName={defaultClassName}
leftContent={
<SvgSearchAlternate
style={{
width: 13,
height: 13,
flexShrink: 0,
color: value ? theme.menuItemTextSelected : 'inherit',
margin: '5px 0 5px 5px',
}}
/>
}
rightContent={
value && (
<View title={t('Clear search term')}>
<Button
variant="bare"
style={{ padding: 8 }}
onPress={() => onChangeValue?.('')}
>
<SvgRemove style={{ width: 8, height: 8 }} />
</Button>
</View>
)
}
value={value}
onEscape={() => onChangeValue?.('')}
onChangeValue={onChangeValue}
{...props}
/>
);
},
);
Search.displayName = 'Search';

View File

@@ -207,7 +207,7 @@ function ConfigureField({
>
{type !== 'boolean' && (
<GenericInput
inputRef={inputRef}
ref={inputRef}
field={field}
subfield={subfield}
type={

View File

@@ -56,7 +56,7 @@ export function NameFilter({
/>
<Input
id="name-field"
inputRef={inputRef}
ref={inputRef}
defaultValue={name || ''}
onChangeValue={setName}
/>

View File

@@ -53,7 +53,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
({ disabled, style, onUpdate, ...props }, ref) => {
return (
<Input
inputRef={ref}
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}

View File

@@ -340,7 +340,8 @@ const ChildTransactionEdit = forwardRef(
editingField &&
editingField !== getFieldName(transaction.id, 'amount')
}
focused={amountFocused}
autoFocus={amountFocused}
autoSelect={amountFocused}
value={amountToInteger(transaction.amount)}
zeroSign={amountSign}
style={{ marginRight: 8 }}

View File

@@ -69,7 +69,7 @@ export function AccountAutocompleteModal({
)}
<View style={{ flex: 1 }}>
<AccountAutocomplete
focused={true}
autoFocus={true}
embedded={true}
closeOnBlur={false}
onClose={close}

View File

@@ -78,7 +78,7 @@ export function CategoryAutocompleteModal({
value={month ? monthUtils.sheetForMonth(month) : ''}
>
<CategoryAutocomplete
focused={true}
autoFocus={true}
embedded={true}
closeOnBlur={false}
showSplitOption={false}

View File

@@ -1,5 +1,6 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useState } from 'react';
import { Form } from 'react-aria-components';
import { useCategories } from '../../hooks/useCategories';
import { theme } from '../../style';
@@ -27,7 +28,7 @@ export function ConfirmCategoryDeleteModal({
const group = categoryGroups.find(g => g.id === groupId);
const category = categories.find(c => c.id === categoryId);
const renderError = (error: string) => {
const renderError = useCallback((error: string) => {
let msg: string;
switch (error) {
@@ -48,10 +49,24 @@ export function ConfirmCategoryDeleteModal({
{msg}
</Text>
);
};
}, []);
const isIncome = !!(category || group).is_income;
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
if (!transferCategory) {
setError('required-transfer');
} else {
onDelete(transferCategory);
close();
}
},
[onDelete, transferCategory],
);
return (
<Modal
name="confirm-category-delete"
@@ -63,82 +78,75 @@ export function ConfirmCategoryDeleteModal({
title="Confirm Delete"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
{group ? (
<Block>
Categories in the group <strong>{group.name}</strong> are used
by existing transactions
{!isIncome &&
' or it has a positive leftover balance currently'}
. <strong>Are you sure you want to delete it?</strong> If so,
you must select another category to transfer existing
transactions and balance to.
</Block>
) : (
<Block>
<strong>{category.name}</strong> is used by existing
transactions
{!isIncome &&
' or it has a positive leftover balance currently'}
. <strong>Are you sure you want to delete it?</strong> If so,
you must select another category to transfer existing
transactions and balance to.
</Block>
)}
<Form onSubmit={e => onSubmit(e, { close })}>
<View style={{ lineHeight: 1.5 }}>
{group ? (
<Block>
Categories in the group <strong>{group.name}</strong> are used
by existing transactions
{!isIncome &&
' or it has a positive leftover balance currently'}
. <strong>Are you sure you want to delete it?</strong> If so,
you must select another category to transfer existing
transactions and balance to.
</Block>
) : (
<Block>
<strong>{category.name}</strong> is used by existing
transactions
{!isIncome &&
' or it has a positive leftover balance currently'}
. <strong>Are you sure you want to delete it?</strong> If so,
you must select another category to transfer existing
transactions and balance to.
</Block>
)}
{error && renderError(error)}
{error && renderError(error)}
<View
style={{
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
}}
>
<Text>Transfer to:</Text>
<View style={{ flex: 1, marginLeft: 10, marginRight: 30 }}>
<CategoryAutocomplete
categoryGroups={
group
? categoryGroups.filter(
g => g.id !== group.id && !!g.is_income === isIncome,
)
: categoryGroups
.filter(g => !!g.is_income === isIncome)
.map(g => ({
...g,
categories: g.categories.filter(
c => c.id !== category.id,
),
}))
}
value={transferCategory}
focused={true}
inputProps={{
placeholder: 'Select category...',
}}
onSelect={category => setTransferCategory(category)}
showHiddenCategories={true}
/>
</View>
<Button
variant="primary"
onPress={() => {
if (!transferCategory) {
setError('required-transfer');
} else {
onDelete(transferCategory);
close();
}
<View
style={{
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
}}
>
Delete
</Button>
<Text>Transfer to:</Text>
<View style={{ flex: 1, marginLeft: 10, marginRight: 30 }}>
<CategoryAutocomplete
categoryGroups={
group
? categoryGroups.filter(
g =>
g.id !== group.id && !!g.is_income === isIncome,
)
: categoryGroups
.filter(g => !!g.is_income === isIncome)
.map(g => ({
...g,
categories: g.categories.filter(
c => c.id !== category.id,
),
}))
}
value={transferCategory}
autoFocus={true}
inputProps={{
placeholder: 'Select category...',
}}
onSelect={category => setTransferCategory(category)}
showHiddenCategories={true}
/>
</View>
<Button variant="primary" type="submit">
Delete
</Button>
</View>
</View>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -1,8 +1,8 @@
import React from 'react';
import React, { type FormEvent, useCallback } from 'react';
import { Form } from 'react-aria-components';
import { styles } from '../../style';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
@@ -24,6 +24,15 @@ export function ConfirmTransactionDeleteModal({
}
: {};
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
onConfirm();
close();
},
[onConfirm],
);
return (
<Modal name="confirm-transaction-delete">
{({ state: { close } }) => (
@@ -32,37 +41,35 @@ export function ConfirmTransactionDeleteModal({
title="Confirm Delete"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>{message}</Paragraph>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
}}
>
<Button
<Form onSubmit={e => onSubmit(e, { close })}>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>{message}</Paragraph>
<View
style={{
marginRight: 10,
...narrowButtonStyle,
flexDirection: 'row',
justifyContent: 'flex-end',
}}
onPress={close}
>
Cancel
</Button>
<InitialFocus>
<Button
style={{
marginRight: 10,
...narrowButtonStyle,
}}
onPress={close}
>
Cancel
</Button>
<Button
variant="primary"
type="submit"
style={narrowButtonStyle}
onPress={() => {
onConfirm();
close();
}}
autoFocus
>
Delete
</Button>
</InitialFocus>
</View>
</View>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -1,9 +1,9 @@
// @ts-strict-ignore
import React from 'react';
import React, { type FormEvent, useCallback } from 'react';
import { Form } from 'react-aria-components';
import { Block } from '../common/Block';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
@@ -18,6 +18,16 @@ export function ConfirmTransactionEditModal({
onConfirm,
confirmReason,
}: ConfirmTransactionEditProps) {
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
close();
onConfirm();
},
[onConfirm],
);
return (
<Modal
name="confirm-transaction-edit"
@@ -29,81 +39,79 @@ export function ConfirmTransactionEditModal({
title="Reconciled Transaction"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
{confirmReason === 'batchDeleteWithReconciled' ? (
<Block>
Deleting reconciled transactions may bring your reconciliation
out of balance.
</Block>
) : confirmReason === 'batchEditWithReconciled' ? (
<Block>
Editing reconciled transactions may bring your reconciliation
out of balance.
</Block>
) : confirmReason === 'batchDuplicateWithReconciled' ? (
<Block>
Duplicating reconciled transactions may bring your
reconciliation out of balance.
</Block>
) : confirmReason === 'editReconciled' ? (
<Block>
Saving your changes to this reconciled transaction may bring
your reconciliation out of balance.
</Block>
) : confirmReason === 'unlockReconciled' ? (
<Block>
Unlocking this transaction means you wont be warned about
changes that can impact your reconciled balance. (Changes to
amount, account, payee, etc).
</Block>
) : confirmReason === 'deleteReconciled' ? (
<Block>
Deleting this reconciled transaction may bring your
reconciliation out of balance.
</Block>
) : (
<Block>Are you sure you want to edit this transaction?</Block>
)}
<Form onSubmit={e => onSubmit(e, { close })}>
<View style={{ lineHeight: 1.5 }}>
{confirmReason === 'batchDeleteWithReconciled' ? (
<Block>
Deleting reconciled transactions may bring your reconciliation
out of balance.
</Block>
) : confirmReason === 'batchEditWithReconciled' ? (
<Block>
Editing reconciled transactions may bring your reconciliation
out of balance.
</Block>
) : confirmReason === 'batchDuplicateWithReconciled' ? (
<Block>
Duplicating reconciled transactions may bring your
reconciliation out of balance.
</Block>
) : confirmReason === 'editReconciled' ? (
<Block>
Saving your changes to this reconciled transaction may bring
your reconciliation out of balance.
</Block>
) : confirmReason === 'unlockReconciled' ? (
<Block>
Unlocking this transaction means you wont be warned about
changes that can impact your reconciled balance. (Changes to
amount, account, payee, etc).
</Block>
) : confirmReason === 'deleteReconciled' ? (
<Block>
Deleting this reconciled transaction may bring your
reconciliation out of balance.
</Block>
) : (
<Block>Are you sure you want to edit this transaction?</Block>
)}
<View
style={{
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
}}
>
<View
style={{
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-end',
justifyContent: 'flex-start',
alignItems: 'center',
}}
>
<Button
aria-label="Cancel"
style={{ marginRight: 10 }}
onPress={() => {
close();
onCancel();
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
}}
>
Cancel
</Button>
<InitialFocus>
<Button
aria-label="Cancel"
style={{ marginRight: 10 }}
onPress={() => {
close();
onCancel?.();
}}
>
Cancel
</Button>
<Button
aria-label="Confirm"
variant="primary"
onPress={() => {
close();
onConfirm();
}}
type="submit"
autoFocus
>
Confirm
</Button>
</InitialFocus>
</View>
</View>
</View>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import React, { type FormEvent, useCallback } from 'react';
import { Form } from 'react-aria-components';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
@@ -15,6 +15,15 @@ export function ConfirmUnlinkAccountModal({
accountName,
onUnlink,
}: ConfirmUnlinkAccountProps) {
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
onUnlink();
close();
},
[onUnlink],
);
return (
<Modal
name="confirm-unlink-account"
@@ -26,38 +35,32 @@ export function ConfirmUnlinkAccountModal({
title="Confirm Unlink"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>
Are you sure you want to unlink <strong>{accountName}</strong>?
</Paragraph>
<Form onSubmit={e => onSubmit(e, { close })}>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>
Are you sure you want to unlink <strong>{accountName}</strong>?
</Paragraph>
<Paragraph>
Transactions will no longer be synchronized with this account and
must be manually entered.
</Paragraph>
<Paragraph>
Transactions will no longer be synchronized with this account
and must be manually entered.
</Paragraph>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
}}
>
<Button style={{ marginRight: 10 }} onPress={close}>
Cancel
</Button>
<InitialFocus>
<Button
variant="primary"
onPress={() => {
onUnlink();
close();
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
}}
>
<Button style={{ marginRight: 10 }} onPress={close}>
Cancel
</Button>
<Button variant="primary" type="submit" autoFocus>
Unlink
</Button>
</InitialFocus>
</View>
</View>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -12,7 +12,6 @@ import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
import { SvgDotsHorizontalTriple } from '../../icons/v1';
import { theme } from '../../style';
import { Button, ButtonWithLoading } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Link } from '../common/Link';
import { Menu } from '../common/Menu';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
@@ -190,19 +189,18 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) {
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
{upgradingAccountId == null && (
<View style={{ gap: 10 }}>
<InitialFocus>
<Button
variant="primary"
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
}}
onPress={onCreateLocalAccount}
>
Create local account
</Button>
</InitialFocus>
<Button
variant="primary"
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
}}
onPress={onCreateLocalAccount}
autoFocus
>
Create local account
</Button>
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Text>
<strong>Create a local account</strong> if you want to add

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useState } from 'react';
import { Form } from 'react-aria-components';
import { useDispatch } from 'react-redux';
@@ -11,7 +11,6 @@ import { getCreateKeyError } from 'loot-core/src/shared/errors';
import { styles, theme } from '../../style';
import { ButtonWithLoading } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Link } from '../common/Link';
import {
@@ -43,26 +42,37 @@ export function CreateEncryptionKeyModal({
const isRecreating = options.recreate;
async function onCreateKey(close: () => void) {
if (password !== '' && !loading) {
setLoading(true);
setError(null);
const onCreateKey = useCallback(
async ({ close }: { close: () => void }) => {
if (password !== '' && !loading) {
setLoading(true);
setError(null);
const res = await send('key-make', { password });
if (res.error) {
setLoading(null);
setError(getCreateKeyError(res.error));
return;
const res = await send('key-make', { password });
if (res.error) {
setLoading(null);
setError(getCreateKeyError(res.error));
return;
}
dispatch(loadGlobalPrefs());
dispatch(loadAllFiles());
dispatch(sync());
setLoading(false);
close();
}
},
[password, loading, dispatch],
);
dispatch(loadGlobalPrefs());
dispatch(loadAllFiles());
dispatch(sync());
setLoading(false);
close();
}
}
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
onCreateKey({ close });
},
[onCreateKey],
);
return (
<Modal name="create-encryption-key">
@@ -72,89 +82,84 @@ export function CreateEncryptionKeyModal({
title={isRecreating ? 'Generate new key' : 'Enable encryption'}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{
maxWidth: 600,
overflowX: 'hidden',
overflowY: 'auto',
flex: 1,
}}
>
{!isRecreating ? (
<>
<Paragraph style={{ marginTop: 5 }}>
To enable end-to-end encryption, you need to create a key. We
will generate a key based on a password and use it to encrypt
from now on. <strong>This requires a sync reset</strong> and
all other devices will have to revert to this version of your
data.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
linkColor="purple"
>
Learn more
</Link>
</Paragraph>
<Paragraph>
<ul
className={css({
marginTop: 0,
'& li': { marginBottom: 8 },
})}
>
<li>
<strong>Important:</strong> if you forget this password{' '}
<em>and</em> you dont have any local copies of your data,
you will lose access to all your data. The data cannot be
decrypted without the password.
</li>
<li>
This key only applies to this file. You will need to
generate a new key for each file you want to encrypt.
</li>
<li>
If youve already downloaded your data on other devices,
you will need to reset them. Actual will automatically
take you through this process.
</li>
<li>
It is recommended for the encryption password to be
different than the log-in password in order to better
protect your data.
</li>
</ul>
</Paragraph>
</>
) : (
<>
<Paragraph style={{ marginTop: 5 }}>
This will generate a new key for encrypting your data.{' '}
<strong>This requires a sync reset</strong> and all other
devices will have to revert to this version of your data.
Actual will take you through that process on those devices.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
linkColor="purple"
>
Learn more
</Link>
</Paragraph>
<Paragraph>
Key generation is randomized. The same password will create
different keys, so this will change your key regardless of the
password being different.
</Paragraph>
</>
)}
</View>
<Form
onSubmit={e => {
e.preventDefault();
onCreateKey(close);
}}
>
<Form onSubmit={e => onSubmit(e, { close })}>
<View
style={{
maxWidth: 600,
overflowX: 'hidden',
overflowY: 'auto',
flex: 1,
}}
>
{!isRecreating ? (
<>
<Paragraph style={{ marginTop: 5 }}>
To enable end-to-end encryption, you need to create a key.
We will generate a key based on a password and use it to
encrypt from now on.{' '}
<strong>This requires a sync reset</strong> and all other
devices will have to revert to this version of your data.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
linkColor="purple"
>
Learn more
</Link>
</Paragraph>
<Paragraph>
<ul
className={css({
marginTop: 0,
'& li': { marginBottom: 8 },
})}
>
<li>
<strong>Important:</strong> if you forget this password{' '}
<em>and</em> you dont have any local copies of your
data, you will lose access to all your data. The data
cannot be decrypted without the password.
</li>
<li>
This key only applies to this file. You will need to
generate a new key for each file you want to encrypt.
</li>
<li>
If youve already downloaded your data on other devices,
you will need to reset them. Actual will automatically
take you through this process.
</li>
<li>
It is recommended for the encryption password to be
different than the log-in password in order to better
protect your data.
</li>
</ul>
</Paragraph>
</>
) : (
<>
<Paragraph style={{ marginTop: 5 }}>
This will generate a new key for encrypting your data.{' '}
<strong>This requires a sync reset</strong> and all other
devices will have to revert to this version of your data.
Actual will take you through that process on those devices.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
linkColor="purple"
>
Learn more
</Link>
</Paragraph>
<Paragraph>
Key generation is randomized. The same password will create
different keys, so this will change your key regardless of
the password being different.
</Paragraph>
</>
)}
</View>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontWeight: 600, marginBottom: 3 }}>Password</Text>
@@ -171,16 +176,16 @@ export function CreateEncryptionKeyModal({
</View>
)}
<InitialFocus>
<Input
type={showPassword ? 'text' : 'password'}
style={{
width: isNarrowWidth ? '100%' : '50%',
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
}}
onChange={e => setPassword(e.target.value)}
/>
</InitialFocus>
<Input
type={showPassword ? 'text' : 'password'}
style={{
width: isNarrowWidth ? '100%' : '50%',
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
}}
onChangeValue={value => setPassword(value)}
autoFocus
autoSelect
/>
<Text style={{ marginTop: 5 }}>
<label style={{ userSelect: 'none' }}>
<input

View File

@@ -11,7 +11,6 @@ import { useNavigate } from '../../hooks/useNavigate';
import { theme } from '../../style';
import { Button } from '../common/Button2';
import { FormError } from '../common/FormError';
import { InitialFocus } from '../common/InitialFocus';
import { InlineField } from '../common/InlineField';
import { Input } from '../common/Input';
import { Link } from '../common/Link';
@@ -77,18 +76,18 @@ export function CreateLocalAccountModal() {
<View>
<Form onSubmit={onSubmit}>
<InlineField label="Name" width="100%">
<InitialFocus>
<Input
name="name"
value={name}
onChange={event => setName(event.target.value)}
onBlur={event => {
const name = event.target.value.trim();
validateAndSetName(name);
}}
style={{ flex: 1 }}
/>
</InitialFocus>
<Input
name="name"
value={name}
onChangeValue={setName}
onBlur={event => {
const name = event.target.value.trim();
validateAndSetName(name);
}}
style={{ flex: 1 }}
autoFocus
autoSelect
/>
</InlineField>
{nameError && (
<FormError style={{ marginLeft: 75, color: theme.warningText }}>
@@ -155,7 +154,7 @@ export function CreateLocalAccountModal() {
name="balance"
inputMode="decimal"
value={balance}
onChange={event => setBalance(event.target.value)}
onChangeValue={value => setBalance(value)}
onBlur={event => {
const balance = event.target.value.trim();
setBalance(balance);

View File

@@ -45,7 +45,7 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
const { isNarrowWidth } = useResponsive();
let label, editor, minWidth;
const inputStyle = {
':focus': { boxShadow: 0 },
'&[data-focused]': { boxShadow: 0 },
...(isNarrowWidth && itemStyle),
};
@@ -60,7 +60,7 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
<DateSelect
value={formatDate(parseISO(today), dateFormat)}
dateFormat={dateFormat}
focused={true}
autoFocus={true}
embedded={true}
onUpdate={() => {}}
onSelect={date => {
@@ -186,7 +186,6 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
<Input
id="noteInput"
autoFocus
focused={true}
onEnter={e => {
onSelectNote(e.target.value, noteAmend);
close();
@@ -201,7 +200,7 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
label = 'Amount';
editor = ({ close }) => (
<Input
focused={true}
autoFocus
onEnter={e => {
onSelect(e.target.value);
close();

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useState } from 'react';
import { Form } from 'react-aria-components';
import { type FinanceModals } from 'loot-core/src/client/state-types/modals';
@@ -8,7 +8,6 @@ import { getTestKeyError } from 'loot-core/src/shared/errors';
import { styles, theme } from '../../style';
import { Button, ButtonWithLoading } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Link } from '../common/Link';
import {
@@ -37,25 +36,36 @@ export function FixEncryptionKeyModal({
const [showPassword, setShowPassword] = useState(false);
const { isNarrowWidth } = useResponsive();
async function onUpdateKey(close: () => void) {
if (password !== '' && !loading) {
setLoading(true);
setError(null);
const onUpdateKey = useCallback(
async ({ close }: { close: () => void }) => {
if (password !== '' && !loading) {
setLoading(true);
setError(null);
const { error } = await send('key-test', {
password,
fileId: cloudFileId,
});
if (error) {
setError(getTestKeyError(error));
setLoading(false);
return;
const { error } = await send('key-test', {
password,
fileId: cloudFileId,
});
if (error) {
setError(getTestKeyError(error));
setLoading(false);
return;
}
onSuccess?.();
close();
}
},
[cloudFileId, loading, onSuccess, password],
);
onSuccess?.();
close();
}
}
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
onUpdateKey({ close });
},
[onUpdateKey],
);
return (
<Modal name="fix-encryption-key">
@@ -69,45 +79,40 @@ export function FixEncryptionKeyModal({
}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{
maxWidth: 500,
overflowX: 'hidden',
overflowY: 'auto',
flex: 1,
}}
>
{hasExistingKey ? (
<Paragraph>
This file was encrypted with a different key than you are
currently using. This probably means you changed your password.
Enter your current password to update your key.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
>
Learn more
</Link>
</Paragraph>
) : (
<Paragraph>
We dont have a key that encrypts or decrypts this file. Enter
the password for this file to create the key for encryption.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
>
Learn more
</Link>
</Paragraph>
)}
</View>
<Form
onSubmit={e => {
e.preventDefault();
onUpdateKey(close);
}}
>
<Form onSubmit={e => onSubmit(e, { close })}>
<View
style={{
maxWidth: 500,
overflowX: 'hidden',
overflowY: 'auto',
flex: 1,
}}
>
{hasExistingKey ? (
<Paragraph>
This file was encrypted with a different key than you are
currently using. This probably means you changed your
password. Enter your current password to update your key.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
>
Learn more
</Link>
</Paragraph>
) : (
<Paragraph>
We dont have a key that encrypts or decrypts this file. Enter
the password for this file to create the key for encryption.{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
>
Learn more
</Link>
</Paragraph>
)}
</View>
<View
style={{
marginTop: 15,
@@ -128,16 +133,16 @@ export function FixEncryptionKeyModal({
{error}
</View>
)}
<InitialFocus>
<Input
type={showPassword ? 'text' : 'password'}
style={{
width: isNarrowWidth ? '100%' : '50%',
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
}}
onChange={e => setPassword(e.target.value)}
/>
</InitialFocus>
<Input
type={showPassword ? 'text' : 'password'}
style={{
width: isNarrowWidth ? '100%' : '50%',
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
}}
onChangeValue={value => setPassword(value)}
autoFocus
autoSelect
/>
<Text style={{ marginTop: 5 }}>
<label style={{ userSelect: 'none' }}>
<input

View File

@@ -183,7 +183,7 @@ export function GoCardlessExternalMsgModal({
<FormField>
<FormLabel title={t('Choose your bank:')} htmlFor="bank-field" />
<Autocomplete
focused
autoFocus
strict
highlightFirst
suggestions={bankOptions}

View File

@@ -1,11 +1,11 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useState } from 'react';
import { Form } from 'react-aria-components';
import { send } from 'loot-core/src/platform/client/fetch';
import { Error } from '../alerts';
import { ButtonWithLoading } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Link } from '../common/Link';
import {
@@ -30,29 +30,34 @@ export const GoCardlessInitialiseModal = ({
const [isValid, setIsValid] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (close: () => void) => {
if (!secretId || !secretKey) {
setIsValid(false);
return;
}
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
setIsLoading(true);
if (!secretId || !secretKey) {
setIsValid(false);
return;
}
await Promise.all([
send('secret-set', {
name: 'gocardless_secretId',
value: secretId,
}),
send('secret-set', {
name: 'gocardless_secretKey',
value: secretKey,
}),
]);
setIsLoading(true);
onSuccess();
setIsLoading(false);
close();
};
await Promise.all([
send('secret-set', {
name: 'gocardless_secretId',
value: secretId,
}),
send('secret-set', {
name: 'gocardless_secretKey',
value: secretKey,
}),
]);
onSuccess();
setIsLoading(false);
close();
},
[onSuccess, secretId, secretKey],
);
return (
<Modal name="gocardless-init" containerProps={{ style: { width: '30vw' } }}>
@@ -62,24 +67,24 @@ export const GoCardlessInitialiseModal = ({
title="Set-up GoCardless"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
In order to enable bank-sync via GoCardless (only for EU banks)
you will need to create access credentials. This can be done by
creating an account with{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync/"
linkColor="purple"
>
GoCardless
</Link>
.
</Text>
<Form onSubmit={e => onSubmit(e, { close })}>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
In order to enable bank-sync via GoCardless (only for EU banks)
you will need to create access credentials. This can be done by
creating an account with{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync/"
linkColor="purple"
>
GoCardless
</Link>
.
</Text>
<FormField>
<FormLabel title="Secret ID:" htmlFor="secret-id-field" />
<InitialFocus>
<FormField>
<FormLabel title="Secret ID:" htmlFor="secret-id-field" />
<Input
id="secret-id-field"
type="password"
@@ -88,41 +93,41 @@ export const GoCardlessInitialiseModal = ({
setSecretId(value);
setIsValid(true);
}}
autoFocus
autoSelect
/>
</InitialFocus>
</FormField>
</FormField>
<FormField>
<FormLabel title="Secret Key:" htmlFor="secret-key-field" />
<Input
id="secret-key-field"
type="password"
value={secretKey}
onChangeValue={value => {
setSecretKey(value);
setIsValid(true);
}}
/>
</FormField>
<FormField>
<FormLabel title="Secret Key:" htmlFor="secret-key-field" />
<Input
id="secret-key-field"
type="password"
value={secretKey}
onChangeValue={value => {
setSecretKey(value);
setIsValid(true);
}}
/>
</FormField>
{!isValid && (
<Error>
It is required to provide both the secret id and secret key.
</Error>
)}
</View>
{!isValid && (
<Error>
It is required to provide both the secret id and secret key.
</Error>
)}
</View>
<ModalButtons>
<ButtonWithLoading
variant="primary"
isLoading={isLoading}
onPress={() => {
onSubmit(close);
}}
>
Save and continue
</ButtonWithLoading>
</ModalButtons>
<ModalButtons>
<ButtonWithLoading
variant="primary"
type="submit"
isLoading={isLoading}
>
Save and continue
</ButtonWithLoading>
</ModalButtons>
</Form>
</>
)}
</Modal>

View File

@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useState } from 'react';
import { Form } from 'react-aria-components';
import { envelopeBudget } from 'loot-core/client/queries';
import { styles } from '../../style';
import { useEnvelopeSheetValue } from '../budget/envelope/EnvelopeBudgetComponents';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { FieldLabel } from '../mobile/MobileForms';
@@ -20,11 +20,16 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
const available = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
const [amount, setAmount] = useState<number>(0);
const _onSubmit = (newAmount: number) => {
if (newAmount) {
onSubmit?.(newAmount);
}
};
const _onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (amount) {
onSubmit?.(amount);
}
},
[amount, onSubmit],
);
return (
<Modal name="hold-buffer">
@@ -34,9 +39,9 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
title="Hold Buffer"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<FieldLabel title="Hold this amount:" />
<InitialFocus>
<Form onSubmit={_onSubmit}>
<View>
<FieldLabel title="Hold this amount:" />
<AmountInput
value={available}
autoDecimals={true}
@@ -48,32 +53,30 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
height: styles.mobileMinHeight,
}}
onUpdate={setAmount}
onEnter={() => {
_onSubmit(amount);
close();
}}
autoFocus
autoSelect
/>
</InitialFocus>
</View>
<View
style={{
justifyContent: 'center',
alignItems: 'center',
paddingTop: 10,
}}
>
<Button
variant="primary"
</View>
<View
style={{
height: styles.mobileMinHeight,
marginLeft: styles.mobileEditingPadding,
marginRight: styles.mobileEditingPadding,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 10,
}}
onPress={() => _onSubmit(amount)}
>
Hold
</Button>
</View>
<Button
variant="primary"
type="submit"
style={{
height: styles.mobileMinHeight,
marginLeft: styles.mobileEditingPadding,
marginRight: styles.mobileEditingPadding,
}}
>
Hold
</Button>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Form } from 'react-aria-components';
import deepEqual from 'deep-equal';
@@ -539,7 +540,7 @@ export function ImportTransactionsModal({ options }) {
setTransactions(newTransactions);
}
async function onImport(close) {
const onImport = useCallback(async () => {
setLoadingState('importing');
const finalTransactions = [];
@@ -664,8 +665,38 @@ export function ImportTransactionsModal({ options }) {
if (onImported) {
onImported(didChange);
}
close();
}
}, [
accountId,
categories.list,
clearOnImport,
delimiter,
fallbackMissingPayeeToMemo,
fieldMappings,
filetype,
flipAmount,
getPayees,
hasHeaderRow,
importTransactions,
inOutMode,
multiplierAmount,
onImported,
outValue,
parseDateFormat,
reconcile,
savePrefs,
skipLines,
splitMode,
transactions,
]);
const onSubmit = useCallback(
(e, { close }) => {
e.preventDefault();
onImport({ close });
close();
},
[onImport],
);
const runImportPreview = useCallback(async () => {
const transactionPreview = await getImportPreview(
@@ -731,339 +762,338 @@ export function ImportTransactionsModal({ options }) {
}
rightContent={<ModalCloseButton onPress={close} />}
/>
{error && !error.parsed && (
<View style={{ alignItems: 'center', marginBottom: 15 }}>
<Text style={{ marginRight: 10, color: theme.errorText }}>
<strong>Error:</strong> {error.message}
</Text>
</View>
)}
{(!error || !error.parsed) && (
<View
style={{
flex: 'unset',
height: 300,
border: '1px solid ' + theme.tableBorder,
}}
>
<TableHeader headers={headers} />
<Form onSubmit={e => onSubmit(e, { close })}>
{error && !error.parsed && (
<View style={{ alignItems: 'center', marginBottom: 15 }}>
<Text style={{ marginRight: 10, color: theme.errorText }}>
<strong>Error:</strong> {error.message}
</Text>
</View>
)}
{(!error || !error.parsed) && (
<View
style={{
flex: 'unset',
height: 300,
border: '1px solid ' + theme.tableBorder,
}}
>
<TableHeader headers={headers} />
<TableWithNavigator
items={transactions.filter(
trans =>
!trans.isMatchedTransaction ||
(trans.isMatchedTransaction && reconcile),
)}
fields={['payee', 'category', 'amount']}
style={{ backgroundColor: theme.tableHeaderBackground }}
getItemKey={index => index}
renderEmpty={() => {
return (
<View
style={{
textAlign: 'center',
marginTop: 25,
color: theme.tableHeaderText,
fontStyle: 'italic',
}}
>
No transactions found
<TableWithNavigator
items={transactions.filter(
trans =>
!trans.isMatchedTransaction ||
(trans.isMatchedTransaction && reconcile),
)}
fields={['payee', 'category', 'amount']}
style={{ backgroundColor: theme.tableHeaderBackground }}
getItemKey={index => index}
renderEmpty={() => {
return (
<View
style={{
textAlign: 'center',
marginTop: 25,
color: theme.tableHeaderText,
fontStyle: 'italic',
}}
>
No transactions found
</View>
);
}}
renderItem={({ key, style, item }) => (
<View key={key} style={style}>
<Transaction
transaction={item}
showParsed={filetype === 'csv' || filetype === 'qif'}
parseDateFormat={parseDateFormat}
dateFormat={dateFormat}
fieldMappings={fieldMappings}
splitMode={splitMode}
inOutMode={inOutMode}
outValue={outValue}
flipAmount={flipAmount}
multiplierAmount={multiplierAmount}
categories={categories.list}
onCheckTransaction={onCheckTransaction}
reconcile={reconcile}
/>
</View>
)}
/>
</View>
)}
{error && error.parsed && (
<View
style={{
color: theme.errorText,
alignItems: 'center',
marginTop: 10,
}}
>
<Text style={{ maxWidth: 450, marginBottom: 15 }}>
<strong>Error:</strong> {error.message}
</Text>
{error.parsed && (
<Button onPress={() => onNewFile()}>
Select new file...
</Button>
)}
</View>
)}
{filetype === 'csv' && (
<View style={{ marginTop: 10 }}>
<FieldMappings
transactions={transactions}
onChange={onUpdateFields}
mappings={fieldMappings}
splitMode={splitMode}
inOutMode={inOutMode}
hasHeaderRow={hasHeaderRow}
/>
</View>
)}
{isOfxFile(filetype) && (
<CheckboxOption
id="form_fallback_missing_payee"
checked={fallbackMissingPayeeToMemo}
onChange={() => {
setFallbackMissingPayeeToMemo(state => !state);
parse(
filename,
getParseOptions('ofx', {
fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo,
}),
);
}}
renderItem={({ key, style, item }) => (
<View key={key} style={style}>
<Transaction
transaction={item}
showParsed={filetype === 'csv' || filetype === 'qif'}
parseDateFormat={parseDateFormat}
dateFormat={dateFormat}
fieldMappings={fieldMappings}
splitMode={splitMode}
inOutMode={inOutMode}
outValue={outValue}
flipAmount={flipAmount}
multiplierAmount={multiplierAmount}
categories={categories.list}
onCheckTransaction={onCheckTransaction}
reconcile={reconcile}
/>
</View>
)}
/>
</View>
)}
{error && error.parsed && (
<View
style={{
color: theme.errorText,
alignItems: 'center',
marginTop: 10,
}}
>
<Text style={{ maxWidth: 450, marginBottom: 15 }}>
<strong>Error:</strong> {error.message}
</Text>
{error.parsed && (
<Button onPress={() => onNewFile()}>Select new file...</Button>
)}
</View>
)}
{filetype === 'csv' && (
<View style={{ marginTop: 10 }}>
<FieldMappings
transactions={transactions}
onChange={onUpdateFields}
mappings={fieldMappings}
splitMode={splitMode}
inOutMode={inOutMode}
hasHeaderRow={hasHeaderRow}
/>
</View>
)}
{isOfxFile(filetype) && (
<CheckboxOption
id="form_fallback_missing_payee"
checked={fallbackMissingPayeeToMemo}
onChange={() => {
setFallbackMissingPayeeToMemo(state => !state);
parse(
filename,
getParseOptions('ofx', {
fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo,
}),
);
}}
>
Use Memo as a fallback for empty Payees
</CheckboxOption>
)}
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
<CheckboxOption
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(!reconcile);
}}
>
Merge with existing transactions
</CheckboxOption>
)}
{/*Import Options */}
{(filetype === 'qif' || filetype === 'csv') && (
<View style={{ marginTop: 10 }}>
<Stack
direction="row"
align="flex-start"
spacing={1}
style={{ marginTop: 5 }}
>
{/*Date Format */}
<View>
{(filetype === 'qif' || filetype === 'csv') && (
<DateFormatSelect
transactions={transactions}
fieldMappings={fieldMappings}
parseDateFormat={parseDateFormat}
onChange={value => {
setParseDateFormat(value);
runImportPreview();
}}
/>
)}
</View>
Use Memo as a fallback for empty Payees
</CheckboxOption>
)}
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
<CheckboxOption
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(!reconcile);
}}
>
Merge with existing transactions
</CheckboxOption>
)}
{/* CSV Options */}
{filetype === 'csv' && (
<View style={{ marginLeft: 10, gap: 5 }}>
<SectionLabel title="CSV OPTIONS" />
<label
style={{
display: 'flex',
flexDirection: 'row',
gap: 5,
alignItems: 'baseline',
}}
>
Delimiter:
<Select
options={[
[',', ','],
[';', ';'],
['|', '|'],
['\t', 'tab'],
]}
value={delimiter}
{/*Import Options */}
{(filetype === 'qif' || filetype === 'csv') && (
<View style={{ marginTop: 10 }}>
<Stack
direction="row"
align="flex-start"
spacing={1}
style={{ marginTop: 5 }}
>
{/*Date Format */}
<View>
{(filetype === 'qif' || filetype === 'csv') && (
<DateFormatSelect
transactions={transactions}
fieldMappings={fieldMappings}
parseDateFormat={parseDateFormat}
onChange={value => {
setDelimiter(value);
parse(
filename,
getParseOptions('csv', {
delimiter: value,
hasHeaderRow,
skipLines,
}),
);
setParseDateFormat(value);
runImportPreview();
}}
style={{ width: 50 }}
/>
</label>
<label
style={{
display: 'flex',
flexDirection: 'row',
gap: 5,
alignItems: 'baseline',
}}
>
Skip lines:
<Input
type="number"
value={skipLines}
min="0"
onChangeValue={value => {
setSkipLines(+value);
)}
</View>
{/* CSV Options */}
{filetype === 'csv' && (
<View style={{ marginLeft: 10, gap: 5 }}>
<SectionLabel title="CSV OPTIONS" />
<label
style={{
display: 'flex',
flexDirection: 'row',
gap: 5,
alignItems: 'baseline',
}}
>
Delimiter:
<Select
options={[
[',', ','],
[';', ';'],
['|', '|'],
['\t', 'tab'],
]}
value={delimiter}
onChange={value => {
setDelimiter(value);
parse(
filename,
getParseOptions('csv', {
delimiter: value,
hasHeaderRow,
skipLines,
}),
);
}}
style={{ width: 50 }}
/>
</label>
<label
style={{
display: 'flex',
flexDirection: 'row',
gap: 5,
alignItems: 'baseline',
}}
>
Skip lines:
<Input
type="number"
value={skipLines}
min="0"
onChangeValue={value => {
setSkipLines(+value);
parse(
filename,
getParseOptions('csv', {
delimiter,
hasHeaderRow,
skipLines: +value,
}),
);
}}
style={{ width: 50 }}
/>
</label>
<CheckboxOption
id="form_has_header"
checked={hasHeaderRow}
onChange={() => {
setHasHeaderRow(!hasHeaderRow);
parse(
filename,
getParseOptions('csv', {
delimiter,
hasHeaderRow,
skipLines: +value,
hasHeaderRow: !hasHeaderRow,
skipLines,
}),
);
}}
style={{ width: 50 }}
/>
</label>
<CheckboxOption
id="form_has_header"
checked={hasHeaderRow}
onChange={() => {
setHasHeaderRow(!hasHeaderRow);
parse(
filename,
getParseOptions('csv', {
delimiter,
hasHeaderRow: !hasHeaderRow,
skipLines,
}),
);
}}
>
File has header row
</CheckboxOption>
<CheckboxOption
id="clear_on_import"
checked={clearOnImport}
onChange={() => {
setClearOnImport(!clearOnImport);
}}
>
Clear transactions on import
</CheckboxOption>
<CheckboxOption
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(!reconcile);
}}
>
Merge with existing transactions
</CheckboxOption>
</View>
)}
<View style={{ flex: 1 }} />
<View style={{ marginRight: 10, gap: 5 }}>
<SectionLabel title="AMOUNT OPTIONS" />
<CheckboxOption
id="form_flip"
checked={flipAmount}
disabled={splitMode || inOutMode}
onChange={() => {
setFlipAmount(!flipAmount);
runImportPreview();
}}
>
Flip amount
</CheckboxOption>
{filetype === 'csv' && (
<>
>
File has header row
</CheckboxOption>
<CheckboxOption
id="form_split"
checked={splitMode}
disabled={inOutMode || flipAmount}
id="clear_on_import"
checked={clearOnImport}
onChange={() => {
onSplitMode();
runImportPreview();
setClearOnImport(!clearOnImport);
}}
>
Split amount into separate inflow/outflow columns
Clear transactions on import
</CheckboxOption>
<InOutOption
inOutMode={inOutMode}
outValue={outValue}
disabled={splitMode || flipAmount}
onToggle={() => {
setInOutMode(!inOutMode);
runImportPreview();
<CheckboxOption
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(!reconcile);
}}
onChangeText={setOutValue}
/>
</>
>
Merge with existing transactions
</CheckboxOption>
</View>
)}
<MultiplierOption
multiplierEnabled={multiplierEnabled}
multiplierAmount={multiplierAmount}
onToggle={() => {
setMultiplierEnabled(!multiplierEnabled);
setMultiplierAmount('');
runImportPreview();
}}
onChangeAmount={onMultiplierChange}
/>
</View>
</Stack>
</View>
)}
<View style={{ flexDirection: 'row', marginTop: 5 }}>
{/*Submit Button */}
<View
style={{
alignSelf: 'flex-end',
flexDirection: 'row',
alignItems: 'center',
gap: '1em',
}}
>
<ButtonWithLoading
variant="primary"
autoFocus
isDisabled={
transactions?.filter(
trans => !trans.isMatchedTransaction && trans.selected,
).length === 0
}
isLoading={loadingState === 'importing'}
onPress={() => {
onImport(close);
<View style={{ flex: 1 }} />
<View style={{ marginRight: 10, gap: 5 }}>
<SectionLabel title="AMOUNT OPTIONS" />
<CheckboxOption
id="form_flip"
checked={flipAmount}
disabled={splitMode || inOutMode}
onChange={() => {
setFlipAmount(!flipAmount);
runImportPreview();
}}
>
Flip amount
</CheckboxOption>
{filetype === 'csv' && (
<>
<CheckboxOption
id="form_split"
checked={splitMode}
disabled={inOutMode || flipAmount}
onChange={() => {
onSplitMode();
runImportPreview();
}}
>
Split amount into separate inflow/outflow columns
</CheckboxOption>
<InOutOption
inOutMode={inOutMode}
outValue={outValue}
disabled={splitMode || flipAmount}
onToggle={() => {
setInOutMode(!inOutMode);
runImportPreview();
}}
onChangeText={setOutValue}
/>
</>
)}
<MultiplierOption
multiplierEnabled={multiplierEnabled}
multiplierAmount={multiplierAmount}
onToggle={() => {
setMultiplierEnabled(!multiplierEnabled);
setMultiplierAmount('');
runImportPreview();
}}
onChangeAmount={onMultiplierChange}
/>
</View>
</Stack>
</View>
)}
<View style={{ flexDirection: 'row', marginTop: 5 }}>
{/*Submit Button */}
<View
style={{
alignSelf: 'flex-end',
flexDirection: 'row',
alignItems: 'center',
gap: '1em',
}}
>
Import{' '}
{
transactions?.filter(
trans => !trans.isMatchedTransaction && trans.selected,
).length
}{' '}
transactions
</ButtonWithLoading>
<ButtonWithLoading
variant="primary"
type="submit"
autoFocus
isDisabled={
transactions?.filter(trans => !trans.isMatchedTransaction)
.length === 0
}
isLoading={loadingState === 'importing'}
>
Import{' '}
{
transactions?.filter(trans => !trans.isMatchedTransaction)
.length
}{' '}
transactions
</ButtonWithLoading>
</View>
</View>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -66,7 +66,7 @@ export function PayeeAutocompleteModal({
<PayeeAutocomplete
payees={payees}
accounts={accounts}
focused={true}
autoFocus={true}
embedded={true}
closeOnBlur={false}
onClose={close}

View File

@@ -219,7 +219,7 @@ function TableRow({
>
{focusedField === 'account' ? (
<Autocomplete
focused
autoFocus
strict
highlightFirst
suggestions={availableAccountOptions}

View File

@@ -1,5 +1,6 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { type FormEvent, useCallback, useState } from 'react';
import { Form } from 'react-aria-components';
import { send } from 'loot-core/src/platform/client/fetch';
@@ -28,23 +29,28 @@ export const SimpleFinInitialiseModal = ({
const [isValid, setIsValid] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (close: () => void) => {
if (!token) {
setIsValid(false);
return;
}
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
setIsLoading(true);
if (!token) {
setIsValid(false);
return;
}
await send('secret-set', {
name: 'simplefin_token',
value: token,
});
setIsLoading(true);
onSuccess();
setIsLoading(false);
close();
};
await send('secret-set', {
name: 'simplefin_token',
value: token,
});
onSuccess();
setIsLoading(false);
close();
},
[onSuccess, token],
);
return (
<Modal name="simplefin-init" containerProps={{ style: { width: 300 } }}>
@@ -54,49 +60,49 @@ export const SimpleFinInitialiseModal = ({
title="Set-up SimpleFIN"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
In order to enable bank-sync via SimpleFIN (only for North
American banks) you will need to create a token. This can be done
by creating an account with{' '}
<Link
variant="external"
to="https://bridge.simplefin.org/"
linkColor="purple"
<Form onSubmit={e => onSubmit(e, { close })}>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
In order to enable bank-sync via SimpleFIN (only for North
American banks) you will need to create a token. This can be
done by creating an account with{' '}
<Link
variant="external"
to="https://bridge.simplefin.org/"
linkColor="purple"
>
SimpleFIN
</Link>
.
</Text>
<FormField>
<FormLabel title="Token:" htmlFor="token-field" />
<Input
id="token-field"
type="password"
value={token}
onChangeValue={value => {
setToken(value);
setIsValid(true);
}}
/>
</FormField>
{!isValid && <Error>It is required to provide a token.</Error>}
</View>
<ModalButtons>
<ButtonWithLoading
variant="primary"
type="submit"
autoFocus
isLoading={isLoading}
>
SimpleFIN
</Link>
.
</Text>
<FormField>
<FormLabel title="Token:" htmlFor="token-field" />
<Input
id="token-field"
type="password"
value={token}
onChangeValue={value => {
setToken(value);
setIsValid(true);
}}
/>
</FormField>
{!isValid && <Error>It is required to provide a token.</Error>}
</View>
<ModalButtons>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={isLoading}
onPress={() => {
onSubmit(close);
}}
>
Save and continue
</ButtonWithLoading>
</ModalButtons>
Save and continue
</ButtonWithLoading>
</ModalButtons>
</Form>
</>
)}
</Modal>

View File

@@ -4,13 +4,13 @@ import React, {
type ComponentType,
type ComponentPropsWithoutRef,
type FormEvent,
useCallback,
} from 'react';
import { Form } from 'react-aria-components';
import { styles } from '../../style';
import { Button } from '../common/Button2';
import { FormError } from '../common/FormError';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, type ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { InputField } from '../mobile/MobileForms';
@@ -35,37 +35,36 @@ export function SingleInputModal({
const [value, setValue] = useState('');
const [errorMessage, setErrorMessage] = useState(null);
const _onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const _onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
const error = onValidate?.(value);
if (error) {
setErrorMessage(error);
return;
}
const error = onValidate?.(value);
if (error) {
setErrorMessage(error);
return;
}
onSubmit?.(value);
};
onSubmit?.(value);
close();
},
[onSubmit, onValidate, value],
);
return (
<Modal name={name}>
{({ state: { close } }) => (
<>
<Header rightContent={<ModalCloseButton onPress={close} />} />
<Form
onSubmit={e => {
_onSubmit(e);
close();
}}
>
<Form onSubmit={e => _onSubmit(e, { close })}>
<View>
<InitialFocus>
<InputField
placeholder={inputPlaceholder}
defaultValue={value}
onChangeValue={setValue}
/>
</InitialFocus>
<InputField
placeholder={inputPlaceholder}
value={value}
onChangeValue={setValue}
autoFocus
autoSelect
/>
{errorMessage && (
<FormError
style={{

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
import { Form } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
@@ -12,7 +13,6 @@ import {
removeCategoriesFromGroups,
} from '../budget/util';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { FieldLabel, TapField } from '../mobile/MobileForms';
@@ -57,7 +57,7 @@ export function TransferModal({
const [toCategoryId, setToCategoryId] = useState<string | null>(null);
const dispatch = useDispatch();
const openCategoryModal = () => {
const openCategoryModal = useCallback(() => {
dispatch(
pushModal('category-autocomplete', {
categoryGroups,
@@ -68,13 +68,24 @@ export function TransferModal({
},
}),
);
};
}, [categoryGroups, month, dispatch]);
const _onSubmit = (newAmount: number, categoryId: string | null) => {
if (newAmount && categoryId) {
onSubmit?.(newAmount, categoryId);
}
};
const _onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
e.preventDefault();
if (!toCategoryId) {
openCategoryModal();
return;
}
if (amount && toCategoryId) {
onSubmit?.(amount, toCategoryId);
}
close();
},
[toCategoryId, amount, openCategoryModal, onSubmit],
);
const toCategory = categories.find(c => c.id === toCategoryId);
@@ -86,10 +97,10 @@ export function TransferModal({
title={title}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<Form onSubmit={e => _onSubmit(e, { close })}>
<View>
<FieldLabel title={t('Transfer this amount:')} />
<InitialFocus>
<View>
<FieldLabel title={t('Transfer this amount:')} />
<AmountInput
value={initialAmount}
autoDecimals={true}
@@ -101,45 +112,39 @@ export function TransferModal({
height: styles.mobileMinHeight,
}}
onUpdate={setAmount}
onEnter={() => {
if (!toCategoryId) {
openCategoryModal();
}
}}
autoFocus
autoSelect
/>
</InitialFocus>
</View>
</View>
<FieldLabel title="To:" />
<TapField
tabIndex={0}
value={toCategory?.name}
onClick={openCategoryModal}
/>
<FieldLabel title="To:" />
<TapField
tabIndex={0}
value={toCategory?.name}
onClick={openCategoryModal}
/>
<View
style={{
justifyContent: 'center',
alignItems: 'center',
paddingTop: 10,
}}
>
<Button
variant="primary"
<View
style={{
height: styles.mobileMinHeight,
marginLeft: styles.mobileEditingPadding,
marginRight: styles.mobileEditingPadding,
}}
onPress={() => {
_onSubmit(amount, toCategoryId);
close();
justifyContent: 'center',
alignItems: 'center',
paddingTop: 10,
}}
>
<Trans>Transfer</Trans>
</Button>
<Button
variant="primary"
type="submit"
style={{
height: styles.mobileMinHeight,
marginLeft: styles.mobileEditingPadding,
marginRight: styles.mobileEditingPadding,
}}
>
<Trans>Transfer</Trans>
</Button>
</View>
</View>
</View>
</Form>
</>
)}
</Modal>

View File

@@ -243,7 +243,7 @@ export const ManagePayees = ({
<Search
placeholder={t('Filter payees...')}
value={filter}
onChange={applyFilter}
onChangeValue={applyFilter}
/>
</View>

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { styles } from '../../style';
import { Block } from '../common/Block';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
@@ -22,22 +21,22 @@ export const ReportCardName = ({
}: ReportCardNameProps) => {
if (isEditing) {
return (
<InitialFocus>
<Input
className={NON_DRAGGABLE_AREA_CLASS_NAME}
defaultValue={name}
onEnter={e => onChange(e.currentTarget.value)}
onUpdate={onChange}
onEscape={onClose}
style={{
...styles.mediumText,
marginTop: -6,
marginBottom: -1,
marginLeft: -6,
width: Math.max(20, name.length) + 'ch',
}}
/>
</InitialFocus>
<Input
className={NON_DRAGGABLE_AREA_CLASS_NAME}
defaultValue={name}
onEnter={e => onChange(e.currentTarget.value)}
onUpdate={onChange}
onEscape={onClose}
style={{
...styles.mediumText,
marginTop: -6,
marginBottom: -1,
marginLeft: -6,
width: Math.max(20, name.length) + 'ch',
}}
autoFocus
autoSelect
/>
);
}

View File

@@ -42,7 +42,7 @@ export function SaveReportChoose({ onApply }: SaveReportChooseProps) {
<View style={{ flex: 1 }} />
</View>
<GenericInput
inputRef={inputRef}
ref={inputRef}
field="report"
subfield={null}
type="saved"

View File

@@ -69,7 +69,7 @@ export function SaveReportName({
<Input
value={name}
id="name-field"
inputRef={inputRef}
ref={inputRef}
onChangeValue={setName}
style={{ marginTop: 10 }}
/>

View File

@@ -19,7 +19,6 @@ import { theme } from '../../style';
import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
@@ -470,17 +469,17 @@ export function ScheduleDetails({ id, transaction }) {
<Stack direction="row" style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Schedule Name')} htmlFor="name-field" />
<InitialFocus>
<GenericInput
field="string"
type="string"
value={state.fields.name}
multi={false}
onChange={e => {
dispatch({ type: 'set-field', field: 'name', value: e });
}}
/>
</InitialFocus>
<GenericInput
field="string"
type="string"
value={state.fields.name}
multi={false}
onChange={e => {
dispatch({ type: 'set-field', field: 'name', value: e });
}}
autoFocus
autoSelect
/>
</FormField>
</Stack>
<Stack direction="row" style={{ marginTop: 20 }}>

View File

@@ -14,7 +14,6 @@ import {
import { SvgAdd } from '../../icons/v0';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Search } from '../common/Search';
import { Text } from '../common/Text';
@@ -96,16 +95,16 @@ export function ScheduleLink({
{ count: ids?.length ?? 0 },
)}
</Text>
<InitialFocus>
<Search
inputRef={searchInput}
isInModal
width={300}
placeholder={t('Filter schedules…')}
value={filter}
onChange={setFilter}
/>
</InitialFocus>
<Search
ref={searchInput}
isInModal
width={300}
placeholder={t('Filter schedules…')}
value={filter}
onChangeValue={setFilter}
autoFocus
autoSelect
/>
{ids.length === 1 && (
<Button
variant="primary"

View File

@@ -95,7 +95,7 @@ export function Schedules() {
<Search
placeholder={t('Filter schedules…')}
value={filter}
onChange={setFilter}
onChangeValue={setFilter}
/>
</View>
</View>

View File

@@ -10,7 +10,6 @@ import React, {
useState,
type ComponentProps,
type KeyboardEvent,
type MutableRefObject,
} from 'react';
import { css } from '@emotion/css';
@@ -27,6 +26,7 @@ import {
currentDate,
} from 'loot-core/src/shared/months';
import { useMergedRefs } from '../../hooks/useMergedRefs';
import { useSyncedPref } from '../../hooks/useSyncedPref';
import { styles, theme } from '../../style';
import { Input } from '../common/Input';
@@ -185,232 +185,235 @@ type DateSelectProps = {
isOpen?: boolean;
embedded?: boolean;
dateFormat: string;
focused?: boolean;
autoFocus?: boolean;
autoSelect?: boolean;
openOnFocus?: boolean;
inputRef?: MutableRefObject<HTMLInputElement>;
shouldSaveFromKey?: (e: KeyboardEvent<HTMLInputElement>) => boolean;
clearOnBlur?: boolean;
onUpdate?: (selectedDate: string) => void;
onSelect: (selectedDate: string) => void;
};
export function DateSelect({
id,
containerProps,
inputProps,
value: defaultValue,
isOpen,
embedded,
dateFormat = 'yyyy-MM-dd',
focused,
openOnFocus = true,
inputRef: originalInputRef,
shouldSaveFromKey = defaultShouldSaveFromKey,
clearOnBlur = true,
onUpdate,
onSelect,
}: DateSelectProps) {
const parsedDefaultValue = useMemo(() => {
if (defaultValue) {
const date = parseISO(defaultValue);
if (isValid(date)) {
return format(date, dateFormat);
export const DateSelect = forwardRef<HTMLInputElement, DateSelectProps>(
(
{
id,
containerProps,
inputProps,
value: defaultValue,
isOpen,
embedded,
dateFormat = 'yyyy-MM-dd',
autoFocus,
autoSelect,
openOnFocus = true,
shouldSaveFromKey = defaultShouldSaveFromKey,
clearOnBlur = true,
onUpdate,
onSelect,
},
ref,
) => {
const parsedDefaultValue = useMemo(() => {
if (defaultValue) {
const date = parseISO(defaultValue);
if (isValid(date)) {
return format(date, dateFormat);
}
}
}
return '';
}, [defaultValue, dateFormat]);
return '';
}, [defaultValue, dateFormat]);
const picker = useRef(null);
const [value, setValue] = useState(parsedDefaultValue);
const [open, setOpen] = useState(embedded || isOpen || false);
const inputRef = useRef(null);
const picker = useRef(null);
const [value, setValue] = useState(parsedDefaultValue);
const [open, setOpen] = useState(embedded || isOpen || false);
const inputRef = useRef(null);
const mergedRef = useMergedRefs(ref, inputRef);
useLayoutEffect(() => {
if (originalInputRef) {
originalInputRef.current = inputRef.current;
}
}, []);
// This is confusing, so let me explain: `selectedValue` should be
// renamed to `currentValue`. It represents the current highlighted
// value in the date select and always changes as the user moves
// around. `userSelectedValue` represents the last value that the
// user actually selected (with enter or click). Having both allows
// us to make various UX decisions
const [selectedValue, setSelectedValue] = useState(value);
const userSelectedValue = useRef(selectedValue);
// This is confusing, so let me explain: `selectedValue` should be
// renamed to `currentValue`. It represents the current highlighted
// value in the date select and always changes as the user moves
// around. `userSelectedValue` represents the last value that the
// user actually selected (with enter or click). Having both allows
// us to make various UX decisions
const [selectedValue, setSelectedValue] = useState(value);
const userSelectedValue = useRef(selectedValue);
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
useEffect(() => {
userSelectedValue.current = value;
}, [value]);
useEffect(() => {
userSelectedValue.current = value;
}, [value]);
useEffect(() => setValue(parsedDefaultValue), [parsedDefaultValue]);
useEffect(() => setValue(parsedDefaultValue), [parsedDefaultValue]);
useEffect(() => {
if (getDayMonthRegex(dateFormat).test(value)) {
// Support only entering the month and day (4/5). This is complex
// because of the various date formats - we need to derive
// the right day/month format from it
const test = parse(value, getDayMonthFormat(dateFormat), new Date());
if (isValid(test)) {
onUpdate?.(format(test, 'yyyy-MM-dd'));
setSelectedValue(format(test, dateFormat));
}
} else if (getShortYearRegex(dateFormat).test(value)) {
// Support entering the year as only two digits (4/5/19)
const test = parse(value, getShortYearFormat(dateFormat), new Date());
if (isValid(test)) {
onUpdate?.(format(test, 'yyyy-MM-dd'));
setSelectedValue(format(test, dateFormat));
}
} else {
const test = parse(value, dateFormat, new Date());
if (isValid(test)) {
const date = format(test, 'yyyy-MM-dd');
onUpdate?.(date);
setSelectedValue(value);
}
}
}, [value]);
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) &&
!e.shiftKey &&
!e.metaKey &&
!e.altKey &&
open
) {
picker.current.handleInputKeyDown(e);
} else if (e.key === 'Escape') {
setValue(parsedDefaultValue);
setSelectedValue(parsedDefaultValue);
if (parsedDefaultValue === value) {
if (open) {
if (!embedded) {
e.stopPropagation();
}
setOpen(false);
useEffect(() => {
if (getDayMonthRegex(dateFormat).test(value)) {
// Support only entering the month and day (4/5). This is complex
// because of the various date formats - we need to derive
// the right day/month format from it
const test = parse(value, getDayMonthFormat(dateFormat), new Date());
if (isValid(test)) {
onUpdate?.(format(test, 'yyyy-MM-dd'));
setSelectedValue(format(test, dateFormat));
}
} else if (getShortYearRegex(dateFormat).test(value)) {
// Support entering the year as only two digits (4/5/19)
const test = parse(value, getShortYearFormat(dateFormat), new Date());
if (isValid(test)) {
onUpdate?.(format(test, 'yyyy-MM-dd'));
setSelectedValue(format(test, dateFormat));
}
} else {
const test = parse(value, dateFormat, new Date());
if (isValid(test)) {
const date = format(test, 'yyyy-MM-dd');
onUpdate?.(date);
setSelectedValue(value);
}
}
}, [value]);
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) &&
!e.shiftKey &&
!e.metaKey &&
!e.altKey &&
open
) {
picker.current.handleInputKeyDown(e);
} else if (e.key === 'Escape') {
setValue(parsedDefaultValue);
setSelectedValue(parsedDefaultValue);
if (parsedDefaultValue === value) {
if (open) {
if (!embedded) {
e.stopPropagation();
}
setOpen(false);
}
} else {
setOpen(true);
onUpdate?.(defaultValue);
}
} else if (shouldSaveFromKey(e)) {
setValue(selectedValue);
setOpen(false);
const date = parse(selectedValue, dateFormat, new Date());
onSelect(format(date, 'yyyy-MM-dd'));
if (open && e.key === 'Enter') {
// This stops the event from propagating up
e.stopPropagation();
e.preventDefault();
}
const { onKeyDown } = inputProps || {};
onKeyDown?.(e);
} else if (!open) {
setOpen(true);
onUpdate?.(defaultValue);
}
} else if (shouldSaveFromKey(e)) {
setValue(selectedValue);
setOpen(false);
const date = parse(selectedValue, dateFormat, new Date());
onSelect(format(date, 'yyyy-MM-dd'));
if (open && e.key === 'Enter') {
// This stops the event from propagating up
e.stopPropagation();
e.preventDefault();
}
const { onKeyDown } = inputProps || {};
onKeyDown?.(e);
} else if (!open) {
setOpen(true);
if (inputRef.current) {
inputRef.current.setSelectionRange(0, 10000);
if (inputRef.current) {
inputRef.current.setSelectionRange(0, 10000);
}
}
}
}
function onChange(e) {
setValue(e.target.value);
}
const maybeWrapTooltip = content => {
if (embedded) {
return open ? content : null;
function onChange(e) {
setValue(e.target.value);
}
const maybeWrapTooltip = content => {
if (embedded) {
return open ? content : null;
}
return (
<Popover
triggerRef={inputRef}
placement="bottom start"
offset={2}
isOpen={open}
isNonModal
onOpenChange={() => setOpen(false)}
style={{ ...styles.popover, minWidth: 225 }}
data-testid="date-select-tooltip"
>
{content}
</Popover>
);
};
return (
<Popover
triggerRef={inputRef}
placement="bottom start"
offset={2}
isOpen={open}
isNonModal
onOpenChange={() => setOpen(false)}
style={{ ...styles.popover, minWidth: 225 }}
data-testid="date-select-tooltip"
>
{content}
</Popover>
);
};
<View {...containerProps}>
<Input
id={id}
{...inputProps}
autoFocus={autoFocus}
autoSelect={autoSelect}
ref={mergedRef}
value={value}
onPointerUp={() => {
if (!embedded) {
setOpen(true);
}
}}
onKeyDown={onKeyDown}
onChange={onChange}
onFocus={e => {
if (!embedded && openOnFocus) {
setOpen(true);
}
inputProps?.onFocus?.(e);
}}
onBlur={e => {
if (!embedded) {
setOpen(false);
}
inputProps?.onBlur?.(e);
return (
<View {...containerProps}>
<Input
id={id}
focused={focused}
{...inputProps}
inputRef={inputRef}
value={value}
onPointerUp={() => {
if (!embedded) {
setOpen(true);
}
}}
onKeyDown={onKeyDown}
onChange={onChange}
onFocus={e => {
if (!embedded && openOnFocus) {
setOpen(true);
}
inputProps?.onFocus?.(e);
}}
onBlur={e => {
if (!embedded) {
setOpen(false);
}
inputProps?.onBlur?.(e);
if (clearOnBlur) {
// If value is empty, that drives what gets selected.
// Otherwise the input is reset to whatever is already
// selected
if (value === '') {
setSelectedValue(null);
onSelect(null);
} else {
setValue(selectedValue || '');
if (clearOnBlur) {
// If value is empty, that drives what gets selected.
// Otherwise the input is reset to whatever is already
// selected
if (value === '') {
setSelectedValue(null);
onSelect(null);
} else {
setValue(selectedValue || '');
const date = parse(selectedValue, dateFormat, new Date());
if (date instanceof Date && !isNaN(date.valueOf())) {
onSelect(format(date, 'yyyy-MM-dd'));
const date = parse(selectedValue, dateFormat, new Date());
if (date instanceof Date && !isNaN(date.valueOf())) {
onSelect(format(date, 'yyyy-MM-dd'));
}
}
}
}
}}
/>
{maybeWrapTooltip(
<DatePicker
ref={picker}
value={selectedValue}
firstDayOfWeekIdx={firstDayOfWeekIdx}
dateFormat={dateFormat}
onUpdate={date => {
setSelectedValue(format(date, dateFormat));
onUpdate?.(format(date, 'yyyy-MM-dd'));
}}
onSelect={date => {
setValue(format(date, dateFormat));
onSelect(format(date, 'yyyy-MM-dd'));
setOpen(false);
}}
/>,
)}
</View>
);
}
/>
{maybeWrapTooltip(
<DatePicker
ref={picker}
value={selectedValue}
firstDayOfWeekIdx={firstDayOfWeekIdx}
dateFormat={dateFormat}
onUpdate={date => {
setSelectedValue(format(date, dateFormat));
onUpdate?.(format(date, 'yyyy-MM-dd'));
}}
onSelect={date => {
setValue(format(date, dateFormat));
onSelect(format(date, 'yyyy-MM-dd'));
setOpen(false);
}}
/>,
)}
</View>
);
},
);
DateSelect.displayName = 'DateSelect';

View File

@@ -17,7 +17,6 @@ import { useDateFormat } from '../../hooks/useDateFormat';
import { SvgAdd, SvgSubtract } from '../../icons/v0';
import { theme } from '../../style';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
@@ -393,16 +392,16 @@ function RecurringScheduleTooltip({
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<label htmlFor="start">From</label>
<InitialFocus>
<DateSelect
id="start"
inputProps={{ placeholder: 'Start Date' }}
value={config.start}
onSelect={value => updateField('start', value)}
containerProps={{ style: { width: 100 } }}
dateFormat={dateFormat}
/>
</InitialFocus>
<DateSelect
id="start"
inputProps={{ placeholder: 'Start Date' }}
value={config.start}
onSelect={value => updateField('start', value)}
containerProps={{ style: { width: 100 } }}
dateFormat={dateFormat}
autoFocus
autoSelect
/>
<Select
id="repeat_end_dropdown"
options={[
@@ -420,7 +419,7 @@ function RecurringScheduleTooltip({
style={{ width: 40 }}
type="number"
min={1}
onChange={e => updateField('endOccurrences', e.target.value)}
onChangeValue={value => updateField('endOccurrences', value)}
defaultValue={config.endOccurrences || 1}
/>
<Text>occurrence{config.endOccurrences === '1' ? '' : 's'}</Text>
@@ -450,7 +449,7 @@ function RecurringScheduleTooltip({
style={{ width: 40 }}
type="number"
min={1}
onChange={e => updateField('interval', e.target.value)}
onChangeValue={value => updateField('interval', value)}
defaultValue={config.interval || 1}
/>
<Select

View File

@@ -17,7 +17,6 @@ import { SvgExpandArrow } from '../../icons/v0';
import { SvgAdd } from '../../icons/v1';
import { styles, theme } from '../../style';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Input } from '../common/Input';
import { Menu } from '../common/Menu';
import { Popover } from '../common/Popover';
@@ -195,25 +194,25 @@ function EditableBudgetName() {
if (editing) {
return (
<InitialFocus>
<Input
style={{
width: 160,
fontSize: 16,
fontWeight: 500,
}}
defaultValue={budgetName}
onEnter={async e => {
const inputEl = e.target as HTMLInputElement;
const newBudgetName = inputEl.value;
if (newBudgetName.trim() !== '') {
setBudgetNamePref(newBudgetName);
setEditing(false);
}
}}
onBlur={() => setEditing(false)}
/>
</InitialFocus>
<Input
style={{
width: 160,
fontSize: 16,
fontWeight: 500,
}}
defaultValue={budgetName}
onEnter={async e => {
const inputEl = e.target as HTMLInputElement;
const newBudgetName = inputEl.value;
if (newBudgetName.trim() !== '') {
setBudgetNamePref(newBudgetName);
setEditing(false);
}
}}
onBlur={() => setEditing(false)}
autoFocus
autoSelect
/>
);
}

View File

@@ -88,6 +88,16 @@ export type Binding<
value?: Spreadsheets[SheetName][SheetFieldName];
query?: Query;
};
export type SheetResult<
SheetName extends SheetNames,
FieldName extends SheetFields<SheetName>,
> = {
name: string;
value: Spreadsheets[SheetName][FieldName];
query?: Query;
};
export const parametrizedField =
<SheetName extends SheetNames>() =>
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>

View File

@@ -10,6 +10,7 @@ import {
type SheetFields,
type SheetNames,
type Binding,
type SheetResult,
} from '.';
export function useSheetValue<
@@ -17,7 +18,7 @@ export function useSheetValue<
FieldName extends SheetFields<SheetName>,
>(
binding: Binding<SheetName, FieldName>,
onChange?: (result) => void,
onChange?: (result: SheetResult<SheetName, FieldName>) => void,
): Spreadsheets[SheetName][FieldName] {
const { sheetName, fullSheetName } = useSheetName(binding);

View File

@@ -737,7 +737,7 @@ function PayeeCell({
}}
showManagePayees={true}
clearOnBlur={false}
focused={true}
autoFocus={true}
onUpdate={(id, value) => onUpdate?.(value)}
onSelect={onSave}
onManagePayees={() => onManagePayees(payee?.id)}
@@ -1273,7 +1273,7 @@ const Transaction = memo(function Transaction({
accounts={accounts}
shouldSaveFromKey={shouldSaveFromKey}
clearOnBlur={false}
focused={true}
autoFocus={true}
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
onUpdate={onUpdate}
onSelect={onSave}
@@ -1497,7 +1497,7 @@ const Transaction = memo(function Transaction({
<CategoryAutocomplete
categoryGroups={categoryGroups}
value={categoryId}
focused={true}
autoFocus={true}
clearOnBlur={false}
showSplitOption={!isChild && !isParent}
shouldSaveFromKey={shouldSaveFromKey}

View File

@@ -1,14 +1,16 @@
// @ts-strict-ignore
import React, {
type Ref,
useRef,
useState,
useEffect,
type FocusEventHandler,
type KeyboardEventHandler,
type CSSProperties,
forwardRef,
} from 'react';
import { css } from '@emotion/css';
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
import { amountToInteger, appendDecimals } from 'loot-core/src/shared/util';
@@ -23,7 +25,6 @@ import { useFormat } from '../spreadsheet/useFormat';
type AmountInputProps = {
id?: string;
inputRef?: Ref<HTMLInputElement>;
value: number;
zeroSign?: '-' | '+';
onChangeValue?: (value: string) => void;
@@ -33,125 +34,134 @@ type AmountInputProps = {
onUpdate?: (amount: number) => void;
style?: CSSProperties;
inputStyle?: CSSProperties;
focused?: boolean;
autoFocus?: boolean;
autoSelect?: boolean;
disabled?: boolean;
autoDecimals?: boolean;
};
export function AmountInput({
id,
inputRef,
value: initialValue,
zeroSign = '-', // + or -
onFocus,
onBlur,
onChangeValue,
onUpdate,
onEnter,
style,
inputStyle,
focused,
disabled = false,
autoDecimals = false,
}: AmountInputProps) {
const format = useFormat();
const [symbol, setSymbol] = useState<'+' | '-'>(
initialValue === 0 ? zeroSign : initialValue > 0 ? '+' : '-',
);
export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
(
{
id,
value: initialValue,
zeroSign = '-', // + or -
onFocus,
onBlur,
onChangeValue,
onUpdate,
onEnter,
style,
inputStyle,
autoFocus,
autoSelect,
disabled = false,
autoDecimals = false,
},
ref,
) => {
const format = useFormat();
const [symbol, setSymbol] = useState<'+' | '-'>(
initialValue === 0 ? zeroSign : initialValue > 0 ? '+' : '-',
);
const initialValueAbsolute = format(Math.abs(initialValue || 0), 'financial');
const [value, setValue] = useState(initialValueAbsolute);
useEffect(() => setValue(initialValueAbsolute), [initialValueAbsolute]);
const initialValueAbsolute = format(
Math.abs(initialValue || 0),
'financial',
);
const [value, setValue] = useState(initialValueAbsolute);
useEffect(() => setValue(initialValueAbsolute), [initialValueAbsolute]);
const buttonRef = useRef();
const ref = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
const [hideFraction] = useSyncedPref('hideFraction');
const buttonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
const [hideFraction] = useSyncedPref('hideFraction');
useEffect(() => {
if (focused) {
ref.current?.focus();
}
}, [focused]);
function onSwitch() {
const amount = getAmount();
if (amount === 0) {
setSymbol(symbol === '+' ? '-' : '+');
}
fireUpdate(amount * -1);
}
function getAmount() {
const signedValued = symbol === '-' ? symbol + value : value;
return amountToInteger(evalArithmetic(signedValued));
}
function onInputTextChange(val) {
val = autoDecimals
? appendDecimals(val, String(hideFraction) === 'true')
: val;
setValue(val ? val : '');
onChangeValue?.(val);
}
function fireUpdate(amount) {
onUpdate?.(amount);
if (amount > 0) {
setSymbol('+');
} else if (amount < 0) {
setSymbol('-');
}
}
function onInputAmountBlur(e) {
if (!ref.current?.contains(e.relatedTarget)) {
function onSwitch() {
const amount = getAmount();
fireUpdate(amount);
}
onBlur?.(e);
}
return (
<InputWithContent
id={id}
inputRef={mergedRef}
inputMode="decimal"
leftContent={
<Button
variant="bare"
isDisabled={disabled}
aria-label={`Make ${symbol === '-' ? 'positive' : 'negative'}`}
style={{ padding: '0 7px' }}
onPress={onSwitch}
ref={buttonRef}
>
{symbol === '-' && (
<SvgSubtract style={{ width: 8, height: 8, color: 'inherit' }} />
)}
{symbol === '+' && (
<SvgAdd style={{ width: 8, height: 8, color: 'inherit' }} />
)}
</Button>
if (amount === 0) {
setSymbol(symbol === '+' ? '-' : '+');
}
value={value}
disabled={disabled}
focused={focused}
style={{ flex: 1, alignItems: 'stretch', ...style }}
inputStyle={inputStyle}
onKeyUp={e => {
if (e.key === 'Enter') {
const amount = getAmount();
fireUpdate(amount);
fireUpdate(amount * -1);
}
function getAmount() {
const signedValued = symbol === '-' ? symbol + value : value;
return amountToInteger(evalArithmetic(signedValued));
}
function onInputTextChange(val) {
val = autoDecimals
? appendDecimals(val, String(hideFraction) === 'true')
: val;
setValue(val ? val : '');
onChangeValue?.(val);
}
function fireUpdate(amount) {
onUpdate?.(amount);
if (amount > 0) {
setSymbol('+');
} else if (amount < 0) {
setSymbol('-');
}
}
function onInputAmountBlur(e) {
if (!inputRef.current?.contains(e.relatedTarget)) {
const amount = getAmount();
fireUpdate(amount);
}
onBlur?.(e);
}
return (
<InputWithContent
id={id}
ref={mergedRef}
inputMode="decimal"
leftContent={
<Button
variant="bare"
isDisabled={disabled}
aria-label={`Make ${symbol === '-' ? 'positive' : 'negative'}`}
style={{ padding: '0 7px', flexShrink: 0 }}
onPress={onSwitch}
ref={buttonRef}
>
{symbol === '-' && (
<SvgSubtract style={{ width: 8, height: 8, color: 'inherit' }} />
)}
{symbol === '+' && (
<SvgAdd style={{ width: 8, height: 8, color: 'inherit' }} />
)}
</Button>
}
}}
onChangeValue={onInputTextChange}
onBlur={onInputAmountBlur}
onFocus={onFocus}
onEnter={onEnter}
/>
);
}
value={value}
disabled={disabled}
autoFocus={autoFocus}
autoSelect={autoSelect}
containerClassName={css({
flex: 1,
alignItems: 'stretch',
'& input': inputStyle,
...style,
})}
onKeyUp={e => {
if (e.key === 'Enter') {
const amount = getAmount();
fireUpdate(amount);
}
}}
onChangeValue={onInputTextChange}
onBlur={onInputAmountBlur}
onFocus={onFocus}
onEnter={onEnter}
/>
);
},
);
AmountInput.displayName = 'AmountInput';
export function BetweenAmountInput({ defaultValue, onChange }) {
const [num1, setNum1] = useState(defaultValue.num1);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef } from 'react';
import { useSelector } from 'react-redux';
import { useReports } from 'loot-core/client/data-hooks/reports';
@@ -22,243 +22,277 @@ import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker';
import { AmountInput } from './AmountInput';
import { PercentInput } from './PercentInput';
export function GenericInput({
field,
subfield,
type,
numberFormatType = undefined,
multi,
value,
inputRef,
style,
onChange,
}) {
const { grouped: categoryGroups } = useCategories();
const { data: savedReports } = useReports();
const saved = useSelector(state => state.queries.saved);
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
export const GenericInput = forwardRef(
(
{
field,
subfield,
type,
numberFormatType = undefined,
multi,
value,
style,
onChange,
autoFocus,
autoSelect,
},
ref,
) => {
const { grouped: categoryGroups } = useCategories();
const { data: savedReports } = useReports();
const saved = useSelector(state => state.queries.saved);
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const getNumberInputByFormatType = numberFormatType => {
switch (numberFormatType) {
case 'currency':
return (
<AmountInput
inputRef={inputRef}
value={amountToInteger(value)}
onUpdate={v => onChange(integerToAmount(v))}
/>
);
case 'percentage':
return (
<PercentInput
inputRef={inputRef}
value={value}
onUpdatePercent={onChange}
/>
);
default:
return (
<Input
inputRef={inputRef}
defaultValue={value || ''}
placeholder="nothing"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
/>
);
const getNumberInputByFormatType = numberFormatType => {
switch (numberFormatType) {
case 'currency':
return (
<AmountInput
ref={ref}
value={amountToInteger(value)}
onUpdate={v => onChange(integerToAmount(v))}
autoFocus={autoFocus}
autoSelect={autoSelect}
/>
);
case 'percentage':
return (
<PercentInput
ref={ref}
value={value}
onUpdatePercent={onChange}
autoFocus={autoFocus}
autoSelect={autoSelect}
/>
);
default:
return (
<Input
ref={ref}
defaultValue={value || ''}
placeholder="nothing"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
autoFocus={autoFocus}
autoSelect={autoSelect}
/>
);
}
};
// This makes the UI more resilient in case of faulty data
if (multi && !Array.isArray(value)) {
value = [];
} else if (!multi && Array.isArray(value)) {
return null;
}
};
// This makes the UI more resilient in case of faulty data
if (multi && !Array.isArray(value)) {
value = [];
} else if (!multi && Array.isArray(value)) {
return null;
}
const showPlaceholder = multi ? value.length === 0 : true;
const autocompleteType = multi ? 'multi' : 'single';
const showPlaceholder = multi ? value.length === 0 : true;
const autocompleteType = multi ? 'multi' : 'single';
let content;
switch (type) {
case 'id':
switch (field) {
case 'payee':
content = (
<PayeeAutocomplete
type={autocompleteType}
showMakeTransfer={false}
openOnFocus={true}
value={value}
onSelect={onChange}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
case 'account':
content = (
<AccountAutocomplete
type={autocompleteType}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
case 'category':
content = (
<CategoryAutocomplete
type={autocompleteType}
categoryGroups={categoryGroups}
value={value}
openOnFocus={true}
onSelect={onChange}
showHiddenCategories={false}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
default:
}
break;
case 'saved':
switch (field) {
case 'saved':
content = (
<FilterAutocomplete
type={autocompleteType}
saved={saved}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
case 'report':
content = (
<ReportAutocomplete
type={autocompleteType}
saved={savedReports}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
default:
}
break;
case 'date':
switch (subfield) {
case 'month':
content = (
<Input
inputRef={inputRef}
defaultValue={value || ''}
placeholder={getMonthYearFormat(dateFormat).toLowerCase()}
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
/>
);
break;
case 'year':
content = (
<Input
inputRef={inputRef}
defaultValue={value || ''}
placeholder="yyyy"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
/>
);
break;
default:
if (value && value.frequency) {
let content;
switch (type) {
case 'id':
switch (field) {
case 'payee':
content = (
<RecurringSchedulePicker
<PayeeAutocomplete
type={autocompleteType}
showMakeTransfer={false}
openOnFocus={true}
value={value}
buttonStyle={{ justifyContent: 'flex-start' }}
onChange={onChange}
/>
);
} else {
content = (
<DateSelect
value={value}
dateFormat={dateFormat}
openOnFocus={false}
inputRef={inputRef}
inputProps={{ placeholder: dateFormat.toLowerCase() }}
onSelect={onChange}
inputProps={{
ref,
autoFocus,
autoSelect,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
}
break;
}
break;
break;
case 'boolean':
content = (
<Checkbox
checked={value}
value={value}
onChange={() => onChange(!value)}
/>
);
break;
case 'account':
content = (
<AccountAutocomplete
type={autocompleteType}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
ref,
autoFocus,
autoSelect,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
default:
if (multi) {
case 'category':
content = (
<CategoryAutocomplete
type={autocompleteType}
categoryGroups={categoryGroups}
value={value}
openOnFocus={true}
onSelect={onChange}
showHiddenCategories={false}
inputProps={{
ref,
autoFocus,
autoSelect,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
default:
}
break;
case 'saved':
switch (field) {
case 'saved':
content = (
<FilterAutocomplete
type={autocompleteType}
saved={saved}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
ref,
autoFocus,
autoSelect,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
case 'report':
content = (
<ReportAutocomplete
type={autocompleteType}
saved={savedReports}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
ref,
autoFocus,
autoSelect,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
default:
}
break;
case 'date':
switch (subfield) {
case 'month':
content = (
<Input
ref={ref}
defaultValue={value || ''}
placeholder={getMonthYearFormat(dateFormat).toLowerCase()}
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
autoFocus={autoFocus}
autoSelect={autoSelect}
/>
);
break;
case 'year':
content = (
<Input
ref={ref}
defaultValue={value || ''}
placeholder="yyyy"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
autoFocus={autoFocus}
autoSelect={autoSelect}
/>
);
break;
default:
if (value && value.frequency) {
content = (
<RecurringSchedulePicker
value={value}
buttonStyle={{ justifyContent: 'flex-start' }}
onChange={onChange}
/>
);
} else {
content = (
<DateSelect
ref={ref}
value={value}
dateFormat={dateFormat}
openOnFocus={false}
inputProps={{
autoFocus,
autoSelect,
placeholder: dateFormat.toLowerCase(),
}}
onSelect={onChange}
/>
);
}
break;
}
break;
case 'boolean':
content = (
<Autocomplete
type={autocompleteType}
suggestions={[]}
<Checkbox
checked={value}
value={value}
inputProps={{ inputRef }}
onSelect={onChange}
onChange={() => onChange(!value)}
/>
);
} else if (type === 'number') {
content = getNumberInputByFormatType(numberFormatType);
} else {
content = (
<Input
inputRef={inputRef}
defaultValue={value || ''}
placeholder="nothing"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
/>
);
}
break;
}
break;
return <View style={{ flex: 1, ...style }}>{content}</View>;
}
default:
if (multi) {
content = (
<Autocomplete
type={autocompleteType}
suggestions={[]}
value={value}
inputProps={{ autoFocus, autoSelect, ref }}
onSelect={onChange}
/>
);
} else if (type === 'number') {
content = getNumberInputByFormatType(numberFormatType);
} else {
content = (
<Input
ref={ref}
defaultValue={value || ''}
placeholder="nothing"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
autoFocus={autoFocus}
autoSelect={autoSelect}
/>
);
}
break;
}
return <View style={{ flex: 1, ...style }}>{content}</View>;
},
);
GenericInput.displayName = 'GenericInput';

View File

@@ -1,11 +1,11 @@
import React, {
type Ref,
useRef,
useState,
useEffect,
type FocusEventHandler,
type FocusEvent,
type CSSProperties,
forwardRef,
} from 'react';
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
@@ -16,109 +16,113 @@ import { useFormat } from '../spreadsheet/useFormat';
type PercentInputProps = {
id?: string;
inputRef?: Ref<HTMLInputElement>;
value?: number;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChangeValue?: (value: string) => void;
onUpdatePercent?: (percent: number) => void;
style?: CSSProperties;
focused?: boolean;
autoFocus?: boolean;
autoSelect?: boolean;
disabled?: boolean;
};
const clampToPercent = (value: number) => Math.max(Math.min(value, 100), 0);
export function PercentInput({
id,
inputRef,
value: initialValue = 0,
onFocus,
onBlur,
onChangeValue,
onUpdatePercent,
style,
focused,
disabled = false,
}: PercentInputProps) {
const format = useFormat();
export const PercentInput = forwardRef<HTMLInputElement, PercentInputProps>(
(
{
id,
value: initialValue = 0,
onFocus,
onBlur,
onChangeValue,
onUpdatePercent,
style,
autoFocus,
autoSelect,
disabled = false,
},
ref,
) => {
const format = useFormat();
const [value, setValue] = useState(() =>
format(clampToPercent(initialValue), 'percentage'),
);
useEffect(() => {
const clampedInitialValue = clampToPercent(initialValue);
if (clampedInitialValue !== initialValue) {
setValue(format(clampedInitialValue, 'percentage'));
onUpdatePercent?.(clampedInitialValue);
}
}, [initialValue, onUpdatePercent, format]);
const [value, setValue] = useState(() =>
format(clampToPercent(initialValue), 'percentage'),
);
useEffect(() => {
const clampedInitialValue = clampToPercent(initialValue);
if (clampedInitialValue !== initialValue) {
setValue(format(clampedInitialValue, 'percentage'));
onUpdatePercent?.(clampedInitialValue);
}
}, [initialValue, onUpdatePercent, format]);
const ref = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
const inputRef = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
useEffect(() => {
if (focused) {
ref.current?.focus();
}
}, [focused]);
function onSelectionChange() {
if (!inputRef.current) {
return;
}
function onSelectionChange() {
if (!ref.current) {
return;
const selectionStart = inputRef.current.selectionStart;
const selectionEnd = inputRef.current.selectionEnd;
if (
selectionStart === selectionEnd &&
selectionStart !== null &&
selectionStart >= inputRef.current.value.length
) {
inputRef.current.setSelectionRange(
inputRef.current.value.length - 1,
inputRef.current.value.length - 1,
);
}
}
const selectionStart = ref.current.selectionStart;
const selectionEnd = ref.current.selectionEnd;
if (
selectionStart === selectionEnd &&
selectionStart !== null &&
selectionStart >= ref.current.value.length
) {
ref.current.setSelectionRange(
ref.current.value.length - 1,
ref.current.value.length - 1,
function onInputTextChange(val: string) {
const number = val.replace(/[^0-9.]/g, '');
setValue(number ? format(number, 'percentage') : '');
onChangeValue?.(number);
}
function fireUpdate() {
const clampedValue = clampToPercent(
evalArithmetic(value.replace('%', '')),
);
onUpdatePercent?.(clampedValue);
onInputTextChange(String(clampedValue));
}
}
function onInputTextChange(val: string) {
const number = val.replace(/[^0-9.]/g, '');
setValue(number ? format(number, 'percentage') : '');
onChangeValue?.(number);
}
function fireUpdate() {
const clampedValue = clampToPercent(evalArithmetic(value.replace('%', '')));
onUpdatePercent?.(clampedValue);
onInputTextChange(String(clampedValue));
}
function onInputAmountBlur(e: FocusEvent<HTMLInputElement>) {
if (!ref.current?.contains(e.relatedTarget)) {
fireUpdate();
function onInputAmountBlur(e: FocusEvent<HTMLInputElement>) {
if (!inputRef.current?.contains(e.relatedTarget)) {
fireUpdate();
}
onBlur?.(e);
}
onBlur?.(e);
}
return (
<Input
id={id}
inputRef={mergedRef}
inputMode="decimal"
value={value}
disabled={disabled}
focused={focused}
style={{ flex: 1, alignItems: 'stretch', ...style }}
onKeyUp={e => {
if (e.key === 'Enter') {
fireUpdate();
}
}}
onChangeValue={onInputTextChange}
onBlur={onInputAmountBlur}
onFocus={onFocus}
onSelect={onSelectionChange}
/>
);
}
return (
<Input
id={id}
ref={mergedRef}
inputMode="decimal"
value={value}
disabled={disabled}
autoFocus={autoFocus}
autoSelect={autoSelect}
style={{ flex: 1, alignItems: 'stretch', ...style }}
onKeyUp={e => {
if (e.key === 'Enter') {
fireUpdate();
}
}}
onChangeValue={onInputTextChange}
onBlur={onInputAmountBlur}
onFocus={onFocus}
onSelect={onSelectionChange}
/>
);
},
);
PercentInput.displayName = 'PercentInput';