@duro-app/ui 0.12.2 → 0.14.1

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 (135) hide show
  1. package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
  2. package/dist/components/PageShell/PageShell.d.ts +15 -0
  3. package/dist/components/PageShell/PageShell.d.ts.map +1 -0
  4. package/dist/components/PageShell/index.d.ts +3 -0
  5. package/dist/components/PageShell/index.d.ts.map +1 -0
  6. package/dist/components/PageShell/styles.css.d.ts +41 -0
  7. package/dist/components/PageShell/styles.css.d.ts.map +1 -0
  8. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  9. package/dist/index.css +1 -1
  10. package/dist/index.d.ts +1 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3203 -3355
  13. package/dist/index.js.map +1 -1
  14. package/package.json +4 -4
  15. package/src/components/Alert/Alert.stories.tsx +76 -0
  16. package/src/components/Alert/Alert.tsx +45 -0
  17. package/src/components/Alert/styles.css.ts +50 -0
  18. package/src/components/Badge/Badge.stories.tsx +94 -0
  19. package/src/components/Badge/Badge.tsx +21 -0
  20. package/src/components/Badge/styles.css.ts +51 -0
  21. package/src/components/Button/Button.stories.tsx +130 -0
  22. package/src/components/Button/Button.tsx +48 -0
  23. package/src/components/Button/styles.css.ts +107 -0
  24. package/src/components/Callout/Callout.stories.tsx +97 -0
  25. package/src/components/Callout/Callout.tsx +39 -0
  26. package/src/components/Callout/index.ts +1 -0
  27. package/src/components/Callout/styles.css.ts +45 -0
  28. package/src/components/Card/Card.stories.tsx +119 -0
  29. package/src/components/Card/Card.tsx +35 -0
  30. package/src/components/Card/styles.css.ts +67 -0
  31. package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
  32. package/src/components/Checkbox/Checkbox.tsx +73 -0
  33. package/src/components/Checkbox/styles.css.ts +57 -0
  34. package/src/components/Cluster/Cluster.stories.tsx +92 -0
  35. package/src/components/Cluster/Cluster.tsx +43 -0
  36. package/src/components/Cluster/styles.css.ts +25 -0
  37. package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
  38. package/src/components/EmptyState/EmptyState.tsx +19 -0
  39. package/src/components/EmptyState/styles.css.ts +25 -0
  40. package/src/components/Field/Field.stories.tsx +92 -0
  41. package/src/components/Field/Field.tsx +80 -0
  42. package/src/components/Field/FieldContext.ts +14 -0
  43. package/src/components/Field/styles.css.ts +25 -0
  44. package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
  45. package/src/components/Fieldset/Fieldset.tsx +49 -0
  46. package/src/components/Fieldset/index.ts +1 -0
  47. package/src/components/Fieldset/styles.css.ts +33 -0
  48. package/src/components/Grid/Grid.stories.tsx +107 -0
  49. package/src/components/Grid/Grid.tsx +41 -0
  50. package/src/components/Grid/styles.css.ts +25 -0
  51. package/src/components/Heading/Heading.tsx +48 -0
  52. package/src/components/Heading/styles.css.ts +26 -0
  53. package/src/components/Icon/Icon.tsx +168 -0
  54. package/src/components/Icon/index.ts +2 -0
  55. package/src/components/Inline/Inline.stories.tsx +88 -0
  56. package/src/components/Inline/Inline.tsx +45 -0
  57. package/src/components/Inline/styles.css.ts +27 -0
  58. package/src/components/Input/Input.stories.tsx +89 -0
  59. package/src/components/Input/Input.tsx +77 -0
  60. package/src/components/Input/styles.css.ts +60 -0
  61. package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
  62. package/src/components/InputGroup/InputGroup.tsx +60 -0
  63. package/src/components/InputGroup/InputGroupContext.ts +11 -0
  64. package/src/components/InputGroup/styles.css.ts +61 -0
  65. package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
  66. package/src/components/LinkButton/LinkButton.tsx +42 -0
  67. package/src/components/LinkButton/styles.css.ts +56 -0
  68. package/src/components/Menu/Menu.stories.tsx +146 -0
  69. package/src/components/Menu/Menu.tsx +151 -0
  70. package/src/components/Menu/MenuContext.ts +20 -0
  71. package/src/components/Menu/styles.css.ts +89 -0
  72. package/src/components/Menu/useMenuRoot.ts +136 -0
  73. package/src/components/PageShell/PageShell.tsx +45 -0
  74. package/src/components/PageShell/index.ts +2 -0
  75. package/src/components/PageShell/styles.css.ts +26 -0
  76. package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
  77. package/src/components/ScrollArea/ScrollArea.tsx +170 -0
  78. package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
  79. package/src/components/ScrollArea/styles.css.ts +81 -0
  80. package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
  81. package/src/components/Select/Select.stories.tsx +144 -0
  82. package/src/components/Select/Select.tsx +183 -0
  83. package/src/components/Select/SelectContext.ts +24 -0
  84. package/src/components/Select/styles.css.ts +97 -0
  85. package/src/components/Select/useSelectRoot.ts +178 -0
  86. package/src/components/SideNav/SideNav.stories.tsx +77 -0
  87. package/src/components/SideNav/SideNav.tsx +172 -0
  88. package/src/components/SideNav/SideNavContext.ts +18 -0
  89. package/src/components/SideNav/styles.css.ts +95 -0
  90. package/src/components/Spinner/Spinner.stories.tsx +59 -0
  91. package/src/components/Spinner/Spinner.tsx +24 -0
  92. package/src/components/Spinner/styles.css.ts +47 -0
  93. package/src/components/Stack/Stack.stories.tsx +103 -0
  94. package/src/components/Stack/Stack.tsx +33 -0
  95. package/src/components/Stack/styles.css.ts +21 -0
  96. package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
  97. package/src/components/StatusIcon/StatusIcon.tsx +24 -0
  98. package/src/components/StatusIcon/styles.css.ts +27 -0
  99. package/src/components/Switch/Switch.stories.tsx +88 -0
  100. package/src/components/Switch/Switch.tsx +78 -0
  101. package/src/components/Switch/styles.css.ts +71 -0
  102. package/src/components/Table/Table.stories.tsx +308 -0
  103. package/src/components/Table/Table.tsx +179 -0
  104. package/src/components/Table/styles.css.ts +97 -0
  105. package/src/components/Tabs/Tabs.stories.tsx +142 -0
  106. package/src/components/Tabs/Tabs.tsx +210 -0
  107. package/src/components/Tabs/TabsContext.ts +20 -0
  108. package/src/components/Tabs/styles.css.ts +98 -0
  109. package/src/components/Tabs/useTabsRoot.ts +42 -0
  110. package/src/components/Text/Text.tsx +52 -0
  111. package/src/components/Text/styles.css.ts +57 -0
  112. package/src/components/Textarea/Textarea.stories.tsx +80 -0
  113. package/src/components/Textarea/Textarea.tsx +50 -0
  114. package/src/components/Textarea/styles.css.ts +56 -0
  115. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
  116. package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
  117. package/src/components/Toggle/Toggle.stories.tsx +84 -0
  118. package/src/components/Toggle/Toggle.tsx +85 -0
  119. package/src/components/Toggle/styles.css.ts +66 -0
  120. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
  121. package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
  122. package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
  123. package/src/components/ToggleGroup/styles.css.ts +17 -0
  124. package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
  125. package/src/components/Tooltip/Tooltip.tsx +97 -0
  126. package/src/components/Tooltip/styles.css.ts +56 -0
  127. package/src/docs/Spacing.mdx +80 -0
  128. package/src/docs/Spacing.stories.tsx +202 -0
  129. package/src/docs/Typography.mdx +93 -0
  130. package/src/docs/Typography.stories.tsx +211 -0
  131. package/src/docs/helpers.tsx +135 -0
  132. package/src/hooks/useContainerQuery.ts +54 -0
  133. package/src/hooks/useControllableValue.ts +18 -0
  134. package/src/index.ts +56 -0
  135. package/src/stubs/assets-registry.ts +3 -0
