@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,83 @@
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 { boxVariants } from './box.variants.js';
5
+ import type { BoxProps } from './box.types.js';
6
+
7
+ /**
8
+ * `<Box>` — the surface primitive of @dashforge/tw.
9
+ *
10
+ * What it IS:
11
+ * A polymorphic container with typed surface variants (plain /
12
+ * outlined / elevated / soft / solid) × 7 intent colours, plus
13
+ * spacing / sizing / rounded / elevation as enumerated token-scale
14
+ * props. Replaces MUI's Box + Paper + Card + Surface in one component.
15
+ *
16
+ * What it is NOT (deliberate, enforced by the prop set):
17
+ * • Not a flex container → use <Stack>
18
+ * • Not a grid container → use <Grid>
19
+ * • Not a paragraph → use <Typography>
20
+ * • Not a button → use <Button>
21
+ *
22
+ * The "Box is not flex" rule is the spine of the layout layer. Without
23
+ * it, every `<div>` in an app gravitates back to Box and the surface
24
+ * vs layout distinction collapses. Box's job is the SURFACE around
25
+ * content; Stack/Grid's job is the ARRANGEMENT of content. Two
26
+ * primitives, two responsibilities.
27
+ *
28
+ * When BOTH `as` and `asChild` are passed, `asChild` wins. Same rule
29
+ * as Typography — documented to avoid the dimension-bloat of a
30
+ * discriminated union over an 11-axis props type.
31
+ *
32
+ * Layering:
33
+ * • Sits BENEATH every visual chrome — every card, every panel,
34
+ * every section background.
35
+ * • Composes ON TOP of @dashforge/tw-tokens (color + spacing + radius
36
+ * + shadow scales) via the dashforgePreset CSS variables.
37
+ * • Pairs with Typography for text content, Stack/Grid for layout
38
+ * children inside it.
39
+ */
40
+ export const Box = forwardRef<HTMLElement, BoxProps>(
41
+ function Box(props, ref) {
42
+ const {
43
+ variant,
44
+ color,
45
+ elevation,
46
+ rounded,
47
+ p, px, py, m, mx, my,
48
+ fullWidth,
49
+ fullHeight,
50
+ as,
51
+ asChild = false,
52
+ sx,
53
+ children,
54
+ ...rest
55
+ } = props;
56
+
57
+ const classes = cn(
58
+ boxVariants({
59
+ variant, color, elevation, rounded,
60
+ p, px, py, m, mx, my,
61
+ fullWidth, fullHeight,
62
+ }),
63
+ sx,
64
+ );
65
+
66
+ if (asChild) {
67
+ return (
68
+ <Slot ref={ref} className={classes} {...rest}>
69
+ {children as ReactElement}
70
+ </Slot>
71
+ );
72
+ }
73
+
74
+ const Tag = (as ?? 'div') as ElementType;
75
+ return (
76
+ <Tag ref={ref as never} className={classes} {...rest}>
77
+ {children}
78
+ </Tag>
79
+ );
80
+ },
81
+ );
82
+
83
+ Box.displayName = 'Box';
@@ -0,0 +1,61 @@
1
+ import type { ElementType, HTMLAttributes } from 'react';
2
+ import type { BoxVariants } from './box.variants.js';
3
+
4
+ /**
5
+ * Props for `<Box>` — the surface primitive.
6
+ *
7
+ * What lives in props (typed, ergonomic):
8
+ * • Surface — variant, color, elevation, rounded
9
+ * • Spacing — p, px, py, m, mx, my (token-scale steps)
10
+ * • Sizing — fullWidth, fullHeight
11
+ * • Polymorphism — as, asChild
12
+ * • Override escape — sx (utility string, merged via tailwind-merge)
13
+ *
14
+ * What does NOT live here (deliberate, see Box.tsx header):
15
+ * • display / flex / grid / gap → use Stack or Grid
16
+ * • position / top / z-index → use sx
17
+ * • overflow / cursor → use sx
18
+ * • animation / transition → use sx
19
+ *
20
+ * Native attribute overrides:
21
+ * • `className` is omitted in favour of `sx` (string of utilities,
22
+ * resolved by tailwind-merge so consumer wins over variant defaults)
23
+ * — same convention as Button/TextField/Checkbox/Switch.
24
+ * • `color` is omitted: collides with the deprecated HTML4 `color`
25
+ * attribute. Our typed `color` (intent) is the right one.
26
+ */
27
+ export interface BoxProps
28
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'className' | 'color'>,
29
+ Pick<BoxVariants,
30
+ 'variant' | 'color' | 'elevation' | 'rounded'
31
+ | 'p' | 'px' | 'py' | 'm' | 'mx' | 'my'
32
+ | 'fullWidth' | 'fullHeight'> {
33
+ /**
34
+ * Override the rendered HTML tag. Defaults to `'div'`. Useful when
35
+ * the surface should also carry semantic meaning — `<Box as="section">`
36
+ * for a page section, `<Box as="article">` for a card-shaped article.
37
+ *
38
+ * Ignored when `asChild` is true.
39
+ */
40
+ as?: ElementType;
41
+
42
+ /**
43
+ * Render via Radix `Slot` — the Box styles paint onto the single
44
+ * React child instead of wrapping it in our own element. Useful for
45
+ * `<Box asChild><Link>...</Link></Box>` to get a styled router link
46
+ * with no extra DOM wrapper.
47
+ *
48
+ * Mutually exclusive with `as` (when both are passed, `asChild` wins
49
+ * — see Box.tsx for the reasoning).
50
+ */
51
+ asChild?: boolean;
52
+
53
+ /**
54
+ * Utility classes appended to the variant chain. Resolved via
55
+ * `tailwind-merge` so the consumer's classes always win over the
56
+ * variant defaults. Use for one-off overrides AND for utility
57
+ * dimensions Box deliberately doesn't expose as props (overflow,
58
+ * position, animation, etc.).
59
+ */
60
+ sx?: string;
61
+ }
@@ -0,0 +1,211 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * `boxVariants` — the surface primitive for @dashforge/tw.
5
+ *
6
+ * Architectural choice (planned with the user — F9 deep dive):
7
+ *
8
+ * Box replaces FOUR overlapping concepts from MUI in one component:
9
+ * • Box (typed div)
10
+ * • Paper (surface with elevation)
11
+ * • Card (Paper specialisation)
12
+ * • Surface (Joy UI's outlined / soft / solid / plain variants)
13
+ *
14
+ * The reason for the consolidation: in MUI you have to compose two or
15
+ * three of these to express even basic intent ("an outlined card with
16
+ * warning tone"). Here, one `<Box variant="outlined" color="warning">`
17
+ * says exactly that.
18
+ *
19
+ * Variant taxonomy (5 axes, intentionally non-overlapping with Stack/Grid):
20
+ *
21
+ * • plain — bare div + padding + radius. The escape hatch.
22
+ * • outlined — 1px border + subtle bg tint. The "card lite".
23
+ * • elevated — bg surface + shadow scale (0-5). The "floating panel".
24
+ * • soft — semi-transparent intent bg + intent text. The "callout".
25
+ * • solid — solid intent bg + contrasting text. The "CTA banner".
26
+ *
27
+ * `color` applies to outlined/soft/solid (each gets the 7 intent
28
+ * variants). `elevated` is color-agnostic (always neutral surface +
29
+ * shadow scale). `plain` is everything-agnostic.
30
+ *
31
+ * Spacing axes (p/px/py/m/mx/my): mapped explicitly to the 11 token
32
+ * steps from @dashforge/tw-tokens (0, 0.5, 1, 2, 3, 4, 6, 8, 12, 16, 24).
33
+ * Tailwind JIT requires literal class strings — building them dynamically
34
+ * with template literals would purge them. The verbosity below is the
35
+ * cost of keeping the bundle CSS-pure and rebuild-free.
36
+ *
37
+ * What this does NOT do (deliberate, see component docs):
38
+ * • No display / flex / grid props → use Stack or Grid
39
+ * • No position / overflow / z-index → use `sx`
40
+ * • No animation / transition → use `sx`
41
+ *
42
+ * The "Box is not flex" rule is the spine of the layout layer. Without
43
+ * it, every `<div>` in an app gravitates back to Box and the surface
44
+ * vs layout distinction collapses — exactly the failure mode this
45
+ * primitive exists to prevent.
46
+ */
47
+ export const boxVariants = tv({
48
+ base: 'block',
49
+
50
+ variants: {
51
+ /*
52
+ * Surface variant. Compound with `color` for outlined / soft / solid;
53
+ * standalone for plain / elevated.
54
+ */
55
+ variant: {
56
+ plain: '',
57
+ outlined: 'border',
58
+ elevated: 'bg-white dark:bg-neutral-900',
59
+ soft: '',
60
+ solid: '',
61
+ },
62
+
63
+ /*
64
+ * Intent color. Only meaningful when variant is outlined / soft /
65
+ * solid (resolved via compoundVariants below). For plain / elevated
66
+ * this axis is ignored at the visual level — but kept in the type
67
+ * so the prop is always available without conditional typing.
68
+ */
69
+ color: {
70
+ primary: '',
71
+ secondary: '',
72
+ success: '',
73
+ warning: '',
74
+ danger: '',
75
+ info: '',
76
+ neutral: '',
77
+ },
78
+
79
+ /*
80
+ * Shadow scale — relevant for `variant='elevated'`. We keep elevation
81
+ * as a separate axis (not folded into `variant`) so consumers can
82
+ * dial it up/down without changing the variant. Default `0` = no
83
+ * shadow (consistent with MUI's elevation=0).
84
+ */
85
+ elevation: {
86
+ 0: 'shadow-none',
87
+ 1: 'shadow-sm',
88
+ 2: 'shadow',
89
+ 3: 'shadow-md',
90
+ 4: 'shadow-lg',
91
+ 5: 'shadow-xl',
92
+ },
93
+
94
+ rounded: {
95
+ none: 'rounded-none',
96
+ sm: 'rounded-sm',
97
+ md: 'rounded-md',
98
+ lg: 'rounded-lg',
99
+ xl: 'rounded-xl',
100
+ '2xl':'rounded-2xl',
101
+ full: 'rounded-full',
102
+ },
103
+
104
+ /*
105
+ * Spacing — six axes (p/px/py/m/mx/my), 11 token steps each.
106
+ * Literals enumerated explicitly so Tailwind's JIT scanner finds
107
+ * every class. Token steps mirror @dashforge/tw-tokens spacing scale.
108
+ */
109
+ p: {
110
+ 0: 'p-0', '0.5': 'p-0.5', 1: 'p-1', 2: 'p-2', 3: 'p-3',
111
+ 4: 'p-4', 6: 'p-6', 8: 'p-8', 12: 'p-12', 16: 'p-16',
112
+ 24: 'p-24',
113
+ },
114
+ px: {
115
+ 0: 'px-0', '0.5': 'px-0.5', 1: 'px-1', 2: 'px-2', 3: 'px-3',
116
+ 4: 'px-4', 6: 'px-6', 8: 'px-8', 12: 'px-12', 16: 'px-16',
117
+ 24: 'px-24',
118
+ },
119
+ py: {
120
+ 0: 'py-0', '0.5': 'py-0.5', 1: 'py-1', 2: 'py-2', 3: 'py-3',
121
+ 4: 'py-4', 6: 'py-6', 8: 'py-8', 12: 'py-12', 16: 'py-16',
122
+ 24: 'py-24',
123
+ },
124
+ m: {
125
+ 0: 'm-0', '0.5': 'm-0.5', 1: 'm-1', 2: 'm-2', 3: 'm-3',
126
+ 4: 'm-4', 6: 'm-6', 8: 'm-8', 12: 'm-12', 16: 'm-16',
127
+ 24: 'm-24',
128
+ },
129
+ mx: {
130
+ 0: 'mx-0', '0.5': 'mx-0.5', 1: 'mx-1', 2: 'mx-2', 3: 'mx-3',
131
+ 4: 'mx-4', 6: 'mx-6', 8: 'mx-8', 12: 'mx-12', 16: 'mx-16',
132
+ 24: 'mx-24',
133
+ },
134
+ my: {
135
+ 0: 'my-0', '0.5': 'my-0.5', 1: 'my-1', 2: 'my-2', 3: 'my-3',
136
+ 4: 'my-4', 6: 'my-6', 8: 'my-8', 12: 'my-12', 16: 'my-16',
137
+ 24: 'my-24',
138
+ },
139
+
140
+ fullWidth: { true: 'w-full' },
141
+ fullHeight: { true: 'h-full' },
142
+ },
143
+
144
+ /*
145
+ * Compound variants — where surface × color get their actual visual.
146
+ * Twenty-one entries: 7 (outlined) + 7 (soft) + 7 (solid).
147
+ * `plain` and `elevated` don't appear here (no color contribution).
148
+ *
149
+ * Dark-mode pair is baked in: light-mode picks the 50-300 steps,
150
+ * dark-mode picks the 800-950 steps — both reactive to setMode()
151
+ * via the @dashforge/tw-theme CSS variables.
152
+ */
153
+ compoundVariants: [
154
+ // ─── outlined × color ─────────────────────────────────────────────
155
+ { variant: 'outlined', color: 'primary',
156
+ class: 'border-primary-300 bg-primary-50/40 dark:border-primary-800 dark:bg-primary-950/30' },
157
+ { variant: 'outlined', color: 'secondary',
158
+ class: 'border-secondary-300 bg-secondary-50/40 dark:border-secondary-800 dark:bg-secondary-950/30' },
159
+ { variant: 'outlined', color: 'success',
160
+ class: 'border-success-300 bg-success-50/40 dark:border-success-800 dark:bg-success-950/30' },
161
+ { variant: 'outlined', color: 'warning',
162
+ class: 'border-warning-300 bg-warning-50/40 dark:border-warning-800 dark:bg-warning-950/30' },
163
+ { variant: 'outlined', color: 'danger',
164
+ class: 'border-danger-300 bg-danger-50/40 dark:border-danger-800 dark:bg-danger-950/30' },
165
+ { variant: 'outlined', color: 'info',
166
+ class: 'border-info-300 bg-info-50/40 dark:border-info-800 dark:bg-info-950/30' },
167
+ { variant: 'outlined', color: 'neutral',
168
+ class: 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900' },
169
+
170
+ // ─── soft × color ─────────────────────────────────────────────────
171
+ { variant: 'soft', color: 'primary',
172
+ class: 'bg-primary-100 text-primary-900 dark:bg-primary-950/50 dark:text-primary-100' },
173
+ { variant: 'soft', color: 'secondary',
174
+ class: 'bg-secondary-100 text-secondary-900 dark:bg-secondary-950/50 dark:text-secondary-100' },
175
+ { variant: 'soft', color: 'success',
176
+ class: 'bg-success-100 text-success-900 dark:bg-success-950/50 dark:text-success-100' },
177
+ { variant: 'soft', color: 'warning',
178
+ class: 'bg-warning-100 text-warning-900 dark:bg-warning-950/50 dark:text-warning-100' },
179
+ { variant: 'soft', color: 'danger',
180
+ class: 'bg-danger-100 text-danger-900 dark:bg-danger-950/50 dark:text-danger-100' },
181
+ { variant: 'soft', color: 'info',
182
+ class: 'bg-info-100 text-info-900 dark:bg-info-950/50 dark:text-info-100' },
183
+ { variant: 'soft', color: 'neutral',
184
+ class: 'bg-neutral-100 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100' },
185
+
186
+ // ─── solid × color ────────────────────────────────────────────────
187
+ { variant: 'solid', color: 'primary',
188
+ class: 'bg-primary-600 text-white dark:bg-primary-500' },
189
+ { variant: 'solid', color: 'secondary',
190
+ class: 'bg-secondary-600 text-white dark:bg-secondary-500' },
191
+ { variant: 'solid', color: 'success',
192
+ class: 'bg-success-600 text-white dark:bg-success-500' },
193
+ { variant: 'solid', color: 'warning',
194
+ class: 'bg-warning-500 text-white dark:bg-warning-600' },
195
+ { variant: 'solid', color: 'danger',
196
+ class: 'bg-danger-600 text-white dark:bg-danger-500' },
197
+ { variant: 'solid', color: 'info',
198
+ class: 'bg-info-600 text-white dark:bg-info-500' },
199
+ { variant: 'solid', color: 'neutral',
200
+ class: 'bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-900' },
201
+ ],
202
+
203
+ defaultVariants: {
204
+ variant: 'plain',
205
+ color: 'neutral',
206
+ elevation: 0,
207
+ rounded: 'none',
208
+ },
209
+ });
210
+
211
+ export type BoxVariants = VariantProps<typeof boxVariants>;
@@ -109,6 +109,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
109
109
  sx
110
110
  );
111
111
 
112
+ /*
113
+ * `aria-busy` announces the loading state to assistive tech.
114
+ * `disabled` alone hides the reason (perm-denied vs loading vs
115
+ * intrinsic), so SR users only hear "dimmed/inactive" without
116
+ * knowing why. Adding `aria-busy={true}` while loading distinguishes
117
+ * "wait for the action to finish" from "you can't do this".
118
+ */
119
+ const ariaBusy = loading ? true : undefined;
120
+
112
121
  // `asChild` renders through Radix Slot: the immediate child element
113
122
  // gets the resolved className, no extra Button DOM is emitted.
114
123
  if (asChild) {
@@ -118,6 +127,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
118
127
  className={classes}
119
128
  data-disabled={effectiveDisabled || undefined}
120
129
  aria-disabled={effectiveDisabled || undefined}
130
+ aria-busy={ariaBusy}
121
131
  >
122
132
  {children}
123
133
  </Slot>
@@ -129,6 +139,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
129
139
  ref={ref}
130
140
  type={rest.type ?? 'button'}
131
141
  disabled={effectiveDisabled}
142
+ aria-busy={ariaBusy}
132
143
  className={classes}
133
144
  {...rest}
134
145
  >
@@ -193,11 +193,35 @@ export function Checkbox(props: CheckboxProps) {
193
193
  ref={registration?.ref as React.Ref<HTMLButtonElement> | undefined}
194
194
  className={cn(v.control(), slotProps?.control?.className)}
195
195
  >
196
+ {/*
197
+ * Radix.Indicator natively mounts only when `data-state` is
198
+ * `checked` or `indeterminate` — i.e. it tracks Radix's
199
+ * internal state directly, no React state dependency.
200
+ *
201
+ * The previous implementation used `forceMount` + a React
202
+ * conditional `{resolvedChecked === true ? <CheckIcon /> : null}`,
203
+ * which broke standalone uncontrolled mode: Radix would flip
204
+ * its internal `data-state` on click (turning the control blue
205
+ * via `data-[state=checked]:bg-primary-500`) but the React
206
+ * snapshot for `resolvedChecked` stayed stale, so the
207
+ * `<CheckIcon />` never mounted. Result: blue box with no
208
+ * tick after user interaction.
209
+ *
210
+ * Dropping forceMount + the conditional defers the mount
211
+ * decision to Radix (the single source of truth in all three
212
+ * modes — controlled, uncontrolled, bridge). Indicator mounts
213
+ * exactly when the checkbox is checked OR indeterminate.
214
+ *
215
+ * Indeterminate caveat: this renders the check glyph for
216
+ * BOTH checked AND indeterminate states. Previously
217
+ * indeterminate rendered nothing inside the blue square
218
+ * (same level of broken). A future improvement would render
219
+ * a dash for indeterminate — out of scope here.
220
+ */}
196
221
  <RadixCheckbox.Indicator
197
222
  className={cn(v.indicator(), slotProps?.indicator?.className)}
198
- forceMount
199
223
  >
200
- {resolvedChecked === true ? <CheckIcon className="h-full w-full" /> : null}
224
+ <CheckIcon className="h-full w-full" />
201
225
  </RadixCheckbox.Indicator>
202
226
  </RadixCheckbox.Root>
203
227
 
@@ -0,0 +1,209 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { render } from '@testing-library/react';
4
+ import { Container } from './Container.js';
5
+
6
+ describe('<Container>', () => {
7
+ // ─── Default rendering ──────────────────────────────────────────────
8
+ describe('default rendering', () => {
9
+ it('renders a <div> by default', () => {
10
+ const { container } = render(<Container>x</Container>);
11
+ expect(container.firstElementChild?.tagName).toBe('DIV');
12
+ });
13
+
14
+ it('has mx-auto + w-full baseline', () => {
15
+ const { container } = render(<Container>x</Container>);
16
+ const cls = container.firstElementChild?.className ?? '';
17
+ expect(cls).toContain('mx-auto');
18
+ expect(cls).toContain('w-full');
19
+ });
20
+
21
+ it('default size is xl (max-w-screen-xl)', () => {
22
+ const { container } = render(<Container>x</Container>);
23
+ expect(container.firstElementChild?.className).toContain('max-w-screen-xl');
24
+ });
25
+
26
+ it('default px is true — responsive padding ramp', () => {
27
+ const { container } = render(<Container>x</Container>);
28
+ const cls = container.firstElementChild?.className ?? '';
29
+ expect(cls).toContain('px-4');
30
+ expect(cls).toContain('sm:px-6');
31
+ expect(cls).toContain('lg:px-8');
32
+ });
33
+
34
+ it('default centerContent is false — no flex column', () => {
35
+ const { container } = render(<Container>x</Container>);
36
+ const cls = container.firstElementChild?.className ?? '';
37
+ // baseline doesn't include flex
38
+ expect(cls.split(' ')).not.toContain('flex');
39
+ });
40
+ });
41
+
42
+ // ─── Size axis ──────────────────────────────────────────────────────
43
+ describe('size', () => {
44
+ it('size="sm"', () => {
45
+ const { container } = render(<Container size="sm">x</Container>);
46
+ expect(container.firstElementChild?.className).toContain('max-w-screen-sm');
47
+ });
48
+
49
+ it('size="lg"', () => {
50
+ const { container } = render(<Container size="lg">x</Container>);
51
+ expect(container.firstElementChild?.className).toContain('max-w-screen-lg');
52
+ });
53
+
54
+ it('size="2xl"', () => {
55
+ const { container } = render(<Container size="2xl">x</Container>);
56
+ expect(container.firstElementChild?.className).toContain('max-w-screen-2xl');
57
+ });
58
+
59
+ it('size="fluid" emits no max-w-* class', () => {
60
+ const { container } = render(<Container size="fluid">x</Container>);
61
+ const cls = container.firstElementChild?.className ?? '';
62
+ expect(cls).not.toContain('max-w-screen');
63
+ // still mx-auto + w-full baseline
64
+ expect(cls).toContain('mx-auto');
65
+ });
66
+ });
67
+
68
+ // ─── Padding axis ───────────────────────────────────────────────────
69
+ describe('padding', () => {
70
+ it('px={false} skips the responsive padding ramp', () => {
71
+ const { container } = render(<Container px={false}>x</Container>);
72
+ const cls = container.firstElementChild?.className ?? '';
73
+ expect(cls).not.toContain('px-4');
74
+ expect(cls).not.toContain('sm:px-6');
75
+ });
76
+ });
77
+
78
+ // ─── Center content ─────────────────────────────────────────────────
79
+ describe('centerContent', () => {
80
+ it('centerContent turns into flex column items-center', () => {
81
+ const { container } = render(<Container centerContent>x</Container>);
82
+ const cls = container.firstElementChild?.className ?? '';
83
+ expect(cls).toContain('flex');
84
+ expect(cls).toContain('flex-col');
85
+ expect(cls).toContain('items-center');
86
+ });
87
+ });
88
+
89
+ // ─── Override semantics ─────────────────────────────────────────────
90
+ describe('override', () => {
91
+ it('sx wins over default size via tailwind-merge', () => {
92
+ const { container } = render(
93
+ <Container sx="max-w-3xl">x</Container>,
94
+ );
95
+ const cls = container.firstElementChild?.className ?? '';
96
+ expect(cls).toContain('max-w-3xl');
97
+ // tailwind-merge collapses the conflicting max-w-screen-xl
98
+ expect(cls).not.toContain('max-w-screen-xl');
99
+ });
100
+
101
+ it('sx can add vertical padding (Container does not expose it)', () => {
102
+ const { container } = render(<Container sx="py-12">x</Container>);
103
+ expect(container.firstElementChild?.className).toContain('py-12');
104
+ });
105
+ });
106
+
107
+ // ─── Polymorphism ───────────────────────────────────────────────────
108
+ describe('polymorphism', () => {
109
+ it('as="main" renders <main> with Container chrome', () => {
110
+ const { container } = render(<Container as="main">x</Container>);
111
+ const el = container.firstElementChild;
112
+ expect(el?.tagName).toBe('MAIN');
113
+ expect(el?.className).toContain('mx-auto');
114
+ });
115
+
116
+ it('asChild paints onto the child element', () => {
117
+ const { container } = render(
118
+ <Container size="lg" asChild>
119
+ <article>x</article>
120
+ </Container>,
121
+ );
122
+ const el = container.firstElementChild;
123
+ expect(el?.tagName).toBe('ARTICLE');
124
+ expect(el?.className).toContain('max-w-screen-lg');
125
+ });
126
+
127
+ it('asChild wins over as when both are passed', () => {
128
+ const { container } = render(
129
+ <Container as="main" asChild>
130
+ <section>x</section>
131
+ </Container>,
132
+ );
133
+ expect(container.firstElementChild?.tagName).toBe('SECTION');
134
+ });
135
+ });
136
+
137
+ // ─── Pass-through ────────────────────────────────────────────────────
138
+ describe('pass-through', () => {
139
+ it('forwards data-* + aria-*', () => {
140
+ const { container } = render(
141
+ <Container data-testid="c" aria-label="page">x</Container>,
142
+ );
143
+ const el = container.firstElementChild;
144
+ expect(el?.getAttribute('data-testid')).toBe('c');
145
+ expect(el?.getAttribute('aria-label')).toBe('page');
146
+ });
147
+ });
148
+
149
+ // ─── F11-bis edge cases ─────────────────────────────────────────────
150
+ describe('axis coexistence', () => {
151
+ it('size + centerContent + px=true (all three combine)', () => {
152
+ const { container } = render(
153
+ <Container size="md" centerContent px>x</Container>,
154
+ );
155
+ const cls = container.firstElementChild?.className ?? '';
156
+ expect(cls).toContain('max-w-screen-md');
157
+ expect(cls).toContain('flex');
158
+ expect(cls).toContain('flex-col');
159
+ expect(cls).toContain('items-center');
160
+ expect(cls).toContain('px-4');
161
+ });
162
+
163
+ it('fluid + centerContent (full-bleed centered hero pattern)', () => {
164
+ const { container } = render(
165
+ <Container size="fluid" centerContent>x</Container>,
166
+ );
167
+ const cls = container.firstElementChild?.className ?? '';
168
+ expect(cls).not.toContain('max-w-screen');
169
+ expect(cls).toContain('flex-col');
170
+ expect(cls).toContain('items-center');
171
+ });
172
+
173
+ it('fluid + px=false (full-bleed edge-to-edge — hero image pattern)', () => {
174
+ const { container } = render(
175
+ <Container size="fluid" px={false}>x</Container>,
176
+ );
177
+ const cls = container.firstElementChild?.className ?? '';
178
+ expect(cls).not.toContain('max-w-screen');
179
+ expect(cls).not.toContain('px-4');
180
+ // still has mx-auto + w-full baseline
181
+ expect(cls).toContain('mx-auto');
182
+ });
183
+ });
184
+
185
+ describe('nested fluid-outer + capped-inner pattern', () => {
186
+ it('outer fluid colored band + inner capped content renders both', () => {
187
+ const { container } = render(
188
+ <Container size="fluid" px={false} sx="bg-primary-50 py-16">
189
+ <Container size="lg">
190
+ <p>inner</p>
191
+ </Container>
192
+ </Container>,
193
+ );
194
+ const outer = container.firstElementChild;
195
+ const inner = outer?.querySelector('div');
196
+ expect(outer?.className).toContain('bg-primary-50');
197
+ expect(outer?.className).not.toContain('max-w-screen');
198
+ expect(inner?.className).toContain('max-w-screen-lg');
199
+ });
200
+ });
201
+
202
+ describe('every size value', () => {
203
+ const SIZES = ['sm', 'md', 'lg', 'xl', '2xl'] as const;
204
+ it.each(SIZES)('size="%s" emits max-w-screen-%s', (s) => {
205
+ const { container } = render(<Container size={s}>x</Container>);
206
+ expect(container.firstElementChild?.className).toContain(`max-w-screen-${s}`);
207
+ });
208
+ });
209
+ });