@dashforge/tw 0.1.0-beta → 0.2.1-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 (195) hide show
  1. package/A11Y.md +130 -0
  2. package/CHANGELOG.md +231 -0
  3. package/dist/index.d.ts +1 -82
  4. package/dist/index.esm.js +1608 -11
  5. package/dist/src/components/AspectRatio/AspectRatio.d.ts +36 -0
  6. package/dist/src/components/AspectRatio/AspectRatio.d.ts.map +1 -0
  7. package/dist/src/components/AspectRatio/aspectRatio.types.d.ts +50 -0
  8. package/dist/src/components/AspectRatio/aspectRatio.types.d.ts.map +1 -0
  9. package/dist/src/components/Box/Box.d.ts +36 -0
  10. package/dist/src/components/Box/Box.d.ts.map +1 -0
  11. package/dist/src/components/Box/box.types.d.ts +54 -0
  12. package/dist/src/components/Box/box.types.d.ts.map +1 -0
  13. package/dist/src/components/Box/box.variants.d.ts +402 -0
  14. package/dist/src/components/Box/box.variants.d.ts.map +1 -0
  15. package/dist/src/components/Button/Button.d.ts.map +1 -1
  16. package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -1
  17. package/dist/src/components/Container/Container.d.ts +35 -0
  18. package/dist/src/components/Container/Container.d.ts.map +1 -0
  19. package/dist/src/components/Container/container.types.d.ts +41 -0
  20. package/dist/src/components/Container/container.types.d.ts.map +1 -0
  21. package/dist/src/components/Container/container.variants.d.ts +93 -0
  22. package/dist/src/components/Container/container.variants.d.ts.map +1 -0
  23. package/dist/src/components/Divider/Divider.d.ts +50 -0
  24. package/dist/src/components/Divider/Divider.d.ts.map +1 -0
  25. package/dist/src/components/Divider/divider.types.d.ts +47 -0
  26. package/dist/src/components/Divider/divider.types.d.ts.map +1 -0
  27. package/dist/src/components/Divider/divider.variants.d.ts +129 -0
  28. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -0
  29. package/dist/src/components/Grid/Grid.d.ts +32 -0
  30. package/dist/src/components/Grid/Grid.d.ts.map +1 -0
  31. package/dist/src/components/Grid/grid.types.d.ts +103 -0
  32. package/dist/src/components/Grid/grid.types.d.ts.map +1 -0
  33. package/dist/src/components/Grid/grid.variants.d.ts +453 -0
  34. package/dist/src/components/Grid/grid.variants.d.ts.map +1 -0
  35. package/dist/src/components/NumberField/NumberField.d.ts.map +1 -1
  36. package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  37. package/dist/src/components/Stack/Stack.d.ts +24 -0
  38. package/dist/src/components/Stack/Stack.d.ts.map +1 -0
  39. package/dist/src/components/Stack/stack.types.d.ts +61 -0
  40. package/dist/src/components/Stack/stack.types.d.ts.map +1 -0
  41. package/dist/src/components/Stack/stack.variants.d.ts +163 -0
  42. package/dist/src/components/Stack/stack.variants.d.ts.map +1 -0
  43. package/dist/src/components/Typography/Typography.d.ts +42 -0
  44. package/dist/src/components/Typography/Typography.d.ts.map +1 -0
  45. package/dist/src/components/Typography/typography.types.d.ts +61 -0
  46. package/dist/src/components/Typography/typography.types.d.ts.map +1 -0
  47. package/dist/src/components/Typography/typography.variants.d.ts +167 -0
  48. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -0
  49. package/dist/src/components/VisuallyHidden/VisuallyHidden.d.ts +42 -0
  50. package/dist/src/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  51. package/dist/src/components/VisuallyHidden/visuallyHidden.types.d.ts +42 -0
  52. package/dist/src/components/VisuallyHidden/visuallyHidden.types.d.ts.map +1 -0
  53. package/dist/src/index.d.ts +23 -1
  54. package/dist/src/index.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/components/AspectRatio/AspectRatio.test.tsx +173 -0
  57. package/src/components/AspectRatio/AspectRatio.tsx +73 -0
  58. package/src/components/AspectRatio/aspectRatio.types.ts +54 -0
  59. package/src/components/Box/Box.test.tsx +349 -0
  60. package/src/components/Box/Box.tsx +83 -0
  61. package/src/components/Box/box.types.ts +61 -0
  62. package/src/components/Box/box.variants.ts +211 -0
  63. package/src/components/Button/Button.tsx +11 -0
  64. package/src/components/Checkbox/Checkbox.tsx +26 -2
  65. package/src/components/Container/Container.test.tsx +209 -0
  66. package/src/components/Container/Container.tsx +74 -0
  67. package/src/components/Container/container.types.ts +45 -0
  68. package/src/components/Container/container.variants.ts +81 -0
  69. package/src/components/Divider/Divider.test.tsx +241 -0
  70. package/src/components/Divider/Divider.tsx +140 -0
  71. package/src/components/Divider/divider.types.ts +52 -0
  72. package/src/components/Divider/divider.variants.ts +115 -0
  73. package/src/components/Grid/Grid.test.tsx +321 -0
  74. package/src/components/Grid/Grid.tsx +148 -0
  75. package/src/components/Grid/grid.types.ts +112 -0
  76. package/src/components/Grid/grid.variants.ts +133 -0
  77. package/src/components/NumberField/NumberField.tsx +30 -2
  78. package/src/components/RadioGroup/RadioGroup.tsx +23 -1
  79. package/src/components/Stack/Stack.test.tsx +309 -0
  80. package/src/components/Stack/Stack.tsx +115 -0
  81. package/src/components/Stack/stack.types.ts +68 -0
  82. package/src/components/Stack/stack.variants.ts +80 -0
  83. package/src/components/Typography/Typography.test.tsx +230 -0
  84. package/src/components/Typography/Typography.tsx +112 -0
  85. package/src/components/Typography/typography.types.ts +65 -0
  86. package/src/components/Typography/typography.variants.ts +113 -0
  87. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +86 -0
  88. package/src/components/VisuallyHidden/VisuallyHidden.tsx +59 -0
  89. package/src/components/VisuallyHidden/visuallyHidden.types.ts +45 -0
  90. package/src/index.ts +50 -1
  91. package/LICENSE +0 -21
  92. package/dist/components/AppShell/AppShell.d.ts +0 -32
  93. package/dist/components/AppShell/AppShell.d.ts.map +0 -1
  94. package/dist/components/AppShell/appShell.types.d.ts +0 -62
  95. package/dist/components/AppShell/appShell.types.d.ts.map +0 -1
  96. package/dist/components/AppShell/appShell.variants.d.ts +0 -65
  97. package/dist/components/AppShell/appShell.variants.d.ts.map +0 -1
  98. package/dist/components/Autocomplete/Autocomplete.d.ts +0 -32
  99. package/dist/components/Autocomplete/Autocomplete.d.ts.map +0 -1
  100. package/dist/components/Autocomplete/autocomplete.types.d.ts +0 -214
  101. package/dist/components/Autocomplete/autocomplete.types.d.ts.map +0 -1
  102. package/dist/components/Autocomplete/autocomplete.variants.d.ts +0 -214
  103. package/dist/components/Autocomplete/autocomplete.variants.d.ts.map +0 -1
  104. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +0 -23
  105. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +0 -1
  106. package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts +0 -98
  107. package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts.map +0 -1
  108. package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts +0 -85
  109. package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +0 -1
  110. package/dist/components/Button/Button.d.ts +0 -44
  111. package/dist/components/Button/Button.d.ts.map +0 -1
  112. package/dist/components/Button/button.types.d.ts +0 -66
  113. package/dist/components/Button/button.types.d.ts.map +0 -1
  114. package/dist/components/Button/button.variants.d.ts +0 -104
  115. package/dist/components/Button/button.variants.d.ts.map +0 -1
  116. package/dist/components/Checkbox/Checkbox.d.ts +0 -31
  117. package/dist/components/Checkbox/Checkbox.d.ts.map +0 -1
  118. package/dist/components/Checkbox/checkbox.types.d.ts +0 -86
  119. package/dist/components/Checkbox/checkbox.types.d.ts.map +0 -1
  120. package/dist/components/Checkbox/checkbox.variants.d.ts +0 -109
  121. package/dist/components/Checkbox/checkbox.variants.d.ts.map +0 -1
  122. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +0 -38
  123. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +0 -1
  124. package/dist/components/ConfirmDialog/confirmDialog.types.d.ts +0 -80
  125. package/dist/components/ConfirmDialog/confirmDialog.types.d.ts.map +0 -1
  126. package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts +0 -90
  127. package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts.map +0 -1
  128. package/dist/components/DateTimePicker/DateTimePicker.d.ts +0 -49
  129. package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +0 -1
  130. package/dist/components/DateTimePicker/dateTimePicker.types.d.ts +0 -95
  131. package/dist/components/DateTimePicker/dateTimePicker.types.d.ts.map +0 -1
  132. package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts +0 -165
  133. package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts.map +0 -1
  134. package/dist/components/LeftNav/LeftNav.d.ts +0 -34
  135. package/dist/components/LeftNav/LeftNav.d.ts.map +0 -1
  136. package/dist/components/LeftNav/leftNav.types.d.ts +0 -136
  137. package/dist/components/LeftNav/leftNav.types.d.ts.map +0 -1
  138. package/dist/components/LeftNav/leftNav.variants.d.ts +0 -143
  139. package/dist/components/LeftNav/leftNav.variants.d.ts.map +0 -1
  140. package/dist/components/NumberField/NumberField.d.ts +0 -18
  141. package/dist/components/NumberField/NumberField.d.ts.map +0 -1
  142. package/dist/components/NumberField/numberField.types.d.ts +0 -79
  143. package/dist/components/NumberField/numberField.types.d.ts.map +0 -1
  144. package/dist/components/NumberField/numberField.variants.d.ts +0 -169
  145. package/dist/components/NumberField/numberField.variants.d.ts.map +0 -1
  146. package/dist/components/OTPField/OTPField.d.ts +0 -25
  147. package/dist/components/OTPField/OTPField.d.ts.map +0 -1
  148. package/dist/components/OTPField/otpField.types.d.ts +0 -74
  149. package/dist/components/OTPField/otpField.types.d.ts.map +0 -1
  150. package/dist/components/OTPField/otpField.variants.d.ts +0 -105
  151. package/dist/components/OTPField/otpField.variants.d.ts.map +0 -1
  152. package/dist/components/RadioGroup/RadioGroup.d.ts +0 -35
  153. package/dist/components/RadioGroup/RadioGroup.d.ts.map +0 -1
  154. package/dist/components/RadioGroup/radioGroup.types.d.ts +0 -103
  155. package/dist/components/RadioGroup/radioGroup.types.d.ts.map +0 -1
  156. package/dist/components/RadioGroup/radioGroup.variants.d.ts +0 -166
  157. package/dist/components/RadioGroup/radioGroup.variants.d.ts.map +0 -1
  158. package/dist/components/Snackbar/Snackbar.d.ts +0 -37
  159. package/dist/components/Snackbar/Snackbar.d.ts.map +0 -1
  160. package/dist/components/Snackbar/snackbar.types.d.ts +0 -86
  161. package/dist/components/Snackbar/snackbar.types.d.ts.map +0 -1
  162. package/dist/components/Snackbar/snackbar.variants.d.ts +0 -153
  163. package/dist/components/Snackbar/snackbar.variants.d.ts.map +0 -1
  164. package/dist/components/Switch/Switch.d.ts +0 -15
  165. package/dist/components/Switch/Switch.d.ts.map +0 -1
  166. package/dist/components/Switch/switch.types.d.ts +0 -47
  167. package/dist/components/Switch/switch.types.d.ts.map +0 -1
  168. package/dist/components/Switch/switch.variants.d.ts +0 -105
  169. package/dist/components/Switch/switch.variants.d.ts.map +0 -1
  170. package/dist/components/TextField/TextField.d.ts +0 -25
  171. package/dist/components/TextField/TextField.d.ts.map +0 -1
  172. package/dist/components/TextField/textField.types.d.ts +0 -72
  173. package/dist/components/TextField/textField.types.d.ts.map +0 -1
  174. package/dist/components/TextField/textField.variants.d.ts +0 -160
  175. package/dist/components/TextField/textField.variants.d.ts.map +0 -1
  176. package/dist/components/Textarea/Textarea.d.ts +0 -16
  177. package/dist/components/Textarea/Textarea.d.ts.map +0 -1
  178. package/dist/components/Textarea/textarea.types.d.ts +0 -63
  179. package/dist/components/Textarea/textarea.types.d.ts.map +0 -1
  180. package/dist/components/Textarea/textarea.variants.d.ts +0 -197
  181. package/dist/components/Textarea/textarea.variants.d.ts.map +0 -1
  182. package/dist/components/TopBar/TopBar.d.ts +0 -33
  183. package/dist/components/TopBar/TopBar.d.ts.map +0 -1
  184. package/dist/components/TopBar/topBar.types.d.ts +0 -49
  185. package/dist/components/TopBar/topBar.types.d.ts.map +0 -1
  186. package/dist/components/TopBar/topBar.variants.d.ts +0 -79
  187. package/dist/components/TopBar/topBar.variants.d.ts.map +0 -1
  188. package/dist/components/_shared/resolveValidationState.d.ts +0 -42
  189. package/dist/components/_shared/resolveValidationState.d.ts.map +0 -1
  190. package/dist/hooks/useAccessState.d.ts +0 -37
  191. package/dist/hooks/useAccessState.d.ts.map +0 -1
  192. package/dist/index.d.ts.map +0 -1
  193. package/dist/tsconfig.lib.tsbuildinfo +0 -1
  194. package/dist/utils/cn.d.ts +0 -26
  195. package/dist/utils/cn.d.ts.map +0 -1
