@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.
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/Alert/Alert.stories.d.ts.map +1 -1
- package/dist/components/Badge/Badge.stories.d.ts.map +1 -1
- package/dist/components/Button/Button.stories.d.ts.map +1 -1
- package/dist/components/Callout/Callout.stories.d.ts.map +1 -1
- package/dist/components/Card/Card.stories.d.ts.map +1 -1
- package/dist/components/Checkbox/Checkbox.stories.d.ts.map +1 -1
- package/dist/components/Cluster/Cluster.stories.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.stories.d.ts.map +1 -1
- package/dist/components/Field/Field.stories.d.ts.map +1 -1
- package/dist/components/Grid/Grid.d.ts +19 -2
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +16 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/columns.d.ts +27 -0
- package/dist/components/Grid/columns.d.ts.map +1 -0
- package/dist/components/Grid/styles.css.d.ts +77 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Inline/Inline.stories.d.ts.map +1 -1
- package/dist/components/Input/Input.stories.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/List/List.stories.d.ts.map +1 -1
- package/dist/components/PageShell/PageShell.meta.d.ts.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.stories.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
- package/dist/components/Spinner/Spinner.stories.d.ts.map +1 -1
- package/dist/components/Stack/Stack.stories.d.ts.map +1 -1
- package/dist/components/StatusIcon/StatusIcon.stories.d.ts.map +1 -1
- package/dist/components/Switch/Switch.stories.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Tabs/Tabs.meta.d.ts.map +1 -1
- package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
- package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.stories.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.stories.d.ts.map +1 -1
- package/dist/components/Tooltip/Tooltip.stories.d.ts.map +1 -1
- package/dist/docs/Spacing.stories.d.ts.map +1 -1
- package/dist/docs/Typography.stories.d.ts.map +1 -1
- package/dist/docs/helpers.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +524 -398
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.stories.tsx +7 -5
- package/src/components/Alert/Alert.stories.tsx +2 -1
- package/src/components/Badge/Badge.stories.tsx +3 -2
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Callout/Callout.stories.tsx +2 -1
- package/src/components/Card/Card.stories.tsx +5 -3
- package/src/components/Checkbox/Checkbox.stories.tsx +2 -1
- package/src/components/Cluster/Cluster.stories.tsx +4 -3
- package/src/components/ColorMode/ColorMode.stories.tsx +2 -1
- package/src/components/Field/Field.stories.tsx +3 -2
- package/src/components/Grid/Grid.meta.ts +20 -2
- package/src/components/Grid/Grid.stories.tsx +106 -4
- package/src/components/Grid/Grid.tsx +134 -9
- package/src/components/Grid/columns.ts +58 -0
- package/src/components/Grid/styles.css.ts +85 -0
- package/src/components/Inline/Inline.stories.tsx +5 -4
- package/src/components/Input/Input.stories.tsx +2 -1
- package/src/components/InputGroup/InputGroup.stories.tsx +2 -1
- package/src/components/LinkButton/LinkButton.stories.tsx +3 -2
- package/src/components/List/List.stories.tsx +2 -1
- package/src/components/PageShell/PageShell.meta.ts +12 -1
- package/src/components/ScrollArea/ScrollArea.stories.tsx +12 -9
- package/src/components/SideNav/SideNav.meta.ts +5 -0
- package/src/components/Spinner/Spinner.stories.tsx +2 -1
- package/src/components/Stack/Stack.stories.tsx +5 -4
- package/src/components/StatusIcon/StatusIcon.stories.tsx +11 -4
- package/src/components/Switch/Switch.stories.tsx +2 -1
- package/src/components/Table/Table.stories.tsx +7 -2
- package/src/components/Tabs/Tabs.meta.ts +5 -0
- package/src/components/Tag/Tag.stories.tsx +11 -3
- package/src/components/Textarea/Textarea.stories.tsx +2 -1
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +9 -9
- package/src/components/Toast/Toast.stories.tsx +9 -2
- package/src/components/Toggle/Toggle.stories.tsx +2 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +2 -1
- package/src/components/Tooltip/Tooltip.stories.tsx +3 -2
- package/src/docs/Spacing.stories.tsx +12 -11
- package/src/docs/Typography.stories.tsx +4 -3
- 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": "
|
|
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": "^
|
|
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:
|
|
44
|
-
padding:
|
|
45
|
+
gap: spacing.xl,
|
|
46
|
+
padding: spacing.lg,
|
|
45
47
|
},
|
|
46
48
|
label: {
|
|
47
|
-
fontSize:
|
|
48
|
-
fontWeight:
|
|
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:
|
|
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:
|
|
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:
|
|
56
|
-
stack: {display: 'flex', flexDirection: 'column', gap:
|
|
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:
|
|
87
|
-
stack: {display: 'flex', flexDirection: 'column', gap:
|
|
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:
|
|
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:
|
|
90
|
+
gap: spacing.md,
|
|
89
91
|
},
|
|
90
|
-
stack: {display: 'flex', flexDirection: 'column', gap:
|
|
91
|
-
muted: {color: colors.textMuted, fontSize:
|
|
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:
|
|
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:
|
|
39
|
-
fontSize:
|
|
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:
|
|
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:
|
|
66
|
-
stackWide: {display: 'flex', flexDirection: 'column', gap:
|
|
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
|
-
'
|
|
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: `//
|
|
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:
|
|
38
|
+
borderRadius: radii.xs,
|
|
35
39
|
textAlign: 'center',
|
|
36
|
-
fontSize:
|
|
40
|
+
fontSize: typography.fontSizeSm,
|
|
37
41
|
},
|
|
38
42
|
label: {
|
|
39
|
-
fontSize:
|
|
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="split" — 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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
+
}
|