@duro-app/ui 2.0.1 → 3.1.0

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 (85) hide show
  1. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  2. package/dist/components/Alert/Alert.stories.d.ts.map +1 -1
  3. package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
  4. package/dist/components/Button/Button.stories.d.ts.map +1 -1
  5. package/dist/components/Callout/Callout.stories.d.ts.map +1 -1
  6. package/dist/components/Card/Card.stories.d.ts.map +1 -1
  7. package/dist/components/Checkbox/Checkbox.stories.d.ts.map +1 -1
  8. package/dist/components/Cluster/Cluster.stories.d.ts.map +1 -1
  9. package/dist/components/ColorMode/ColorMode.stories.d.ts.map +1 -1
  10. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  11. package/dist/components/Grid/Grid.d.ts +19 -2
  12. package/dist/components/Grid/Grid.d.ts.map +1 -1
  13. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  14. package/dist/components/Grid/Grid.stories.d.ts +16 -0
  15. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  16. package/dist/components/Grid/columns.d.ts +27 -0
  17. package/dist/components/Grid/columns.d.ts.map +1 -0
  18. package/dist/components/Grid/styles.css.d.ts +77 -0
  19. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  20. package/dist/components/Inline/Inline.stories.d.ts.map +1 -1
  21. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  22. package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
  23. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  24. package/dist/components/List/List.stories.d.ts.map +1 -1
  25. package/dist/components/PageShell/PageShell.meta.d.ts.map +1 -1
  26. package/dist/components/ScrollArea/ScrollArea.stories.d.ts.map +1 -1
  27. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  28. package/dist/components/Spinner/Spinner.stories.d.ts.map +1 -1
  29. package/dist/components/Stack/Stack.stories.d.ts.map +1 -1
  30. package/dist/components/StatusIcon/StatusIcon.stories.d.ts.map +1 -1
  31. package/dist/components/Switch/Switch.stories.d.ts.map +1 -1
  32. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  33. package/dist/components/Tabs/Tabs.meta.d.ts.map +1 -1
  34. package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
  35. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  36. package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
  37. package/dist/components/Toggle/Toggle.stories.d.ts.map +1 -1
  38. package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
  39. package/dist/components/Tooltip/Tooltip.stories.d.ts.map +1 -1
  40. package/dist/docs/Spacing.stories.d.ts.map +1 -1
  41. package/dist/docs/Typography.stories.d.ts.map +1 -1
  42. package/dist/docs/helpers.d.ts.map +1 -1
  43. package/dist/index.css +1 -1
  44. package/dist/index.js +524 -398
  45. package/dist/index.js.map +1 -1
  46. package/package.json +2 -2
  47. package/src/components/ActionBar/ActionBar.stories.tsx +7 -5
  48. package/src/components/Alert/Alert.stories.tsx +2 -1
  49. package/src/components/Badge/Badge.stories.tsx +3 -2
  50. package/src/components/Button/Button.stories.tsx +3 -2
  51. package/src/components/Callout/Callout.stories.tsx +2 -1
  52. package/src/components/Card/Card.stories.tsx +5 -3
  53. package/src/components/Checkbox/Checkbox.stories.tsx +2 -1
  54. package/src/components/Cluster/Cluster.stories.tsx +4 -3
  55. package/src/components/ColorMode/ColorMode.stories.tsx +2 -1
  56. package/src/components/Field/Field.stories.tsx +3 -2
  57. package/src/components/Grid/Grid.meta.ts +20 -2
  58. package/src/components/Grid/Grid.stories.tsx +106 -4
  59. package/src/components/Grid/Grid.tsx +134 -9
  60. package/src/components/Grid/columns.ts +58 -0
  61. package/src/components/Grid/styles.css.ts +85 -0
  62. package/src/components/Inline/Inline.stories.tsx +5 -4
  63. package/src/components/Input/Input.stories.tsx +2 -1
  64. package/src/components/InputGroup/InputGroup.stories.tsx +2 -1
  65. package/src/components/LinkButton/LinkButton.stories.tsx +3 -2
  66. package/src/components/List/List.stories.tsx +2 -1
  67. package/src/components/PageShell/PageShell.meta.ts +12 -1
  68. package/src/components/ScrollArea/ScrollArea.stories.tsx +12 -9
  69. package/src/components/SideNav/SideNav.meta.ts +5 -0
  70. package/src/components/Spinner/Spinner.stories.tsx +2 -1
  71. package/src/components/Stack/Stack.stories.tsx +5 -4
  72. package/src/components/StatusIcon/StatusIcon.stories.tsx +11 -4
  73. package/src/components/Switch/Switch.stories.tsx +2 -1
  74. package/src/components/Table/Table.stories.tsx +7 -2
  75. package/src/components/Tabs/Tabs.meta.ts +5 -0
  76. package/src/components/Tag/Tag.stories.tsx +11 -3
  77. package/src/components/Textarea/Textarea.stories.tsx +2 -1
  78. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +9 -9
  79. package/src/components/Toast/Toast.stories.tsx +9 -2
  80. package/src/components/Toggle/Toggle.stories.tsx +2 -1
  81. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +2 -1
  82. package/src/components/Tooltip/Tooltip.stories.tsx +3 -2
  83. package/src/docs/Spacing.stories.tsx +12 -11
  84. package/src/docs/Typography.stories.tsx +4 -3
  85. package/src/docs/helpers.tsx +5 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "2.0.1",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@tanstack/react-virtual": "^3.14.6",
