@duro-app/ui 0.14.1 → 0.15.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 (37) hide show
  1. package/dist/components/ActionBar/ActionBar.d.ts +17 -0
  2. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -0
  3. package/dist/components/ActionBar/ActionBar.stories.d.ts +14 -0
  4. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -0
  5. package/dist/components/ActionBar/styles.css.d.ts +81 -0
  6. package/dist/components/ActionBar/styles.css.d.ts.map +1 -0
  7. package/dist/components/Button/Button.d.ts +1 -1
  8. package/dist/components/Button/Button.d.ts.map +1 -1
  9. package/dist/components/Button/styles.css.d.ts +5 -0
  10. package/dist/components/Button/styles.css.d.ts.map +1 -1
  11. package/dist/components/Checkbox/Checkbox.d.ts +3 -1
  12. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
  13. package/dist/components/Select/Select.d.ts +2 -1
  14. package/dist/components/Select/Select.d.ts.map +1 -1
  15. package/dist/components/Table/Table.d.ts +3 -2
  16. package/dist/components/Table/Table.d.ts.map +1 -1
  17. package/dist/components/ThemeProvider/ThemeProvider.d.ts +2 -0
  18. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  19. package/dist/index.css +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1368 -1210
  23. package/dist/index.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/ActionBar/ActionBar.stories.tsx +272 -0
  26. package/src/components/ActionBar/ActionBar.tsx +80 -0
  27. package/src/components/ActionBar/styles.css.ts +106 -0
  28. package/src/components/Button/Button.tsx +1 -1
  29. package/src/components/Button/styles.css.ts +11 -0
  30. package/src/components/Checkbox/Checkbox.tsx +4 -0
  31. package/src/components/Select/Select.stories.tsx +4 -4
  32. package/src/components/Select/Select.tsx +2 -1
  33. package/src/components/Table/Table.tsx +2 -1
  34. package/src/components/ThemeProvider/ThemeProvider.tsx +21 -2
  35. package/src/components/ToggleGroup/ToggleGroup.tsx +1 -1
  36. package/src/components/Tooltip/Tooltip.stories.tsx +1 -1
  37. package/src/index.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,7 +38,7 @@
38
38
  "react-strict-dom": "^0.0.55"
39
39
  },
