@dashforge/tw 0.1.0-beta → 0.2.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/dist/index.d.ts +1 -82
- package/dist/index.esm.js +1563 -2
- package/dist/src/components/AspectRatio/AspectRatio.d.ts +36 -0
- package/dist/src/components/AspectRatio/AspectRatio.d.ts.map +1 -0
- package/dist/src/components/AspectRatio/aspectRatio.types.d.ts +50 -0
- package/dist/src/components/AspectRatio/aspectRatio.types.d.ts.map +1 -0
- package/dist/src/components/Box/Box.d.ts +36 -0
- package/dist/src/components/Box/Box.d.ts.map +1 -0
- package/dist/src/components/Box/box.types.d.ts +54 -0
- package/dist/src/components/Box/box.types.d.ts.map +1 -0
- package/dist/src/components/Box/box.variants.d.ts +402 -0
- package/dist/src/components/Box/box.variants.d.ts.map +1 -0
- package/dist/src/components/Container/Container.d.ts +35 -0
- package/dist/src/components/Container/Container.d.ts.map +1 -0
- package/dist/src/components/Container/container.types.d.ts +41 -0
- package/dist/src/components/Container/container.types.d.ts.map +1 -0
- package/dist/src/components/Container/container.variants.d.ts +93 -0
- package/dist/src/components/Container/container.variants.d.ts.map +1 -0
- package/dist/src/components/Divider/Divider.d.ts +50 -0
- package/dist/src/components/Divider/Divider.d.ts.map +1 -0
- package/dist/src/components/Divider/divider.types.d.ts +47 -0
- package/dist/src/components/Divider/divider.types.d.ts.map +1 -0
- package/dist/src/components/Divider/divider.variants.d.ts +129 -0
- package/dist/src/components/Divider/divider.variants.d.ts.map +1 -0
- package/dist/src/components/Grid/Grid.d.ts +32 -0
- package/dist/src/components/Grid/Grid.d.ts.map +1 -0
- package/dist/src/components/Grid/grid.types.d.ts +103 -0
- package/dist/src/components/Grid/grid.types.d.ts.map +1 -0
- package/dist/src/components/Grid/grid.variants.d.ts +453 -0
- package/dist/src/components/Grid/grid.variants.d.ts.map +1 -0
- package/dist/src/components/Stack/Stack.d.ts +24 -0
- package/dist/src/components/Stack/Stack.d.ts.map +1 -0
- package/dist/src/components/Stack/stack.types.d.ts +61 -0
- package/dist/src/components/Stack/stack.types.d.ts.map +1 -0
- package/dist/src/components/Stack/stack.variants.d.ts +163 -0
- package/dist/src/components/Stack/stack.variants.d.ts.map +1 -0
- package/dist/src/components/Typography/Typography.d.ts +42 -0
- package/dist/src/components/Typography/Typography.d.ts.map +1 -0
- package/dist/src/components/Typography/typography.types.d.ts +61 -0
- package/dist/src/components/Typography/typography.types.d.ts.map +1 -0
- package/dist/src/components/Typography/typography.variants.d.ts +167 -0
- package/dist/src/components/Typography/typography.variants.d.ts.map +1 -0
- package/dist/src/components/VisuallyHidden/VisuallyHidden.d.ts +42 -0
- package/dist/src/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/src/components/VisuallyHidden/visuallyHidden.types.d.ts +42 -0
- package/dist/src/components/VisuallyHidden/visuallyHidden.types.d.ts.map +1 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/AspectRatio/AspectRatio.test.tsx +173 -0
- package/src/components/AspectRatio/AspectRatio.tsx +73 -0
- package/src/components/AspectRatio/aspectRatio.types.ts +54 -0
- package/src/components/Box/Box.test.tsx +349 -0
- package/src/components/Box/Box.tsx +83 -0
- package/src/components/Box/box.types.ts +61 -0
- package/src/components/Box/box.variants.ts +211 -0
- package/src/components/Container/Container.test.tsx +209 -0
- package/src/components/Container/Container.tsx +74 -0
- package/src/components/Container/container.types.ts +45 -0
- package/src/components/Container/container.variants.ts +81 -0
- package/src/components/Divider/Divider.test.tsx +241 -0
- package/src/components/Divider/Divider.tsx +140 -0
- package/src/components/Divider/divider.types.ts +52 -0
- package/src/components/Divider/divider.variants.ts +115 -0
- package/src/components/Grid/Grid.test.tsx +321 -0
- package/src/components/Grid/Grid.tsx +148 -0
- package/src/components/Grid/grid.types.ts +112 -0
- package/src/components/Grid/grid.variants.ts +133 -0
- package/src/components/Stack/Stack.test.tsx +309 -0
- package/src/components/Stack/Stack.tsx +115 -0
- package/src/components/Stack/stack.types.ts +68 -0
- package/src/components/Stack/stack.variants.ts +80 -0
- package/src/components/Typography/Typography.test.tsx +230 -0
- package/src/components/Typography/Typography.tsx +112 -0
- package/src/components/Typography/typography.types.ts +65 -0
- package/src/components/Typography/typography.variants.ts +113 -0
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +86 -0
- package/src/components/VisuallyHidden/VisuallyHidden.tsx +59 -0
- package/src/components/VisuallyHidden/visuallyHidden.types.ts +45 -0
- package/src/index.ts +49 -0
- package/dist/components/AppShell/AppShell.d.ts +0 -32
- package/dist/components/AppShell/AppShell.d.ts.map +0 -1
- package/dist/components/AppShell/appShell.types.d.ts +0 -62
- package/dist/components/AppShell/appShell.types.d.ts.map +0 -1
- package/dist/components/AppShell/appShell.variants.d.ts +0 -65
- package/dist/components/AppShell/appShell.variants.d.ts.map +0 -1
- package/dist/components/Autocomplete/Autocomplete.d.ts +0 -32
- package/dist/components/Autocomplete/Autocomplete.d.ts.map +0 -1
- package/dist/components/Autocomplete/autocomplete.types.d.ts +0 -214
- package/dist/components/Autocomplete/autocomplete.types.d.ts.map +0 -1
- package/dist/components/Autocomplete/autocomplete.variants.d.ts +0 -214
- package/dist/components/Autocomplete/autocomplete.variants.d.ts.map +0 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +0 -23
- package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +0 -1
- package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts +0 -98
- package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts.map +0 -1
- package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts +0 -85
- package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +0 -1
- package/dist/components/Button/Button.d.ts +0 -44
- package/dist/components/Button/Button.d.ts.map +0 -1
- package/dist/components/Button/button.types.d.ts +0 -66
- package/dist/components/Button/button.types.d.ts.map +0 -1
- package/dist/components/Button/button.variants.d.ts +0 -104
- package/dist/components/Button/button.variants.d.ts.map +0 -1
- package/dist/components/Checkbox/Checkbox.d.ts +0 -31
- package/dist/components/Checkbox/Checkbox.d.ts.map +0 -1
- package/dist/components/Checkbox/checkbox.types.d.ts +0 -86
- package/dist/components/Checkbox/checkbox.types.d.ts.map +0 -1
- package/dist/components/Checkbox/checkbox.variants.d.ts +0 -109
- package/dist/components/Checkbox/checkbox.variants.d.ts.map +0 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +0 -38
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +0 -1
- package/dist/components/ConfirmDialog/confirmDialog.types.d.ts +0 -80
- package/dist/components/ConfirmDialog/confirmDialog.types.d.ts.map +0 -1
- package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts +0 -90
- package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts.map +0 -1
- package/dist/components/DateTimePicker/DateTimePicker.d.ts +0 -49
- package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +0 -1
- package/dist/components/DateTimePicker/dateTimePicker.types.d.ts +0 -95
- package/dist/components/DateTimePicker/dateTimePicker.types.d.ts.map +0 -1
- package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts +0 -165
- package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts.map +0 -1
- package/dist/components/LeftNav/LeftNav.d.ts +0 -34
- package/dist/components/LeftNav/LeftNav.d.ts.map +0 -1
- package/dist/components/LeftNav/leftNav.types.d.ts +0 -136
- package/dist/components/LeftNav/leftNav.types.d.ts.map +0 -1
- package/dist/components/LeftNav/leftNav.variants.d.ts +0 -143
- package/dist/components/LeftNav/leftNav.variants.d.ts.map +0 -1
- package/dist/components/NumberField/NumberField.d.ts +0 -18
- package/dist/components/NumberField/NumberField.d.ts.map +0 -1
- package/dist/components/NumberField/numberField.types.d.ts +0 -79
- package/dist/components/NumberField/numberField.types.d.ts.map +0 -1
- package/dist/components/NumberField/numberField.variants.d.ts +0 -169
- package/dist/components/NumberField/numberField.variants.d.ts.map +0 -1
- package/dist/components/OTPField/OTPField.d.ts +0 -25
- package/dist/components/OTPField/OTPField.d.ts.map +0 -1
- package/dist/components/OTPField/otpField.types.d.ts +0 -74
- package/dist/components/OTPField/otpField.types.d.ts.map +0 -1
- package/dist/components/OTPField/otpField.variants.d.ts +0 -105
- package/dist/components/OTPField/otpField.variants.d.ts.map +0 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts +0 -35
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +0 -1
- package/dist/components/RadioGroup/radioGroup.types.d.ts +0 -103
- package/dist/components/RadioGroup/radioGroup.types.d.ts.map +0 -1
- package/dist/components/RadioGroup/radioGroup.variants.d.ts +0 -166
- package/dist/components/RadioGroup/radioGroup.variants.d.ts.map +0 -1
- package/dist/components/Snackbar/Snackbar.d.ts +0 -37
- package/dist/components/Snackbar/Snackbar.d.ts.map +0 -1
- package/dist/components/Snackbar/snackbar.types.d.ts +0 -86
- package/dist/components/Snackbar/snackbar.types.d.ts.map +0 -1
- package/dist/components/Snackbar/snackbar.variants.d.ts +0 -153
- package/dist/components/Snackbar/snackbar.variants.d.ts.map +0 -1
- package/dist/components/Switch/Switch.d.ts +0 -15
- package/dist/components/Switch/Switch.d.ts.map +0 -1
- package/dist/components/Switch/switch.types.d.ts +0 -47
- package/dist/components/Switch/switch.types.d.ts.map +0 -1
- package/dist/components/Switch/switch.variants.d.ts +0 -105
- package/dist/components/Switch/switch.variants.d.ts.map +0 -1
- package/dist/components/TextField/TextField.d.ts +0 -25
- package/dist/components/TextField/TextField.d.ts.map +0 -1
- package/dist/components/TextField/textField.types.d.ts +0 -72
- package/dist/components/TextField/textField.types.d.ts.map +0 -1
- package/dist/components/TextField/textField.variants.d.ts +0 -160
- package/dist/components/TextField/textField.variants.d.ts.map +0 -1
- package/dist/components/Textarea/Textarea.d.ts +0 -16
- package/dist/components/Textarea/Textarea.d.ts.map +0 -1
- package/dist/components/Textarea/textarea.types.d.ts +0 -63
- package/dist/components/Textarea/textarea.types.d.ts.map +0 -1
- package/dist/components/Textarea/textarea.variants.d.ts +0 -197
- package/dist/components/Textarea/textarea.variants.d.ts.map +0 -1
- package/dist/components/TopBar/TopBar.d.ts +0 -33
- package/dist/components/TopBar/TopBar.d.ts.map +0 -1
- package/dist/components/TopBar/topBar.types.d.ts +0 -49
- package/dist/components/TopBar/topBar.types.d.ts.map +0 -1
- package/dist/components/TopBar/topBar.variants.d.ts +0 -79
- package/dist/components/TopBar/topBar.variants.d.ts.map +0 -1
- package/dist/components/_shared/resolveValidationState.d.ts +0 -42
- package/dist/components/_shared/resolveValidationState.d.ts.map +0 -1
- package/dist/hooks/useAccessState.d.ts +0 -37
- package/dist/hooks/useAccessState.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/tsconfig.lib.tsbuildinfo +0 -1
- package/dist/utils/cn.d.ts +0 -26
- package/dist/utils/cn.d.ts.map +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `stackVariants` — flex container 1D, the layout primitive of @dashforge/tw.
|
|
5
|
+
*
|
|
6
|
+
* Architectural role (planned with the user — F9 deep dive):
|
|
7
|
+
*
|
|
8
|
+
* Stack is the ONLY way to do flex in this library. Box is the
|
|
9
|
+
* surface (border / bg / shadow); Stack is the arrangement (direction
|
|
10
|
+
* / align / justify / gap). Two primitives, two responsibilities,
|
|
11
|
+
* zero overlap.
|
|
12
|
+
*
|
|
13
|
+
* This rules out the MUI failure mode where every `<Box display="flex"
|
|
14
|
+
* gap={2}>` quietly becomes the de-facto flex container, drowning
|
|
15
|
+
* the surface vs layout distinction. Here, if you see `<Stack>` in
|
|
16
|
+
* the JSX, you KNOW it's flex; if you see `<Box>`, you KNOW it's
|
|
17
|
+
* not. The component name carries the intent.
|
|
18
|
+
*
|
|
19
|
+
* Axes:
|
|
20
|
+
* • direction — row / col (+ reverse variants)
|
|
21
|
+
* • align / justify — cross-axis / main-axis alignment
|
|
22
|
+
* • gap — token-scale step (mirror Box spacing scale)
|
|
23
|
+
* • wrap — flex-wrap
|
|
24
|
+
* • divider — runtime-only (handled in Stack.tsx, not here)
|
|
25
|
+
*
|
|
26
|
+
* Sizing (`fullWidth`, `fullHeight`) is duplicated from Box because
|
|
27
|
+
* Stack often plays the role of a full-width strip / full-height
|
|
28
|
+
* column — re-typing `sx="w-full"` every time would be friction.
|
|
29
|
+
*/
|
|
30
|
+
export const stackVariants = tv({
|
|
31
|
+
base: 'flex',
|
|
32
|
+
|
|
33
|
+
variants: {
|
|
34
|
+
direction: {
|
|
35
|
+
row: 'flex-row',
|
|
36
|
+
col: 'flex-col',
|
|
37
|
+
'row-reverse': 'flex-row-reverse',
|
|
38
|
+
'col-reverse': 'flex-col-reverse',
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
align: {
|
|
42
|
+
start: 'items-start',
|
|
43
|
+
center: 'items-center',
|
|
44
|
+
end: 'items-end',
|
|
45
|
+
stretch: 'items-stretch',
|
|
46
|
+
baseline: 'items-baseline',
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
justify: {
|
|
50
|
+
start: 'justify-start',
|
|
51
|
+
center: 'justify-center',
|
|
52
|
+
end: 'justify-end',
|
|
53
|
+
between: 'justify-between',
|
|
54
|
+
around: 'justify-around',
|
|
55
|
+
evenly: 'justify-evenly',
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* `gap` — explicit literal mapping for the 11 token-scale steps.
|
|
60
|
+
* Same set as Box's spacing axes (p, m, etc.) so muscle memory
|
|
61
|
+
* carries over: `<Stack gap={4}>` aligns visually with `<Box p={4}>`.
|
|
62
|
+
*/
|
|
63
|
+
gap: {
|
|
64
|
+
0: 'gap-0', '0.5': 'gap-0.5', 1: 'gap-1', 2: 'gap-2', 3: 'gap-3',
|
|
65
|
+
4: 'gap-4', 6: 'gap-6', 8: 'gap-8', 12: 'gap-12', 16: 'gap-16',
|
|
66
|
+
24: 'gap-24',
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
wrap: { true: 'flex-wrap' },
|
|
70
|
+
|
|
71
|
+
fullWidth: { true: 'w-full' },
|
|
72
|
+
fullHeight: { true: 'h-full' },
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
defaultVariants: {
|
|
76
|
+
direction: 'col',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export type StackVariants = VariantProps<typeof stackVariants>;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import { Typography } from './Typography.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Suite mirrors the Button.test.tsx shape (rendering · variants · color ·
|
|
8
|
+
* override · polymorphism) so the test surface is uniform across the
|
|
9
|
+
* package.
|
|
10
|
+
*
|
|
11
|
+
* Strategy: render → query → assert on `tagName` (for HTML-element
|
|
12
|
+
* resolution) and `className` (for variant resolution). We deliberately
|
|
13
|
+
* DON'T snapshot: snapshots churn on every variant-class edit and offer
|
|
14
|
+
* no signal on the actual contract — the contract is "this variant
|
|
15
|
+
* emits THESE specific Tailwind utility classes", which we assert
|
|
16
|
+
* positively below.
|
|
17
|
+
*/
|
|
18
|
+
describe('<Typography>', () => {
|
|
19
|
+
// ─── Rendering / default tag mapping ────────────────────────────────
|
|
20
|
+
describe('default tag mapping', () => {
|
|
21
|
+
it('renders <p> for body1 by default', () => {
|
|
22
|
+
const { container } = render(<Typography>hello</Typography>);
|
|
23
|
+
expect(container.firstElementChild?.tagName).toBe('P');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders <h1> for variant="h1"', () => {
|
|
27
|
+
const { container } = render(<Typography variant="h1">title</Typography>);
|
|
28
|
+
expect(container.firstElementChild?.tagName).toBe('H1');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders <h3> for variant="h3"', () => {
|
|
32
|
+
const { container } = render(<Typography variant="h3">title</Typography>);
|
|
33
|
+
expect(container.firstElementChild?.tagName).toBe('H3');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('renders <span> for variant="caption"', () => {
|
|
37
|
+
const { container } = render(<Typography variant="caption">note</Typography>);
|
|
38
|
+
expect(container.firstElementChild?.tagName).toBe('SPAN');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders <span> for variant="overline"', () => {
|
|
42
|
+
const { container } = render(<Typography variant="overline">label</Typography>);
|
|
43
|
+
expect(container.firstElementChild?.tagName).toBe('SPAN');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Variant classes ────────────────────────────────────────────────
|
|
48
|
+
describe('variant → utility chain', () => {
|
|
49
|
+
it('h1 has text-5xl + font-bold', () => {
|
|
50
|
+
const { container } = render(<Typography variant="h1">x</Typography>);
|
|
51
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
52
|
+
expect(cls).toContain('text-5xl');
|
|
53
|
+
expect(cls).toContain('font-bold');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('body2 has text-sm', () => {
|
|
57
|
+
const { container } = render(<Typography variant="body2">x</Typography>);
|
|
58
|
+
expect(container.firstElementChild?.className).toContain('text-sm');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('overline has uppercase + tracking', () => {
|
|
62
|
+
const { container } = render(<Typography variant="overline">x</Typography>);
|
|
63
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
64
|
+
expect(cls).toContain('uppercase');
|
|
65
|
+
expect(cls).toMatch(/tracking-\[/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── Color intent ───────────────────────────────────────────────────
|
|
70
|
+
describe('color', () => {
|
|
71
|
+
it('color="primary" emits text-primary-* with dark variant', () => {
|
|
72
|
+
const { container } = render(<Typography color="primary">x</Typography>);
|
|
73
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
74
|
+
expect(cls).toContain('text-primary-700');
|
|
75
|
+
expect(cls).toContain('dark:text-primary-400');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('color="danger" emits text-danger-*', () => {
|
|
79
|
+
const { container } = render(<Typography color="danger">x</Typography>);
|
|
80
|
+
expect(container.firstElementChild?.className).toContain('text-danger-700');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('color="inherit" emits text-inherit', () => {
|
|
84
|
+
const { container } = render(<Typography color="inherit">x</Typography>);
|
|
85
|
+
expect(container.firstElementChild?.className).toContain('text-inherit');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── Override semantics ─────────────────────────────────────────────
|
|
90
|
+
describe('override', () => {
|
|
91
|
+
it('sx wins over variant defaults via tailwind-merge', () => {
|
|
92
|
+
const { container } = render(
|
|
93
|
+
<Typography variant="h1" color="primary" sx="text-pink-500">x</Typography>,
|
|
94
|
+
);
|
|
95
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
96
|
+
// tailwind-merge collapses the conflicting `text-*` colour to the last one
|
|
97
|
+
expect(cls).toContain('text-pink-500');
|
|
98
|
+
expect(cls).not.toContain('text-primary-700');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('weight prop overrides variant default weight', () => {
|
|
102
|
+
const { container } = render(<Typography variant="h1" weight="normal">x</Typography>);
|
|
103
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
104
|
+
// h1's default `font-bold` is dropped by tailwind-merge, replaced by font-normal
|
|
105
|
+
expect(cls).toContain('font-normal');
|
|
106
|
+
expect(cls).not.toContain('font-bold');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('truncate adds the truncate utility', () => {
|
|
110
|
+
const { container } = render(<Typography truncate>x</Typography>);
|
|
111
|
+
expect(container.firstElementChild?.className).toContain('truncate');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('gutterBottom adds mb-3', () => {
|
|
115
|
+
const { container } = render(<Typography gutterBottom>x</Typography>);
|
|
116
|
+
expect(container.firstElementChild?.className).toContain('mb-3');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ─── Polymorphism — `as` and `asChild` ───────────────────────────────
|
|
121
|
+
describe('polymorphism', () => {
|
|
122
|
+
it('as="section" overrides the variant-default tag', () => {
|
|
123
|
+
const { container } = render(
|
|
124
|
+
<Typography variant="h1" as="section">x</Typography>,
|
|
125
|
+
);
|
|
126
|
+
expect(container.firstElementChild?.tagName).toBe('SECTION');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('asChild renders the single child element with merged className', () => {
|
|
130
|
+
const { container } = render(
|
|
131
|
+
<Typography variant="h2" color="primary" asChild>
|
|
132
|
+
<a href="#">link</a>
|
|
133
|
+
</Typography>,
|
|
134
|
+
);
|
|
135
|
+
const el = container.firstElementChild;
|
|
136
|
+
expect(el?.tagName).toBe('A');
|
|
137
|
+
expect(el?.className).toContain('text-4xl');
|
|
138
|
+
expect(el?.className).toContain('text-primary-700');
|
|
139
|
+
expect(el?.getAttribute('href')).toBe('#');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('asChild wins over `as` when both are passed', () => {
|
|
143
|
+
const { container } = render(
|
|
144
|
+
<Typography variant="h1" as="section" asChild>
|
|
145
|
+
<article>x</article>
|
|
146
|
+
</Typography>,
|
|
147
|
+
);
|
|
148
|
+
// Slot renders the child element, ignoring `as`
|
|
149
|
+
expect(container.firstElementChild?.tagName).toBe('ARTICLE');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Pass-through ────────────────────────────────────────────────────
|
|
154
|
+
describe('pass-through props', () => {
|
|
155
|
+
it('forwards data-* attributes', () => {
|
|
156
|
+
const { container } = render(
|
|
157
|
+
<Typography data-testid="typo">x</Typography>,
|
|
158
|
+
);
|
|
159
|
+
expect(container.firstElementChild?.getAttribute('data-testid')).toBe('typo');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('forwards aria-label', () => {
|
|
163
|
+
const { container } = render(
|
|
164
|
+
<Typography aria-label="heading">x</Typography>,
|
|
165
|
+
);
|
|
166
|
+
expect(container.firstElementChild?.getAttribute('aria-label')).toBe('heading');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ─── F11-bis edge cases ─────────────────────────────────────────────
|
|
171
|
+
describe('multi-axis combinations', () => {
|
|
172
|
+
it('variant=h2 + color=primary + weight=normal + align=center + truncate + gutterBottom', () => {
|
|
173
|
+
const { container } = render(
|
|
174
|
+
<Typography
|
|
175
|
+
variant="h2"
|
|
176
|
+
color="primary"
|
|
177
|
+
weight="normal"
|
|
178
|
+
align="center"
|
|
179
|
+
truncate
|
|
180
|
+
gutterBottom
|
|
181
|
+
>
|
|
182
|
+
x
|
|
183
|
+
</Typography>,
|
|
184
|
+
);
|
|
185
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
186
|
+
expect(cls).toContain('text-4xl'); // h2 size
|
|
187
|
+
expect(cls).toContain('text-primary-700'); // color intent
|
|
188
|
+
expect(cls).toContain('font-normal'); // weight override (vs h2 default font-bold)
|
|
189
|
+
expect(cls).not.toContain('font-bold'); // overridden
|
|
190
|
+
expect(cls).toContain('text-center'); // align
|
|
191
|
+
expect(cls).toContain('truncate'); // truncate
|
|
192
|
+
expect(cls).toContain('mb-3'); // gutterBottom
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('noWrap + truncate: truncate wins (later in chain, tailwind-merge collapses whitespace-nowrap)', () => {
|
|
196
|
+
const { container } = render(<Typography noWrap truncate>x</Typography>);
|
|
197
|
+
// truncate utility already includes whitespace-nowrap — tailwind-merge
|
|
198
|
+
// collapses the redundant nowrap from noWrap
|
|
199
|
+
expect(container.firstElementChild?.className).toContain('truncate');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('every variant maps to its expected default tag', () => {
|
|
203
|
+
const cases: Array<[Parameters<typeof Typography>[0]['variant'], string]> = [
|
|
204
|
+
['h1', 'H1'], ['h2', 'H2'], ['h3', 'H3'], ['h4', 'H4'], ['h5', 'H5'], ['h6', 'H6'],
|
|
205
|
+
['subtitle1', 'P'], ['subtitle2', 'P'],
|
|
206
|
+
['body1', 'P'], ['body2', 'P'],
|
|
207
|
+
['caption', 'SPAN'], ['overline', 'SPAN'],
|
|
208
|
+
];
|
|
209
|
+
cases.forEach(([variant, tag]) => {
|
|
210
|
+
const { container } = render(<Typography variant={variant}>x</Typography>);
|
|
211
|
+
expect(container.firstElementChild?.tagName).toBe(tag);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('color edge cases', () => {
|
|
217
|
+
const INTENTS = ['primary', 'secondary', 'success', 'warning', 'danger', 'info', 'muted'] as const;
|
|
218
|
+
it.each(INTENTS)('color="%s" emits the right text-* + dark pair', (color) => {
|
|
219
|
+
const { container } = render(<Typography color={color}>x</Typography>);
|
|
220
|
+
const cls = container.firstElementChild?.className ?? '';
|
|
221
|
+
if (color === 'muted') {
|
|
222
|
+
expect(cls).toContain('text-neutral-600');
|
|
223
|
+
expect(cls).toContain('dark:text-neutral-400');
|
|
224
|
+
} else {
|
|
225
|
+
expect(cls).toContain(`text-${color}-700`);
|
|
226
|
+
expect(cls).toContain(`dark:text-${color}-400`);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { forwardRef, type ElementType, type ReactElement } from 'react';
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
3
|
+
import { cn } from '../../utils/cn.js';
|
|
4
|
+
import { typographyVariants } from './typography.variants.js';
|
|
5
|
+
import type { TypographyProps } from './typography.types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default HTML tag per variant. Headings get their semantic level (h1→h1
|
|
9
|
+
* etc.); subtitle/body get `<p>` (block, paragraph semantics);
|
|
10
|
+
* caption/overline get `<span>` (inline, no implicit block break).
|
|
11
|
+
*
|
|
12
|
+
* Override per-instance via the `as` prop — useful when the semantic
|
|
13
|
+
* heading level should differ from the visual scale (e.g. a hero
|
|
14
|
+
* rendered as `<h2>` but visually styled `h1`).
|
|
15
|
+
*/
|
|
16
|
+
const VARIANT_TO_TAG: Record<NonNullable<TypographyProps['variant']>, ElementType> = {
|
|
17
|
+
h1: 'h1',
|
|
18
|
+
h2: 'h2',
|
|
19
|
+
h3: 'h3',
|
|
20
|
+
h4: 'h4',
|
|
21
|
+
h5: 'h5',
|
|
22
|
+
h6: 'h6',
|
|
23
|
+
subtitle1: 'p',
|
|
24
|
+
subtitle2: 'p',
|
|
25
|
+
body1: 'p',
|
|
26
|
+
body2: 'p',
|
|
27
|
+
caption: 'span',
|
|
28
|
+
overline: 'span',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `<Typography>` — semantic typed text, the foundation of every readable
|
|
33
|
+
* surface in @dashforge/tw.
|
|
34
|
+
*
|
|
35
|
+
* Why this exists:
|
|
36
|
+
* Tailwind ships a typographic scale (`text-xl`, `font-bold`,
|
|
37
|
+
* `leading-relaxed`) but leaves the SEMANTIC HTML tag and the
|
|
38
|
+
* intent-coloured palette to the consumer. That's fine for one-off
|
|
39
|
+
* marketing surfaces, but at app scale it means every `<h2>` and every
|
|
40
|
+
* body paragraph re-derives its own utility chain — and they drift.
|
|
41
|
+
*
|
|
42
|
+
* Typography moves that decision into a typed prop set: the visual
|
|
43
|
+
* scale, the intent colour, the alignment, the truncation all live in
|
|
44
|
+
* `tailwind-variants` and resolve to the same utility chain everywhere
|
|
45
|
+
* in the app. The default HTML tag is inferred from `variant` so the
|
|
46
|
+
* semantic layer follows the visual layer; `as` and `asChild` are the
|
|
47
|
+
* two escape hatches when you need something else.
|
|
48
|
+
*
|
|
49
|
+
* Layering:
|
|
50
|
+
* • Sits BENEATH every component that renders text — `<Button>`'s
|
|
51
|
+
* label, `<TextField>`'s helper text, MDX prose in our own docs.
|
|
52
|
+
* • Composes ON TOP of `@dashforge/tw-tokens` colour scales (so
|
|
53
|
+
* `color="primary"` paints `text-primary-700` in light, `-400` in dark,
|
|
54
|
+
* reactive to `setMode()`).
|
|
55
|
+
* • Polymorphic via Radix Slot — pairs cleanly with router `<Link>`,
|
|
56
|
+
* `<button>`, or `<a>` without injecting an extra wrapper element.
|
|
57
|
+
*
|
|
58
|
+
* Polymorphism rules — `as` vs `asChild`:
|
|
59
|
+
* • `as` swaps the rendered tag (we still render the element ourselves).
|
|
60
|
+
* • `asChild` removes our element entirely — the single React child
|
|
61
|
+
* becomes the rendered tag, with our className/ref merged onto it.
|
|
62
|
+
* This is the Radix Slot pattern; use when you need a router Link
|
|
63
|
+
* to style as a heading.
|
|
64
|
+
* When BOTH are passed, `asChild` wins. We considered making this a
|
|
65
|
+
* compile-time error via discriminated unions, but the API surface
|
|
66
|
+
* already has 8 axes and adding one more dimension to the props type
|
|
67
|
+
* would inflate IntelliSense suggestions for marginal benefit. The
|
|
68
|
+
* runtime preference is documented; the test asserts it.
|
|
69
|
+
*/
|
|
70
|
+
export const Typography = forwardRef<HTMLElement, TypographyProps>(
|
|
71
|
+
function Typography(props, ref) {
|
|
72
|
+
const {
|
|
73
|
+
variant = 'body1',
|
|
74
|
+
color,
|
|
75
|
+
weight,
|
|
76
|
+
align,
|
|
77
|
+
truncate,
|
|
78
|
+
noWrap,
|
|
79
|
+
gutterBottom,
|
|
80
|
+
as,
|
|
81
|
+
asChild = false,
|
|
82
|
+
sx,
|
|
83
|
+
children,
|
|
84
|
+
...rest
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
const classes = cn(
|
|
88
|
+
typographyVariants({ variant, color, weight, align, truncate, noWrap, gutterBottom }),
|
|
89
|
+
sx,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// asChild wins over `as` when both are passed (see component header).
|
|
93
|
+
if (asChild) {
|
|
94
|
+
return (
|
|
95
|
+
<Slot ref={ref} className={classes} {...rest}>
|
|
96
|
+
{children as ReactElement}
|
|
97
|
+
</Slot>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Resolve the rendered tag: explicit `as` > variant default > fallback.
|
|
102
|
+
const Tag = (as ?? VARIANT_TO_TAG[variant] ?? 'span') as ElementType;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Tag ref={ref as never} className={classes} {...rest}>
|
|
106
|
+
{children}
|
|
107
|
+
</Tag>
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
Typography.displayName = 'Typography';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ElementType, HTMLAttributes } from 'react';
|
|
2
|
+
import type { TypographyVariants } from './typography.variants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for `<Typography>`.
|
|
6
|
+
*
|
|
7
|
+
* Composition with native `HTMLAttributes`:
|
|
8
|
+
* • `className` is omitted in favour of `sx` (string of utility classes)
|
|
9
|
+
* — same convention as Button/TextField/Checkbox/Switch in this
|
|
10
|
+
* package. `sx` is merged via `tailwind-merge`, so the consumer's
|
|
11
|
+
* classes always win over variant defaults.
|
|
12
|
+
* • `color` is omitted because the native HTML attribute (the
|
|
13
|
+
* deprecated `color="red"` from HTML4) collides with our intent
|
|
14
|
+
* prop — TypeScript would otherwise widen to a confusing union.
|
|
15
|
+
*
|
|
16
|
+
* Variant axes picked from `TypographyVariants`:
|
|
17
|
+
* • `variant` — the type scale (h1–h6, subtitle1/2, body1/2, caption, overline)
|
|
18
|
+
* • `color` — the intent (primary, secondary, success, warning, danger, info, neutral, muted, inherit)
|
|
19
|
+
* • `weight` — overrides the variant's default font-weight
|
|
20
|
+
* • `align` — text-align
|
|
21
|
+
* • `truncate` — one-line ellipsis
|
|
22
|
+
* • `noWrap` — one-line without ellipsis
|
|
23
|
+
* • `gutterBottom` — adds mb-3 (mirror of MUI's same prop)
|
|
24
|
+
*
|
|
25
|
+
* Polymorphism:
|
|
26
|
+
* • `as` — override the HTML tag while keeping the variant's visual
|
|
27
|
+
* style (e.g. `<Typography variant="h1" as="h2">` renders an
|
|
28
|
+
* `<h2>` styled like h1 — useful when the semantic heading
|
|
29
|
+
* level matters more than the type scale).
|
|
30
|
+
* • `asChild` — render as the single child element via Radix Slot
|
|
31
|
+
* (so the styles paint onto a `<Link>`, `<a>`, etc.).
|
|
32
|
+
* Mutually exclusive with `as` — see component header for
|
|
33
|
+
* the rationale.
|
|
34
|
+
*/
|
|
35
|
+
export interface TypographyProps
|
|
36
|
+
extends Omit<HTMLAttributes<HTMLElement>, 'className' | 'color'>,
|
|
37
|
+
Pick<TypographyVariants, 'variant' | 'color' | 'weight' | 'align' | 'truncate' | 'noWrap' | 'gutterBottom'> {
|
|
38
|
+
/**
|
|
39
|
+
* Override the HTML tag. Defaults to a sensible mapping per variant
|
|
40
|
+
* (h1→h1, …, body1→p, caption→span, overline→span). Use when the
|
|
41
|
+
* semantic heading level should differ from the visual scale —
|
|
42
|
+
* e.g. a "hero" rendered as an `<h2>` but styled like `h1`.
|
|
43
|
+
*
|
|
44
|
+
* Ignored when `asChild` is true.
|
|
45
|
+
*/
|
|
46
|
+
as?: ElementType;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render via Radix `Slot` — the Typography styles paint onto the
|
|
50
|
+
* single React child instead of wrapping it in our own tag. Useful
|
|
51
|
+
* for `<Typography asChild><Link>...</Link></Typography>` to get a
|
|
52
|
+
* styled router link with no extra DOM.
|
|
53
|
+
*
|
|
54
|
+
* Mutually exclusive with `as` (when both are passed, `asChild` wins
|
|
55
|
+
* and `as` is ignored — see Typography.tsx for the reason).
|
|
56
|
+
*/
|
|
57
|
+
asChild?: boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Utility classes appended to the variant chain. Resolved via
|
|
61
|
+
* `tailwind-merge` so the consumer's classes always win over the
|
|
62
|
+
* variant defaults — e.g. `sx="text-pink-500"` overrides `color="primary"`.
|
|
63
|
+
*/
|
|
64
|
+
sx?: string;
|
|
65
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `typographyVariants` — the full type scale for the @dashforge/tw library.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the MUI Typography variant set (h1–h6 · subtitle1/2 · body1/2 ·
|
|
7
|
+
* caption · overline) so the mental model carries over for developers
|
|
8
|
+
* moving between the two ecosystems. Each variant baseline maps to a
|
|
9
|
+
* Tailwind utility chain that resolves through the @dashforge/tw-tokens
|
|
10
|
+
* scale (so the visual stays in sync with the rest of the system when the
|
|
11
|
+
* token theme is patched).
|
|
12
|
+
*
|
|
13
|
+
* Variant axes are intentionally ORTHOGONAL — `variant` chooses the type
|
|
14
|
+
* scale, `weight` overrides the variant's default weight (useful for "h2
|
|
15
|
+
* but lighter"), `color` picks the intent, `align` picks the axis. They
|
|
16
|
+
* never collide, so consumers can mix them freely.
|
|
17
|
+
*
|
|
18
|
+
* Two boolean flags (`truncate`, `noWrap`) encode the most common one-line
|
|
19
|
+
* patterns; `gutterBottom` adds the conventional bottom margin used when a
|
|
20
|
+
* heading precedes a paragraph block (mirror of MUI's same flag).
|
|
21
|
+
*/
|
|
22
|
+
export const typographyVariants = tv({
|
|
23
|
+
base: 'text-inherit',
|
|
24
|
+
|
|
25
|
+
variants: {
|
|
26
|
+
/*
|
|
27
|
+
* `variant` is the type-scale axis.
|
|
28
|
+
*
|
|
29
|
+
* The default font-weight is baked into each variant (headings come
|
|
30
|
+
* with semibold/bold by default). Consumers override per-instance via
|
|
31
|
+
* the `weight` axis below — when set, `weight` wins because it's
|
|
32
|
+
* declared later in the cn() chain and tailwind-merge resolves the
|
|
33
|
+
* last `font-*` to win.
|
|
34
|
+
*/
|
|
35
|
+
variant: {
|
|
36
|
+
h1: 'text-5xl font-bold leading-[1.05] tracking-[-0.025em]',
|
|
37
|
+
h2: 'text-4xl font-bold leading-[1.1] tracking-[-0.022em]',
|
|
38
|
+
h3: 'text-3xl font-semibold leading-[1.15] tracking-[-0.02em]',
|
|
39
|
+
h4: 'text-2xl font-semibold leading-snug tracking-[-0.015em]',
|
|
40
|
+
h5: 'text-xl font-semibold leading-snug',
|
|
41
|
+
h6: 'text-lg font-semibold leading-normal',
|
|
42
|
+
subtitle1: 'text-base font-medium leading-relaxed',
|
|
43
|
+
subtitle2: 'text-sm font-medium leading-relaxed',
|
|
44
|
+
body1: 'text-base font-normal leading-relaxed',
|
|
45
|
+
body2: 'text-sm font-normal leading-relaxed',
|
|
46
|
+
caption: 'text-xs font-normal leading-normal',
|
|
47
|
+
overline: 'text-xs font-semibold uppercase tracking-[0.12em] leading-normal',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* `color` is the intent axis. Pairs with the @dashforge/tw-theme
|
|
52
|
+
* reactive colour vars so the choice survives theme patches and dark
|
|
53
|
+
* mode flips. `inherit` is the escape hatch — used inside a Box that
|
|
54
|
+
* has set its own color (e.g. `<Box variant="solid" color="primary">`
|
|
55
|
+
* paints white text).
|
|
56
|
+
*/
|
|
57
|
+
color: {
|
|
58
|
+
inherit: 'text-inherit',
|
|
59
|
+
primary: 'text-primary-700 dark:text-primary-400',
|
|
60
|
+
secondary: 'text-secondary-700 dark:text-secondary-400',
|
|
61
|
+
success: 'text-success-700 dark:text-success-400',
|
|
62
|
+
warning: 'text-warning-700 dark:text-warning-400',
|
|
63
|
+
danger: 'text-danger-700 dark:text-danger-400',
|
|
64
|
+
info: 'text-info-700 dark:text-info-400',
|
|
65
|
+
neutral: 'text-neutral-900 dark:text-neutral-100',
|
|
66
|
+
muted: 'text-neutral-600 dark:text-neutral-400',
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* `weight` overrides the variant's default weight. When unset, the
|
|
71
|
+
* variant's own weight wins. When set, this axis appears LATER in the
|
|
72
|
+
* cn() chain so tailwind-merge resolves to this value.
|
|
73
|
+
*/
|
|
74
|
+
weight: {
|
|
75
|
+
normal: 'font-normal',
|
|
76
|
+
medium: 'font-medium',
|
|
77
|
+
semibold: 'font-semibold',
|
|
78
|
+
bold: 'font-bold',
|
|
79
|
+
extrabold: 'font-extrabold',
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
align: {
|
|
83
|
+
left: 'text-left',
|
|
84
|
+
center: 'text-center',
|
|
85
|
+
right: 'text-right',
|
|
86
|
+
justify: 'text-justify',
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* `truncate` collapses to a one-line ellipsis. `noWrap` is the looser
|
|
91
|
+
* sibling — keeps the text on one line but lets it overflow without
|
|
92
|
+
* the `…`. Mutually-exclusive intent-wise; if both are passed,
|
|
93
|
+
* `truncate` wins (later in the cn() chain).
|
|
94
|
+
*/
|
|
95
|
+
truncate: { true: 'truncate' },
|
|
96
|
+
noWrap: { true: 'whitespace-nowrap' },
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* `gutterBottom` adds the conventional bottom margin used when a
|
|
100
|
+
* heading precedes a paragraph block. Mirror of MUI's same prop —
|
|
101
|
+
* familiar to developers crossing from the MUI side.
|
|
102
|
+
*/
|
|
103
|
+
gutterBottom: { true: 'mb-3' },
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
defaultVariants: {
|
|
107
|
+
variant: 'body1',
|
|
108
|
+
color: 'inherit',
|
|
109
|
+
align: 'left',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type TypographyVariants = VariantProps<typeof typographyVariants>;
|