@@ -0,0 +1,50 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {useFieldContext} from '../Field/FieldContext'
3
+ import {styles} from './styles.css'
4
+
5
+ export type TextareaVariant = 'default' | 'error'
6
+
7
+ interface TextareaProps {
8
+ variant?: TextareaVariant
9
+ name?: string
10
+ placeholder?: string
11
+ required?: boolean
12
+ rows?: number
13
+ value?: string
14
+ defaultValue?: string
15
+ disabled?: boolean
16
+ onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
17
+ }
18
+
19
+ export function Textarea({
20
+ variant = 'default',
21
+ name,
22
+ placeholder,
23
+ required,
24
+ rows = 3,
25
+ value,
26
+ defaultValue,
27
+ disabled,
28
+ onChange,
29
+ }: TextareaProps) {
30
+ const ctx = useFieldContext()
31
+
32
+ return (
33
+ <html.textarea
34
+ id={ctx?.controlId}
35
+ name={name}
36
+ placeholder={placeholder}
37
+ required={required}
38
+ rows={rows}
39
+ value={value}
40
+ defaultValue={defaultValue}
41
+ disabled={disabled}
42
+ aria-describedby={
43
+ ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
44
+ }
45
+ aria-invalid={ctx?.invalid || variant === 'error' || undefined}
46
+ onChange={onChange}
47
+ style={[styles.base, styles[variant]]}
48
+ />
49
+ )
50
+ }
@@ -0,0 +1,56 @@
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
+ boxSizing: 'border-box',
9
+ width: '100%',
10
+ paddingTop: spacing.sm,
11
+ paddingBottom: spacing.sm,
12
+ paddingLeft: spacing.md,
13
+ paddingRight: spacing.md,
14
+ fontFamily: typography.fontFamily,
15
+ fontSize: typography.fontSizeSm,
16
+ lineHeight: typography.lineHeight,
17
+ color: colors.text,
18
+ backgroundColor: colors.bg,
19
+ borderWidth: 1,
20
+ borderStyle: 'solid',
21
+ borderRadius: radii.sm,
22
+ resize: 'vertical' as const,
23
+ transitionProperty: 'border-color',
24
+ transitionDuration: '150ms',
25
+ transitionTimingFunction: 'ease',
26
+ outlineWidth: {
27
+ default: 0,
28
+ ':focus-visible': 2,
29
+ },
30
+ outlineStyle: {
31
+ default: 'none',
32
+ ':focus-visible': 'solid',
33
+ },
34
+ outlineColor: {
35
+ default: 'transparent',
36
+ ':focus-visible': colors.accent,
37
+ },
38
+ outlineOffset: {
39
+ default: 0,
40
+ ':focus-visible': 1,
41
+ },
42
+ },
43
+ default: {
44
+ borderColor: {
45
+ default: colors.border,
46
+ ':hover': colors.textMuted,
47
+ ':focus': colors.accent,
48
+ },
49
+ },
50
+ error: {
51
+ borderColor: {
52
+ default: colors.error,
53
+ ':focus': colors.error,
54
+ },
55
+ },
56
+ })
@@ -0,0 +1,163 @@
1
+ import type React from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect} from 'storybook/test'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {ThemeProvider, type ThemeName} from './ThemeProvider'
6
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
7
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
8
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
9
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
10
+
11
+ const meta: Meta<typeof ThemeProvider> = {
12
+ title: 'Theme/ThemeProvider',
13
+ component: ThemeProvider,
14
+ }
15
+
16
+ export default meta
17
+ type Story = StoryObj<typeof ThemeProvider>
18
+
19
+ const sampleStyles = css.create({
20
+ container: {
21
+ padding: 24,
22
+ backgroundColor: colors.bg,
23
+ color: colors.text,
24
+ borderRadius: radii.md,
25
+ fontFamily: typography.fontFamily,
26
+ },
27
+ card: {
28
+ padding: 16,
29
+ backgroundColor: colors.bgCard,
30
+ borderWidth: 1,
31
+ borderStyle: 'solid',
32
+ borderColor: colors.border,
33
+ borderRadius: radii.md,
34
+ boxShadow: shadows.md,
35
+ marginBottom: 12,
36
+ },
37
+ title: {
38
+ fontSize: typography.fontSizeLg,
39
+ fontWeight: typography.fontWeightSemibold,
40
+ marginBottom: 8,
41
+ },
42
+ muted: {
43
+ color: colors.textMuted,
44
+ fontSize: typography.fontSizeSm,
45
+ },
46
+ accent: {
47
+ color: colors.accent,
48
+ fontWeight: typography.fontWeightMedium,
49
+ },
50
+ row: {
51
+ display: 'flex',
52
+ gap: 16,
53
+ flexWrap: 'wrap',
54
+ },
55
+ swatch: {
56
+ width: 48,
57
+ height: 48,
58
+ borderRadius: radii.sm,
59
+ borderWidth: 1,
60
+ borderStyle: 'solid',
61
+ borderColor: colors.border,
62
+ },
63
+ errorSwatch: {backgroundColor: colors.error},
64
+ successSwatch: {backgroundColor: colors.success},
65
+ warningSwatch: {backgroundColor: colors.warning},
66
+ accentSwatch: {backgroundColor: colors.accent},
67
+ })
68
+
69
+ function SampleContent() {
70
+ return (
71
+ <html.div style={sampleStyles.container}>
72
+ <html.div style={sampleStyles.card}>
73
+ <html.div style={sampleStyles.title}>Sample Card</html.div>
74
+ <html.span style={sampleStyles.muted}>This is a muted description.</html.span>
75
+ </html.div>
76
+ <html.div style={sampleStyles.card}>
77
+ <html.span style={sampleStyles.accent}>Accent colored text</html.span>
78
+ </html.div>
79
+ <html.div style={sampleStyles.row}>
80
+ <html.div style={[sampleStyles.swatch, sampleStyles.errorSwatch]} />
81
+ <html.div style={[sampleStyles.swatch, sampleStyles.successSwatch]} />
82
+ <html.div style={[sampleStyles.swatch, sampleStyles.warningSwatch]} />
83
+ <html.div style={[sampleStyles.swatch, sampleStyles.accentSwatch]} />
84
+ </html.div>
85
+ </html.div>
86
+ )
87
+ }
88
+
89
+ export const Dark: Story = {
90
+ args: {theme: 'dark'},
91
+ render: (args: {theme?: ThemeName; children?: React.ReactNode}) => (
92
+ <ThemeProvider {...args}>
93
+ <SampleContent />
94
+ </ThemeProvider>
95
+ ),
96
+ play: async ({canvas}) => {
97
+ await expect(canvas.getByText('Sample Card')).toBeInTheDocument()
98
+ await expect(canvas.getByText('Accent colored text')).toBeInTheDocument()
99
+ },
100
+ }
101
+
102
+ export const Light: Story = {
103
+ args: {theme: 'light'},
104
+ render: (args: {theme?: ThemeName; children?: React.ReactNode}) => (
105
+ <ThemeProvider {...args}>
106
+ <SampleContent />
107
+ </ThemeProvider>
108
+ ),
109
+ play: async ({canvas}) => {
110
+ await expect(canvas.getByText('Sample Card')).toBeInTheDocument()
111
+ },
112
+ }
113
+
114
+ export const HighContrast: Story = {
115
+ args: {theme: 'high-contrast'},
116
+ render: (args: {theme?: ThemeName; children?: React.ReactNode}) => (
117
+ <ThemeProvider {...args}>
118
+ <SampleContent />
119
+ </ThemeProvider>
120
+ ),
121
+ play: async ({canvas}) => {
122
+ await expect(canvas.getByText('Sample Card')).toBeInTheDocument()
123
+ },
124
+ }
125
+
126
+ const sideBySideStyles = css.create({
127
+ wrapper: {
128
+ display: 'flex',
129
+ gap: 24,
130
+ flexWrap: 'wrap',
131
+ },
132
+ column: {
133
+ flex: 1,
134
+ minWidth: 280,
135
+ },
136
+ label: {
137
+ fontSize: typography.fontSizeSm,
138
+ fontWeight: typography.fontWeightSemibold,
139
+ marginBottom: 8,
140
+ color: '#888',
141
+ },
142
+ })
143
+
144
+ export const AllThemes: Story = {
145
+ render: () => (
146
+ <html.div style={sideBySideStyles.wrapper}>
147
+ {(['dark', 'light', 'high-contrast'] as const).map((theme) => (
148
+ <html.div key={theme} style={sideBySideStyles.column}>
149
+ <html.div style={sideBySideStyles.label}>{theme}</html.div>
150
+ <ThemeProvider theme={theme}>
151
+ <SampleContent />
152
+ </ThemeProvider>
153
+ </html.div>
154
+ ))}
155
+ </html.div>
156
+ ),
157
+ play: async ({canvas}) => {
158
+ // All three theme labels rendered
159
+ await expect(canvas.getByText('dark')).toBeInTheDocument()
160
+ await expect(canvas.getByText('light')).toBeInTheDocument()
161
+ await expect(canvas.getByText('high-contrast')).toBeInTheDocument()
162
+ },
163
+ }
@@ -0,0 +1,33 @@
1
+ import type {ReactNode} from 'react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
4
+ import {highContrastTheme, highContrastShadows} from '@duro-app/tokens/themes/high-contrast.css'
5
+
6
+ export type ThemeName = 'dark' | 'light' | 'high-contrast'
7
+
8
+ interface ThemeProviderProps {
9
+ theme?: ThemeName
10
+ children: ReactNode
11
+ }
12
+
13
+ const themeMap: Partial<Record<ThemeName, readonly [typeof lightTheme, typeof lightShadows]>> = {
14
+ light: [lightTheme, lightShadows],
15
+ 'high-contrast': [highContrastTheme, highContrastShadows],
16
+ }
17
+
18
+ const styles = css.create({
19
+ root: {
20
+ display: 'contents',
21
+ },
22
+ })
23
+
24
+ // react-strict-dom's style prop rejects Theme<VarGroup<{named keys}>> because the
25
+ // concrete VarGroup lacks the generic index signature. This is a known typing gap.
26
+ type DivStyle = Parameters<typeof html.div>[0]['style']
27
+
28
+ export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
29
+ const overrides = themeMap[theme]
30
+ const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
31
+
32
+ return <html.div style={themeStyles}>{children}</html.div>
33
+ }
@@ -0,0 +1,84 @@
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 {Toggle} from './Toggle'
5
+
6
+ const meta: Meta<typeof Toggle> = {
7
+ title: 'Components/Toggle',
8
+ component: Toggle,
9
+ args: {
10
+ onPressedChange: fn(),
11
+ },
12
+ argTypes: {
13
+ pressed: {control: 'boolean'},
14
+ disabled: {control: 'boolean'},
15
+ defaultPressed: {control: 'boolean'},
16
+ size: {control: 'select', options: ['default', 'small']},
17
+ },
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj<typeof Toggle>
22
+
23
+ export const Default: Story = {
24
+ args: {children: 'Bold'},
25
+ play: async ({canvas, userEvent, args}) => {
26
+ const toggle = canvas.getByRole('button')
27
+ await expect(toggle).toBeInTheDocument()
28
+ await expect(toggle).toHaveAttribute('aria-pressed', 'false')
29
+
30
+ await userEvent.click(toggle)
31
+ await expect(args.onPressedChange).toHaveBeenCalledWith(true)
32
+ },
33
+ }
34
+
35
+ export const Pressed: Story = {
36
+ args: {defaultPressed: true, children: 'Bold'},
37
+ play: async ({canvas}) => {
38
+ const toggle = canvas.getByRole('button')
39
+ await expect(toggle).toHaveAttribute('aria-pressed', 'true')
40
+ },
41
+ }
42
+
43
+ export const Disabled: Story = {
44
+ args: {disabled: true, children: 'Bold'},
45
+ play: async ({canvas, userEvent, args}) => {
46
+ const toggle = canvas.getByRole('button')
47
+ await expect(toggle).toBeDisabled()
48
+
49
+ await userEvent.click(toggle)
50
+ await expect(args.onPressedChange).not.toHaveBeenCalled()
51
+ },
52
+ }
53
+
54
+ export const Small: Story = {
55
+ args: {size: 'small', children: 'B'},
56
+ play: async ({canvas}) => {
57
+ await expect(canvas.getByRole('button')).toBeInTheDocument()
58
+ },
59
+ }
60
+
61
+ const stackStyles = css.create({
62
+ stack: {display: 'flex', gap: 8},
63
+ })
64
+
65
+ export const AllVariants: Story = {
66
+ render: () => (
67
+ <html.div style={stackStyles.stack}>
68
+ <Toggle>Off</Toggle>
69
+ <Toggle defaultPressed>On</Toggle>
70
+ <Toggle disabled>Disabled</Toggle>
71
+ <Toggle disabled defaultPressed>
72
+ Disabled On
73
+ </Toggle>
74
+ <Toggle size="small">Sm</Toggle>
75
+ <Toggle size="small" defaultPressed>
76
+ Sm On
77
+ </Toggle>
78
+ </html.div>
79
+ ),
80
+ play: async ({canvas}) => {
81
+ const buttons = canvas.getAllByRole('button')
82
+ await expect(buttons.length).toBe(6)
83
+ },
84
+ }
@@ -0,0 +1,85 @@
1
+ import {type ReactNode, useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
5
+ import {styles} from './styles.css'
6
+
7
+ export type ToggleSize = 'default' | 'small'
8
+
9
+ interface ToggleProps {
10
+ /** Controlled pressed state (standalone usage). */
11
+ pressed?: boolean
12
+ /** Initial pressed state (uncontrolled, standalone usage). */
13
+ defaultPressed?: boolean
14
+ /** Callback fired when the pressed state changes (standalone usage). */
15
+ onPressedChange?: (pressed: boolean) => void
16
+ /** Unique value when used inside a ToggleGroup. */
17
+ value?: string
18
+ /** Prevents user interaction. */
19
+ disabled?: boolean
20
+ /** Size variant (overridden by ToggleGroup when grouped). */
21
+ size?: ToggleSize
22
+ 'aria-label'?: string
23
+ children: ReactNode
24
+ }
25
+
26
+ const sizeMap = {
27
+ default: styles.sizeDefault,
28
+ small: styles.sizeSmall,
29
+ } as const
30
+
31
+ export function Toggle({
32
+ pressed: controlledPressed,
33
+ defaultPressed = false,
34
+ onPressedChange,
35
+ value,
36
+ disabled: disabledProp = false,
37
+ size: sizeProp = 'default',
38
+ 'aria-label': ariaLabel,
39
+ children,
40
+ }: ToggleProps) {
41
+ const group = useToggleGroup()
42
+
43
+ // When inside a ToggleGroup, derive pressed state from group context
44
+ const groupPressed = group && value !== undefined ? group.value.includes(value) : undefined
45
+ const isGrouped = group !== null
46
+ const disabled = disabledProp || (group?.disabled ?? false)
47
+ const size = group?.size ?? sizeProp
48
+
49
+ const [standalonePressed, setStandalonePressed] = useControllableValue(
50
+ controlledPressed,
51
+ defaultPressed,
52
+ onPressedChange,
53
+ )
54
+
55
+ const pressed = groupPressed ?? standalonePressed
56
+
57
+ const handleClick = useCallback(() => {
58
+ if (disabled) return
59
+ if (isGrouped && value !== undefined) {
60
+ group.toggle(value)
61
+ } else {
62
+ setStandalonePressed(!pressed)
63
+ }
64
+ }, [disabled, isGrouped, value, group, pressed, setStandalonePressed])
65
+
66
+ return (
67
+ <html.button
68
+ type="button"
69
+ aria-pressed={pressed}
70
+ aria-label={ariaLabel}
71
+ disabled={disabled}
72
+ onClick={handleClick}
73
+ data-pressed={pressed ? '' : undefined}
74
+ style={[
75
+ styles.base,
76
+ sizeMap[size],
77
+ pressed ? styles.pressed : styles.unpressed,
78
+ isGrouped && styles.grouped,
79
+ disabled && styles.disabled,
80
+ ]}
81
+ >
82
+ {children}
83
+ </html.button>
84
+ )
85
+ }
@@ -0,0 +1,66 @@
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
+ display: 'inline-flex',
9
+ alignItems: 'center',
10
+ justifyContent: 'center',
11
+ borderWidth: 1,
12
+ borderStyle: 'solid',
13
+ cursor: 'pointer',
14
+ fontFamily: typography.fontFamily,
15
+ fontWeight: typography.fontWeightMedium,
16
+ transitionProperty: 'background-color, border-color, color, opacity',
17
+ transitionDuration: '150ms',
18
+ transitionTimingFunction: 'ease',
19
+ outlineWidth: {default: 0, ':focus-visible': 2},
20
+ outlineStyle: 'solid',
21
+ outlineColor: colors.accent,
22
+ outlineOffset: 2,
23
+ },
24
+ sizeDefault: {
25
+ padding: `${spacing.sm} ${spacing.md}`,
26
+ fontSize: typography.fontSizeSm,
27
+ borderRadius: radii.sm,
28
+ gap: spacing.sm,
29
+ },
30
+ sizeSmall: {
31
+ padding: `${spacing.xs} ${spacing.sm}`,
32
+ fontSize: typography.fontSizeXs,
33
+ borderRadius: radii.sm,
34
+ gap: spacing.xs,
35
+ },
36
+ unpressed: {
37
+ backgroundColor: {
38
+ default: 'transparent',
39
+ ':hover': colors.bgCardHover,
40
+ },
41
+ borderColor: {
42
+ default: colors.border,
43
+ ':hover': colors.textMuted,
44
+ },
45
+ color: colors.textMuted,
46
+ },
47
+ pressed: {
48
+ backgroundColor: {
49
+ default: colors.accent,
50
+ ':hover': colors.accentHover,
51
+ },
52
+ borderColor: colors.accent,
53
+ color: colors.accentContrast,
54
+ },
55
+ grouped: {
56
+ borderWidth: 0,
57
+ borderRadius: 0,
58
+ borderRightWidth: 1,
59
+ borderRightStyle: 'solid',
60
+ borderRightColor: colors.border,
61
+ },
62
+ disabled: {
63
+ opacity: 0.5,
64
+ cursor: 'not-allowed',
65
+ },
66
+ })
@@ -0,0 +1,159 @@
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 {ToggleGroup} from './ToggleGroup'
5
+ import {Toggle} from '../Toggle/Toggle'
6
+
7
+ const meta: Meta<typeof ToggleGroup> = {
8
+ title: 'Components/ToggleGroup',
9
+ component: ToggleGroup,
10
+ args: {
11
+ onValueChange: fn(),
12
+ },
13
+ argTypes: {
14
+ multiple: {control: 'boolean'},
15
+ disabled: {control: 'boolean'},
16
+ orientation: {control: 'select', options: ['horizontal', 'vertical']},
17
+ size: {control: 'select', options: ['default', 'small']},
18
+ },
19
+ }
20
+
21
+ export default meta
22
+ type Story = StoryObj<typeof ToggleGroup>
23
+
24
+ export const Single: Story = {
25
+ args: {defaultValue: ['center']},
26
+ render: (args) => (
27
+ <ToggleGroup {...args}>
28
+ <Toggle value="left" aria-label="Align left">
29
+ Left
30
+ </Toggle>
31
+ <Toggle value="center" aria-label="Align center">
32
+ Center
33
+ </Toggle>
34
+ <Toggle value="right" aria-label="Align right">
35
+ Right
36
+ </Toggle>
37
+ </ToggleGroup>
38
+ ),
39
+ play: async ({canvas, userEvent}) => {
40
+ const buttons = canvas.getAllByRole('button')
41
+ await expect(buttons.length).toBe(3)
42
+
43
+ // Center should be pressed by default
44
+ await expect(buttons[1]).toHaveAttribute('aria-pressed', 'true')
45
+
46
+ // Click left — should deselect center
47
+ await userEvent.click(buttons[0])
48
+ await expect(buttons[0]).toHaveAttribute('aria-pressed', 'true')
49
+ await expect(buttons[1]).toHaveAttribute('aria-pressed', 'false')
50
+ },
51
+ }
52
+
53
+ export const Multiple: Story = {
54
+ args: {multiple: true, defaultValue: ['bold']},
55
+ render: (args) => (
56
+ <ToggleGroup {...args}>
57
+ <Toggle value="bold" aria-label="Bold">
58
+ B
59
+ </Toggle>
60
+ <Toggle value="italic" aria-label="Italic">
61
+ I
62
+ </Toggle>
63
+ <Toggle value="underline" aria-label="Underline">
64
+ U
65
+ </Toggle>
66
+ </ToggleGroup>
67
+ ),
68
+ play: async ({canvas, userEvent}) => {
69
+ const buttons = canvas.getAllByRole('button')
70
+ await expect(buttons[0]).toHaveAttribute('aria-pressed', 'true')
71
+
72
+ // Click italic — both bold and italic should be pressed
73
+ await userEvent.click(buttons[1])
74
+ await expect(buttons[0]).toHaveAttribute('aria-pressed', 'true')
75
+ await expect(buttons[1]).toHaveAttribute('aria-pressed', 'true')
76
+ },
77
+ }
78
+
79
+ export const Disabled: Story = {
80
+ args: {disabled: true, defaultValue: ['center']},
81
+ render: (args) => (
82
+ <ToggleGroup {...args}>
83
+ <Toggle value="left">Left</Toggle>
84
+ <Toggle value="center">Center</Toggle>
85
+ <Toggle value="right">Right</Toggle>
86
+ </ToggleGroup>
87
+ ),
88
+ play: async ({canvas, userEvent, args}) => {
89
+ const buttons = canvas.getAllByRole('button')
90
+ await expect(buttons[0]).toBeDisabled()
91
+ await expect(buttons[1]).toBeDisabled()
92
+
93
+ await userEvent.click(buttons[0])
94
+ await expect(args.onValueChange).not.toHaveBeenCalled()
95
+ },
96
+ }
97
+
98
+ export const Small: Story = {
99
+ args: {size: 'small', defaultValue: ['b']},
100
+ render: (args) => (
101
+ <ToggleGroup {...args}>
102
+ <Toggle value="b">B</Toggle>
103
+ <Toggle value="i">I</Toggle>
104
+ <Toggle value="u">U</Toggle>
105
+ </ToggleGroup>
106
+ ),
107
+ play: async ({canvas}) => {
108
+ await expect(canvas.getAllByRole('button').length).toBe(3)
109
+ },
110
+ }
111
+
112
+ export const Vertical: Story = {
113
+ args: {orientation: 'vertical', defaultValue: ['top']},
114
+ render: (args) => (
115
+ <ToggleGroup {...args}>
116
+ <Toggle value="top">Top</Toggle>
117
+ <Toggle value="middle">Middle</Toggle>
118
+ <Toggle value="bottom">Bottom</Toggle>
119
+ </ToggleGroup>
120
+ ),
121
+ play: async ({canvas}) => {
122
+ const group = canvas.getByRole('group')
123
+ await expect(group).toHaveAttribute('aria-orientation', 'vertical')
124
+ },
125
+ }
126
+
127
+ const stackStyles = css.create({
128
+ stack: {display: 'flex', flexDirection: 'column', gap: 16},
129
+ })
130
+
131
+ export const AllVariants: Story = {
132
+ render: () => (
133
+ <html.div style={stackStyles.stack}>
134
+ <ToggleGroup defaultValue={['center']}>
135
+ <Toggle value="left">Left</Toggle>
136
+ <Toggle value="center">Center</Toggle>
137
+ <Toggle value="right">Right</Toggle>
138
+ </ToggleGroup>
139
+ <ToggleGroup multiple defaultValue={['bold', 'italic']}>
140
+ <Toggle value="bold">B</Toggle>
141
+ <Toggle value="italic">I</Toggle>
142
+ <Toggle value="underline">U</Toggle>
143
+ </ToggleGroup>
144
+ <ToggleGroup size="small" defaultValue={['a']}>
145
+ <Toggle value="a">A</Toggle>
146
+ <Toggle value="b">B</Toggle>
147
+ <Toggle value="c">C</Toggle>
148
+ </ToggleGroup>
149
+ <ToggleGroup disabled defaultValue={['x']}>
150
+ <Toggle value="x">X</Toggle>
151
+ <Toggle value="y">Y</Toggle>
152
+ </ToggleGroup>
153
+ </html.div>
154
+ ),
155
+ play: async ({canvas}) => {
156
+ const groups = canvas.getAllByRole('group')
157
+ await expect(groups.length).toBe(4)
158
+ },
159
+ }