@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,97 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Callout} from './Callout'
5
+
6
+ const meta: Meta<typeof Callout> = {
7
+ title: 'Components/Callout',
8
+ component: Callout,
9
+ argTypes: {
10
+ variant: {
11
+ control: 'select',
12
+ options: ['error', 'success', 'warning', 'info'],
13
+ },
14
+ },
15
+ }
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof Callout>
19
+
20
+ export const Info: Story = {
21
+ args: {
22
+ variant: 'info',
23
+ children:
24
+ 'A new version of the application is available. Please save your work and refresh the page to get the latest features and security updates.',
25
+ },
26
+ }
27
+
28
+ export const Warning: Story = {
29
+ args: {
30
+ variant: 'warning',
31
+ children:
32
+ "It looks like your certificate isn't installed yet. Install the .p12 file from your email, then click the button below. After installing the certificate, you may need to close and reopen your browser for it to be recognized.",
33
+ },
34
+ }
35
+
36
+ export const Success: Story = {
37
+ args: {
38
+ variant: 'success',
39
+ children:
40
+ 'Your account has been created and your certificate is installed. You can now access all resources assigned to your groups. Check your email for a welcome guide with next steps.',
41
+ },
42
+ }
43
+
44
+ export const Error: Story = {
45
+ args: {
46
+ variant: 'error',
47
+ children:
48
+ 'We could not verify your identity. This may happen if your invite link has expired or was already used. Please contact your administrator to request a new invitation.',
49
+ },
50
+ }
51
+
52
+ export const ShortText: Story = {
53
+ name: 'Short text (still wraps)',
54
+ args: {
55
+ variant: 'info',
56
+ children: 'System update available.',
57
+ },
58
+ }
59
+
60
+ export const NoIcon: Story = {
61
+ args: {
62
+ variant: 'warning',
63
+ icon: false,
64
+ children: 'This callout has no icon — behaves like a simple box.',
65
+ },
66
+ }
67
+
68
+ const stackStyles = css.create({
69
+ stack: {display: 'flex', flexDirection: 'column', gap: 12},
70
+ })
71
+
72
+ export const AllVariants: Story = {
73
+ render: () => (
74
+ <html.div style={stackStyles.stack}>
75
+ <Callout variant="error">
76
+ We could not verify your identity. This may happen if your invite link has expired or was
77
+ already used. Please contact your administrator.
78
+ </Callout>
79
+ <Callout variant="success">
80
+ Your account has been created and your certificate is installed. You can now access all
81
+ resources assigned to your groups.
82
+ </Callout>
83
+ <Callout variant="warning">
84
+ It looks like your certificate is not installed yet. Install the .p12 file from your email,
85
+ then click the button below.
86
+ </Callout>
87
+ <Callout variant="info">
88
+ A new version of the application is available. Please save your work and refresh the page to
89
+ get the latest features.
90
+ </Callout>
91
+ </html.div>
92
+ ),
93
+ play: async ({canvas}) => {
94
+ const callouts = canvas.getAllByRole('note')
95
+ await expect(callouts.length).toBe(4)
96
+ },
97
+ }
@@ -0,0 +1,39 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {Icon} from '../Icon'
4
+ import type {IconName} from '../Icon'
5
+ import {styles} from './styles.css'
6
+
7
+ export type CalloutVariant = 'error' | 'success' | 'warning' | 'info'
8
+
9
+ const defaultIcons: Record<CalloutVariant, IconName> = {
10
+ info: 'info-circle-filled',
11
+ warning: 'alert-triangle-filled',
12
+ success: 'check-circle-filled',
13
+ error: 'x-circle-filled',
14
+ }
15
+
16
+ interface CalloutProps {
17
+ variant?: CalloutVariant
18
+ /** Built-in icon name, custom ReactNode, or false to hide. Defaults to variant icon. */
19
+ icon?: IconName | ReactNode | false
20
+ children: ReactNode
21
+ }
22
+
23
+ function resolveIcon(icon: CalloutProps['icon'], variant: CalloutVariant): ReactNode | null {
24
+ if (icon === false) return null
25
+ if (icon === undefined) return <Icon name={defaultIcons[variant]} size={36} />
26
+ if (typeof icon === 'string') return <Icon name={icon as IconName} size={36} />
27
+ return icon
28
+ }
29
+
30
+ export function Callout({variant = 'info', icon, children}: CalloutProps) {
31
+ const resolvedIcon = resolveIcon(icon, variant)
32
+
33
+ return (
34
+ <html.div role="note" style={[styles.base, styles[variant]]}>
35
+ {resolvedIcon && <html.span style={styles.icon}>{resolvedIcon}</html.span>}
36
+ {children}
37
+ </html.div>
38
+ )
39
+ }
@@ -0,0 +1 @@
1
+ export {Callout, type CalloutVariant} from './Callout'
@@ -0,0 +1,45 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} 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
+ base: {
8
+ padding: spacing.md,
9
+ borderRadius: radii.sm,
10
+ borderWidth: 1,
11
+ borderStyle: 'solid',
12
+ fontSize: typography.fontSizeSm,
13
+ lineHeight: typography.lineHeight,
14
+ overflow: 'hidden', // contain the float
15
+ },
16
+ icon: {
17
+ float: 'left',
18
+ marginRight: spacing.sm,
19
+ marginBottom: spacing.xs,
20
+ marginTop: 2,
21
+ display: 'inline-flex',
22
+ alignItems: 'center',
23
+ justifyContent: 'center',
24
+ },
25
+ error: {
26
+ backgroundColor: colors.errorBg,
27
+ borderColor: colors.errorBorder,
28
+ color: colors.errorText,
29
+ },
30
+ success: {
31
+ backgroundColor: colors.successBg,
32
+ borderColor: colors.successBorder,
33
+ color: colors.successText,
34
+ },
35
+ warning: {
36
+ backgroundColor: colors.warningBg,
37
+ borderColor: colors.warningBorder,
38
+ color: colors.warningText,
39
+ },
40
+ info: {
41
+ backgroundColor: colors.infoBg,
42
+ borderColor: colors.infoBorder,
43
+ color: colors.infoText,
44
+ },
45
+ })
@@ -0,0 +1,119 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Card} from './Card'
5
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
6
+
7
+ const meta: Meta<typeof Card> = {
8
+ title: 'Components/Card',
9
+ component: Card,
10
+ argTypes: {
11
+ variant: {
12
+ control: 'select',
13
+ options: ['elevated', 'outlined', 'filled', 'interactive'],
14
+ },
15
+ size: {
16
+ control: 'select',
17
+ options: ['default', 'compact', 'full'],
18
+ },
19
+ header: {control: 'text'},
20
+ },
21
+ }
22
+
23
+ export default meta
24
+ type Story = StoryObj<typeof Card>
25
+
26
+ export const Elevated: Story = {
27
+ args: {
28
+ variant: 'elevated',
29
+ header: 'Elevated Card',
30
+ children: 'This card has a shadow and larger radius, like a page-level container.',
31
+ },
32
+ play: async ({canvas}) => {
33
+ await expect(canvas.getByText('Elevated Card')).toBeInTheDocument()
34
+ await expect(canvas.getByText(/shadow and larger radius/)).toBeInTheDocument()
35
+ },
36
+ }
37
+
38
+ export const Outlined: Story = {
39
+ args: {
40
+ variant: 'outlined',
41
+ header: 'Outlined Card',
42
+ children: 'A bordered card section with a title.',
43
+ },
44
+ play: async ({canvas}) => {
45
+ await expect(canvas.getByText('Outlined Card')).toBeInTheDocument()
46
+ },
47
+ }
48
+
49
+ export const Filled: Story = {
50
+ args: {
51
+ variant: 'filled',
52
+ children: 'A subtle filled card without border or shadow.',
53
+ },
54
+ }
55
+
56
+ export const Interactive: Story = {
57
+ args: {
58
+ variant: 'interactive',
59
+ children: 'Hover me! I translate up and show an accent border.',
60
+ },
61
+ play: async ({canvas}) => {
62
+ await expect(canvas.getByText(/Hover me/)).toBeInTheDocument()
63
+ },
64
+ }
65
+
66
+ export const Compact: Story = {
67
+ args: {
68
+ variant: 'outlined',
69
+ size: 'compact',
70
+ header: 'Compact',
71
+ children: 'Less padding for tighter layouts.',
72
+ },
73
+ }
74
+
75
+ export const Full: Story = {
76
+ args: {
77
+ variant: 'elevated',
78
+ size: 'full',
79
+ header: 'Full Padding',
80
+ children: 'Extra padding for page-level card containers.',
81
+ },
82
+ }
83
+
84
+ const gridStyles = css.create({
85
+ grid: {
86
+ display: 'grid',
87
+ gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
88
+ gap: 16,
89
+ },
90
+ stack: {display: 'flex', flexDirection: 'column', gap: 16},
91
+ muted: {color: colors.textMuted, fontSize: '0.875rem'},
92
+ })
93
+
94
+ export const AllVariants: Story = {
95
+ render: () => (
96
+ <html.div style={gridStyles.stack}>
97
+ <html.div style={gridStyles.grid}>
98
+ <Card variant="elevated" header="Elevated">
99
+ <html.span style={gridStyles.muted}>Shadow + border</html.span>
100
+ </Card>
101
+ <Card variant="outlined" header="Outlined">
102
+ <html.span style={gridStyles.muted}>Border only</html.span>
103
+ </Card>
104
+ <Card variant="filled">
105
+ <html.span style={gridStyles.muted}>Background only</html.span>
106
+ </Card>
107
+ <Card variant="interactive">
108
+ <html.span style={gridStyles.muted}>Hover to interact</html.span>
109
+ </Card>
110
+ </html.div>
111
+ </html.div>
112
+ ),
113
+ play: async ({canvas}) => {
114
+ await expect(canvas.getByText('Elevated')).toBeInTheDocument()
115
+ await expect(canvas.getByText('Outlined')).toBeInTheDocument()
116
+ await expect(canvas.getByText('Background only')).toBeInTheDocument()
117
+ await expect(canvas.getByText('Hover to interact')).toBeInTheDocument()
118
+ },
119
+ }
@@ -0,0 +1,35 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ export type CardVariant = 'elevated' | 'outlined' | 'filled' | 'interactive'
6
+ export type CardSize = 'default' | 'compact' | 'full'
7
+
8
+ interface CardProps {
9
+ variant?: CardVariant
10
+ size?: CardSize
11
+ header?: string
12
+ onClick?: () => void
13
+ children: ReactNode
14
+ }
15
+
16
+ const sizeMap = {
17
+ default: styles.sizeDefault,
18
+ compact: styles.sizeCompact,
19
+ full: styles.sizeFull,
20
+ } as const
21
+
22
+ export function Card({
23
+ variant = 'outlined',
24
+ size = 'default',
25
+ header,
26
+ onClick,
27
+ children,
28
+ }: CardProps) {
29
+ return (
30
+ <html.div onClick={onClick} style={[styles.base, styles[variant], sizeMap[size]]}>
31
+ {header && <html.div style={styles.header}>{header}</html.div>}
32
+ {children}
33
+ </html.div>
34
+ )
35
+ }
@@ -0,0 +1,67 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+
7
+ export const styles = css.create({
8
+ base: {
9
+ backgroundColor: colors.bgCard,
10
+ color: colors.text,
11
+ fontFamily: typography.fontFamily,
12
+ },
13
+ // Variants
14
+ elevated: {
15
+ borderRadius: radii.lg,
16
+ borderWidth: 1,
17
+ borderStyle: 'solid',
18
+ borderColor: colors.border,
19
+ boxShadow: shadows.md,
20
+ },
21
+ outlined: {
22
+ borderRadius: radii.md,
23
+ borderWidth: 1,
24
+ borderStyle: 'solid',
25
+ borderColor: colors.border,
26
+ },
27
+ filled: {
28
+ borderRadius: radii.md,
29
+ },
30
+ interactive: {
31
+ borderRadius: radii.md,
32
+ borderWidth: 1,
33
+ borderStyle: 'solid',
34
+ borderColor: {
35
+ default: colors.border,
36
+ ':hover': colors.accent,
37
+ },
38
+ backgroundColor: {
39
+ default: colors.bgCard,
40
+ ':hover': colors.bgCardHover,
41
+ },
42
+ cursor: 'pointer',
43
+ transitionProperty: 'background-color, border-color, transform',
44
+ transitionDuration: '150ms',
45
+ transitionTimingFunction: 'ease',
46
+ transform: {
47
+ default: 'translateY(0)',
48
+ ':hover': 'translateY(-2px)',
49
+ },
50
+ },
51
+ // Sizes (padding)
52
+ sizeDefault: {
53
+ padding: spacing.lg,
54
+ },
55
+ sizeCompact: {
56
+ padding: spacing.md,
57
+ },
58
+ sizeFull: {
59
+ padding: spacing.xl,
60
+ },
61
+ // Header
62
+ header: {
63
+ fontSize: typography.fontSizeLg,
64
+ fontWeight: typography.fontWeightSemibold,
65
+ marginBottom: spacing.md,
66
+ },
67
+ })
@@ -0,0 +1,88 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Checkbox} from './Checkbox'
5
+
6
+ const meta: Meta<typeof Checkbox> = {
7
+ title: 'Components/Checkbox',
8
+ component: Checkbox,
9
+ args: {
10
+ onChange: fn(),
11
+ },
12
+ argTypes: {
13
+ checked: {control: 'boolean'},
14
+ disabled: {control: 'boolean'},
15
+ defaultChecked: {control: 'boolean'},
16
+ },
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof Checkbox>
21
+
22
+ export const Default: Story = {
23
+ args: {children: 'Accept terms and conditions'},
24
+ play: async ({canvas, userEvent, args}) => {
25
+ const checkbox = canvas.getByRole('checkbox')
26
+ await expect(checkbox).toBeInTheDocument()
27
+ await expect(checkbox).toBeEnabled()
28
+
29
+ await userEvent.click(checkbox)
30
+ await expect(args.onChange).toHaveBeenCalledTimes(1)
31
+ },
32
+ }
33
+
34
+ export const Checked: Story = {
35
+ args: {defaultChecked: true, children: 'Already checked'},
36
+ play: async ({canvas}) => {
37
+ const checkbox = canvas.getByRole('checkbox')
38
+ await expect(checkbox).toBeChecked()
39
+ },
40
+ }
41
+
42
+ export const Disabled: Story = {
43
+ args: {disabled: true, children: 'Disabled option'},
44
+ play: async ({canvas, userEvent, args}) => {
45
+ const checkbox = canvas.getByRole('checkbox')
46
+ await expect(checkbox).toBeDisabled()
47
+
48
+ await userEvent.click(checkbox)
49
+ await expect(args.onChange).not.toHaveBeenCalled()
50
+ },
51
+ }
52
+
53
+ export const DisabledChecked: Story = {
54
+ args: {disabled: true, defaultChecked: true, children: 'Disabled checked'},
55
+ play: async ({canvas}) => {
56
+ const checkbox = canvas.getByRole('checkbox')
57
+ await expect(checkbox).toBeDisabled()
58
+ await expect(checkbox).toBeChecked()
59
+ },
60
+ }
61
+
62
+ export const WithoutLabel: Story = {
63
+ args: {name: 'solo'},
64
+ play: async ({canvas}) => {
65
+ await expect(canvas.getByRole('checkbox')).toBeInTheDocument()
66
+ },
67
+ }
68
+
69
+ const stackStyles = css.create({
70
+ stack: {display: 'flex', flexDirection: 'column', gap: 12},
71
+ })
72
+
73
+ export const AllVariants: Story = {
74
+ render: () => (
75
+ <html.div style={stackStyles.stack}>
76
+ <Checkbox>Unchecked</Checkbox>
77
+ <Checkbox defaultChecked>Checked</Checkbox>
78
+ <Checkbox disabled>Disabled</Checkbox>
79
+ <Checkbox disabled defaultChecked>
80
+ Disabled checked
81
+ </Checkbox>
82
+ </html.div>
83
+ ),
84
+ play: async ({canvas}) => {
85
+ const checkboxes = canvas.getAllByRole('checkbox')
86
+ await expect(checkboxes.length).toBe(4)
87
+ },
88
+ }
@@ -0,0 +1,73 @@
1
+ import {type ReactNode, useState, useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ interface CheckboxProps {
6
+ name?: string
7
+ value?: string
8
+ checked?: boolean
9
+ defaultChecked?: boolean
10
+ disabled?: boolean
11
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
12
+ children?: ReactNode
13
+ }
14
+
15
+ export function Checkbox({
16
+ name,
17
+ value,
18
+ checked: controlledChecked,
19
+ defaultChecked = false,
20
+ disabled = false,
21
+ onChange,
22
+ children,
23
+ }: CheckboxProps) {
24
+ const isControlled = controlledChecked !== undefined
25
+ const [internalChecked, setInternalChecked] = useState(defaultChecked)
26
+ const isChecked = isControlled ? controlledChecked : internalChecked
27
+
28
+ const handleChange = useCallback(
29
+ (e: React.ChangeEvent<HTMLInputElement>) => {
30
+ if (!isControlled) {
31
+ setInternalChecked(e.target.checked)
32
+ }
33
+ onChange?.(e)
34
+ },
35
+ [isControlled, onChange],
36
+ )
37
+
38
+ return (
39
+ <html.label style={[styles.root, disabled && styles.rootDisabled]}>
40
+ <html.input
41
+ type="checkbox"
42
+ name={name}
43
+ value={value}
44
+ checked={isControlled ? controlledChecked : undefined}
45
+ defaultChecked={!isControlled ? defaultChecked : undefined}
46
+ disabled={disabled}
47
+ onChange={handleChange}
48
+ style={styles.input}
49
+ />
50
+ <html.span
51
+ style={[styles.box, isChecked ? styles.boxChecked : styles.boxUnchecked]}
52
+ aria-hidden
53
+ >
54
+ <svg
55
+ width={12}
56
+ height={12}
57
+ viewBox="0 0 12 12"
58
+ fill="none"
59
+ style={{opacity: isChecked ? 1 : 0}}
60
+ >
61
+ <polyline
62
+ points="2.5 6 5 8.5 9.5 3.5"
63
+ stroke="currentColor"
64
+ strokeWidth={1.5}
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ />
68
+ </svg>
69
+ </html.span>
70
+ {children && <html.span>{children}</html.span>}
71
+ </html.label>
72
+ )
73
+ }
@@ -0,0 +1,57 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} 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: 'inline-flex',
9
+ alignItems: 'center',
10
+ gap: spacing.sm,
11
+ cursor: 'pointer',
12
+ fontSize: typography.fontSizeSm,
13
+ color: colors.text,
14
+ lineHeight: typography.lineHeight,
15
+ },
16
+ rootDisabled: {
17
+ opacity: 0.5,
18
+ cursor: 'not-allowed',
19
+ },
20
+ box: {
21
+ width: 18,
22
+ height: 18,
23
+ borderWidth: 1,
24
+ borderStyle: 'solid',
25
+ borderRadius: radii.sm,
26
+ display: 'inline-flex',
27
+ alignItems: 'center',
28
+ justifyContent: 'center',
29
+ flexShrink: 0,
30
+ transitionProperty: 'background-color, border-color',
31
+ transitionDuration: '150ms',
32
+ transitionTimingFunction: 'ease',
33
+ },
34
+ boxUnchecked: {
35
+ backgroundColor: colors.bg,
36
+ borderColor: {
37
+ default: colors.border,
38
+ ':hover': colors.textMuted,
39
+ },
40
+ },
41
+ boxChecked: {
42
+ backgroundColor: colors.accent,
43
+ borderColor: colors.accent,
44
+ },
45
+ check: {
46
+ width: 12,
47
+ height: 12,
48
+ color: colors.accentContrast,
49
+ },
50
+ input: {
51
+ position: 'absolute',
52
+ width: 1,
53
+ height: 1,
54
+ opacity: 0,
55
+ overflow: 'hidden',
56
+ },
57
+ })