@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.
- package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
- 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 +3203 -3355
- 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 +49 -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,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
|
+
}
|