@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.
- package/dist/components/PageShell/PageShell.d.ts +15 -0
- package/dist/components/PageShell/PageShell.d.ts.map +1 -0
- package/dist/components/PageShell/index.d.ts +3 -0
- package/dist/components/PageShell/index.d.ts.map +1 -0
- package/dist/components/PageShell/styles.css.d.ts +41 -0
- package/dist/components/PageShell/styles.css.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3283 -3434
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/Alert/Alert.stories.tsx +76 -0
- package/src/components/Alert/Alert.tsx +45 -0
- package/src/components/Alert/styles.css.ts +50 -0
- package/src/components/Badge/Badge.stories.tsx +94 -0
- package/src/components/Badge/Badge.tsx +21 -0
- package/src/components/Badge/styles.css.ts +51 -0
- package/src/components/Button/Button.stories.tsx +130 -0
- package/src/components/Button/Button.tsx +48 -0
- package/src/components/Button/styles.css.ts +107 -0
- package/src/components/Callout/Callout.stories.tsx +97 -0
- package/src/components/Callout/Callout.tsx +39 -0
- package/src/components/Callout/index.ts +1 -0
- package/src/components/Callout/styles.css.ts +45 -0
- package/src/components/Card/Card.stories.tsx +119 -0
- package/src/components/Card/Card.tsx +35 -0
- package/src/components/Card/styles.css.ts +67 -0
- package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
- package/src/components/Checkbox/Checkbox.tsx +73 -0
- package/src/components/Checkbox/styles.css.ts +57 -0
- package/src/components/Cluster/Cluster.stories.tsx +92 -0
- package/src/components/Cluster/Cluster.tsx +43 -0
- package/src/components/Cluster/styles.css.ts +25 -0
- package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
- package/src/components/EmptyState/EmptyState.tsx +19 -0
- package/src/components/EmptyState/styles.css.ts +25 -0
- package/src/components/Field/Field.stories.tsx +92 -0
- package/src/components/Field/Field.tsx +80 -0
- package/src/components/Field/FieldContext.ts +14 -0
- package/src/components/Field/styles.css.ts +25 -0
- package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
- package/src/components/Fieldset/Fieldset.tsx +48 -0
- package/src/components/Fieldset/index.ts +1 -0
- package/src/components/Fieldset/styles.css.ts +33 -0
- package/src/components/Grid/Grid.stories.tsx +107 -0
- package/src/components/Grid/Grid.tsx +41 -0
- package/src/components/Grid/styles.css.ts +25 -0
- package/src/components/Heading/Heading.tsx +48 -0
- package/src/components/Heading/styles.css.ts +26 -0
- package/src/components/Icon/Icon.tsx +168 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Inline/Inline.stories.tsx +88 -0
- package/src/components/Inline/Inline.tsx +45 -0
- package/src/components/Inline/styles.css.ts +27 -0
- package/src/components/Input/Input.stories.tsx +89 -0
- package/src/components/Input/Input.tsx +77 -0
- package/src/components/Input/styles.css.ts +60 -0
- package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
- package/src/components/InputGroup/InputGroup.tsx +60 -0
- package/src/components/InputGroup/InputGroupContext.ts +11 -0
- package/src/components/InputGroup/styles.css.ts +61 -0
- package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
- package/src/components/LinkButton/LinkButton.tsx +42 -0
- package/src/components/LinkButton/styles.css.ts +56 -0
- package/src/components/Menu/Menu.stories.tsx +146 -0
- package/src/components/Menu/Menu.tsx +151 -0
- package/src/components/Menu/MenuContext.ts +20 -0
- package/src/components/Menu/styles.css.ts +89 -0
- package/src/components/Menu/useMenuRoot.ts +136 -0
- package/src/components/PageShell/PageShell.tsx +45 -0
- package/src/components/PageShell/index.ts +2 -0
- package/src/components/PageShell/styles.css.ts +26 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
- package/src/components/ScrollArea/ScrollArea.tsx +170 -0
- package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
- package/src/components/ScrollArea/styles.css.ts +81 -0
- package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
- package/src/components/Select/Select.stories.tsx +144 -0
- package/src/components/Select/Select.tsx +183 -0
- package/src/components/Select/SelectContext.ts +24 -0
- package/src/components/Select/styles.css.ts +97 -0
- package/src/components/Select/useSelectRoot.ts +178 -0
- package/src/components/SideNav/SideNav.stories.tsx +77 -0
- package/src/components/SideNav/SideNav.tsx +172 -0
- package/src/components/SideNav/SideNavContext.ts +18 -0
- package/src/components/SideNav/styles.css.ts +95 -0
- package/src/components/Spinner/Spinner.stories.tsx +59 -0
- package/src/components/Spinner/Spinner.tsx +24 -0
- package/src/components/Spinner/styles.css.ts +47 -0
- package/src/components/Stack/Stack.stories.tsx +103 -0
- package/src/components/Stack/Stack.tsx +33 -0
- package/src/components/Stack/styles.css.ts +21 -0
- package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
- package/src/components/StatusIcon/StatusIcon.tsx +24 -0
- package/src/components/StatusIcon/styles.css.ts +27 -0
- package/src/components/Switch/Switch.stories.tsx +88 -0
- package/src/components/Switch/Switch.tsx +78 -0
- package/src/components/Switch/styles.css.ts +71 -0
- package/src/components/Table/Table.stories.tsx +308 -0
- package/src/components/Table/Table.tsx +179 -0
- package/src/components/Table/styles.css.ts +97 -0
- package/src/components/Tabs/Tabs.stories.tsx +142 -0
- package/src/components/Tabs/Tabs.tsx +210 -0
- package/src/components/Tabs/TabsContext.ts +20 -0
- package/src/components/Tabs/styles.css.ts +98 -0
- package/src/components/Tabs/useTabsRoot.ts +42 -0
- package/src/components/Text/Text.tsx +52 -0
- package/src/components/Text/styles.css.ts +57 -0
- package/src/components/Textarea/Textarea.stories.tsx +80 -0
- package/src/components/Textarea/Textarea.tsx +50 -0
- package/src/components/Textarea/styles.css.ts +56 -0
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
- package/src/components/Toggle/Toggle.stories.tsx +84 -0
- package/src/components/Toggle/Toggle.tsx +85 -0
- package/src/components/Toggle/styles.css.ts +66 -0
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
- package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
- package/src/components/ToggleGroup/styles.css.ts +17 -0
- package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
- package/src/components/Tooltip/Tooltip.tsx +97 -0
- package/src/components/Tooltip/styles.css.ts +56 -0
- package/src/docs/Spacing.mdx +80 -0
- package/src/docs/Spacing.stories.tsx +202 -0
- package/src/docs/Typography.mdx +93 -0
- package/src/docs/Typography.stories.tsx +211 -0
- package/src/docs/helpers.tsx +135 -0
- package/src/hooks/useContainerQuery.ts +54 -0
- package/src/hooks/useControllableValue.ts +18 -0
- package/src/index.ts +56 -0
- 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
|
+
})
|