[AI] Implement standard sizes on Button, Text and Input components (#9002)
* [AI] Add standard size system to Button, Text and Input components
* [AI] Migrate modals, settings, rules, schedules and sidebar text to standard sizes
* [AI] Migrate mobile, budget, manager, banksync and transactions text to standard sizes
* [AI] Migrate reports, formula and news text to standard sizes
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #9002
* Revert "Update VRT screenshots"
This reverts commit 35c947ef54.
* [AI] Fix line-height rendering as px strings in size tokens
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #9002
* [AI] Tighten bank sync mobile row gap so larger labels fit on one line
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #9002
* [AI] Restore amount display size to preserve underline spacing
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #9002
* [AI] Pass caller-provided size through SingleAutocomplete render input
* [AI] Omit forced line-height on medium-sized controls to match the default look
* [AI] Document standard component sizes in DESIGN.md
* [AI] Adopt sidebar spacing tokens where values match exactly
* [AI] Scope the mobile touch-target rule to touch-primary control sizes
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@@ -135,6 +135,30 @@ All hex values in this file (frontmatter included) are the light-theme palette m
|
||||
- **Body** (400, 16px): Default text.
|
||||
- **Label** (400, 13px): Table meta, menus, dense secondary text; 12px and 10px steps exist for the tightest spots.
|
||||
|
||||
### Standard Component Sizes
|
||||
|
||||
`Button`, `Text` and `Input` accept a `size` prop: `small` | `medium` | `large` | `extra-large` (the `ComponentSize` vocabulary in `packages/component-library/src/tokens.ts`). Omitting the prop renders exactly as before — fully backward compatible. Sizes are responsive: they step up one notch on mobile/narrow for readability and touch targets, and collapse to the compact desktop scale from 512px/730px up. Breakpoint groups mirror the `useResponsive` view modes: narrow <512, small 512–729, medium 730–1099, wide ≥1100.
|
||||
|
||||
Text values (fontSize / lineHeight, px):
|
||||
|
||||
| Size | narrow | small | medium | wide |
|
||||
| ----------- | ------- | ------- | ------- | ------- |
|
||||
| small | 12 / 16 | 12 / 16 | 12 / 16 | 12 / 16 |
|
||||
| medium | 13 / 18 | 13 / 18 | 13 / 18 | 13 / 18 |
|
||||
| large | 16 / 22 | 15 / 20 | 15 / 20 | 15 / 20 |
|
||||
| extra-large | 17 / 24 | 17 / 24 | 16 / 22 | 16 / 22 |
|
||||
|
||||
Control values for Button/Input (paddingY / paddingX / min-height, px):
|
||||
|
||||
| Size | narrow | small | medium | wide |
|
||||
| ----------- | ------------ | ----------- | ----------- | ----------- |
|
||||
| small | 3 / 8 / 24 | 3 / 8 / 24 | 3 / 8 / 24 | 3 / 8 / 24 |
|
||||
| medium | 5 / 10 / — | 5 / 10 / — | 5 / 10 / — | 5 / 10 / — |
|
||||
| large | 8 / 12 / 36 | 6 / 12 / 32 | 6 / 12 / 32 | 6 / 12 / 32 |
|
||||
| extra-large | 10 / 14 / 40 | 8 / 12 / 36 | 8 / 12 / 36 | 8 / 12 / 36 |
|
||||
|
||||
`size="medium"` reproduces today's default control look exactly (5px/10px padding, 13px text, no min-height) and, on controls, inherits line-height like the default; the other sizes apply their scale line-height. `Text` always applies its size's line-height, while the omitted prop forces no line-height at all. `extra-large` narrow hits the 40px mobile touch target. The touch-target guarantee applies to touch-primary controls: at narrow, use `large` or `extra-large` for controls the user taps; `small` stays the compact 24px desktop-density choice at every breakpoint and is not meant for touch-primary controls.
|
||||
|
||||
### Named Rules
|
||||
|
||||
**The Tabular Number Rule.** Every standalone financial figure renders with `font-feature-settings: "tnum", "ss01", "ss04"` (via `FinancialText` or `styles.tnum`) so digits align in columns and disambiguate. A proportional-figure money column is a bug.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Button',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'A basic button. Without an explicit size it keeps the legacy compact look.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||||
<Button>Normal</Button>
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="bare">Bare</Button>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The available button variants: normal, primary and bare.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
||||
<Button size="small">Small</Button>
|
||||
<Button size="medium">Medium</Button>
|
||||
<Button size="large">Large</Button>
|
||||
<Button size="extra-large">Extra Large</Button>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Standard sizes. Values adapt to the viewport: resize the story canvas below 512px, 730px and 1100px to see the responsive steps.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SizeVariantGrid: Story = {
|
||||
render: () => (
|
||||
<View style={{ gap: 10 }}>
|
||||
{(['small', 'medium', 'large', 'extra-large'] as const).map(size => (
|
||||
<View
|
||||
key={size}
|
||||
style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}
|
||||
>
|
||||
<View style={{ width: 90 }}>
|
||||
{size === 'extra-large' ? 'extra-large' : size}
|
||||
</View>
|
||||
<Button size={size}>Normal</Button>
|
||||
<Button size={size} variant="primary">
|
||||
Primary
|
||||
</Button>
|
||||
<Button size={size} variant="bare">
|
||||
Bare
|
||||
</Button>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Every size across the normal, primary and bare variants.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: 'Disabled button',
|
||||
isDisabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Disabled buttons prevent user interaction.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,12 @@ import { css, cx } from '@emotion/css';
|
||||
import { AnimatedLoading } from './icons/AnimatedLoading';
|
||||
import { styles } from './styles';
|
||||
import { theme } from './theme';
|
||||
import {
|
||||
componentSizeControl,
|
||||
componentSizeText,
|
||||
sizeResponsiveStyles,
|
||||
type ComponentSize,
|
||||
} from './tokens';
|
||||
import { View } from './View';
|
||||
|
||||
const backgroundColor: {
|
||||
@@ -123,8 +129,66 @@ const _getActiveStyles = (
|
||||
}
|
||||
};
|
||||
|
||||
type ButtonProps = ComponentPropsWithoutRef<typeof ReactAriaButton> & {
|
||||
// Builds the padding/typography overrides for an explicit `size`, varying
|
||||
// by breakpoint via media queries (no per-instance hooks). Bare buttons
|
||||
// use uniform padding; framed buttons use the paddingX/paddingY split.
|
||||
const getButtonSizeOverrides = (
|
||||
size: ComponentSize,
|
||||
isBare: boolean,
|
||||
): Record<string, unknown> => {
|
||||
const control = componentSizeControl[size];
|
||||
const text = componentSizeText[size];
|
||||
|
||||
const getGroupStyles = (group: keyof typeof control) => {
|
||||
const { paddingY, paddingX, minHeight } = control[group];
|
||||
const { fontSize, lineHeight } = text[group];
|
||||
|
||||
return {
|
||||
padding: isBare ? `${paddingY}px` : `${paddingY}px ${paddingX}px`,
|
||||
...(minHeight != null ? { minHeight } : null),
|
||||
fontSize,
|
||||
// Medium matches today's default look, which inherits line-height;
|
||||
// only the other sizes force their scale line-height.
|
||||
...(size === 'medium' ? {} : { lineHeight }),
|
||||
};
|
||||
};
|
||||
|
||||
return sizeResponsiveStyles({
|
||||
narrow: getGroupStyles('narrow'),
|
||||
small: getGroupStyles('small'),
|
||||
medium: getGroupStyles('medium'),
|
||||
wide: getGroupStyles('wide'),
|
||||
});
|
||||
};
|
||||
|
||||
const buttonSizeOverrides: Record<
|
||||
ComponentSize,
|
||||
{ bare: Record<string, unknown>; framed: Record<string, unknown> }
|
||||
> = {
|
||||
small: {
|
||||
bare: getButtonSizeOverrides('small', true),
|
||||
framed: getButtonSizeOverrides('small', false),
|
||||
},
|
||||
medium: {
|
||||
bare: getButtonSizeOverrides('medium', true),
|
||||
framed: getButtonSizeOverrides('medium', false),
|
||||
},
|
||||
large: {
|
||||
bare: getButtonSizeOverrides('large', true),
|
||||
framed: getButtonSizeOverrides('large', false),
|
||||
},
|
||||
'extra-large': {
|
||||
bare: getButtonSizeOverrides('extra-large', true),
|
||||
framed: getButtonSizeOverrides('extra-large', false),
|
||||
},
|
||||
};
|
||||
|
||||
type ButtonProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof ReactAriaButton>,
|
||||
'size'
|
||||
> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ComponentSize;
|
||||
bounce?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
@@ -133,11 +197,21 @@ type ButtonVariant = 'normal' | 'primary' | 'bare' | 'menu' | 'menuSelected';
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(props, ref) => {
|
||||
const { children, variant = 'normal', bounce = true, ...restProps } = props;
|
||||
const {
|
||||
children,
|
||||
variant = 'normal',
|
||||
bounce = true,
|
||||
size,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const variantWithDisabled: ButtonVariant | `${ButtonVariant}Disabled` =
|
||||
props.isDisabled ? `${variant}Disabled` : variant;
|
||||
|
||||
const sizeOverrides = size
|
||||
? buttonSizeOverrides[size][variant === 'bare' ? 'bare' : 'framed']
|
||||
: null;
|
||||
|
||||
const defaultButtonClassName: string = useMemo(
|
||||
() =>
|
||||
css({
|
||||
@@ -155,10 +229,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
transition: 'box-shadow .25s',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
...styles.smallText,
|
||||
...sizeOverrides,
|
||||
'&[data-hovered]': _getHoveredStyles(variant),
|
||||
'&[data-pressed]': _getActiveStyles(variant, bounce),
|
||||
}),
|
||||
[bounce, variant, variantWithDisabled],
|
||||
[bounce, variant, variantWithDisabled, sizeOverrides],
|
||||
);
|
||||
|
||||
const className = restProps.className;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { BigInput, Input } from './Input';
|
||||
import { Text } from './Text';
|
||||
|
||||
// jsdom's getComputedStyle does not evaluate media queries, so the base
|
||||
// (narrow) values always win there. Responsive steps are asserted
|
||||
// against the generated stylesheet instead: find the media block and
|
||||
// check that one of the element's emotion classes declares the expected
|
||||
// value inside it.
|
||||
|
||||
const getStyleText = () =>
|
||||
Array.from(document.querySelectorAll('style'))
|
||||
.map(style => style.textContent ?? '')
|
||||
.join('');
|
||||
|
||||
const expectMediaDeclaration = (
|
||||
element: Element,
|
||||
media: string,
|
||||
declaration: string,
|
||||
) => {
|
||||
const cssText = getStyleText();
|
||||
const classCandidates = [...element.classList].filter(className =>
|
||||
className.startsWith('css-'),
|
||||
);
|
||||
expect(classCandidates.length).toBeGreaterThan(0);
|
||||
|
||||
// Several emotion classes may carry the same media query, so scan
|
||||
// every occurrence of it and check each following rule block.
|
||||
let searchFrom = 0;
|
||||
let isMatched = false;
|
||||
|
||||
for (;;) {
|
||||
const mediaIndex = cssText.indexOf(media, searchFrom);
|
||||
if (mediaIndex === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const segment = cssText.slice(mediaIndex);
|
||||
const nextMedia = segment.indexOf('@media', 1);
|
||||
const block = nextMedia === -1 ? segment : segment.slice(0, nextMedia);
|
||||
|
||||
if (
|
||||
classCandidates.some(
|
||||
className =>
|
||||
block.includes(`.${className}`) && block.includes(declaration),
|
||||
)
|
||||
) {
|
||||
isMatched = true;
|
||||
break;
|
||||
}
|
||||
|
||||
searchFrom = mediaIndex + media.length;
|
||||
}
|
||||
|
||||
expect(isMatched).toBe(true);
|
||||
};
|
||||
|
||||
describe('Button size', () => {
|
||||
it('keeps the legacy look when size is omitted', () => {
|
||||
render(<Button>Default</Button>);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Default' });
|
||||
const computed = getComputedStyle(button);
|
||||
|
||||
expect(computed.padding).toBe('5px 10px');
|
||||
expect(computed.fontSize).toBe('13px');
|
||||
});
|
||||
|
||||
it('applies small size styles', () => {
|
||||
render(<Button size="small">Small</Button>);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Small' });
|
||||
const computed = getComputedStyle(button);
|
||||
|
||||
expect(computed.padding).toBe('3px 8px');
|
||||
expect(computed.fontSize).toBe('12px');
|
||||
expect(computed.minHeight).toBe('24px');
|
||||
});
|
||||
|
||||
it('applies extra-large size styles with responsive steps', () => {
|
||||
render(<Button size="extra-large">Extra Large</Button>);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Extra Large' });
|
||||
const computed = getComputedStyle(button);
|
||||
|
||||
// Base (narrow): comfortable mobile / tap-target scale.
|
||||
expect(computed.fontSize).toBe('17px');
|
||||
expect(computed.minHeight).toBe('40px');
|
||||
|
||||
// Steps down at the small and medium breakpoints.
|
||||
expectMediaDeclaration(
|
||||
button,
|
||||
'@media (min-width: 512px)',
|
||||
'min-height:36px',
|
||||
);
|
||||
expectMediaDeclaration(
|
||||
button,
|
||||
'@media (min-width: 730px)',
|
||||
'font-size:16px',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses uniform padding for bare buttons with an explicit size', () => {
|
||||
render(
|
||||
<Button size="large" variant="bare">
|
||||
Bare
|
||||
</Button>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Bare' });
|
||||
const computed = getComputedStyle(button);
|
||||
|
||||
expect(computed.padding).toBe('8px');
|
||||
expectMediaDeclaration(button, '@media (min-width: 512px)', 'padding:6px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Text size', () => {
|
||||
it('applies no font-size opinion when size is omitted', () => {
|
||||
render(<Text data-testid="plain">Plain</Text>);
|
||||
|
||||
const plain = screen.getByTestId('plain');
|
||||
const reference = plain.ownerDocument.createElement('span');
|
||||
plain.ownerDocument.body.appendChild(reference);
|
||||
|
||||
expect(getComputedStyle(plain).fontSize).toBe(
|
||||
getComputedStyle(reference).fontSize,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies size styles when provided', () => {
|
||||
render(<Text size="large">Large text</Text>);
|
||||
|
||||
const text = screen.getByText('Large text');
|
||||
const computed = getComputedStyle(text);
|
||||
|
||||
// Base (narrow) value; steps down to 15px from the small breakpoint.
|
||||
expect(computed.fontSize).toBe('16px');
|
||||
expect(computed.lineHeight).toBe('22px');
|
||||
expectMediaDeclaration(text, '@media (min-width: 512px)', 'font-size:15px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input size', () => {
|
||||
it('keeps the legacy look when size is omitted', () => {
|
||||
render(<Input placeholder="Default" />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Default');
|
||||
const computed = getComputedStyle(input);
|
||||
|
||||
expect(computed.padding).toBe('5px');
|
||||
expect(computed.fontSize).toBe('13px');
|
||||
});
|
||||
|
||||
it('applies size styles when provided', () => {
|
||||
render(<Input size="large" placeholder="Large" />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Large');
|
||||
const computed = getComputedStyle(input);
|
||||
|
||||
expect(computed.padding).toBe('8px');
|
||||
expect(computed.fontSize).toBe('16px');
|
||||
expectMediaDeclaration(input, '@media (min-width: 512px)', 'padding:6px');
|
||||
});
|
||||
|
||||
it('keeps the legacy BigInput look when size is omitted', () => {
|
||||
render(<BigInput placeholder="Big" />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Big');
|
||||
const computed = getComputedStyle(input);
|
||||
|
||||
expect(computed.padding).toBe('10px');
|
||||
expect(computed.fontSize).toBe('15px');
|
||||
expect(computed.borderStyle).toBe('none');
|
||||
});
|
||||
|
||||
it('applies size styles to BigInput while keeping its chrome', () => {
|
||||
render(<BigInput size="extra-large" placeholder="Big XL" />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Big XL');
|
||||
const computed = getComputedStyle(input);
|
||||
|
||||
expect(computed.padding).toBe('10px');
|
||||
expect(computed.fontSize).toBe('17px');
|
||||
expect(computed.borderStyle).toBe('none');
|
||||
expectMediaDeclaration(input, '@media (min-width: 730px)', 'padding:8px');
|
||||
});
|
||||
});
|
||||
@@ -213,3 +213,29 @@ export const PasswordInput: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<View style={{ gap: 10 }}>
|
||||
<Input size="small" placeholder="Small" />
|
||||
<Input size="medium" placeholder="Medium" />
|
||||
<Input size="large" placeholder="Large" />
|
||||
<Input size="extra-large" placeholder="Extra Large" />
|
||||
</View>
|
||||
),
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 250 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The standard size vocabulary. Values adapt to the viewport: resize the story canvas below 512px, 730px and 1100px to see the responsive steps.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,12 @@ import { css, cx } from '@emotion/css';
|
||||
import { useResponsive } from './hooks/useResponsive';
|
||||
import { styles } from './styles';
|
||||
import { theme } from './theme';
|
||||
import {
|
||||
componentSizeControl,
|
||||
componentSizeText,
|
||||
sizeResponsiveStyles,
|
||||
type ComponentSize,
|
||||
} from './tokens';
|
||||
|
||||
export const baseInputStyle = {
|
||||
outline: 0,
|
||||
@@ -23,24 +29,82 @@ export const baseInputStyle = {
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
};
|
||||
|
||||
export const defaultInputClassName = css({
|
||||
...baseInputStyle,
|
||||
color: theme.formInputText,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
'&[data-focused]': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
color: theme.formInputTextPlaceholder,
|
||||
},
|
||||
'::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
...styles.smallText,
|
||||
});
|
||||
// Builds the padding/typography overrides for an explicit `size`,
|
||||
// varying by breakpoint via media queries (no hooks). Inputs use a
|
||||
// uniform padding on all sides, like today's default.
|
||||
const getInputSizeStyles = (size: ComponentSize): Record<string, unknown> => {
|
||||
const control = componentSizeControl[size];
|
||||
const text = componentSizeText[size];
|
||||
|
||||
export type InputProps = ComponentPropsWithRef<typeof ReactAriaInput> & {
|
||||
const getGroupStyles = (group: keyof typeof control) => {
|
||||
const { paddingY, minHeight } = control[group];
|
||||
const { fontSize, lineHeight } = text[group];
|
||||
|
||||
return {
|
||||
padding: paddingY,
|
||||
...(minHeight != null ? { minHeight } : null),
|
||||
fontSize,
|
||||
// Medium matches today's default look, which inherits line-height;
|
||||
// only the other sizes force their scale line-height.
|
||||
...(size === 'medium' ? {} : { lineHeight }),
|
||||
};
|
||||
};
|
||||
|
||||
return sizeResponsiveStyles({
|
||||
narrow: getGroupStyles('narrow'),
|
||||
small: getGroupStyles('small'),
|
||||
medium: getGroupStyles('medium'),
|
||||
wide: getGroupStyles('wide'),
|
||||
});
|
||||
};
|
||||
|
||||
const inputSizeStyles = {
|
||||
small: getInputSizeStyles('small'),
|
||||
medium: getInputSizeStyles('medium'),
|
||||
large: getInputSizeStyles('large'),
|
||||
'extra-large': getInputSizeStyles('extra-large'),
|
||||
} satisfies Record<ComponentSize, Record<string, unknown>>;
|
||||
|
||||
// Single source of truth for input class composition: base chrome, then
|
||||
// either the default typography (styles.smallText) or the explicit size
|
||||
// overrides, then any extra overrides — all in one css() call so there
|
||||
// is never a cross-class specificity race.
|
||||
const buildInputClassName = (
|
||||
size?: ComponentSize,
|
||||
overrides?: Record<string, unknown>,
|
||||
) =>
|
||||
css({
|
||||
...baseInputStyle,
|
||||
color: theme.formInputText,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
'&[data-focused]': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
color: theme.formInputTextPlaceholder,
|
||||
},
|
||||
'::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
...(size ? inputSizeStyles[size] : styles.smallText),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const defaultInputClassName = buildInputClassName();
|
||||
|
||||
const inputSizeClassNames = {
|
||||
small: buildInputClassName('small'),
|
||||
medium: buildInputClassName('medium'),
|
||||
large: buildInputClassName('large'),
|
||||
'extra-large': buildInputClassName('extra-large'),
|
||||
} satisfies Record<ComponentSize, string>;
|
||||
|
||||
export type InputProps = Omit<
|
||||
ComponentPropsWithRef<typeof ReactAriaInput>,
|
||||
'size'
|
||||
> & {
|
||||
size?: ComponentSize;
|
||||
onEnter?: (value: string, event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onEscape?: (value: string, event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onChangeValue?: (
|
||||
@@ -57,15 +121,20 @@ export function Input({
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const baseClassName = size
|
||||
? inputSizeClassNames[size]
|
||||
: defaultInputClassName;
|
||||
|
||||
return (
|
||||
<ReactAriaInput
|
||||
ref={ref}
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps => cx(defaultInputClassName, className(renderProps))
|
||||
: cx(defaultInputClassName, className)
|
||||
? renderProps => cx(baseClassName, className(renderProps))
|
||||
: cx(baseClassName, className)
|
||||
}
|
||||
{...props}
|
||||
onKeyUp={e => {
|
||||
@@ -91,22 +160,40 @@ export function Input({
|
||||
);
|
||||
}
|
||||
|
||||
const defaultBigInputClassName = css({
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
// BigInput chrome: borderless with the ambient shadow instead. The
|
||||
// legacy look (size omitted) keeps today's exact values (padding 10,
|
||||
// fontSize 15); an explicit size swaps in the size tokens.
|
||||
const bigInputChrome: Record<string, unknown> = {
|
||||
border: 'none',
|
||||
...styles.shadow,
|
||||
'&[data-focused]': { border: 'none', ...styles.shadow },
|
||||
};
|
||||
|
||||
const defaultBigInputClassName = buildInputClassName(undefined, {
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
...bigInputChrome,
|
||||
});
|
||||
|
||||
export function BigInput({ className, ...props }: InputProps) {
|
||||
const bigInputSizeClassNames = {
|
||||
small: buildInputClassName('small', bigInputChrome),
|
||||
medium: buildInputClassName('medium', bigInputChrome),
|
||||
large: buildInputClassName('large', bigInputChrome),
|
||||
'extra-large': buildInputClassName('extra-large', bigInputChrome),
|
||||
} satisfies Record<ComponentSize, string>;
|
||||
|
||||
export function BigInput({ size, className, ...props }: InputProps) {
|
||||
const baseClassName = size
|
||||
? bigInputSizeClassNames[size]
|
||||
: defaultBigInputClassName;
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps => cx(defaultBigInputClassName, className(renderProps))
|
||||
: cx(defaultBigInputClassName, className)
|
||||
? renderProps => cx(baseClassName, className(renderProps))
|
||||
: cx(baseClassName, className)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -110,3 +110,24 @@ export const InlineUsage: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text size="small">Small (12px)</Text>
|
||||
<Text size="medium">Medium (13px)</Text>
|
||||
<Text size="large">Large (15px, 16px on mobile)</Text>
|
||||
<Text size="extra-large">
|
||||
Extra Large (17px on mobile/tablet, 16px on desktop)
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The standard size vocabulary. Values adapt to the viewport: resize the story canvas below 512px, 730px and 1100px to see the responsive steps.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,21 +4,57 @@ import type { HTMLProps, ReactNode, Ref } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
import {
|
||||
componentSizeText,
|
||||
sizeResponsiveStyles,
|
||||
type ComponentSize,
|
||||
} from './tokens';
|
||||
|
||||
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'style'> & {
|
||||
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'size' | 'style'> & {
|
||||
innerRef?: Ref<HTMLSpanElement>;
|
||||
className?: string;
|
||||
size?: ComponentSize;
|
||||
children?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
// Per-size typography, varying by breakpoint via media queries (no
|
||||
// hooks). Precomputed once at module level so rendering stays cheap.
|
||||
const getTextSizeStyles = (size: ComponentSize): Record<string, unknown> => {
|
||||
const text = componentSizeText[size];
|
||||
|
||||
const getGroupStyles = (group: keyof typeof text) => {
|
||||
const { fontSize, lineHeight } = text[group];
|
||||
|
||||
return { fontSize, lineHeight };
|
||||
};
|
||||
|
||||
return sizeResponsiveStyles({
|
||||
narrow: getGroupStyles('narrow'),
|
||||
small: getGroupStyles('small'),
|
||||
medium: getGroupStyles('medium'),
|
||||
wide: getGroupStyles('wide'),
|
||||
});
|
||||
};
|
||||
|
||||
const textSizeStyles = {
|
||||
small: getTextSizeStyles('small'),
|
||||
medium: getTextSizeStyles('medium'),
|
||||
large: getTextSizeStyles('large'),
|
||||
'extra-large': getTextSizeStyles('extra-large'),
|
||||
} satisfies Record<ComponentSize, Record<string, unknown>>;
|
||||
|
||||
export const Text = forwardRef<HTMLSpanElement, TextProps>((props, ref) => {
|
||||
const { className = '', style, innerRef, ...restProps } = props;
|
||||
const { className = '', style, innerRef, size, ...restProps } = props;
|
||||
return (
|
||||
<span
|
||||
{...restProps}
|
||||
ref={innerRef ?? ref}
|
||||
className={cx(className, css(style))}
|
||||
className={
|
||||
size
|
||||
? cx(className, css({ ...textSizeStyles[size], ...style }))
|
||||
: cx(className, css(style))
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -51,3 +51,127 @@ export const radius: Record<RadiusSize, number> = {
|
||||
sm: 4,
|
||||
pill: 999,
|
||||
};
|
||||
|
||||
// Standard component size vocabulary shared by Button, Text and Input.
|
||||
// Values are looked up per breakpoint group so components can render
|
||||
// denser on desktop and more comfortable on mobile without hooks.
|
||||
export type ComponentSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||
|
||||
// Breakpoint groups mirror the view modes in useResponsive:
|
||||
// narrow <512, small 512-729, medium 730-1099, wide >=1100.
|
||||
// `narrow` is the base (no media query); every other group is a
|
||||
// `min-width` query at its breakpoint.
|
||||
type BreakpointGroup = 'narrow' | 'small' | 'medium' | 'wide';
|
||||
|
||||
const breakpointGroupMediaQueries: Record<
|
||||
Exclude<BreakpointGroup, 'narrow'>,
|
||||
string
|
||||
> = {
|
||||
small: `@media (min-width: ${tokens.breakpoint_small})`,
|
||||
medium: `@media (min-width: ${tokens.breakpoint_medium})`,
|
||||
wide: `@media (min-width: ${tokens.breakpoint_wide})`,
|
||||
};
|
||||
|
||||
type ComponentSizeTextValues = {
|
||||
fontSize: number;
|
||||
// lineHeight is on emotion's unitless list, so a plain number would
|
||||
// render as a multiplier (e.g. `line-height: 18` = 18x). Always a
|
||||
// px string so the value renders as an absolute line box.
|
||||
lineHeight: string;
|
||||
};
|
||||
|
||||
type ComponentSizeControlValues = {
|
||||
paddingY: number;
|
||||
paddingX: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
// Typographic scale, aligned with styles.ts (smallText 13, mediumText 15,
|
||||
// verySmallText 12, mobileMenuItem 17). Mobile steps sizes up one notch
|
||||
// for readability; desktop stays compact.
|
||||
export const componentSizeText: Record<
|
||||
ComponentSize,
|
||||
Record<BreakpointGroup, ComponentSizeTextValues>
|
||||
> = {
|
||||
small: {
|
||||
narrow: { fontSize: 12, lineHeight: '16px' },
|
||||
small: { fontSize: 12, lineHeight: '16px' },
|
||||
medium: { fontSize: 12, lineHeight: '16px' },
|
||||
wide: { fontSize: 12, lineHeight: '16px' },
|
||||
},
|
||||
medium: {
|
||||
narrow: { fontSize: 13, lineHeight: '18px' },
|
||||
small: { fontSize: 13, lineHeight: '18px' },
|
||||
medium: { fontSize: 13, lineHeight: '18px' },
|
||||
wide: { fontSize: 13, lineHeight: '18px' },
|
||||
},
|
||||
large: {
|
||||
narrow: { fontSize: 16, lineHeight: '22px' },
|
||||
small: { fontSize: 15, lineHeight: '20px' },
|
||||
medium: { fontSize: 15, lineHeight: '20px' },
|
||||
wide: { fontSize: 15, lineHeight: '20px' },
|
||||
},
|
||||
'extra-large': {
|
||||
narrow: { fontSize: 17, lineHeight: '24px' },
|
||||
small: { fontSize: 17, lineHeight: '24px' },
|
||||
medium: { fontSize: 16, lineHeight: '22px' },
|
||||
wide: { fontSize: 16, lineHeight: '22px' },
|
||||
},
|
||||
};
|
||||
|
||||
// Padding and min-height for controls (Button, Input). The `medium` row
|
||||
// reproduces today's default look exactly (5px / 5px 10px, no
|
||||
// min-height). `extra-large` narrow hits the 40px mobile touch target.
|
||||
export const componentSizeControl: Record<
|
||||
ComponentSize,
|
||||
Record<BreakpointGroup, ComponentSizeControlValues>
|
||||
> = {
|
||||
small: {
|
||||
narrow: { paddingY: 3, paddingX: spacing.sm, minHeight: 24 },
|
||||
small: { paddingY: 3, paddingX: spacing.sm, minHeight: 24 },
|
||||
medium: { paddingY: 3, paddingX: spacing.sm, minHeight: 24 },
|
||||
wide: { paddingY: 3, paddingX: spacing.sm, minHeight: 24 },
|
||||
},
|
||||
medium: {
|
||||
narrow: { paddingY: 5, paddingX: 10 },
|
||||
small: { paddingY: 5, paddingX: 10 },
|
||||
medium: { paddingY: 5, paddingX: 10 },
|
||||
wide: { paddingY: 5, paddingX: 10 },
|
||||
},
|
||||
large: {
|
||||
narrow: { paddingY: spacing.sm, paddingX: spacing.md, minHeight: 36 },
|
||||
small: { paddingY: 6, paddingX: spacing.md, minHeight: 32 },
|
||||
medium: { paddingY: 6, paddingX: spacing.md, minHeight: 32 },
|
||||
wide: { paddingY: 6, paddingX: spacing.md, minHeight: 32 },
|
||||
},
|
||||
'extra-large': {
|
||||
narrow: { paddingY: 10, paddingX: 14, minHeight: 40 },
|
||||
small: { paddingY: spacing.sm, paddingX: spacing.md, minHeight: 36 },
|
||||
medium: { paddingY: spacing.sm, paddingX: spacing.md, minHeight: 36 },
|
||||
wide: { paddingY: spacing.sm, paddingX: spacing.md, minHeight: 36 },
|
||||
},
|
||||
};
|
||||
|
||||
type ResponsiveStyleObject = Record<string, unknown>;
|
||||
|
||||
// Flattens a per-breakpoint-group value table into a single emotion style
|
||||
// object: the `narrow` values become the base and each larger group is
|
||||
// emitted as a min-width media query — but only when its values actually
|
||||
// differ from the previous group, keeping generated CSS lean.
|
||||
export function sizeResponsiveStyles(
|
||||
values: Record<BreakpointGroup, ResponsiveStyleObject>,
|
||||
): ResponsiveStyleObject {
|
||||
const styles: ResponsiveStyleObject = { ...values.narrow };
|
||||
let previousGroup: BreakpointGroup = 'narrow';
|
||||
|
||||
for (const group of ['small', 'medium', 'wide'] as const) {
|
||||
if (
|
||||
JSON.stringify(values[group]) !== JSON.stringify(values[previousGroup])
|
||||
) {
|
||||
styles[breakpointGroupMediaQueries[group]] = values[group];
|
||||
}
|
||||
previousGroup = group;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
@@ -495,7 +495,7 @@ function SingleAutocomplete<T extends AutocompleteItem>({
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
(() => {
|
||||
const { className, style, ...restInputProps } =
|
||||
const { className, style, size, ...restInputProps } =
|
||||
inputProps || {};
|
||||
const downshiftProps = getInputProps({
|
||||
ref: inputRef,
|
||||
@@ -616,6 +616,7 @@ function SingleAutocomplete<T extends AutocompleteItem>({
|
||||
...downshiftProps,
|
||||
...(className && { className }),
|
||||
...(style && { style }),
|
||||
...(size && { size }),
|
||||
};
|
||||
})(),
|
||||
)}
|
||||
|
||||
@@ -132,8 +132,8 @@ function CheckboxOptionWithHelp({
|
||||
</SpaceBetween>
|
||||
{showHelp && (
|
||||
<Text
|
||||
size="medium"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -149,17 +149,17 @@ export function BuiltInProviders({
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 17, fontWeight: 600 }}>
|
||||
<Text size="extra-large" style={{ fontWeight: 600 }}>
|
||||
{provider.displayName}
|
||||
</Text>
|
||||
{provider.isConfigured && provider.credentialSource && (
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 999,
|
||||
backgroundColor: theme.buttonPrimaryBackground,
|
||||
color: theme.buttonPrimaryText,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
@@ -173,11 +173,11 @@ export function BuiltInProviders({
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
size="medium"
|
||||
style={{
|
||||
color: provider.isConfigured
|
||||
? theme.noticeTextDark
|
||||
: theme.pageTextSubdued,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
@@ -259,7 +259,7 @@ export function BuiltInProviders({
|
||||
{provider.supportsPerBudgetFile &&
|
||||
provider.credentialSource === 'global' &&
|
||||
!provider.canConfigure && (
|
||||
<Text style={{ color: theme.pageTextSubdued, fontSize: 13 }}>
|
||||
<Text size="medium" style={{ color: theme.pageTextSubdued }}>
|
||||
<Trans>
|
||||
Reset credentials before setting credentials for this
|
||||
budget file.
|
||||
|
||||
@@ -214,7 +214,7 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Text size="large">
|
||||
<Trans>Field mapping</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -226,7 +226,7 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
setMapping={setMapping}
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15, margin: '1em 0 .5em 0' }}>
|
||||
<Text size="large" style={{ margin: '1em 0 .5em 0' }}>
|
||||
<Trans>Options</Trans>
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -112,7 +112,8 @@ export function FieldMapping({
|
||||
plain
|
||||
>
|
||||
<Text
|
||||
style={{ whiteSpace: 'nowrap', fontSize: 13, fontWeight: 500 }}
|
||||
size="medium"
|
||||
style={{ whiteSpace: 'nowrap', fontWeight: 500 }}
|
||||
>
|
||||
{calculatedActualFieldWidth > 70 ? t('Actual field') : 'Actual'}
|
||||
</Text>
|
||||
@@ -150,7 +151,7 @@ export function FieldMapping({
|
||||
style={{ ...commonCellStyle, paddingLeft: '10px' }}
|
||||
plain
|
||||
>
|
||||
<Text style={{ whiteSpace: 'nowrap', fontSize: 13 }}>
|
||||
<Text size="medium" style={{ whiteSpace: 'nowrap' }}>
|
||||
{field.actualField}
|
||||
</Text>
|
||||
</Cell>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function CleanupAutomation({
|
||||
|
||||
return (
|
||||
<View style={{ gap: 14 }}>
|
||||
<Text style={{ fontSize: 12, color: theme.pageTextLight }}>
|
||||
<Text size="small" style={{ color: theme.pageTextLight }}>
|
||||
<Trans>
|
||||
End of month cleanup is a one-click reallocation of funds. Categories
|
||||
you choose to <strong>send leftover</strong> return their surplus to a
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Select } from '@actual-app/components/select';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { spacing } from '@actual-app/components/tokens';
|
||||
import {
|
||||
dayFromDate,
|
||||
firstDayOfMonth,
|
||||
@@ -129,11 +130,11 @@ export const LimitAutomation = ({
|
||||
</SpaceBetween>
|
||||
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: theme.pageTextLight,
|
||||
display: 'block',
|
||||
marginTop: 8,
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const RemainderAutomation = ({
|
||||
onBlur={commitWeight}
|
||||
/>
|
||||
</FormField>
|
||||
<Text style={{ flex: 2, color: theme.pageTextLight, fontSize: 12 }}>
|
||||
<Text size="small" style={{ flex: 2, color: theme.pageTextLight }}>
|
||||
<Trans>
|
||||
Categories with higher weights get a bigger share of the leftover To
|
||||
Budget.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Input } from '@actual-app/components/input';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { spacing } from '@actual-app/components/tokens';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
@@ -254,7 +255,7 @@ export function QueryManager({ queries, onQueriesChange }: QueryManagerProps) {
|
||||
No queries defined. Click 'Add Query' to create your first query.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, marginTop: 8 }}>
|
||||
<Text size="small" style={{ marginTop: spacing.sm }}>
|
||||
<Trans>
|
||||
Queries allow you to reference filtered transaction data in your
|
||||
formulas using QUERY("queryName") or QUERY_COUNT("queryName")
|
||||
@@ -665,10 +666,10 @@ function QueryItem({
|
||||
<Trans>Import Query Configuration</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: theme.pageTextSubdued,
|
||||
marginBottom: 8,
|
||||
marginBottom: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Trans>Paste the JSON configuration below:</Trans>
|
||||
@@ -845,10 +846,10 @@ function QueryItem({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={{ marginBottom: 8, flex: 1 }}>
|
||||
<View style={{ marginBottom: spacing.sm, flex: 1 }}>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
color: theme.pageTextSubdued,
|
||||
|
||||
@@ -249,8 +249,8 @@ function BudgetFileState({ file, currentUserId }: BudgetFileStateProps) {
|
||||
<Trans>Owner:</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
...styles.verySmallText,
|
||||
color: theme.pageTextLight,
|
||||
paddingLeft: 10,
|
||||
}}
|
||||
|
||||
@@ -455,11 +455,11 @@ export function ConfigServer() {
|
||||
{error && (
|
||||
<>
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
color: theme.errorText,
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{getErrorMessage(error)}
|
||||
@@ -473,10 +473,10 @@ export function ConfigServer() {
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
|
||||
@@ -89,11 +89,11 @@ export function Bootstrap() {
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
color: theme.errorText,
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{getErrorMessage(error)}
|
||||
|
||||
@@ -64,11 +64,11 @@ export function ChangePassword() {
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
color: theme.errorText,
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{getErrorMessage(error)}
|
||||
@@ -77,11 +77,11 @@ export function ChangePassword() {
|
||||
|
||||
{msg && (
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
color: theme.noticeTextLight,
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
|
||||
@@ -228,8 +228,8 @@ function OpenIdLogin({ setError }) {
|
||||
{reviewOpenIdConfiguration && (
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
...styles.verySmallText,
|
||||
color: theme.pageTextLight,
|
||||
fontWeight: 'bold ',
|
||||
width: '100%',
|
||||
@@ -433,11 +433,11 @@ export function Login() {
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
color: theme.errorText,
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{getErrorMessage(error)}
|
||||
|
||||
@@ -162,8 +162,8 @@ export function OpenIdForm({
|
||||
>
|
||||
{!submitButtonDisabled && t('The OpenID provider URL.')}{' '}
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
...styles.verySmallText,
|
||||
color: theme.pageTextLight,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -246,8 +246,8 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
|
||||
)}
|
||||
<Button variant="bare" onPress={onClick}>
|
||||
<Text
|
||||
size="extra-large"
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 500,
|
||||
...styles.underlinedText,
|
||||
...(styles.lineClamp(2) as CSSProperties),
|
||||
|
||||
@@ -91,13 +91,7 @@ function AccountHeader<SheetFieldName extends SheetFields<'account'>>({
|
||||
])}
|
||||
>
|
||||
<View style={{ flex: 1, alignItems: 'center', flexDirection: 'row' }}>
|
||||
<Text
|
||||
style={{
|
||||
...styles.text,
|
||||
fontSize: 17,
|
||||
}}
|
||||
data-testid="name"
|
||||
>
|
||||
<Text size="extra-large" data-testid="name">
|
||||
{name}
|
||||
</Text>
|
||||
<Cheveron
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { spacing } from '@actual-app/components/tokens';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { tsToRelativeTime } from '@actual-app/core/shared/util';
|
||||
import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
@@ -37,21 +38,21 @@ export function BankSyncAccountsListItem({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.tableBorder,
|
||||
borderBottomStyle: 'solid',
|
||||
padding: 16,
|
||||
padding: spacing.lg,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onAction(account, isLinked ? 'edit' : 'link')}
|
||||
>
|
||||
<SpaceBetween gap={60}>
|
||||
<SpaceBetween gap={spacing.lg}>
|
||||
<SpaceBetween
|
||||
direction="vertical"
|
||||
gap={5}
|
||||
style={{ flex: 1, alignItems: 'flex-start' }}
|
||||
>
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: theme.tableText,
|
||||
}}
|
||||
@@ -60,8 +61,8 @@ export function BankSyncAccountsListItem({
|
||||
</Text>
|
||||
{isLinked && (
|
||||
<Text
|
||||
size="medium"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
@@ -70,8 +71,8 @@ export function BankSyncAccountsListItem({
|
||||
)}
|
||||
{isLinked && lastSyncString && (
|
||||
<Text
|
||||
size="medium"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
data-vrt-mask
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@actual-app/components/button';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { spacing } from '@actual-app/components/tokens';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { useUnlinkAccountMutation } from '#accounts';
|
||||
@@ -126,8 +127,8 @@ export function MobileBankSyncAccountEditPage() {
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ fontSize: 15, marginBottom: 10 }}>
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Text size="large" style={{ marginBottom: 10 }}>
|
||||
<Trans>Field mapping</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -140,7 +141,7 @@ export function MobileBankSyncAccountEditPage() {
|
||||
isMobile
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15, marginTop: 20, marginBottom: 10 }}>
|
||||
<Text size="large" style={{ marginTop: 20, marginBottom: 10 }}>
|
||||
<Trans>Options</Trans>
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ export function SchedulesListItem({
|
||||
>
|
||||
{/* Schedule name */}
|
||||
<Text
|
||||
size="large"
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: schedule.name
|
||||
? theme.pageText
|
||||
|
||||
@@ -201,7 +201,7 @@ export function TransactionList({
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Text size="large">
|
||||
<Trans>No transactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -196,7 +196,7 @@ function AppliedFiltersChip() {
|
||||
}}
|
||||
>
|
||||
<SvgFilter width={12} height={12} style={{ flexShrink: 0 }} />
|
||||
<Text style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
<Text size="small" style={{ fontWeight: 500 }}>
|
||||
<Trans>Filters applied</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from '@actual-app/components/input';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { tokens } from '@actual-app/components/tokens';
|
||||
import { spacing, tokens } from '@actual-app/components/tokens';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
|
||||
import type {
|
||||
@@ -230,9 +230,9 @@ export function AutomationEditorPane({
|
||||
<AutomationErrorTitle error={activeError} />
|
||||
</Text>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
marginTop: spacing.xxs,
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
}}
|
||||
@@ -288,11 +288,11 @@ export function AutomationEditorPane({
|
||||
>
|
||||
{NON_CONTRIBUTION_TYPES.has(state.displayType) && (
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: theme.pageTextLight,
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
marginBottom: spacing.xs,
|
||||
}}
|
||||
>
|
||||
{getDisplayTemplateMeta(state.displayType).description}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { spacing } from '@actual-app/components/tokens';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
@@ -165,12 +166,12 @@ export function AutomationListRow({
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
alignItems: 'flex-end',
|
||||
gap: 2,
|
||||
gap: spacing.xxs,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color:
|
||||
|
||||
@@ -185,7 +185,7 @@ export function BudgetAutomationsBody({
|
||||
}}
|
||||
>
|
||||
<View style={{ minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 12, color: theme.pageTextLight }}>
|
||||
<Text size="small" style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Budget automation</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
|
||||
@@ -68,8 +68,8 @@ export function CleanupListRow({
|
||||
</View>
|
||||
<View style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text
|
||||
size="small"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: theme.pageText,
|
||||
display: 'block',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Trans } from 'react-i18next';
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { spacing } from '@actual-app/components/tokens';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { getAutomationExamples } from '#components/budget/goals/automationExamples';
|
||||
@@ -50,10 +51,10 @@ export function EmptyState({ onAdd }: EmptyStateProps) {
|
||||
<Trans>No automations yet</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
size="medium"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextLight,
|
||||
marginTop: 4,
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: 22,
|
||||
display: 'block',
|
||||
}}
|
||||
@@ -112,8 +113,8 @@ export function EmptyState({ onAdd }: EmptyStateProps) {
|
||||
<Icon width={16} height={16} />
|
||||
</View>
|
||||
<Text
|
||||
size="medium"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: theme.pageText,
|
||||
}}
|
||||
|
||||
@@ -270,7 +270,7 @@ export function CloseAccountModal({
|
||||
|
||||
{!canDelete && (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
<Text size="small">
|
||||
<Trans>
|
||||
You can also{' '}
|
||||
<Link
|
||||
|
||||
@@ -196,7 +196,7 @@ export function EnableBankingInitialiseModal({
|
||||
<SvgCheckCircle1
|
||||
style={{ width: 14, height: 14, color: theme.noticeText }}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
|
||||
<Text size="small" style={{ color: theme.pageTextSubdued }}>
|
||||
{keyFileName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -62,12 +62,7 @@ export function EnvelopeBalanceMenuModal({
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<Text size="extra-large" style={{ fontWeight: 400 }}>
|
||||
<Trans>Balance</Trans>
|
||||
</Text>
|
||||
<BalanceWithCarryover
|
||||
|
||||
@@ -107,12 +107,7 @@ export function EnvelopeBudgetMenuModal({
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<Text size="extra-large" style={{ fontWeight: 400 }}>
|
||||
<Trans>Budgeted</Trans>
|
||||
</Text>
|
||||
<AmountInput
|
||||
|
||||
@@ -67,12 +67,7 @@ export function EnvelopeIncomeBalanceMenuModal({
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<Text size="extra-large" style={{ fontWeight: 400 }}>
|
||||
<Trans>Balance</Trans>
|
||||
</Text>
|
||||
<BalanceWithCarryover
|
||||
|
||||
@@ -9,7 +9,7 @@ type SubLabelProps = {
|
||||
|
||||
export function SubLabel({ title }: SubLabelProps) {
|
||||
return (
|
||||
<Text style={{ fontSize: 13, marginBottom: 3, color: theme.pageText }}>
|
||||
<Text size="medium" style={{ marginBottom: 3, color: theme.pageText }}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -564,7 +564,7 @@ export function KeyboardShortcutModal() {
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Text size="large">
|
||||
<Trans>
|
||||
{isSearching
|
||||
? 'No matching shortcuts'
|
||||
|
||||