@@ -0,0 +1,115 @@
1
+ import {
2
+ Children,
3
+ Fragment,
4
+ cloneElement,
5
+ forwardRef,
6
+ isValidElement,
7
+ type ElementType,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { Slot } from '@radix-ui/react-slot';
12
+ import { cn } from '../../utils/cn.js';
13
+ import { stackVariants } from './stack.variants.js';
14
+ import type { StackProps } from './stack.types.js';
15
+
16
+ /**
17
+ * Walk the children, inserting `divider` BETWEEN every consecutive pair.
18
+ *
19
+ * Implementation notes:
20
+ * • `React.Children.toArray` assigns auto-keys but does NOT recursively
21
+ * flatten Fragments — it treats them as opaque single children. So
22
+ * `<Stack><><a/><b/></><c/></Stack>` is 2 boundaries (fragment + c),
23
+ * yielding ONE divider. Hoist items out of the fragment when you
24
+ * need a divider between them. Documented + asserted in the tests.
25
+ * • Dividers are wrapped in `<Fragment>` with a deterministic key
26
+ * derived from the boundary index — stable across re-renders so
27
+ * React reconciles correctly when children re-order.
28
+ * • If `divider` is a valid element, we `cloneElement` once per
29
+ * boundary (cheaper than re-rendering the JSX expression N-1 times).
30
+ * For string/number dividers we wrap in a span automatically.
31
+ */
32
+ function interleaveDividers(children: ReactNode, divider: ReactNode): ReactNode[] {
33
+ const items = Children.toArray(children);
34
+ if (items.length <= 1) return items;
35
+
36
+ const result: ReactNode[] = [];
37
+ items.forEach((child, i) => {
38
+ result.push(child);
39
+ if (i < items.length - 1) {
40
+ const key = `df-stack-divider-${i}`;
41
+ if (isValidElement(divider)) {
42
+ result.push(cloneElement(divider as ReactElement<{ key?: string }>, { key }));
43
+ } else {
44
+ result.push(<Fragment key={key}>{divider}</Fragment>);
45
+ }
46
+ }
47
+ });
48
+ return result;
49
+ }
50
+
51
+ /**
52
+ * `<Stack>` — flex container 1D, the layout primitive.
53
+ *
54
+ * This is the ONLY component in @dashforge/tw that does flex. Box
55
+ * doesn't, Grid does CSS Grid (not flex). The strict naming → engine
56
+ * mapping is the whole point: when you read `<Stack>` in a JSX tree,
57
+ * you instantly know it's flex. No `<Box display="flex" ...>` traps.
58
+ *
59
+ * Direction defaults to `'col'` (vertical stack) — the most common
60
+ * case for forms, sidebars, settings panels. Pass `direction="row"`
61
+ * for horizontal layouts (toolbars, button rows, breadcrumbs).
62
+ *
63
+ * The `divider` prop is the runtime-only piece: TV can't encode the
64
+ * "render this between each child" logic as a class, so we walk the
65
+ * children at render time. The walk is O(n); for n ≤ ~10 (typical
66
+ * Stack content) the cost is negligible. For very long Stacks (1000+
67
+ * items), prefer to render dividers as part of each child instead.
68
+ *
69
+ * When `asChild` is true, the divider prop is silently ignored — Slot
70
+ * requires a single child, and the N-1 insertion has nowhere to act.
71
+ */
72
+ export const Stack = forwardRef<HTMLElement, StackProps>(
73
+ function Stack(props, ref) {
74
+ const {
75
+ direction,
76
+ align,
77
+ justify,
78
+ gap,
79
+ wrap,
80
+ fullWidth,
81
+ fullHeight,
82
+ divider,
83
+ as,
84
+ asChild = false,
85
+ sx,
86
+ children,
87
+ ...rest
88
+ } = props;
89
+
90
+ const classes = cn(
91
+ stackVariants({ direction, align, justify, gap, wrap, fullWidth, fullHeight }),
92
+ sx,
93
+ );
94
+
95
+ if (asChild) {
96
+ // Slot expects a single child; divider has no place here.
97
+ return (
98
+ <Slot ref={ref} className={classes} {...rest}>
99
+ {children as ReactElement}
100
+ </Slot>
101
+ );
102
+ }
103
+
104
+ const Tag = (as ?? 'div') as ElementType;
105
+ const content = divider != null ? interleaveDividers(children, divider) : children;
106
+
107
+ return (
108
+ <Tag ref={ref as never} className={classes} {...rest}>
109
+ {content}
110
+ </Tag>
111
+ );
112
+ },
113
+ );
114
+
115
+ Stack.displayName = 'Stack';
@@ -0,0 +1,68 @@
1
+ import type { ElementType, HTMLAttributes, ReactNode } from 'react';
2
+ import type { StackVariants } from './stack.variants.js';
3
+
4
+ /**
5
+ * Props for `<Stack>` — flex container 1D, the layout primitive.
6
+ *
7
+ * Axes:
8
+ * • direction — flex-direction (default 'col')
9
+ * • align — items-* (cross-axis)
10
+ * • justify — justify-* (main-axis)
11
+ * • gap — token-scale spacing step
12
+ * • wrap — flex-wrap
13
+ * • fullWidth/fullHeight — w-full / h-full
14
+ * • divider — node rendered N-1 times between children
15
+ *
16
+ * Polymorphism:
17
+ * • as — override the HTML tag (default 'div')
18
+ * • asChild — render via Radix Slot onto the single child
19
+ *
20
+ * What Stack does NOT do (route to other primitives):
21
+ * • Surface chrome (border, bg, shadow) → wrap in <Box>
22
+ * • 2D layout (rows AND columns) → use <Grid>
23
+ * • Text styling → <Typography>
24
+ */
25
+ export interface StackProps
26
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'className'>,
27
+ Pick<StackVariants,
28
+ 'direction' | 'align' | 'justify' | 'gap' | 'wrap'
29
+ | 'fullWidth' | 'fullHeight'> {
30
+ /**
31
+ * Node rendered N-1 times BETWEEN children — mirror of MUI Stack's
32
+ * divider prop. Useful for visual separators (a `<hr>`, a thin
33
+ * `<Box variant="outlined" sx="h-px border-0 border-t">`, an
34
+ * `<svg>` glyph) that need to follow the flex direction.
35
+ *
36
+ * The node is cloned for each insertion via `cloneElement`; pass a
37
+ * stable element (not a function) for best React reconciliation.
38
+ * `React.Children.toArray` flattens fragments before the walk, so
39
+ * `<Stack divider={...}><><...></></Stack>` works as expected.
40
+ */
41
+ divider?: ReactNode;
42
+
43
+ /**
44
+ * Override the rendered HTML tag. Defaults to `'div'`. Use when the
45
+ * Stack also has semantic meaning — `<Stack as="nav">` for a nav
46
+ * bar, `<Stack as="ul">` for a list (children become `<li>` via
47
+ * native HTML, not by us).
48
+ *
49
+ * Ignored when `asChild` is true.
50
+ */
51
+ as?: ElementType;
52
+
53
+ /**
54
+ * Render via Radix `Slot` — the Stack styles paint onto the single
55
+ * React child instead of wrapping it. Mutually exclusive with `as`
56
+ * (when both are passed, `asChild` wins). The `divider` prop is
57
+ * ignored when `asChild` is true (the Slot pattern wraps a single
58
+ * node, so the N-1 insertion logic has no place to act).
59
+ */
60
+ asChild?: boolean;
61
+
62
+ /**
63
+ * Utility classes appended to the variant chain. Resolved via
64
+ * `tailwind-merge` so the consumer's classes always win over the
65
+ * variant defaults.
66
+ */
67
+ sx?: string;
68
+ }
@@ -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
+ }