@duro-app/ui 0.12.2 → 0.14.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 (134) hide show
  1. package/dist/components/PageShell/PageShell.d.ts +15 -0
  2. package/dist/components/PageShell/PageShell.d.ts.map +1 -0
  3. package/dist/components/PageShell/index.d.ts +3 -0
  4. package/dist/components/PageShell/index.d.ts.map +1 -0
  5. package/dist/components/PageShell/styles.css.d.ts +41 -0
  6. package/dist/components/PageShell/styles.css.d.ts.map +1 -0
  7. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  8. package/dist/index.css +1 -1
  9. package/dist/index.d.ts +1 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3283 -3434
  12. package/dist/index.js.map +1 -1
  13. package/package.json +4 -4
  14. package/src/components/Alert/Alert.stories.tsx +76 -0
  15. package/src/components/Alert/Alert.tsx +45 -0
  16. package/src/components/Alert/styles.css.ts +50 -0
  17. package/src/components/Badge/Badge.stories.tsx +94 -0
  18. package/src/components/Badge/Badge.tsx +21 -0
  19. package/src/components/Badge/styles.css.ts +51 -0
  20. package/src/components/Button/Button.stories.tsx +130 -0
  21. package/src/components/Button/Button.tsx +48 -0
  22. package/src/components/Button/styles.css.ts +107 -0
  23. package/src/components/Callout/Callout.stories.tsx +97 -0
  24. package/src/components/Callout/Callout.tsx +39 -0
  25. package/src/components/Callout/index.ts +1 -0
  26. package/src/components/Callout/styles.css.ts +45 -0
  27. package/src/components/Card/Card.stories.tsx +119 -0
  28. package/src/components/Card/Card.tsx +35 -0
  29. package/src/components/Card/styles.css.ts +67 -0
  30. package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
  31. package/src/components/Checkbox/Checkbox.tsx +73 -0
  32. package/src/components/Checkbox/styles.css.ts +57 -0
  33. package/src/components/Cluster/Cluster.stories.tsx +92 -0
  34. package/src/components/Cluster/Cluster.tsx +43 -0
  35. package/src/components/Cluster/styles.css.ts +25 -0
  36. package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
  37. package/src/components/EmptyState/EmptyState.tsx +19 -0
  38. package/src/components/EmptyState/styles.css.ts +25 -0
  39. package/src/components/Field/Field.stories.tsx +92 -0
  40. package/src/components/Field/Field.tsx +80 -0
  41. package/src/components/Field/FieldContext.ts +14 -0
  42. package/src/components/Field/styles.css.ts +25 -0
  43. package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
  44. package/src/components/Fieldset/Fieldset.tsx +48 -0
  45. package/src/components/Fieldset/index.ts +1 -0
  46. package/src/components/Fieldset/styles.css.ts +33 -0
  47. package/src/components/Grid/Grid.stories.tsx +107 -0
  48. package/src/components/Grid/Grid.tsx +41 -0
  49. package/src/components/Grid/styles.css.ts +25 -0
  50. package/src/components/Heading/Heading.tsx +48 -0
  51. package/src/components/Heading/styles.css.ts +26 -0
  52. package/src/components/Icon/Icon.tsx +168 -0
  53. package/src/components/Icon/index.ts +2 -0
  54. package/src/components/Inline/Inline.stories.tsx +88 -0
  55. package/src/components/Inline/Inline.tsx +45 -0
  56. package/src/components/Inline/styles.css.ts +27 -0
  57. package/src/components/Input/Input.stories.tsx +89 -0
  58. package/src/components/Input/Input.tsx +77 -0
  59. package/src/components/Input/styles.css.ts +60 -0
  60. package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
  61. package/src/components/InputGroup/InputGroup.tsx +60 -0
  62. package/src/components/InputGroup/InputGroupContext.ts +11 -0
  63. package/src/components/InputGroup/styles.css.ts +61 -0
  64. package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
  65. package/src/components/LinkButton/LinkButton.tsx +42 -0
  66. package/src/components/LinkButton/styles.css.ts +56 -0
  67. package/src/components/Menu/Menu.stories.tsx +146 -0
  68. package/src/components/Menu/Menu.tsx +151 -0
  69. package/src/components/Menu/MenuContext.ts +20 -0
  70. package/src/components/Menu/styles.css.ts +89 -0
  71. package/src/components/Menu/useMenuRoot.ts +136 -0
  72. package/src/components/PageShell/PageShell.tsx +45 -0
  73. package/src/components/PageShell/index.ts +2 -0
  74. package/src/components/PageShell/styles.css.ts +26 -0
  75. package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
  76. package/src/components/ScrollArea/ScrollArea.tsx +170 -0
  77. package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
  78. package/src/components/ScrollArea/styles.css.ts +81 -0
  79. package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
  80. package/src/components/Select/Select.stories.tsx +144 -0
  81. package/src/components/Select/Select.tsx +183 -0
  82. package/src/components/Select/SelectContext.ts +24 -0
  83. package/src/components/Select/styles.css.ts +97 -0
  84. package/src/components/Select/useSelectRoot.ts +178 -0
  85. package/src/components/SideNav/SideNav.stories.tsx +77 -0
  86. package/src/components/SideNav/SideNav.tsx +172 -0
  87. package/src/components/SideNav/SideNavContext.ts +18 -0
  88. package/src/components/SideNav/styles.css.ts +95 -0
  89. package/src/components/Spinner/Spinner.stories.tsx +59 -0
  90. package/src/components/Spinner/Spinner.tsx +24 -0
  91. package/src/components/Spinner/styles.css.ts +47 -0
  92. package/src/components/Stack/Stack.stories.tsx +103 -0
  93. package/src/components/Stack/Stack.tsx +33 -0
  94. package/src/components/Stack/styles.css.ts +21 -0
  95. package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
  96. package/src/components/StatusIcon/StatusIcon.tsx +24 -0
  97. package/src/components/StatusIcon/styles.css.ts +27 -0
  98. package/src/components/Switch/Switch.stories.tsx +88 -0
  99. package/src/components/Switch/Switch.tsx +78 -0
  100. package/src/components/Switch/styles.css.ts +71 -0
  101. package/src/components/Table/Table.stories.tsx +308 -0
  102. package/src/components/Table/Table.tsx +179 -0
  103. package/src/components/Table/styles.css.ts +97 -0
  104. package/src/components/Tabs/Tabs.stories.tsx +142 -0
  105. package/src/components/Tabs/Tabs.tsx +210 -0
  106. package/src/components/Tabs/TabsContext.ts +20 -0
  107. package/src/components/Tabs/styles.css.ts +98 -0
  108. package/src/components/Tabs/useTabsRoot.ts +42 -0
  109. package/src/components/Text/Text.tsx +52 -0
  110. package/src/components/Text/styles.css.ts +57 -0
  111. package/src/components/Textarea/Textarea.stories.tsx +80 -0
  112. package/src/components/Textarea/Textarea.tsx +50 -0
  113. package/src/components/Textarea/styles.css.ts +56 -0
  114. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
  115. package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
  116. package/src/components/Toggle/Toggle.stories.tsx +84 -0
  117. package/src/components/Toggle/Toggle.tsx +85 -0
  118. package/src/components/Toggle/styles.css.ts +66 -0
  119. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
  120. package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
  121. package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
  122. package/src/components/ToggleGroup/styles.css.ts +17 -0
  123. package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
  124. package/src/components/Tooltip/Tooltip.tsx +97 -0
  125. package/src/components/Tooltip/styles.css.ts +56 -0
  126. package/src/docs/Spacing.mdx +80 -0
  127. package/src/docs/Spacing.stories.tsx +202 -0
  128. package/src/docs/Typography.mdx +93 -0
  129. package/src/docs/Typography.stories.tsx +211 -0
  130. package/src/docs/helpers.tsx +135 -0
  131. package/src/hooks/useContainerQuery.ts +54 -0
  132. package/src/hooks/useControllableValue.ts +18 -0
  133. package/src/index.ts +56 -0
  134. package/src/stubs/assets-registry.ts +3 -0