67
- "@duro-app/tokens": "^2.0.1"
67
+ "@duro-app/tokens": "^3.1.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@babel/preset-typescript": "^7.28.0",
@@ -6,6 +6,8 @@ import {ActionBar} from './ActionBar'
6
6
  import {Button} from '../Button/Button'
7
7
  import {Checkbox} from '../Checkbox/Checkbox'
8
8
  import {Table} from '../Table/Table'
9
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
10
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
9
11
 
10
12
  interface ActionBarStoryArgs {
11
13
  onClearSelection: () => void
@@ -40,16 +42,16 @@ const storyStyles = css.create({
40
42
  wrapper: {
41
43
  display: 'flex',
42
44
  flexDirection: 'column',
43
- gap: 32,
44
- padding: 24,
45
+ gap: spacing.xl,
46
+ padding: spacing.lg,
45
47
  },
46
48
  label: {
47
- fontSize: '0.75rem',
48
- fontWeight: 600,
49
+ fontSize: typography.fontSizeXs,
50
+ fontWeight: typography.fontWeightSemibold,
49
51
  textTransform: 'uppercase' as const,
50
52
  letterSpacing: '0.05em',
51
53
  opacity: 0.6,
52
- marginBottom: 8,
54
+ marginBottom: spacing.sm,
53
55
  },
54
56
  })
55
57
 
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Alert} from './Alert'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
6
 
6
7
  const meta: Meta<typeof Alert> = {
7
8
  title: 'Components/Alert',
@@ -57,7 +58,7 @@ export const NoIcon: Story = {
57
58
  }
58
59
 
59
60
  const stackStyles = css.create({
60
- stack: {display: 'flex', flexDirection: 'column', gap: 12},
61
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.ms},
61
62
  })
62
63
 
63
64
  export const AllVariants: Story = {
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Badge} from './Badge'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
6
 
