@duro-app/ui 0.12.1 → 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,24 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+
4
+ export type SpinnerSize = 'sm' | 'md' | 'lg'
5
+
6
+ interface SpinnerProps {
7
+ size?: SpinnerSize
8
+ label?: string
9
+ }
10
+
11
+ const sizeMap = {
12
+ sm: styles.sm,
13
+ md: styles.md,
14
+ lg: styles.lg,
15
+ } as const
16
+
17
+ export function Spinner({size = 'md', label = 'Loading'}: SpinnerProps) {
18
+ return (
19
+ <html.div role="status" style={styles.root}>
20
+ <html.div style={[styles.spinner, sizeMap[size]]} aria-hidden />
21
+ <html.span style={styles.srOnly}>{label}</html.span>
22
+ </html.div>
23
+ )
24
+ }
@@ -0,0 +1,47 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+
4
+ const spin = css.keyframes({
5
+ '0%': {transform: 'rotate(0deg)'},
6
+ '100%': {transform: 'rotate(360deg)'},
7
+ })
8
+
9
+ export const styles = css.create({
10
+ root: {
11
+ display: 'inline-flex',
12
+ alignItems: 'center',
13
+ justifyContent: 'center',
14
+ },
15
+ spinner: {
16
+ borderRadius: '50%',
17
+ borderStyle: 'solid',
18
+ borderColor: colors.border,
19
+ borderTopColor: colors.accent,
20
+ animationName: spin,
21
+ animationDuration: '0.6s',
22
+ animationTimingFunction: 'linear',
23
+ animationIterationCount: 'infinite',
24
+ },
25
+ sm: {
26
+ width: 16,
27
+ height: 16,
28
+ borderWidth: 2,
29
+ },
30
+ md: {
31
+ width: 24,
32
+ height: 24,
33
+ borderWidth: 2,
34
+ },
35
+ lg: {
36
+ width: 40,
37
+ height: 40,
38
+ borderWidth: 3,
39
+ },
40
+ srOnly: {
41
+ position: 'absolute',
42
+ width: 1,
43
+ height: 1,
44
+ overflow: 'hidden',
45
+ clip: 'rect(0, 0, 0, 0)',
46
+ },
47
+ })
@@ -0,0 +1,103 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {Stack} from './Stack'
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 Stack> = {
8
+ title: 'Layout/Stack',
9
+ component: Stack,
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', 'stretch'],
18
+ },
19
+ },
20
+ }
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof Stack>
24
+
25
+ const localStyles = css.create({
26
+ box: {
27
+ backgroundColor: colors.accent,
28
+ color: colors.accentContrast,
29
+ padding: spacing.sm,
30
+ borderRadius: 4,
31
+ textAlign: 'center',
32
+ fontSize: '0.875rem',
33
+ },
34
+ label: {
35
+ fontSize: '0.75rem',
36
+ color: colors.textMuted,
37
+ },
38
+ })
39
+
40
+ const Box = ({children}: {children: string}) => (
41
+ <html.div style={localStyles.box}>{children}</html.div>
42
+ )
43
+
44
+ export const Default: Story = {
45
+ args: {
46
+ gap: 'md',
47
+ align: 'stretch',
48
+ },
49
+ render: (args) => (
50
+ <Stack {...args}>
51
+ <Box>First item</Box>
52
+ <Box>Second item</Box>
53
+ <Box>Third item</Box>
54
+ </Stack>
55
+ ),
56
+ }
57
+
58
+ export const Tight: Story = {
59
+ render: () => (
60
+ <Stack gap="xs">
61
+ <Box>Tight spacing (4px)</Box>
62
+ <Box>Between items</Box>
63
+ <Box>For related content</Box>
64
+ </Stack>
65
+ ),
66
+ }
67
+
68
+ export const Spacious: Story = {
69
+ render: () => (
70
+ <Stack gap="xl">
71
+ <Box>Section one</Box>
72
+ <Box>Section two</Box>
73
+ <Box>Section three</Box>
74
+ </Stack>
75
+ ),
76
+ }
77
+
78
+ export const CenterAligned: Story = {
79
+ render: () => (
80
+ <Stack gap="md" align="center">
81
+ <Box>Short</Box>
82
+ <Box>A wider content block</Box>
83
+ <Box>Medium</Box>
84
+ </Stack>
85
+ ),
86
+ }
87
+
88
+ export const AllGaps: Story = {
89
+ render: () => {
90
+ const gaps = ['xs', 'sm', 'ms', 'md', 'lg', 'xl', 'xxl', 'xxxl'] as const
91
+ return (
92
+ <Stack gap="xl">
93
+ {gaps.map((g) => (
94
+ <Stack key={g} gap={g}>
95
+ <html.span style={localStyles.label}>gap="{g}"</html.span>
96
+ <Box>A</Box>
97
+ <Box>B</Box>
98
+ </Stack>
99
+ ))}
100
+ </Stack>
101
+ )
102
+ },
103
+ }
@@ -0,0 +1,33 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ export type SpacingKey = 'xs' | 'sm' | 'ms' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'
6
+
7
+ interface StackProps {
8
+ gap?: SpacingKey
9
+ align?: 'start' | 'center' | 'end' | 'stretch'
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
+ stretch: styles.alignStretch,
29
+ } as const
30
+
31
+ export function Stack({gap = 'md', align = 'stretch', children}: StackProps) {
32
+ return <html.div style={[styles.base, gapMap[gap], alignMap[align]]}>{children}</html.div>
33
+ }
@@ -0,0 +1,21 @@
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: 'column',
8
+ },
9
+ alignStart: {alignItems: 'flex-start'},
10
+ alignCenter: {alignItems: 'center'},
11
+ alignEnd: {alignItems: 'flex-end'},
12
+ alignStretch: {alignItems: 'stretch'},
13
+ gapXs: {gap: spacing.xs},
14
+ gapSm: {gap: spacing.sm},
15
+ gapMs: {gap: spacing.ms},
16
+ gapMd: {gap: spacing.md},
17
+ gapLg: {gap: spacing.lg},
18
+ gapXl: {gap: spacing.xl},
19
+ gapXxl: {gap: spacing.xxl},
20
+ gapXxxl: {gap: spacing.xxxl},
21
+ })
@@ -0,0 +1,81 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {StatusIcon} from './StatusIcon'
5
+
6
+ const meta: Meta<typeof StatusIcon> = {
7
+ title: 'Components/StatusIcon',
8
+ component: StatusIcon,
9
+ argTypes: {
10
+ name: {
11
+ control: 'select',
12
+ options: ['x-circle', 'check-circle', 'check-done', 'clock', 'forbidden'],
13
+ },
14
+ variant: {
15
+ control: 'select',
16
+ options: ['error', 'success', 'warning', 'muted'],
17
+ },
18
+ size: {control: 'number'},
19
+ },
20
+ }
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof StatusIcon>
24
+
25
+ export const Error: Story = {
26
+ args: {name: 'x-circle', variant: 'error'},
27
+ }
28
+
29
+ export const Success: Story = {
30
+ args: {name: 'check-circle', variant: 'success'},
31
+ }
32
+
33
+ export const Warning: Story = {
34
+ args: {name: 'clock', variant: 'warning'},
35
+ }
36
+
37
+ export const Muted: Story = {
38
+ args: {name: 'forbidden', variant: 'muted'},
39
+ }
40
+
41
+ export const CheckDone: Story = {
42
+ args: {name: 'check-done', variant: 'success'},
43
+ }
44
+
45
+ export const CustomSize: Story = {
46
+ args: {name: 'check-circle', variant: 'success', size: 24},
47
+ }
48
+
49
+ const gridStyles = css.create({
50
+ grid: {display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 24},
51
+ cell: {display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8},
52
+ label: {fontSize: '0.875rem', color: '#888'},
53
+ })
54
+
55
+ export const AllIcons: Story = {
56
+ render: () => (
57
+ <html.div style={gridStyles.grid}>
58
+ {(['x-circle', 'check-circle', 'check-done', 'clock', 'forbidden'] as const).map((name) => (
59
+ <html.div key={name} style={gridStyles.cell}>
60
+ <StatusIcon name={name} variant="muted" />
61
+ <html.span style={gridStyles.label}>{name}</html.span>
62
+ </html.div>
63
+ ))}
64
+ </html.div>
65
+ ),
66
+ }
67
+
68
+ const rowStyles = css.create({
69
+ row: {display: 'flex', gap: 24, alignItems: 'center'},
70
+ })
71
+
72
+ export const AllVariants: Story = {
73
+ render: () => (
74
+ <html.div style={rowStyles.row}>
75
+ <StatusIcon name="x-circle" variant="error" />
76
+ <StatusIcon name="check-circle" variant="success" />
77
+ <StatusIcon name="clock" variant="warning" />
78
+ <StatusIcon name="forbidden" variant="muted" />
79
+ </html.div>
80
+ ),
81
+ }
@@ -0,0 +1,24 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {Icon} from '../Icon'
3
+ import type {IconName} from '../Icon'
4
+ import {styles} from './styles.css'
5
+
6
+ export type StatusIconName = Extract<
7
+ IconName,
8
+ 'x-circle' | 'check-circle' | 'check-done' | 'clock' | 'forbidden'
9
+ >
10
+ export type StatusIconVariant = 'error' | 'success' | 'warning' | 'info' | 'muted'
11
+
12
+ interface StatusIconProps {
13
+ name: StatusIconName
14
+ size?: number
15
+ variant?: StatusIconVariant
16
+ }
17
+
18
+ export function StatusIcon({name, size = 48, variant = 'muted'}: StatusIconProps) {
19
+ return (
20
+ <html.div style={[styles.root, styles[variant]]}>
21
+ <Icon name={name} size={size} />
22
+ </html.div>
23
+ )
24
+ }
@@ -0,0 +1,27 @@
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
+
5
+ export const styles = css.create({
6
+ root: {
7
+ display: 'inline-flex',
8
+ alignItems: 'center',
9
+ justifyContent: 'center',
10
+ marginBottom: spacing.md,
11
+ },
12
+ error: {
13
+ color: colors.error,
14
+ },
15
+ success: {
16
+ color: colors.success,
17
+ },
18
+ warning: {
19
+ color: colors.warning,
20
+ },
21
+ info: {
22
+ color: colors.info,
23
+ },
24
+ muted: {
25
+ color: colors.textMuted,
26
+ },
27
+ })
@@ -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 {Switch} from './Switch'
5
+
6
+ const meta: Meta<typeof Switch> = {
7
+ title: 'Components/Switch',
8
+ component: Switch,
9
+ args: {
10
+ onCheckedChange: 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 Switch>
21
+
22
+ export const Default: Story = {
23
+ args: {children: 'Enable notifications'},
24
+ play: async ({canvas, userEvent, args}) => {
25
+ const switchEl = canvas.getByRole('switch')
26
+ await expect(switchEl).toBeInTheDocument()
27
+ await expect(switchEl).not.toBeChecked()
28
+
29
+ await userEvent.click(switchEl)
30
+ await expect(args.onCheckedChange).toHaveBeenCalledWith(true)
31
+ },
32
+ }
33
+
34
+ export const Checked: Story = {
35
+ args: {defaultChecked: true, children: 'Already enabled'},
36
+ play: async ({canvas}) => {
37
+ const switchEl = canvas.getByRole('switch')
38
+ await expect(switchEl).toBeChecked()
39
+ },
40
+ }
41
+
42
+ export const Disabled: Story = {
43
+ args: {disabled: true, children: 'Cannot toggle'},
44
+ play: async ({canvas, userEvent, args}) => {
45
+ const switchEl = canvas.getByRole('switch')
46
+ await expect(switchEl).toBeDisabled()
47
+
48
+ await userEvent.click(switchEl)
49
+ await expect(args.onCheckedChange).not.toHaveBeenCalled()
50
+ },
51
+ }
52
+
53
+ export const DisabledChecked: Story = {
54
+ args: {disabled: true, defaultChecked: true, children: 'Locked on'},
55
+ play: async ({canvas}) => {
56
+ const switchEl = canvas.getByRole('switch')
57
+ await expect(switchEl).toBeDisabled()
58
+ await expect(switchEl).toBeChecked()
59
+ },
60
+ }
61
+
62
+ export const WithoutLabel: Story = {
63
+ args: {name: 'darkMode'},
64
+ play: async ({canvas}) => {
65
+ await expect(canvas.getByRole('switch')).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
+ <Switch>Unchecked</Switch>
77
+ <Switch defaultChecked>Checked</Switch>
78
+ <Switch disabled>Disabled</Switch>
79
+ <Switch disabled defaultChecked>
80
+ Disabled checked
81
+ </Switch>
82
+ </html.div>
83
+ ),
84
+ play: async ({canvas}) => {
85
+ const switches = canvas.getAllByRole('switch')
86
+ await expect(switches.length).toBe(4)
87
+ },
88
+ }
@@ -0,0 +1,78 @@
1
+ import {type ReactNode, useCallback, useRef} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import {styles} from './styles.css'
5
+
6
+ interface SwitchProps {
7
+ /** Controlled checked state. */
8
+ checked?: boolean
9
+ /** Initial checked state (uncontrolled). */
10
+ defaultChecked?: boolean
11
+ /** Callback fired when the checked state changes. */
12
+ onCheckedChange?: (checked: boolean) => void
13
+ /** Prevents user interaction. */
14
+ disabled?: boolean
15
+ /** Form field name for the hidden input. */
16
+ name?: string
17
+ /** Value submitted when checked. */
18
+ value?: string
19
+ /** Label content rendered beside the switch. */
20
+ children?: ReactNode
21
+ }
22
+
23
+ export function Switch({
24
+ checked: controlledChecked,
25
+ defaultChecked = false,
26
+ onCheckedChange,
27
+ disabled = false,
28
+ name,
29
+ value,
30
+ children,
31
+ }: SwitchProps) {
32
+ const [checked, setChecked] = useControllableValue(
33
+ controlledChecked,
34
+ defaultChecked,
35
+ onCheckedChange,
36
+ )
37
+ const inputRef = useRef<HTMLInputElement>(null)
38
+
39
+ const handleClick = useCallback(() => {
40
+ if (disabled) return
41
+ setChecked(!checked)
42
+ // Keep hidden input in sync for form submission
43
+ if (inputRef.current) {
44
+ inputRef.current.checked = !checked
45
+ }
46
+ }, [disabled, checked, setChecked])
47
+
48
+ return (
49
+ <html.label style={[styles.root, disabled && styles.rootDisabled]}>
50
+ <html.input
51
+ ref={inputRef}
52
+ type="checkbox"
53
+ role="switch"
54
+ name={name}
55
+ value={value}
56
+ checked={controlledChecked !== undefined ? controlledChecked : undefined}
57
+ defaultChecked={controlledChecked === undefined ? defaultChecked : undefined}
58
+ disabled={disabled}
59
+ aria-checked={checked}
60
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
61
+ onCheckedChange?.(e.target.checked)
62
+ }}
63
+ style={styles.input}
64
+ />
65
+ <html.button
66
+ type="button"
67
+ tabIndex={-1}
68
+ aria-hidden
69
+ disabled={disabled}
70
+ onClick={handleClick}
71
+ style={[styles.track, checked ? styles.trackChecked : styles.trackUnchecked]}
72
+ >
73
+ <html.span style={[styles.thumb, checked && styles.thumbChecked]} />
74
+ </html.button>
75
+ {children && <html.span>{children}</html.span>}
76
+ </html.label>
77
+ )
78
+ }
@@ -0,0 +1,71 @@
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
+ track: {
21
+ position: 'relative',
22
+ width: 36,
23
+ height: 20,
24
+ borderRadius: radii.full,
25
+ borderWidth: 0,
26
+ padding: 0,
27
+ cursor: 'inherit',
28
+ transitionProperty: 'background-color',
29
+ transitionDuration: '150ms',
30
+ transitionTimingFunction: 'ease',
31
+ outlineWidth: {default: 0, ':focus-visible': 2},
32
+ outlineStyle: 'solid',
33
+ outlineColor: colors.accent,
34
+ outlineOffset: 2,
35
+ flexShrink: 0,
36
+ },
37
+ trackUnchecked: {
38
+ backgroundColor: {
39
+ default: colors.border,
40
+ ':hover': colors.textMuted,
41
+ },
42
+ },
43
+ trackChecked: {
44
+ backgroundColor: {
45
+ default: colors.accent,
46
+ ':hover': colors.accentHover,
47
+ },
48
+ },
49
+ thumb: {
50
+ position: 'absolute',
51
+ top: 2,
52
+ left: 2,
53
+ width: 16,
54
+ height: 16,
55
+ borderRadius: radii.full,
56
+ backgroundColor: '#fff',
57
+ transitionProperty: 'transform',
58
+ transitionDuration: '150ms',
59
+ transitionTimingFunction: 'ease',
60
+ },
61
+ thumbChecked: {
62
+ transform: 'translateX(16px)',
63
+ },
64
+ input: {
65
+ position: 'absolute',
66
+ width: 1,
67
+ height: 1,
68
+ opacity: 0,
69
+ overflow: 'hidden',
70
+ },
71
+ })