40
40
  "dependencies": {
41
- "@duro-app/tokens": "^0.4.0"
41
+ "@duro-app/tokens": "^0.15.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@babel/preset-typescript": "^7.28.0",
@@ -0,0 +1,272 @@
1
+ import {useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, fn, userEvent, within} from 'storybook/test'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {ActionBar} from './ActionBar'
6
+ import {Button} from '../Button/Button'
7
+ import {Checkbox} from '../Checkbox/Checkbox'
8
+ import {Table} from '../Table/Table'
9
+
10
+ interface ActionBarStoryArgs {
11
+ onClearSelection: () => void
12
+ }
13
+
14
+ const meta: Meta<ActionBarStoryArgs> = {
15
+ title: 'Components/ActionBar',
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj<ActionBarStoryArgs>
20
+
21
+ const storyStyles = css.create({
22
+ wrapper: {
23
+ display: 'flex',
24
+ flexDirection: 'column',
25
+ gap: 32,
26
+ padding: 24,
27
+ },
28
+ label: {
29
+ fontSize: '0.75rem',
30
+ fontWeight: 600,
31
+ textTransform: 'uppercase' as const,
32
+ letterSpacing: '0.05em',
33
+ opacity: 0.6,
34
+ marginBottom: 8,
35
+ },
36
+ })
37
+
38
+ const users = [
39
+ {id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin'},
40
+ {id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'User'},
41
+ {id: '3', name: 'Carol White', email: 'carol@example.com', role: 'User'},
42
+ {id: '4', name: 'Dan Brown', email: 'dan@example.com', role: 'Editor'},
43
+ ]
44
+
45
+ function SelectableTable({
46
+ initialSelected = new Set<string>(),
47
+ onClearSelection,
48
+ }: {
49
+ initialSelected?: Set<string>
50
+ onClearSelection?: () => void
51
+ }) {
52
+ const [selected, setSelected] = useState<Set<string>>(initialSelected)
53
+
54
+ const toggle = (id: string) => {
55
+ setSelected((prev) => {
56
+ const next = new Set(prev)
57
+ if (next.has(id)) next.delete(id)
58
+ else next.add(id)
59
+ return next
60
+ })
61
+ }
62
+
63
+ const toggleAll = () => {
64
+ if (selected.size === users.length) {
65
+ setSelected(new Set())
66
+ } else {
67
+ setSelected(new Set(users.map((u) => u.id)))
68
+ }
69
+ }
70
+
71
+ const clearSelection = () => {
72
+ setSelected(new Set())
73
+ onClearSelection?.()
74
+ }
75
+
76
+ return (
77
+ <>
78
+ <Table.Root columns={5}>
79
+ <Table.Header>
80
+ <Table.Row>
81
+ <Table.HeaderCell aria-label="Select">
82
+ <Checkbox
83
+ checked={selected.size === users.length}
84
+ onChange={toggleAll}
85
+ aria-label="Select all rows"
86
+ />
87
+ </Table.HeaderCell>
88
+ <Table.HeaderCell>Name</Table.HeaderCell>
89
+ <Table.HeaderCell>Email</Table.HeaderCell>
90
+ <Table.HeaderCell>Role</Table.HeaderCell>
91
+ <Table.HeaderCell>Actions</Table.HeaderCell>
92
+ </Table.Row>
93
+ </Table.Header>
94
+ <Table.Body>
95
+ {users.map((u) => (
96
+ <Table.Row key={u.id}>
97
+ <Table.Cell>
98
+ <Checkbox
99
+ checked={selected.has(u.id)}
100
+ onChange={() => toggle(u.id)}
101
+ aria-label={`Select ${u.name}`}
102
+ />
103
+ </Table.Cell>
104
+ <Table.Cell>{u.name}</Table.Cell>
105
+ <Table.Cell>{u.email}</Table.Cell>
106
+ <Table.Cell>{u.role}</Table.Cell>
107
+ <Table.Cell>
108
+ <Button variant="secondary" size="small">
109
+ Edit
110
+ </Button>
111
+ </Table.Cell>
112
+ </Table.Row>
113
+ ))}
114
+ </Table.Body>
115
+ </Table.Root>
116
+ <ActionBar
117
+ selectedItemCount={selected.size}
118
+ onClearSelection={clearSelection}
119
+ >
120
+ <Button variant="secondary" size="small">
121
+ Export
122
+ </Button>
123
+ <Button variant="danger" size="small">
124
+ Delete
125
+ </Button>
126
+ </ActionBar>
127
+ </>
128
+ )
129
+ }
130
+
131
+ export const Default: Story = {
132
+ args: {
133
+ onClearSelection: fn(),
134
+ },
135
+ render: (args) => (
136
+ <SelectableTable
137
+ initialSelected={new Set(['2'])}
138
+ onClearSelection={args.onClearSelection as () => void}
139
+ />
140
+ ),
141
+ play: async ({canvasElement}) => {
142
+ const canvas = within(canvasElement)
143
+ // The ActionBar is portalled to document.body, so query from there
144
+ const toolbar = document.querySelector('[role="toolbar"]')
145
+ await expect(toolbar).toBeInTheDocument()
146
+ await expect(toolbar).toHaveAccessibleName('1 selected')
147
+ },
148
+ }
149
+
150
+ export const MultipleSelected: Story = {
151
+ render: () => (
152
+ <SelectableTable initialSelected={new Set(['1', '3', '4'])} />
153
+ ),
154
+ }
155
+
156
+ export const Emphasized: Story = {
157
+ render: () => {
158
+ function Demo() {
159
+ const [selected, setSelected] = useState(new Set(['2', '3']))
160
+ const toggle = (id: string) => {
161
+ setSelected((prev) => {
162
+ const next = new Set(prev)
163
+ if (next.has(id)) next.delete(id)
164
+ else next.add(id)
165
+ return next
166
+ })
167
+ }
168
+ return (
169
+ <>
170
+ <Table.Root columns={3}>
171
+ <Table.Header>
172
+ <Table.Row>
173
+ <Table.HeaderCell aria-label="Select">
174
+ <Checkbox
175
+ checked={selected.size === users.length}
176
+ aria-label="Select all rows"
177
+ onChange={() =>
178
+ setSelected(
179
+ selected.size === users.length
180
+ ? new Set()
181
+ : new Set(users.map((u) => u.id)),
182
+ )
183
+ }
184
+ />
185
+ </Table.HeaderCell>
186
+ <Table.HeaderCell>Name</Table.HeaderCell>
187
+ <Table.HeaderCell>Email</Table.HeaderCell>
188
+ </Table.Row>
189
+ </Table.Header>
190
+ <Table.Body>
191
+ {users.map((u) => (
192
+ <Table.Row key={u.id}>
193
+ <Table.Cell>
194
+ <Checkbox
195
+ checked={selected.has(u.id)}
196
+ onChange={() => toggle(u.id)}
197
+ aria-label={`Select ${u.name}`}
198
+ />
199
+ </Table.Cell>
200
+ <Table.Cell>{u.name}</Table.Cell>
201
+ <Table.Cell>{u.email}</Table.Cell>
202
+ </Table.Row>
203
+ ))}
204
+ </Table.Body>
205
+ </Table.Root>
206
+ <ActionBar
207
+ selectedItemCount={selected.size}
208
+ isEmphasized
209
+ onClearSelection={() => setSelected(new Set())}
210
+ >
211
+ <Button variant="inverseSecondary" size="small">
212
+ Archive
213
+ </Button>
214
+ <Button variant="inverseSecondary" size="small">
215
+ Delete
216
+ </Button>
217
+ </ActionBar>
218
+ </>
219
+ )
220
+ }
221
+ return <Demo />
222
+ },
223
+ }
224
+
225
+ export const CustomLabel: Story = {
226
+ render: () => (
227
+ <>
228
+ <Table.Root columns={2}>
229
+ <Table.Header>
230
+ <Table.Row>
231
+ <Table.HeaderCell>Name</Table.HeaderCell>
232
+ <Table.HeaderCell>Status</Table.HeaderCell>
233
+ </Table.Row>
234
+ </Table.Header>
235
+ <Table.Body>
236
+ <Table.Row>
237
+ <Table.Cell>Traefik</Table.Cell>
238
+ <Table.Cell>Running</Table.Cell>
239
+ </Table.Row>
240
+ </Table.Body>
241
+ </Table.Root>
242
+ <ActionBar
243
+ selectedItemCount={3}
244
+ selectedLabel={(count) => `${count} users to revoke`}
245
+ onClearSelection={() => {}}
246
+ >
247
+ <Button variant="danger" size="small">
248
+ Confirm revoke
249
+ </Button>
250
+ </ActionBar>
251
+ </>
252
+ ),
253
+ }
254
+
255
+ export const Hidden: Story = {
256
+ render: () => (
257
+ <SelectableTable initialSelected={new Set()} />
258
+ ),
259
+ }
260
+
261
+ export const AllVariants: Story = {
262
+ render: () => (
263
+ <html.div style={storyStyles.wrapper}>
264
+ <html.div>
265
+ <html.span style={storyStyles.label}>
266
+ Default — check boxes to see the ActionBar
267
+ </html.span>
268
+ <SelectableTable initialSelected={new Set(['1'])} />
269
+ </html.div>
270
+ </html.div>
271
+ ),
272
+ }
@@ -0,0 +1,80 @@
1
+ import type {ReactNode} from 'react'
2
+ import {createPortal} from 'react-dom'
3
+ import {html} from 'react-strict-dom'
4
+ import {useOverlayContainer} from '../ThemeProvider/ThemeProvider'
5
+ import {styles} from './styles.css'
6
+
7
+ export interface ActionBarProps {
8
+ /** Number of selected items, or 'all'. Hidden when 0. */
9
+ selectedItemCount: number | 'all'
10
+ /** Label for the selection count. Receives the count string. */
11
+ selectedLabel?: (count: string) => string
12
+ /** Whether to use emphasized (accent) styling. */
13
+ isEmphasized?: boolean
14
+ /** Called when the clear/close button is pressed. */
15
+ onClearSelection: () => void
16
+ /** Action buttons to display. */
17
+ children: ReactNode
18
+ }
19
+
20
+ function ActionBarInner({
21
+ selectedItemCount,
22
+ selectedLabel,
23
+ isEmphasized = false,
24
+ onClearSelection,
25
+ children,
26
+ }: ActionBarProps) {
27
+ const container = useOverlayContainer()
28
+
29
+ if (selectedItemCount === 0) return null
30
+
31
+ const countStr = selectedItemCount === 'all' ? 'All' : `${selectedItemCount}`
32
+ const label = selectedLabel
33
+ ? selectedLabel(countStr)
34
+ : `${countStr} selected`
35
+
36
+ const bar = (
37
+ <html.div
38
+ role="toolbar"
39
+ aria-label={label}
40
+ style={[styles.overlay, isEmphasized && styles.overlayEmphasized]}
41
+ >
42
+ <html.span
43
+ style={[
44
+ styles.selectedCount,
45
+ isEmphasized && styles.selectedCountEmphasized,
46
+ ]}
47
+ >
48
+ {label}
49
+ </html.span>
50
+ <html.div
51
+ style={[
52
+ styles.separator,
53
+ isEmphasized && styles.separatorEmphasized,
54
+ ]}
55
+ />
56
+ <html.div style={styles.actions}>{children}</html.div>
57
+ <html.button
58
+ type="button"
59
+ aria-label="Clear selection"
60
+ onClick={onClearSelection}
61
+ style={[
62
+ styles.closeButton,
63
+ isEmphasized && styles.closeButtonEmphasized,
64
+ ]}
65
+ >
66
+ <svg width={10} height={10} viewBox="0 0 10 10" fill="none" aria-hidden="true">
67
+ <path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
68
+ </svg>
69
+ </html.button>
70
+ </html.div>
71
+ )
72
+
73
+ if (container) {
74
+ return createPortal(bar, container)
75
+ }
76
+
77
+ return bar
78
+ }
79
+
80
+ export const ActionBar = ActionBarInner
@@ -0,0 +1,106 @@
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
+ overlay: {
9
+ position: 'fixed',
10
+ bottom: spacing.lg,
11
+ left: '50%',
12
+ transform: 'translateX(-50%)',
13
+ display: 'flex',
14
+ flexDirection: 'row',
15
+ alignItems: 'center',
16
+ gap: spacing.md,
17
+ paddingTop: spacing.sm,
18
+ paddingBottom: spacing.sm,
19
+ paddingLeft: spacing.md,
20
+ paddingRight: spacing.md,
21
+ backgroundColor: colors.bgCard,
22
+ borderWidth: 1,
23
+ borderStyle: 'solid',
24
+ borderColor: colors.border,
25
+ borderRadius: radii.md,
26
+ boxShadow: shadows.lg,
27
+ zIndex: 50,
28
+ maxWidth: 'calc(100vw - 48px)',
29
+ },
30
+ overlayEmphasized: {
31
+ backgroundColor: colors.accent,
32
+ borderColor: colors.accent,
33
+ },
34
+ selectedCount: {
35
+ fontSize: typography.fontSizeSm,
36
+ fontWeight: typography.fontWeightMedium,
37
+ fontFamily: typography.fontFamily,
38
+ color: colors.text,
39
+ whiteSpace: 'nowrap',
40
+ },
41
+ selectedCountEmphasized: {
42
+ color: colors.accentContrast,
43
+ },
44
+ actions: {
45
+ display: 'flex',
46
+ flexDirection: 'row',
47
+ alignItems: 'center',
48
+ gap: spacing.sm,
49
+ },
50
+ closeButton: {
51
+ display: 'inline-flex',
52
+ alignItems: 'center',
53
+ justifyContent: 'center',
54
+ width: 28,
55
+ height: 28,
56
+ padding: 0,
57
+ backgroundColor: 'transparent',
58
+ borderWidth: 0,
59
+ borderRadius: radii.sm,
60
+ color: {
61
+ default: colors.textMuted,
62
+ ':hover': colors.text,
63
+ },
64
+ cursor: 'pointer',
65
+ transitionProperty: 'color, background-color',
66
+ transitionDuration: '150ms',
67
+ transitionTimingFunction: 'ease',
68
+ outlineWidth: {
69
+ default: 0,
70
+ ':focus-visible': 2,
71
+ },
72
+ outlineStyle: {
73
+ default: 'none',
74
+ ':focus-visible': 'solid',
75
+ },
76
+ outlineColor: {
77
+ default: 'transparent',
78
+ ':focus-visible': colors.accent,
79
+ },
80
+ outlineOffset: {
81
+ default: 0,
82
+ ':focus-visible': 2,
83
+ },
84
+ },
85
+ closeButtonEmphasized: {
86
+ color: {
87
+ default: colors.accentContrast,
88
+ ':hover': colors.accentContrast,
89
+ },
90
+ opacity: {
91
+ default: 0.7,
92
+ ':hover': 1,
93
+ },
94
+ },
95
+ separator: {
96
+ width: 1,
97
+ alignSelf: 'stretch',
98
+ backgroundColor: colors.border,
99
+ marginTop: spacing.xs,
100
+ marginBottom: spacing.xs,
101
+ },
102
+ separatorEmphasized: {
103
+ backgroundColor: colors.accentContrast,
104
+ opacity: 0.3,
105
+ },
106
+ })
@@ -2,7 +2,7 @@ import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
4
 
5
- export type ButtonVariant = 'primary' | 'secondary' | 'link' | 'danger'
5
+ export type ButtonVariant = 'primary' | 'secondary' | 'inverseSecondary' | 'link' | 'danger'
6
6
  export type ButtonSize = 'default' | 'small'
7
7
 
8
8
  interface ButtonProps {
@@ -85,6 +85,17 @@ export const styles = css.create({
85
85
  paddingLeft: 0,
86
86
  paddingRight: 0,
87
87
  },
88
+ inverseSecondary: {
89
+ backgroundColor: {
90
+ default: 'rgba(0, 0, 0, 0.10)',
91
+ ':hover': 'rgba(0, 0, 0, 0.18)',
92
+ },
93
+ borderColor: {
94
+ default: 'rgba(0, 0, 0, 0.55)',
95
+ ':hover': 'rgba(0, 0, 0, 0.70)',
96
+ },
97
+ color: colors.accentContrast,
98
+ },
88
99
  danger: {
89
100
  backgroundColor: {
90
101
  default: colors.error,
@@ -8,6 +8,8 @@ interface CheckboxProps {
8
8
  checked?: boolean
9
9
  defaultChecked?: boolean
10
10
  disabled?: boolean
11
+ /** Accessible label when no visible label (children) is provided. */
12
+ 'aria-label'?: string
11
13
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
12
14
  children?: ReactNode
13
15
  }
@@ -18,6 +20,7 @@ export function Checkbox({
18
20
  checked: controlledChecked,
19
21
  defaultChecked = false,
20
22
  disabled = false,
23
+ 'aria-label': ariaLabel,
21
24
  onChange,
22
25
  children,
23
26
  }: CheckboxProps) {
@@ -44,6 +47,7 @@ export function Checkbox({
44
47
  checked={isControlled ? controlledChecked : undefined}
45
48
  defaultChecked={!isControlled ? defaultChecked : undefined}
46
49
  disabled={disabled}
50
+ aria-label={!children ? ariaLabel : undefined}
47
51
  onChange={handleChange}
48
52
  style={styles.input}
49
53
  />
@@ -12,7 +12,7 @@ type Story = StoryObj
12
12
  export const Default: Story = {
13
13
  render: () => (
14
14
  <Select.Root defaultValue="en">
15
- <Select.Trigger>
15
+ <Select.Trigger aria-label="Language">
16
16
  <Select.Value />
17
17
  <Select.Icon />
18
18
  </Select.Trigger>
@@ -40,7 +40,7 @@ export const Default: Story = {
40
40
  export const OpenAndSelect: Story = {
41
41
  render: () => (
42
42
  <Select.Root defaultValue="en">
43
- <Select.Trigger>
43
+ <Select.Trigger aria-label="Language">
44
44
  <Select.Value />
45
45
  <Select.Icon />
46
46
  </Select.Trigger>
@@ -75,7 +75,7 @@ export const OpenAndSelect: Story = {
75
75
  export const WithPlaceholder: Story = {
76
76
  render: () => (
77
77
  <Select.Root>
78
- <Select.Trigger>
78
+ <Select.Trigger aria-label="Language">
79
79
  <Select.Value placeholder="Choose a language..." />
80
80
  <Select.Icon />
81
81
  </Select.Trigger>
@@ -119,7 +119,7 @@ export const WithPlaceholder: Story = {
119
119
  export const KeyboardNavigation: Story = {
120
120
  render: () => (
121
121
  <Select.Root>
122
- <Select.Trigger>
122
+ <Select.Trigger aria-label="Language">
123
123
  <Select.Value placeholder="Select..." />
124
124
  <Select.Icon />
125
125
  </Select.Trigger>
@@ -28,7 +28,7 @@ function Root({name, defaultValue, value, onValueChange, initialLabels, children
28
28
  }
29
29
 
30
30
  // --- Trigger ---
31
- function Trigger({children}: {children: ReactNode}) {
31
+ function Trigger({children, 'aria-label': ariaLabel}: {children: ReactNode; 'aria-label'?: string}) {
32
32
  const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
33
33
  const localRef = useRef<HTMLButtonElement>(null)
34
34
 
@@ -43,6 +43,7 @@ function Trigger({children}: {children: ReactNode}) {
43
43
  type="button"
44
44
  role={'combobox' as 'listbox'}
45
45
  onClick={toggle}
46
+ aria-label={ariaLabel}
46
47
  aria-expanded={open}
47
48
  aria-haspopup="listbox"
48
49
  aria-controls={open ? listboxId : undefined}
@@ -125,7 +125,7 @@ const CellIndexContext = createContext<{index: number; total: number}>({
125
125
 
126
126
  // --- HeaderCell ---
127
127
 
128
- function HeaderCell({children}: {children: ReactNode}) {
128
+ function HeaderCell({children, 'aria-label': ariaLabel}: {children?: ReactNode; 'aria-label'?: string}) {
129
129
  const {size, variant} = useTable()
130
130
  const {index, total} = useContext(CellIndexContext)
131
131
  const isLast = variant === 'bordered' && index === total - 1
@@ -133,6 +133,7 @@ function HeaderCell({children}: {children: ReactNode}) {
133
133
  return (
134
134
  <html.div
135
135
  role="columnheader"
136
+ aria-label={ariaLabel}
136
137
  style={[
137
138
  styles.headerCell,
138
139
  size === 'sm' ? styles.cellSm : styles.cellMd,
@@ -1,5 +1,6 @@
1
- import type {ReactNode} from 'react'
1
+ import {type ReactNode, createContext, useContext, useRef} from 'react'
2
2
  import {css, html} from 'react-strict-dom'
3
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
4
  import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
4
5
  import {highContrastTheme, highContrastShadows} from '@duro-app/tokens/themes/high-contrast.css'
5
6
 
@@ -18,6 +19,7 @@ const themeMap: Partial<Record<ThemeName, readonly [typeof lightTheme, typeof li
18
19
  const styles = css.create({
19
20
  root: {
20
21
  display: 'contents',
22
+ color: colors.text,
21
23
  },
22
24
  })
23
25
 
@@ -25,9 +27,26 @@ const styles = css.create({
25
27
  // concrete VarGroup lacks the generic index signature. This is a known typing gap.
26
28
  type DivStyle = Parameters<typeof html.div>[0]['style']
27
29
 
30
+ /** Context providing a portal target inside the themed tree. */
31
+ const OverlayContainerContext = createContext<React.RefObject<HTMLDivElement | null> | null>(null)
32
+
33
+ /** Returns the overlay container element for portals (inside the ThemeProvider). */
34
+ export function useOverlayContainer(): HTMLElement {
35
+ const ref = useContext(OverlayContainerContext)
36
+ return ref?.current ?? (typeof document !== 'undefined' ? document.body : null!)
37
+ }
38
+
28
39
  export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
40
+ const overlayRef = useRef<HTMLDivElement>(null)
29
41
  const overrides = themeMap[theme]
30
42
  const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
31
43
 
32
- return <html.div style={themeStyles}>{children}</html.div>
44
+ return (
45
+ <html.div style={themeStyles}>
46
+ <OverlayContainerContext.Provider value={overlayRef}>
47
+ {children}
48
+ <html.div ref={overlayRef} />
49
+ </OverlayContainerContext.Provider>
50
+ </html.div>
51
+ )
33
52
  }
@@ -52,7 +52,7 @@ export function ToggleGroup({
52
52
  return (
53
53
  <ToggleGroupContext.Provider value={{value, toggle, disabled, orientation, size}}>
54
54
  <html.div
55
- role="group"
55
+ role="toolbar"
56
56
  aria-orientation={orientation}
57
57
  style={[styles.root, orientation === 'vertical' && styles.vertical]}
58
58
  >
@@ -26,7 +26,7 @@ export const Default: Story = {
26
26
  <html.div style={centerStyles.center}>
27
27
  <Tooltip.Root content="This is a tooltip" delay={0}>
28
28
  <Tooltip.Trigger>
29
- <html.span>Hover me</html.span>
29
+ <Badge>Hover me</Badge>
30
30
  </Tooltip.Trigger>
31
31
  </Tooltip.Root>
32
32
  </html.div>
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import './strict.css'
2
2
 
3
3
  // Components
4
+ export {ActionBar, type ActionBarProps} from './components/ActionBar/ActionBar'
4
5
  export {Alert, type AlertVariant} from './components/Alert/Alert'
5
6
  export {Icon, type IconName} from './components/Icon'
6
7
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
@@ -32,7 +33,7 @@ export {
32
33
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
33
34
  export {Tabs} from './components/Tabs/Tabs'
34
35
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
35
- export {ThemeProvider, type ThemeName} from './components/ThemeProvider/ThemeProvider'
36
+ export {ThemeProvider, useOverlayContainer, type ThemeName} from './components/ThemeProvider/ThemeProvider'
36
37
  export {Text, type TextVariant, type TextColor} from './components/Text/Text'
37
38
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
38
39
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'