6
7
  const meta: Meta<typeof Badge> = {
7
8
  title: 'Components/Badge',
@@ -52,8 +53,8 @@ export const Small: Story = {
52
53
  }
53
54
 
54
55
  const stackStyles = css.create({
55
- row: {display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'},
56
- stack: {display: 'flex', flexDirection: 'column', gap: 16},
56
+ row: {display: 'flex', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap'},
57
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.md},
57
58
  })
58
59
 
59
60
  export const AllVariants: Story = {
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Button} from './Button'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
6
 
6
7
  const meta: Meta<typeof Button> = {
7
8
  title: 'Components/Button',
@@ -83,8 +84,8 @@ export const Disabled: Story = {
83
84
  }
84
85
 
85
86
  const rowStyles = css.create({
86
- row: {display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap'},
87
- stack: {display: 'flex', flexDirection: 'column', gap: 16},
87
+ row: {display: 'flex', gap: spacing.ms, alignItems: 'center', flexWrap: 'wrap'},
88
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.md},
88
89
  })
89
90
 
90
91
  export const AllVariants: Story = {
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Callout} from './Callout'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
6
 
6
7
  const meta: Meta<typeof Callout> = {
7
8
  title: 'Components/Callout',
@@ -82,7 +83,7 @@ export const NoIcon: Story = {
82
83
  }
83
84
 
84
85
  const stackStyles = css.create({
85
- stack: {display: 'flex', flexDirection: 'column', gap: 12},
86
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.ms},
86
87
  })
87
88
 
88
89
  export const AllVariants: Story = {
@@ -3,6 +3,8 @@ import {expect} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Card} from './Card'
5
5
  import {colors} from '@duro-app/tokens/tokens/colors.css'
6
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
7
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
6
8
 
7
9
  const meta: Meta<typeof Card> = {
8
10
  title: 'Components/Card',
@@ -85,10 +87,10 @@ const gridStyles = css.create({
85
87
  grid: {
86
88
  display: 'grid',
87
89
  gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
88
- gap: 16,
90
+ gap: spacing.md,
89
91
  },
90
- stack: {display: 'flex', flexDirection: 'column', gap: 16},
91
- muted: {color: colors.textMuted, fontSize: '0.875rem'},
92
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.md},
93
+ muted: {color: colors.textMuted, fontSize: typography.fontSizeSm},
92
94
  })
93
95
 
94
96
  export const AllVariants: Story = {
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Checkbox} from './Checkbox'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
6
 
6
7
  const meta: Meta<typeof Checkbox> = {
7
8
  title: 'Components/Checkbox',
@@ -67,7 +68,7 @@ export const WithoutLabel: Story = {
67
68
  }
68
69
 
69
70
  const stackStyles = css.create({
70
- stack: {display: 'flex', flexDirection: 'column', gap: 12},
71
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.ms},
71
72
  })
72
73
 
73
74
  export const AllVariants: Story = {
@@ -2,8 +2,9 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {css, html} from 'react-strict-dom'
3
3
  import {Cluster} from './Cluster'
4
4
  import {colors} from '@duro-app/tokens/tokens/colors.css'
5
- import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
6
6
  import {SPACING_KEYS} from '@duro-app/tokens/keys'
7
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
7
8
 
8
9
  const meta: Meta<typeof Cluster> = {
9
10
  title: 'Layout/Cluster',
@@ -35,8 +36,8 @@ const localStyles = css.create({
35
36
  paddingBottom: spacing.xs,
36
37
  paddingLeft: spacing.sm,
37
38
  paddingRight: spacing.sm,
38
- borderRadius: 12,
39
- fontSize: '0.75rem',
39
+ borderRadius: radii.md,
40
+ fontSize: typography.fontSizeXs,
40
41
  whiteSpace: 'nowrap',
41
42
  },
42
43
  narrow: {
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {ColorModeProvider, ColorModeToggle, useColorMode} from './ColorMode'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
6
 
6
7
  const meta: Meta<typeof ColorModeToggle> = {
7
8
  title: 'Components/ColorMode',
@@ -58,7 +59,7 @@ export const AllModes: Story = {
58
59
  }
59
60
 
60
61
  const readout = css.create({
61
- row: {display: 'flex', alignItems: 'center', gap: 12},
62
+ row: {display: 'flex', alignItems: 'center', gap: spacing.ms},
62
63
  })
63
64
 
64
65
  function Readout() {
@@ -3,6 +3,7 @@ import {expect} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {Field} from './Field'
5
5
  import {Input} from '../Input/Input'
6
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
6
7
 
7
8
  const meta: Meta = {
8
9
  title: 'Components/Field',
@@ -62,8 +63,8 @@ export const WithError: Story = {
62
63
  }
63
64
 
64
65
  const stackStyles = css.create({
65
- stack: {display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400},
66
- stackWide: {display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 600},
66
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.md, maxWidth: 400},
67
+ stackWide: {display: 'flex', flexDirection: 'column', gap: spacing.md, maxWidth: 600},
67
68
  })
68
69
 
69
70
  export const FormExample: Story = {
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'CSS grid layout. Supports fixed column count (1-6) or responsive auto-fit via minColumnWidth.',
5
+ 'Grid layout. Columns are a count (1-6), a list of weights ([1, 2] for one-third / two-thirds), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses to one column on the width of its own container, so it is safe to nest and to place beside a DetailPanel. Weights rather than CSS template strings, so the same props render as CSS grid on web and as a wrapping flex row on native.',
6
6
  whenToUse: [
7
7
  'Card grids, dashboard layouts, multi-column forms',
8
8
  'Responsive layouts that should auto-adjust column count',
9
+ 'A list/detail or nav/content screen — layout="split" | "split-wide", never a hand-rolled gridTemplateColumns with its own @media; a split inside a split is fine, each collapses on its own room',
9
10
  ],
10
11
  whenNotToUse: [
11
12
  'Single-column vertical layout — use Stack',
@@ -17,8 +18,19 @@ export const meta: ComponentMeta = {
17
18
  kind: 'contrast',
18
19
  relationship: 'Grid for multi-column layouts; Stack for a single vertical column',
19
20
  },
21
+ {
22
+ component: 'split-pane',
23
+ kind: 'composition',
24
+ relationship: 'Grid layout="split" with a List and a Panel is the split-pane recipe',
25
+ },
20
26
  ],
21
- example: `// Responsive: columns auto-fit based on min width
27
+ example: `// Split: list ≥ 240px beside a detail pane, one column below md
28
+ <Grid layout="split" gap="lg">
29
+ <List.Root>…</List.Root>
30
+ <Panel.Root>…</Panel.Root>
31
+ </Grid>
32
+
33
+ // Responsive: columns auto-fit based on min width
22
34
  <Grid minColumnWidth="280px" gap="md">
23
35
  <Card>A</Card>
24
36
  <Card>B</Card>
@@ -30,5 +42,11 @@ export const meta: ComponentMeta = {
30
42
  <Card>A</Card>
31
43
  <Card>B</Card>
32
44
  <Card>C</Card>
45
+ </Grid>
46
+
47
+ // Weighted: a one-third / two-thirds split (works on native too)
48
+ <Grid columns={[1, 2]} gap="md">
49
+ <Card>Sidebar</Card>
50
+ <Card>Content</Card>
33
51
  </Grid>`,
34
52
  }
@@ -1,11 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
2
3
  import {css, html} from 'react-strict-dom'
3
4
  import {Grid} from './Grid'
4
5
  import {Stack} from '../Stack/Stack'
5
6
  import {colors} from '@duro-app/tokens/tokens/colors.css'
6
- import {spacing} from '@duro-app/tokens/tokens/spacing.css'
7
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
7
8
  import {SPACING_KEYS} from '@duro-app/tokens/keys'
8
9
  import {useContainerQuery} from '../../hooks/useContainerQuery'
10
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
9
11
 
10
12
  const meta: Meta<typeof Grid> = {
11
13
  title: 'Layout/Grid',
@@ -18,8 +20,10 @@ const meta: Meta<typeof Grid> = {
18
20
  columns: {
19
21
  control: 'select',
20
22
  options: [1, 2, 3, 4, 5, 6],
23
+ description: 'A count, or weights such as [1, 2] for a one-third / two-thirds split',
21
24
  },
22
25
  minColumnWidth: {control: 'text'},
26
+ layout: {control: 'select', options: [undefined, 'split', 'split-wide']},
23
27
  },
24
28
  }
25
29
 
@@ -31,14 +35,20 @@ const localStyles = css.create({
31
35
  backgroundColor: colors.accent,
32
36
  color: colors.accentContrast,
33
37
  padding: spacing.md,
34
- borderRadius: 4,
38
+ borderRadius: radii.xs,
35
39
  textAlign: 'center',
36
- fontSize: '0.875rem',
40
+ fontSize: typography.fontSizeSm,
37
41
  },
38
42
  label: {
39
- fontSize: '0.75rem',
43
+ fontSize: typography.fontSizeXs,
40
44
  color: colors.textMuted,
41
45
  },
46
+ frame: (width: number) => ({
47
+ width,
48
+ borderWidth: 1,
49
+ borderStyle: 'dashed',
50
+ borderColor: colors.border,
51
+ }),
42
52
  })
43
53
 
44
54
  const Cell = ({children}: {children: string}) => (
@@ -83,6 +93,98 @@ export const TwoColumns: Story = {
83
93
  ),
84
94
  }
85
95
 
96
+ export const Split: Story = {
97
+ render: () => (
98
+ <Stack gap="sm">
99
+ <html.span style={localStyles.label}>
100
+ layout=&quot;split&quot; — list ≥ 240px beside the detail, one column when the container is
101
+ narrower than sm; split-wide collapses below md
102
+ </html.span>
103
+ <Grid layout="split" gap="lg">
104
+ <Cell>list</Cell>
105
+ <Cell>detail</Cell>
106
+ </Grid>
107
+ <Grid layout="split-wide" gap="lg">
108
+ <Cell>nav</Cell>
109
+ <Cell>content</Cell>
110
+ </Grid>
111
+ </Stack>
112
+ ),
113
+ }
114
+
115
+ /**
116
+ * A split inside a split: a list/detail board in the content column of a
117
+ * nav/content shell. Each collapses on its OWN container: at 1120 both split;
118
+ * at 900 the shell still splits (≥ md) while the board — left 588px, under
119
+ * sm — stacks; at 700 the shell stacks and the board, now given the whole
120
+ * width, splits again; at 600 both stack. Keyed on the viewport, both would
121
+ * open at 768 and leave the detail pane ~150px.
122
+ */
123
+ export const NestedSplits: Story = {
124
+ render: () => (
125
+ <Stack gap="lg">
126
+ {[1120, 900, 700, 600].map((width) => (
127
+ <Stack key={width} gap="sm">
128
+ <html.span style={localStyles.label}>frame {width}px</html.span>
129
+ <html.div style={localStyles.frame(width)} data-testid={`frame-${width}`}>
130
+ <Grid layout="split-wide" gap="xl">
131
+ <Cell>nav</Cell>
132
+ <Grid layout="split" gap="md">
133
+ <Cell>list</Cell>
134
+ <Cell>detail</Cell>
135
+ </Grid>
136
+ </Grid>
137
+ </html.div>
138
+ </Stack>
139
+ ))}
140
+ </Stack>
141
+ ),
142
+ play: async ({canvas}) => {
143
+ const tracks = (frame: HTMLElement, text: string) => {
144
+ const grid = canvas.getAllByText(text).find((el) => frame.contains(el))
145
+ ?.parentElement as HTMLElement
146
+ return getComputedStyle(grid).gridTemplateColumns.split(' ').length
147
+ }
148
+ const expected: Record<number, [number, number]> = {
149
+ 1120: [2, 2],
150
+ 900: [2, 1],
151
+ 700: [1, 2],
152
+ 600: [1, 1],
153
+ }
154
+ for (const [width, [outer, inner]] of Object.entries(expected)) {
155
+ const frame = canvas.getByTestId(`frame-${width}`)
156
+ await expect(tracks(frame, 'nav'), `shell at ${width}`).toBe(outer)
157
+ await expect(tracks(frame, 'list'), `board at ${width}`).toBe(inner)
158
+ }
159
+ },
160
+ }
161
+
162
+ /**
163
+ * Weighted columns: the portable form of `grid-template-columns: 1fr 2fr`.
164
+ * Weights are what the native Grid can honour too (as flex bases), where a
165
+ * CSS template string would be dropped.
166
+ */
167
+ export const WeightedColumns: Story = {
168
+ render: () => (
169
+ <Grid gap="md" columns={[1, 2]}>
170
+ <Cell>Sidebar (1)</Cell>
171
+ <Cell>Content (2)</Cell>
172
+ <Cell>Sidebar (1)</Cell>
173
+ <Cell>Content (2)</Cell>
174
+ </Grid>
175
+ ),
176
+ play: async ({canvas}) => {
177
+ // The grid is the cells' parent (Storybook wraps the story in its own
178
+ // divs). The browser resolves `1fr 2fr` to pixel tracks, so assert the
179
+ // ratio rather than the string.
180
+ const root = canvas.getAllByText('Sidebar (1)')[0].parentElement as HTMLElement
181
+ const tracks = getComputedStyle(root).gridTemplateColumns.split(' ').map(Number.parseFloat)
182
+ await expect(tracks).toHaveLength(2)
183
+ // Second track is twice the first, within a pixel of rounding.
184
+ await expect(Math.abs(tracks[1] - 2 * tracks[0])).toBeLessThan(1.5)
185
+ },
186
+ }
187
+
86
188
  export const WithContainerQuery: Story = {
87
189
  render: function Render() {
88
190
  const {ref, size} = useContainerQuery<HTMLDivElement>()
@@ -1,12 +1,31 @@
1
- import type {ReactNode} from 'react'
1
+ import {Children, type ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
+ import {isNative} from '../../platform'
5
+ import {cellBasis, cellPosition, columnWeights, gridTemplate, type GridColumns} from './columns'
4
6
  import type {SpacingToken} from '@duro-app/tokens/keys'
5
7
 
8
+ export type GridLayout = 'split' | 'split-wide'
9
+
6
10
  interface GridProps {
7
11
  gap?: SpacingToken
8
- columns?: 1 | 2 | 3 | 4 | 5 | 6
12
+ /** A column count (equal columns) or weights: `[1, 2]` gives a one-third /
13
+ * two-thirds split. Weights are the portable form of a track list — a CSS
14
+ * template string would be silently ignored on native. */
15
+ columns?: GridColumns
16
+ /** Responsive auto-fit: as many columns of at least this width as fit.
17
+ * Wins over `columns` when both are set. On native this approximates to
18
+ * cells of that minimum width that grow and wrap. */
9
19
  minColumnWidth?: string
20
+ /**
21
+ * A named responsive layout. `split` is list/detail (1:2, list ≥ 240px),
22
+ * `split-wide` is nav/content (1:3, nav ≥ 280px). Each collapses to one
23
+ * column on the width of its OWN container — `split` below `sm` (640px),
24
+ * `split-wide` below `md` (768px) — so a split nested inside another
25
+ * split, or beside an open DetailPanel, collapses when it runs out of room
26
+ * rather than when the window does. Wins over `columns` / `minColumnWidth`.
27
+ */
28
+ layout?: GridLayout
10
29
  children: ReactNode
11
30
  }
12
31
 
@@ -21,6 +40,8 @@ const gapMap = {
21
40
  xxxl: styles.gapXxxl,
22
41
  } as const satisfies Record<SpacingToken, unknown>
23
42
 
43
+ // Fixed counts keep their static classes so existing web consumers emit the
44
+ // exact CSS they did before; only weight lists go through the dynamic style.
24
45
  const columnsMap = {
25
46
  1: styles.col1,
26
47
  2: styles.col2,
@@ -30,12 +51,116 @@ const columnsMap = {
30
51
  6: styles.col6,
31
52
  } as const
32
53
 
33
- export function Grid({gap = 'md', columns, minColumnWidth, children}: GridProps) {
34
- const columnStyle = minColumnWidth
35
- ? styles.autoFit(minColumnWidth)
36
- : columns
37
- ? columnsMap[columns]
38
- : undefined
54
+ const layoutMap = {
55
+ split: styles.split,
56
+ 'split-wide': styles.splitWide,
57
+ } as const satisfies Record<GridLayout, unknown>
58
+
59
+ // On native the named layouts are their weights; there is no media query to
60
+ // collapse them, so a split stays a split on a phone (see NativeGrid).
61
+ const layoutWeights = {
62
+ split: [1, 2],
63
+ 'split-wide': [1, 3],
64
+ } as const satisfies Record<GridLayout, readonly number[]>
65
+
66
+ const cellGapLeftMap = {
67
+ xs: styles.cellGapLeftXs,
68
+ sm: styles.cellGapLeftSm,
69
+ ms: styles.cellGapLeftMs,
70
+ md: styles.cellGapLeftMd,
71
+ lg: styles.cellGapLeftLg,
72
+ xl: styles.cellGapLeftXl,
73
+ xxl: styles.cellGapLeftXxl,
74
+ xxxl: styles.cellGapLeftXxxl,
75
+ } as const satisfies Record<SpacingToken, unknown>
76
+
77
+ const cellGapTopMap = {
78
+ xs: styles.cellGapTopXs,
79
+ sm: styles.cellGapTopSm,
80
+ ms: styles.cellGapTopMs,
81
+ md: styles.cellGapTopMd,
82
+ lg: styles.cellGapTopLg,
83
+ xl: styles.cellGapTopXl,
84
+ xxl: styles.cellGapTopXxl,
85
+ xxxl: styles.cellGapTopXxxl,
86
+ } as const satisfies Record<SpacingToken, unknown>
87
+
88
+ export function Grid({gap = 'md', columns, minColumnWidth, layout, children}: GridProps) {
89
+ if (isNative) {
90
+ return (
91
+ <NativeGrid
92
+ gap={gap}
93
+ columns={layout ? layoutWeights[layout] : columns}
94
+ minColumnWidth={layout ? undefined : minColumnWidth}
95
+ >
96
+ {children}
97
+ </NativeGrid>
98
+ )
99
+ }
100
+
101
+ const columnStyle = layout
102
+ ? layoutMap[layout]
103
+ : minColumnWidth
104
+ ? styles.autoFit(minColumnWidth)
105
+ : typeof columns === 'number'
106
+ ? columnsMap[columns]
107
+ : columns
108
+ ? styles.template(gridTemplate(columnWeights(columns) ?? [1]))
109
+ : undefined
110
+
111
+ const grid = <html.div style={[styles.base, gapMap[gap], columnStyle]}>{children}</html.div>
112
+ // A named layout answers its container query from a wrapper: an element
113
+ // cannot query its own size.
114
+ return layout ? <html.div style={styles.splitContainer}>{grid}</html.div> : grid
115
+ }
39
116
 
40
- return <html.div style={[styles.base, gapMap[gap], columnStyle]}>{children}</html.div>
117
+ /**
118
+ * React Native has no grid, so the native Grid is a wrapping flex row with
119
+ * each child in a cell:
120
+ *
121
+ * - Weighted / counted columns: the cell's `flexBasis` is its column's share
122
+ * of the row. Percentages plus a real `gap` would overflow and wrap early,
123
+ * so the gap is padding on the cell instead — left on every cell that does
124
+ * not open a row, top on every row after the first. RN is border-box, so
125
+ * the bases still sum to exactly 100%, and the gap stays a token.
126
+ * - `minColumnWidth`: cells start at that width and grow, and the row uses a
127
+ * real `gap`, since here wrapping is width-driven and row membership is
128
+ * not known from the index.
129
+ *
130
+ * Only this branch wraps children; on web grid children stay direct, so
131
+ * existing DOM trees are untouched.
132
+ */
133
+ function NativeGrid({gap = 'md', columns, minColumnWidth, children}: GridProps) {
134
+ const items = Children.toArray(children)
135
+ if (minColumnWidth) {
136
+ return (
137
+ <html.div style={[styles.nativeRow, gapMap[gap]]}>
138
+ {items.map((child, index) => (
139
+ <html.div key={index} style={styles.nativeMinCell(minColumnWidth)}>
140
+ {child}
141
+ </html.div>
142
+ ))}
143
+ </html.div>
144
+ )
145
+ }
146
+ const weights = columnWeights(columns) ?? [1]
147
+ return (
148
+ <html.div style={styles.nativeRow}>
149
+ {items.map((child, index) => {
150
+ const {opensRow, firstRow} = cellPosition(index, weights.length)
151
+ return (
152
+ <html.div
153
+ key={index}
154
+ style={[
155
+ styles.nativeCell(cellBasis(weights, index)),
156
+ !opensRow && cellGapLeftMap[gap],
157
+ !firstRow && cellGapTopMap[gap],
158
+ ]}
159
+ >
160
+ {child}
161
+ </html.div>
162
+ )
163
+ })}
164
+ </html.div>
165
+ )
41
166
  }
@@ -0,0 +1,58 @@
1
+ // Pure column arithmetic behind Grid. No react-strict-dom, no platform: this
2
+ // is what the unit project tests, and what both the web and native renderers
3
+ // derive their styles from.
4
+
5
+ export type GridColumnCount = 1 | 2 | 3 | 4 | 5 | 6
6
+
7
+ /**
8
+ * Column spec: a count (equal columns) or a list of weights (`[1, 2]` is a
9
+ * one-third / two-thirds split). Weights are the portable form of
10
+ * `grid-template-columns: 1fr 2fr` — the only shape both runtimes can
11
+ * honour, since React Native has no grid.
12
+ */
13
+ export type GridColumns = GridColumnCount | readonly number[]
14
+
15
+ /** Normalise a column spec to weights; `undefined` when no spec was given
16
+ * (the caller then falls back to auto-fit or a single column). An invalid
17
+ * weight list (empty, non-finite or non-positive entries) is a programming
18
+ * error, reported in development and treated as one column. */
19
+ export function columnWeights(columns: GridColumns | undefined): readonly number[] | undefined {
20
+ if (columns === undefined) return undefined
21
+ if (typeof columns === 'number') return Array.from({length: columns}, () => 1)
22
+ const valid = columns.length > 0 && columns.every((w) => Number.isFinite(w) && w > 0)
23
+ if (!valid) {
24
+ if (process.env.NODE_ENV !== 'production') {
25
+ console.error(
26
+ `Grid: \`columns\` must be a non-empty list of positive numbers, got ${JSON.stringify(columns)}. Falling back to one column.`,
27
+ )
28
+ }
29
+ return [1]
30
+ }
31
+ return columns
32
+ }
33
+
34
+ /** Web track list: `[1, 2]` → `"1fr 2fr"`. */
35
+ export function gridTemplate(weights: readonly number[]): string {
36
+ return weights.map((w) => `${w}fr`).join(' ')
37
+ }
38
+
39
+ /** Native cell basis for the child at `index`: its column's share of the row
40
+ * as a percentage string Yoga accepts (`"33.3333%"`). Children flow into
41
+ * columns in order and wrap, exactly like grid auto-placement. */
42
+ export function cellBasis(weights: readonly number[], index: number): string {
43
+ const total = weights.reduce((sum, w) => sum + w, 0)
44
+ const share = (weights[index % weights.length] / total) * 100
45
+ // Four decimals keeps a row summing to 100% within Yoga's tolerance while
46
+ // never exceeding it (which would wrap the last cell).
47
+ return `${Math.floor(share * 10000) / 10000}%`
48
+ }
49
+
50
+ /** Where the child at `index` sits in an `columnCount`-wide flow: whether it
51
+ * opens a row (no gap to its left) and whether it is on the first row (no
52
+ * gap above). Drives the native gap-as-padding scheme. */
53
+ export function cellPosition(
54
+ index: number,
55
+ columnCount: number,
56
+ ): {opensRow: boolean; firstRow: boolean} {
57
+ return {opensRow: index % columnCount === 0, firstRow: index < columnCount}
58
+ }