@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.
Files changed (185) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/dist/index.d.ts +1 -82
  3. package/dist/index.esm.js +1563 -2
  4. package/dist/src/components/AspectRatio/AspectRatio.d.ts +36 -0
  5. package/dist/src/components/AspectRatio/AspectRatio.d.ts.map +1 -0
  6. package/dist/src/components/AspectRatio/aspectRatio.types.d.ts +50 -0
  7. package/dist/src/components/AspectRatio/aspectRatio.types.d.ts.map +1 -0
  8. package/dist/src/components/Box/Box.d.ts +36 -0
  9. package/dist/src/components/Box/Box.d.ts.map +1 -0
  10. package/dist/src/components/Box/box.types.d.ts +54 -0
  11. package/dist/src/components/Box/box.types.d.ts.map +1 -0
  12. package/dist/src/components/Box/box.variants.d.ts +402 -0
  13. package/dist/src/components/Box/box.variants.d.ts.map +1 -0
  14. package/dist/src/components/Container/Container.d.ts +35 -0
  15. package/dist/src/components/Container/Container.d.ts.map +1 -0
  16. package/dist/src/components/Container/container.types.d.ts +41 -0
  17. package/dist/src/components/Container/container.types.d.ts.map +1 -0
  18. package/dist/src/components/Container/container.variants.d.ts +93 -0
  19. package/dist/src/components/Container/container.variants.d.ts.map +1 -0
  20. package/dist/src/components/Divider/Divider.d.ts +50 -0
  21. package/dist/src/components/Divider/Divider.d.ts.map +1 -0
  22. package/dist/src/components/Divider/divider.types.d.ts +47 -0
  23. package/dist/src/components/Divider/divider.types.d.ts.map +1 -0
  24. package/dist/src/components/Divider/divider.variants.d.ts +129 -0
  25. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -0
  26. package/dist/src/components/Grid/Grid.d.ts +32 -0
  27. package/dist/src/components/Grid/Grid.d.ts.map +1 -0
  28. package/dist/src/components/Grid/grid.types.d.ts +103 -0
  29. package/dist/src/components/Grid/grid.types.d.ts.map +1 -0
  30. package/dist/src/components/Grid/grid.variants.d.ts +453 -0
  31. package/dist/src/components/Grid/grid.variants.d.ts.map +1 -0
  32. package/dist/src/components/Stack/Stack.d.ts +24 -0
  33. package/dist/src/components/Stack/Stack.d.ts.map +1 -0
  34. package/dist/src/components/Stack/stack.types.d.ts +61 -0
  35. package/dist/src/components/Stack/stack.types.d.ts.map +1 -0
  36. package/dist/src/components/Stack/stack.variants.d.ts +163 -0
  37. package/dist/src/components/Stack/stack.variants.d.ts.map +1 -0
  38. package/dist/src/components/Typography/Typography.d.ts +42 -0
  39. package/dist/src/components/Typography/Typography.d.ts.map +1 -0
  40. package/dist/src/components/Typography/typography.types.d.ts +61 -0
  41. package/dist/src/components/Typography/typography.types.d.ts.map +1 -0
  42. package/dist/src/components/Typography/typography.variants.d.ts +167 -0
  43. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -0
  44. package/dist/src/components/VisuallyHidden/VisuallyHidden.d.ts +42 -0
  45. package/dist/src/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  46. package/dist/src/components/VisuallyHidden/visuallyHidden.types.d.ts +42 -0
  47. package/dist/src/components/VisuallyHidden/visuallyHidden.types.d.ts.map +1 -0
  48. package/dist/src/index.d.ts +22 -0
  49. package/dist/src/index.d.ts.map +1 -1
  50. package/package.json +3 -3
  51. package/src/components/AspectRatio/AspectRatio.test.tsx +173 -0
  52. package/src/components/AspectRatio/AspectRatio.tsx +73 -0
  53. package/src/components/AspectRatio/aspectRatio.types.ts +54 -0
  54. package/src/components/Box/Box.test.tsx +349 -0
  55. package/src/components/Box/Box.tsx +83 -0
  56. package/src/components/Box/box.types.ts +61 -0
  57. package/src/components/Box/box.variants.ts +211 -0
  58. package/src/components/Container/Container.test.tsx +209 -0
  59. package/src/components/Container/Container.tsx +74 -0
  60. package/src/components/Container/container.types.ts +45 -0
  61. package/src/components/Container/container.variants.ts +81 -0
  62. package/src/components/Divider/Divider.test.tsx +241 -0
  63. package/src/components/Divider/Divider.tsx +140 -0
  64. package/src/components/Divider/divider.types.ts +52 -0
  65. package/src/components/Divider/divider.variants.ts +115 -0
  66. package/src/components/Grid/Grid.test.tsx +321 -0
  67. package/src/components/Grid/Grid.tsx +148 -0
  68. package/src/components/Grid/grid.types.ts +112 -0
  69. package/src/components/Grid/grid.variants.ts +133 -0
  70. package/src/components/Stack/Stack.test.tsx +309 -0
  71. package/src/components/Stack/Stack.tsx +115 -0
  72. package/src/components/Stack/stack.types.ts +68 -0
  73. package/src/components/Stack/stack.variants.ts +80 -0
  74. package/src/components/Typography/Typography.test.tsx +230 -0
  75. package/src/components/Typography/Typography.tsx +112 -0
  76. package/src/components/Typography/typography.types.ts +65 -0
  77. package/src/components/Typography/typography.variants.ts +113 -0
  78. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +86 -0
  79. package/src/components/VisuallyHidden/VisuallyHidden.tsx +59 -0
  80. package/src/components/VisuallyHidden/visuallyHidden.types.ts +45 -0
  81. package/src/index.ts +49 -0
  82. package/dist/components/AppShell/AppShell.d.ts +0 -32
  83. package/dist/components/AppShell/AppShell.d.ts.map +0 -1
  84. package/dist/components/AppShell/appShell.types.d.ts +0 -62
  85. package/dist/components/AppShell/appShell.types.d.ts.map +0 -1
  86. package/dist/components/AppShell/appShell.variants.d.ts +0 -65
  87. package/dist/components/AppShell/appShell.variants.d.ts.map +0 -1
  88. package/dist/components/Autocomplete/Autocomplete.d.ts +0 -32
  89. package/dist/components/Autocomplete/Autocomplete.d.ts.map +0 -1
  90. package/dist/components/Autocomplete/autocomplete.types.d.ts +0 -214
  91. package/dist/components/Autocomplete/autocomplete.types.d.ts.map +0 -1
  92. package/dist/components/Autocomplete/autocomplete.variants.d.ts +0 -214
  93. package/dist/components/Autocomplete/autocomplete.variants.d.ts.map +0 -1
  94. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +0 -23
  95. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +0 -1
  96. package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts +0 -98
  97. package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts.map +0 -1
  98. package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts +0 -85
  99. package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +0 -1
  100. package/dist/components/Button/Button.d.ts +0 -44
  101. package/dist/components/Button/Button.d.ts.map +0 -1
  102. package/dist/components/Button/button.types.d.ts +0 -66
  103. package/dist/components/Button/button.types.d.ts.map +0 -1
  104. package/dist/components/Button/button.variants.d.ts +0 -104
  105. package/dist/components/Button/button.variants.d.ts.map +0 -1
  106. package/dist/components/Checkbox/Checkbox.d.ts +0 -31
  107. package/dist/components/Checkbox/Checkbox.d.ts.map +0 -1
  108. package/dist/components/Checkbox/checkbox.types.d.ts +0 -86
  109. package/dist/components/Checkbox/checkbox.types.d.ts.map +0 -1
  110. package/dist/components/Checkbox/checkbox.variants.d.ts +0 -109
  111. package/dist/components/Checkbox/checkbox.variants.d.ts.map +0 -1
  112. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +0 -38
  113. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +0 -1
  114. package/dist/components/ConfirmDialog/confirmDialog.types.d.ts +0 -80
  115. package/dist/components/ConfirmDialog/confirmDialog.types.d.ts.map +0 -1
  116. package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts +0 -90
  117. package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts.map +0 -1
  118. package/dist/components/DateTimePicker/DateTimePicker.d.ts +0 -49
  119. package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +0 -1
  120. package/dist/components/DateTimePicker/dateTimePicker.types.d.ts +0 -95
  121. package/dist/components/DateTimePicker/dateTimePicker.types.d.ts.map +0 -1
  122. package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts +0 -165
  123. package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts.map +0 -1
  124. package/dist/components/LeftNav/LeftNav.d.ts +0 -34
  125. package/dist/components/LeftNav/LeftNav.d.ts.map +0 -1
  126. package/dist/components/LeftNav/leftNav.types.d.ts +0 -136
  127. package/dist/components/LeftNav/leftNav.types.d.ts.map +0 -1
  128. package/dist/components/LeftNav/leftNav.variants.d.ts +0 -143
  129. package/dist/components/LeftNav/leftNav.variants.d.ts.map +0 -1
  130. package/dist/components/NumberField/NumberField.d.ts +0 -18
  131. package/dist/components/NumberField/NumberField.d.ts.map +0 -1
  132. package/dist/components/NumberField/numberField.types.d.ts +0 -79
  133. package/dist/components/NumberField/numberField.types.d.ts.map +0 -1
  134. package/dist/components/NumberField/numberField.variants.d.ts +0 -169
  135. package/dist/components/NumberField/numberField.variants.d.ts.map +0 -1
  136. package/dist/components/OTPField/OTPField.d.ts +0 -25
  137. package/dist/components/OTPField/OTPField.d.ts.map +0 -1
  138. package/dist/components/OTPField/otpField.types.d.ts +0 -74
  139. package/dist/components/OTPField/otpField.types.d.ts.map +0 -1
  140. package/dist/components/OTPField/otpField.variants.d.ts +0 -105
  141. package/dist/components/OTPField/otpField.variants.d.ts.map +0 -1
  142. package/dist/components/RadioGroup/RadioGroup.d.ts +0 -35
  143. package/dist/components/RadioGroup/RadioGroup.d.ts.map +0 -1
  144. package/dist/components/RadioGroup/radioGroup.types.d.ts +0 -103
  145. package/dist/components/RadioGroup/radioGroup.types.d.ts.map +0 -1
  146. package/dist/components/RadioGroup/radioGroup.variants.d.ts +0 -166
  147. package/dist/components/RadioGroup/radioGroup.variants.d.ts.map +0 -1
  148. package/dist/components/Snackbar/Snackbar.d.ts +0 -37
  149. package/dist/components/Snackbar/Snackbar.d.ts.map +0 -1
  150. package/dist/components/Snackbar/snackbar.types.d.ts +0 -86
  151. package/dist/components/Snackbar/snackbar.types.d.ts.map +0 -1
  152. package/dist/components/Snackbar/snackbar.variants.d.ts +0 -153
  153. package/dist/components/Snackbar/snackbar.variants.d.ts.map +0 -1
  154. package/dist/components/Switch/Switch.d.ts +0 -15
  155. package/dist/components/Switch/Switch.d.ts.map +0 -1
  156. package/dist/components/Switch/switch.types.d.ts +0 -47
  157. package/dist/components/Switch/switch.types.d.ts.map +0 -1
  158. package/dist/components/Switch/switch.variants.d.ts +0 -105
  159. package/dist/components/Switch/switch.variants.d.ts.map +0 -1
  160. package/dist/components/TextField/TextField.d.ts +0 -25
  161. package/dist/components/TextField/TextField.d.ts.map +0 -1
  162. package/dist/components/TextField/textField.types.d.ts +0 -72
  163. package/dist/components/TextField/textField.types.d.ts.map +0 -1
  164. package/dist/components/TextField/textField.variants.d.ts +0 -160
  165. package/dist/components/TextField/textField.variants.d.ts.map +0 -1
  166. package/dist/components/Textarea/Textarea.d.ts +0 -16
  167. package/dist/components/Textarea/Textarea.d.ts.map +0 -1
  168. package/dist/components/Textarea/textarea.types.d.ts +0 -63
  169. package/dist/components/Textarea/textarea.types.d.ts.map +0 -1
  170. package/dist/components/Textarea/textarea.variants.d.ts +0 -197
  171. package/dist/components/Textarea/textarea.variants.d.ts.map +0 -1
  172. package/dist/components/TopBar/TopBar.d.ts +0 -33
  173. package/dist/components/TopBar/TopBar.d.ts.map +0 -1
  174. package/dist/components/TopBar/topBar.types.d.ts +0 -49
  175. package/dist/components/TopBar/topBar.types.d.ts.map +0 -1
  176. package/dist/components/TopBar/topBar.variants.d.ts +0 -79
  177. package/dist/components/TopBar/topBar.variants.d.ts.map +0 -1
  178. package/dist/components/_shared/resolveValidationState.d.ts +0 -42
  179. package/dist/components/_shared/resolveValidationState.d.ts.map +0 -1
  180. package/dist/hooks/useAccessState.d.ts +0 -37
  181. package/dist/hooks/useAccessState.d.ts.map +0 -1
  182. package/dist/index.d.ts.map +0 -1
  183. package/dist/tsconfig.lib.tsbuildinfo +0 -1
  184. package/dist/utils/cn.d.ts +0 -26
  185. 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>;