@@ -0,0 +1,92 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {Cluster} from './Cluster'
4
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
5
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
6
+
7
+ const meta: Meta<typeof Cluster> = {
8
+ title: 'Layout/Cluster',
9
+ component: Cluster,
10
+ argTypes: {
11
+ gap: {
12
+ control: 'select',
13
+ options: ['xs', 'sm', 'ms', 'md', 'lg', 'xl', 'xxl', 'xxxl'],
14
+ },
15
+ align: {
16
+ control: 'select',
17
+ options: ['start', 'center', 'end'],
18
+ },
19
+ justify: {
20
+ control: 'select',
21
+ options: ['start', 'center', 'end', 'between'],
22
+ },
23
+ },
24
+ }
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof Cluster>
28
+
29
+ const localStyles = css.create({
30
+ tag: {
31
+ backgroundColor: colors.accent,
32
+ color: colors.accentContrast,
33
+ paddingTop: spacing.xs,
34
+ paddingBottom: spacing.xs,
35
+ paddingLeft: spacing.sm,
36
+ paddingRight: spacing.sm,
37
+ borderRadius: 12,
38
+ fontSize: '0.75rem',
39
+ whiteSpace: 'nowrap',
40
+ },
41
+ narrow: {
42
+ maxWidth: 300,
43
+ },
44
+ })
45
+
46
+ const Tag = ({children}: {children: string}) => (
47
+ <html.span style={localStyles.tag}>{children}</html.span>
48
+ )
49
+
50
+ export const Default: Story = {
51
+ args: {
52
+ gap: 'sm',
53
+ align: 'start',
54
+ justify: 'start',
55
+ },
56
+ render: (args) => (
57
+ <Cluster {...args}>
58
+ <Tag>React</Tag>
59
+ <Tag>TypeScript</Tag>
60
+ <Tag>Design Systems</Tag>
61
+ <Tag>Tokens</Tag>
62
+ <Tag>Storybook</Tag>
63
+ </Cluster>
64
+ ),
65
+ }
66
+
67
+ export const ManyTags: Story = {
68
+ render: () => (
69
+ <html.div style={localStyles.narrow}>
70
+ <Cluster gap="xs">
71
+ <Tag>React</Tag>
72
+ <Tag>TypeScript</Tag>
73
+ <Tag>Design Systems</Tag>
74
+ <Tag>CSS</Tag>
75
+ <Tag>Tokens</Tag>
76
+ <Tag>Storybook</Tag>
77
+ <Tag>Accessibility</Tag>
78
+ <Tag>Performance</Tag>
79
+ </Cluster>
80
+ </html.div>
81
+ ),
82
+ }
83
+
84
+ export const Centered: Story = {
85
+ render: () => (
86
+ <Cluster gap="sm" justify="center">
87
+ <Tag>Filter A</Tag>
88
+ <Tag>Filter B</Tag>
89
+ <Tag>Filter C</Tag>
90
+ </Cluster>
91
+ ),
92
+ }
@@ -0,0 +1,43 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import type {SpacingKey} from '../Stack/Stack'
5
+
6
+ interface ClusterProps {
7
+ gap?: SpacingKey
8
+ align?: 'start' | 'center' | 'end'
9
+ justify?: 'start' | 'center' | 'end' | 'between'
10
+ children: ReactNode
11
+ }
12
+
13
+ const gapMap = {
14
+ xs: styles.gapXs,
15
+ sm: styles.gapSm,
16
+ ms: styles.gapMs,
17
+ md: styles.gapMd,
18
+ lg: styles.gapLg,
19
+ xl: styles.gapXl,
20
+ xxl: styles.gapXxl,
21
+ xxxl: styles.gapXxxl,
22
+ } as const
23
+
24
+ const alignMap = {
25
+ start: styles.alignStart,
26
+ center: styles.alignCenter,
27
+ end: styles.alignEnd,
28
+ } as const
29
+
30
+ const justifyMap = {
31
+ start: styles.justifyStart,
32
+ center: styles.justifyCenter,
33
+ end: styles.justifyEnd,
34
+ between: styles.justifyBetween,
35
+ } as const
36
+
37
+ export function Cluster({gap = 'sm', align = 'start', justify = 'start', children}: ClusterProps) {
38
+ return (
39
+ <html.div style={[styles.base, gapMap[gap], alignMap[align], justifyMap[justify]]}>
40
+ {children}
41
+ </html.div>
42
+ )
43
+ }
@@ -0,0 +1,25 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+
4
+ export const styles = css.create({
5
+ base: {
6
+ display: 'flex',
7
+ flexDirection: 'row',
8
+ flexWrap: 'wrap',
9
+ },
10
+ alignStart: {alignItems: 'flex-start'},
11
+ alignCenter: {alignItems: 'center'},
12
+ alignEnd: {alignItems: 'flex-end'},
13
+ justifyStart: {justifyContent: 'flex-start'},
14
+ justifyCenter: {justifyContent: 'center'},
15
+ justifyEnd: {justifyContent: 'flex-end'},
16
+ justifyBetween: {justifyContent: 'space-between'},
17
+ gapXs: {gap: spacing.xs},
18
+ gapSm: {gap: spacing.sm},
19
+ gapMs: {gap: spacing.ms},
20
+ gapMd: {gap: spacing.md},
21
+ gapLg: {gap: spacing.lg},
22
+ gapXl: {gap: spacing.xl},
23
+ gapXxl: {gap: spacing.xxl},
24
+ gapXxxl: {gap: spacing.xxxl},
25
+ })
@@ -0,0 +1,54 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {EmptyState} from './EmptyState'
5
+ import {Button} from '../Button/Button'
6
+ import {StatusIcon} from '../StatusIcon/StatusIcon'
7
+
8
+ const meta: Meta<typeof EmptyState> = {
9
+ title: 'Components/EmptyState',
10
+ component: EmptyState,
11
+ }
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof EmptyState>
15
+
16
+ export const Default: Story = {
17
+ args: {message: 'No items found'},
18
+ play: async ({canvas}) => {
19
+ await expect(canvas.getByText('No items found')).toBeInTheDocument()
20
+ },
21
+ }
22
+
23
+ export const WithIcon: Story = {
24
+ args: {
25
+ message: 'Nothing to show here',
26
+ icon: <StatusIcon name="forbidden" variant="muted" size={48} />,
27
+ },
28
+ play: async ({canvas}) => {
29
+ await expect(canvas.getByText('Nothing to show here')).toBeInTheDocument()
30
+ },
31
+ }
32
+
33
+ export const WithAction: Story = {
34
+ args: {
35
+ message: 'No results match your search',
36
+ action: <Button variant="secondary">Clear filters</Button>,
37
+ },
38
+ play: async ({canvas}) => {
39
+ await expect(canvas.getByText('No results match your search')).toBeInTheDocument()
40
+ await expect(canvas.getByRole('button', {name: 'Clear filters'})).toBeInTheDocument()
41
+ },
42
+ }
43
+
44
+ export const WithIconAndAction: Story = {
45
+ args: {
46
+ message: 'Your inbox is empty',
47
+ icon: <StatusIcon name="check-done" variant="success" size={48} />,
48
+ action: <Button variant="primary">Refresh</Button>,
49
+ },
50
+ play: async ({canvas}) => {
51
+ await expect(canvas.getByText('Your inbox is empty')).toBeInTheDocument()
52
+ await expect(canvas.getByRole('button', {name: 'Refresh'})).toBeInTheDocument()
53
+ },
54
+ }
@@ -0,0 +1,19 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ interface EmptyStateProps {
6
+ message: string
7
+ icon?: ReactNode
8
+ action?: ReactNode
9
+ }
10
+
11
+ export function EmptyState({message, icon, action}: EmptyStateProps) {
12
+ return (
13
+ <html.div style={styles.root}>
14
+ {icon}
15
+ <html.p style={styles.message}>{message}</html.p>
16
+ {action && <html.div style={styles.action}>{action}</html.div>}
17
+ </html.div>
18
+ )
19
+ }
@@ -0,0 +1,25 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ root: {
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ alignItems: 'center',
11
+ justifyContent: 'center',
12
+ gap: spacing.sm,
13
+ paddingTop: spacing.xl,
14
+ paddingBottom: spacing.xl,
15
+ textAlign: 'center',
16
+ },
17
+ message: {
18
+ fontSize: typography.fontSizeSm,
19
+ color: colors.textMuted,
20
+ lineHeight: typography.lineHeight,
21
+ },
22
+ action: {
23
+ marginTop: spacing.sm,
24
+ },
25
+ })
@@ -0,0 +1,92 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Field} from './Field'
5
+ import {Input} from '../Input/Input'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/Field',
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <Field.Root>
17
+ <Field.Label>Username</Field.Label>
18
+ <Input placeholder="Enter username" />
19
+ <Field.Description>3-32 characters, letters and numbers only.</Field.Description>
20
+ </Field.Root>
21
+ ),
22
+ play: async ({canvas}) => {
23
+ // Label is associated with input via htmlFor/id
24
+ const input = canvas.getByPlaceholderText('Enter username')
25
+ await expect(input).toBeInTheDocument()
26
+
27
+ const label = canvas.getByText('Username')
28
+ await expect(label).toBeInTheDocument()
29
+ await expect(label.tagName).toBe('LABEL')
30
+
31
+ // Description text is present
32
+ await expect(canvas.getByText('3-32 characters, letters and numbers only.')).toBeInTheDocument()
33
+
34
+ // Input should reference description via aria-describedby
35
+ await expect(input).toHaveAttribute('aria-describedby')
36
+ },
37
+ }
38
+
39
+ export const WithError: Story = {
40
+ render: () => (
41
+ <Field.Root invalid>
42
+ <Field.Label>Email</Field.Label>
43
+ <Input variant="error" placeholder="Enter email" />
44
+ <Field.Error>Please enter a valid email address.</Field.Error>
45
+ </Field.Root>
46
+ ),
47
+ play: async ({canvas}) => {
48
+ const input = canvas.getByPlaceholderText('Enter email')
49
+ await expect(input).toHaveAttribute('aria-invalid', 'true')
50
+
51
+ // Error message with alert role
52
+ const error = canvas.getByRole('alert')
53
+ await expect(error).toBeInTheDocument()
54
+ await expect(error).toHaveTextContent('Please enter a valid email address.')
55
+ },
56
+ }
57
+
58
+ const stackStyles = css.create({
59
+ stack: {display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400},
60
+ })
61
+
62
+ export const FormExample: Story = {
63
+ render: () => (
64
+ <html.div style={stackStyles.stack}>
65
+ <Field.Root>
66
+ <Field.Label>Username</Field.Label>
67
+ <Input placeholder="johndoe" />
68
+ <Field.Description>Must be unique.</Field.Description>
69
+ </Field.Root>
70
+ <Field.Root>
71
+ <Field.Label>Password</Field.Label>
72
+ <Input type="password" placeholder="At least 12 characters" />
73
+ <Field.Description>Minimum 12 characters.</Field.Description>
74
+ </Field.Root>
75
+ <Field.Root invalid>
76
+ <Field.Label>Confirm Password</Field.Label>
77
+ <Input type="password" variant="error" />
78
+ <Field.Error>Passwords do not match.</Field.Error>
79
+ </Field.Root>
80
+ </html.div>
81
+ ),
82
+ play: async ({canvas}) => {
83
+ // All labels rendered
84
+ await expect(canvas.getByText('Username')).toBeInTheDocument()
85
+ await expect(canvas.getByText('Password')).toBeInTheDocument()
86
+ await expect(canvas.getByText('Confirm Password')).toBeInTheDocument()
87
+
88
+ // Error field has alert
89
+ const alert = canvas.getByRole('alert')
90
+ await expect(alert).toHaveTextContent('Passwords do not match.')
91
+ },
92
+ }
@@ -0,0 +1,80 @@
1
+ import {type ReactNode, useId, useMemo} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {FieldContext, useFieldContext} from './FieldContext'
4
+ import {styles} from './styles.css'
5
+
6
+ // --- Root ---
7
+ interface RootProps {
8
+ invalid?: boolean
9
+ children: ReactNode
10
+ }
11
+
12
+ function Root({invalid = false, children}: RootProps) {
13
+ const id = useId()
14
+ const ctx = useMemo(
15
+ () => ({
16
+ controlId: `${id}-control`,
17
+ descriptionId: `${id}-description`,
18
+ errorId: `${id}-error`,
19
+ invalid,
20
+ }),
21
+ [id, invalid],
22
+ )
23
+
24
+ return (
25
+ <FieldContext.Provider value={ctx}>
26
+ <html.div style={styles.root}>{children}</html.div>
27
+ </FieldContext.Provider>
28
+ )
29
+ }
30
+
31
+ // --- Label ---
32
+ interface LabelProps {
33
+ children: ReactNode
34
+ }
35
+
36
+ function Label({children}: LabelProps) {
37
+ const ctx = useFieldContext()
38
+ return (
39
+ <html.label for={ctx?.controlId} style={styles.label}>
40
+ {children}
41
+ </html.label>
42
+ )
43
+ }
44
+
45
+ // --- Description ---
46
+ interface DescriptionProps {
47
+ children: ReactNode
48
+ }
49
+
50
+ function Description({children}: DescriptionProps) {
51
+ const ctx = useFieldContext()
52
+ return (
53
+ <html.span id={ctx?.descriptionId} style={styles.description}>
54
+ {children}
55
+ </html.span>
56
+ )
57
+ }
58
+
59
+ // --- Error ---
60
+ interface ErrorProps {
61
+ children?: ReactNode
62
+ }
63
+
64
+ function Error({children}: ErrorProps) {
65
+ const ctx = useFieldContext()
66
+ if (!ctx?.invalid && !children) return null
67
+
68
+ return (
69
+ <html.span id={ctx?.errorId} role="alert" style={styles.error}>
70
+ {children}
71
+ </html.span>
72
+ )
73
+ }
74
+
75
+ export const Field = {
76
+ Root,
77
+ Label,
78
+ Description,
79
+ Error,
80
+ }
@@ -0,0 +1,14 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ interface FieldContextValue {
4
+ controlId: string
5
+ descriptionId: string
6
+ errorId: string
7
+ invalid: boolean
8
+ }
9
+
10
+ export const FieldContext = createContext<FieldContextValue | null>(null)
11
+
12
+ export function useFieldContext() {
13
+ return useContext(FieldContext)
14
+ }
@@ -0,0 +1,25 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ root: {
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ gap: spacing.xs,
11
+ },
12
+ label: {
13
+ fontSize: typography.fontSizeSm,
14
+ fontWeight: typography.fontWeightMedium,
15
+ color: colors.text,
16
+ },
17
+ description: {
18
+ fontSize: typography.fontSizeXs,
19
+ color: colors.textMuted,
20
+ },
21
+ error: {
22
+ fontSize: typography.fontSizeXs,
23
+ color: colors.error,
24
+ },
25
+ })
@@ -0,0 +1,85 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {Fieldset} from './Fieldset'
3
+ import {Field} from '../Field/Field'
4
+ import {Input} from '../Input/Input'
5
+ import {Button} from '../Button/Button'
6
+
7
+ const meta: Meta<typeof Fieldset.Root> = {
8
+ title: 'Components/Fieldset',
9
+ component: Fieldset.Root,
10
+ argTypes: {
11
+ gap: {
12
+ control: 'select',
13
+ options: ['xs', 'sm', 'ms', 'md', 'lg', 'xl'],
14
+ },
15
+ disabled: {control: 'boolean'},
16
+ },
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof Fieldset.Root>
21
+
22
+ export const Default: Story = {
23
+ args: {gap: 'md'},
24
+ render: (args) => (
25
+ <Fieldset.Root {...args}>
26
+ <Fieldset.Legend>Account Information</Fieldset.Legend>
27
+ <Field.Root>
28
+ <Field.Label>Username</Field.Label>
29
+ <Input placeholder="Enter username" />
30
+ <Field.Description>3-32 characters: letters, numbers, hyphens</Field.Description>
31
+ </Field.Root>
32
+ <Field.Root>
33
+ <Field.Label>Password</Field.Label>
34
+ <Input type="password" placeholder="Enter password" />
35
+ <Field.Description>At least 12 characters</Field.Description>
36
+ </Field.Root>
37
+ <Field.Root>
38
+ <Field.Label>Confirm Password</Field.Label>
39
+ <Input type="password" placeholder="Confirm password" />
40
+ </Field.Root>
41
+ <Button variant="primary" fullWidth>
42
+ Create Account
43
+ </Button>
44
+ </Fieldset.Root>
45
+ ),
46
+ }
47
+
48
+ export const Disabled: Story = {
49
+ args: {gap: 'md', disabled: true},
50
+ render: (args) => (
51
+ <Fieldset.Root {...args}>
52
+ <Fieldset.Legend>Account Information</Fieldset.Legend>
53
+ <Field.Root>
54
+ <Field.Label>Username</Field.Label>
55
+ <Input placeholder="Enter username" />
56
+ </Field.Root>
57
+ <Field.Root>
58
+ <Field.Label>Password</Field.Label>
59
+ <Input type="password" placeholder="Enter password" />
60
+ </Field.Root>
61
+ <Button variant="primary" fullWidth>
62
+ Submit
63
+ </Button>
64
+ </Fieldset.Root>
65
+ ),
66
+ }
67
+
68
+ export const CompactGap: Story = {
69
+ args: {gap: 'sm'},
70
+ render: (args) => (
71
+ <Fieldset.Root {...args}>
72
+ <Field.Root>
73
+ <Field.Label>Email</Field.Label>
74
+ <Input type="email" placeholder="you@example.com" />
75
+ </Field.Root>
76
+ <Field.Root>
77
+ <Field.Label>Name</Field.Label>
78
+ <Input placeholder="Your name" />
79
+ </Field.Root>
80
+ <Button variant="primary" fullWidth>
81
+ Save
82
+ </Button>
83
+ </Fieldset.Root>
84
+ ),
85
+ }
@@ -0,0 +1,48 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ export type FieldsetGap = 'xs' | 'sm' | 'ms' | 'md' | 'lg' | 'xl'
6
+
7
+ const gapMap = {
8
+ xs: styles.gapXs,
9
+ sm: styles.gapSm,
10
+ ms: styles.gapMs,
11
+ md: styles.gapMd,
12
+ lg: styles.gapLg,
13
+ xl: styles.gapXl,
14
+ } as const
15
+
16
+ // --- Root ---
17
+ interface RootProps {
18
+ /** Disables all form controls within the fieldset */
19
+ disabled?: boolean
20
+ /** Gap between child elements */
21
+ gap?: FieldsetGap
22
+ children: ReactNode
23
+ }
24
+
25
+ function Root({disabled = false, gap = 'md', children}: RootProps) {
26
+ return (
27
+ <html.fieldset
28
+ disabled={disabled}
29
+ style={[styles.root, gapMap[gap], disabled && styles.disabled]}
30
+ >
31
+ {children}
32
+ </html.fieldset>
33
+ )
34
+ }
35
+
36
+ // --- Legend ---
37
+ interface LegendProps {
38
+ children: ReactNode
39
+ }
40
+
41
+ function Legend({children}: LegendProps) {
42
+ return <html.legend style={styles.legend}>{children}</html.legend>
43
+ }
44
+
45
+ export const Fieldset = {
46
+ Root,
47
+ Legend,
48
+ }
@@ -0,0 +1 @@
1
+ export {Fieldset, type FieldsetGap} from './Fieldset'
@@ -0,0 +1,33 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ root: {
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ // Reset native fieldset chrome
11
+ borderWidth: 0,
12
+ margin: 0,
13
+ padding: 0,
14
+ minWidth: 0,
15
+ },
16
+ legend: {
17
+ // Reset native legend quirks
18
+ padding: 0,
19
+ fontSize: typography.fontSizeSm,
20
+ fontWeight: typography.fontWeightMedium,
21
+ color: colors.text,
22
+ },
23
+ disabled: {
24
+ opacity: 0.5,
25
+ cursor: 'not-allowed',
26
+ },
27
+ gapXs: {gap: spacing.xs},
28
+ gapSm: {gap: spacing.sm},
29
+ gapMs: {gap: spacing.ms},
30
+ gapMd: {gap: spacing.md},
31
+ gapLg: {gap: spacing.lg},
32
+ gapXl: {gap: spacing.xl},
33
+ })