@duro-app/ui 0.17.1 → 0.19.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 (70) hide show
  1. package/dist/components/ActionBar/ActionBar.d.ts +1 -1
  2. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  3. package/dist/components/ActionBar/ActionBarProvider.d.ts +12 -0
  4. package/dist/components/ActionBar/ActionBarProvider.d.ts.map +1 -0
  5. package/dist/components/Checkbox/Checkbox.meta.d.ts.map +1 -1
  6. package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -1
  7. package/dist/components/Dialog/Dialog.d.ts +58 -0
  8. package/dist/components/Dialog/Dialog.d.ts.map +1 -0
  9. package/dist/components/Dialog/Dialog.stories.d.ts +13 -0
  10. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -0
  11. package/dist/components/Dialog/styles.css.d.ts +140 -0
  12. package/dist/components/Dialog/styles.css.d.ts.map +1 -0
  13. package/dist/components/Drawer/Drawer.d.ts +62 -0
  14. package/dist/components/Drawer/Drawer.d.ts.map +1 -0
  15. package/dist/components/Drawer/Drawer.stories.d.ts +15 -0
  16. package/dist/components/Drawer/Drawer.stories.d.ts.map +1 -0
  17. package/dist/components/Drawer/styles.css.d.ts +184 -0
  18. package/dist/components/Drawer/styles.css.d.ts.map +1 -0
  19. package/dist/components/Drawer/useSwipeDismiss.d.ts +17 -0
  20. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -0
  21. package/dist/components/Field/Field.meta.d.ts.map +1 -1
  22. package/dist/components/Fieldset/Fieldset.meta.d.ts.map +1 -1
  23. package/dist/components/Form/Form.meta.d.ts.map +1 -1
  24. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  25. package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
  26. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  27. package/dist/components/LinkButton/LinkButton.meta.d.ts.map +1 -1
  28. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  29. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  30. package/dist/components/Stack/Stack.meta.d.ts.map +1 -1
  31. package/dist/components/Switch/Switch.meta.d.ts.map +1 -1
  32. package/dist/components/Table/Table.d.ts.map +1 -1
  33. package/dist/components/Table/Table.stories.d.ts +1 -0
  34. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  35. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  36. package/dist/components/ThemeProvider/ThemeProvider.d.ts +0 -2
  37. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  38. package/dist/index.css +1 -1
  39. package/dist/index.d.ts +4 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2869 -1904
  42. package/dist/index.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/components/ActionBar/ActionBar.tsx +28 -12
  45. package/src/components/ActionBar/ActionBarProvider.tsx +100 -0
  46. package/src/components/Checkbox/Checkbox.meta.ts +4 -1
  47. package/src/components/Cluster/Cluster.meta.ts +2 -6
  48. package/src/components/Dialog/Dialog.stories.tsx +325 -0
  49. package/src/components/Dialog/Dialog.tsx +310 -0
  50. package/src/components/Dialog/styles.css.ts +197 -0
  51. package/src/components/Drawer/Drawer.stories.tsx +364 -0
  52. package/src/components/Drawer/Drawer.tsx +370 -0
  53. package/src/components/Drawer/styles.css.ts +242 -0
  54. package/src/components/Drawer/useSwipeDismiss.ts +295 -0
  55. package/src/components/Field/Field.meta.ts +1 -3
  56. package/src/components/Fieldset/Fieldset.meta.ts +1 -3
  57. package/src/components/Form/Form.meta.ts +1 -4
  58. package/src/components/Grid/Grid.meta.ts +1 -3
  59. package/src/components/Inline/Inline.meta.ts +1 -2
  60. package/src/components/Input/Input.meta.ts +2 -7
  61. package/src/components/LinkButton/LinkButton.meta.ts +1 -3
  62. package/src/components/Menu/Menu.meta.ts +1 -4
  63. package/src/components/SideNav/SideNav.meta.ts +1 -4
  64. package/src/components/Stack/Stack.meta.ts +1 -2
  65. package/src/components/Switch/Switch.meta.ts +4 -1
  66. package/src/components/Table/Table.stories.tsx +107 -0
  67. package/src/components/Table/Table.tsx +0 -1
  68. package/src/components/Textarea/Textarea.meta.ts +2 -6
  69. package/src/components/ThemeProvider/ThemeProvider.tsx +2 -19
  70. package/src/index.ts +4 -5
@@ -0,0 +1,295 @@
1
+ import {useRef, useCallback, useEffect} from 'react'
2
+ import type {DrawerAnchor} from './Drawer'
3
+
4
+ /** Threshold: fraction of panel size the user must drag to dismiss */
5
+ const DISMISS_THRESHOLD = 0.3
6
+
7
+ /** Minimum velocity (px/ms) to dismiss even below threshold */
8
+ const VELOCITY_THRESHOLD = 0.5
9
+
10
+ /** Duration for the dismiss slide-out animation (ms) */
11
+ const DISMISS_DURATION = 160
12
+
13
+ /**
14
+ * Max pixels the panel visually "grows" when dragged in the opposite direction.
15
+ * iOS-style: panel stays anchored to its edge, doesn't translate.
16
+ * Instead we show a subtle shadow/scale hint capped at this value.
17
+ */
18
+ const OPPOSITE_MAX_OVERSCROLL = 12
19
+
20
+ interface UseSwipeDismissOptions {
21
+ anchor: DrawerAnchor
22
+ enabled: boolean
23
+ onDismiss: () => void
24
+ panelRef: React.RefObject<HTMLDivElement | null>
25
+ }
26
+
27
+ interface SwipeState {
28
+ startX: number
29
+ startY: number
30
+ currentX: number
31
+ currentY: number
32
+ startTime: number
33
+ swiping: boolean
34
+ panelSize: number
35
+ }
36
+
37
+ /**
38
+ * Hook that adds swipe-to-dismiss gesture to a drawer panel.
39
+ *
40
+ * Dismiss direction: panel follows finger 1:1, backdrop fades.
41
+ * Opposite direction: iOS-style — panel stays anchored, shows subtle
42
+ * overscroll feedback (box-shadow intensifies), then snaps back.
43
+ */
44
+ export function useSwipeDismiss({anchor, enabled, onDismiss, panelRef}: UseSwipeDismissOptions) {
45
+ const stateRef = useRef<SwipeState>({
46
+ startX: 0,
47
+ startY: 0,
48
+ currentX: 0,
49
+ currentY: 0,
50
+ startTime: 0,
51
+ swiping: false,
52
+ panelSize: 0,
53
+ })
54
+
55
+ const isHorizontal = anchor === 'left' || anchor === 'right'
56
+
57
+ /** Returns positive = dismiss direction, negative = opposite */
58
+ const getMovement = useCallback(
59
+ (state: SwipeState) => {
60
+ if (isHorizontal) {
61
+ const dx = state.currentX - state.startX
62
+ return anchor === 'right' ? dx : -dx
63
+ }
64
+ return state.currentY - state.startY
65
+ },
66
+ [anchor, isHorizontal],
67
+ )
68
+
69
+ const applyTransform = useCallback(
70
+ (movement: number, panelEl: HTMLDivElement) => {
71
+ const size = Number(panelEl.dataset.panelSize) || 1
72
+
73
+ if (movement >= 0) {
74
+ // Dismiss direction — 1:1 tracking
75
+ const progress = movement / size
76
+
77
+ if (isHorizontal) {
78
+ const sign = anchor === 'right' ? 1 : -1
79
+ panelEl.style.transform = `translateX(${sign * movement}px)`
80
+ } else {
81
+ panelEl.style.transform = `translateY(${movement}px)`
82
+ }
83
+
84
+ // Fade backdrop proportionally
85
+ const backdrop = panelEl.closest('[data-drawer-viewport]')
86
+ ?.previousElementSibling as HTMLElement | null
87
+ if (backdrop) {
88
+ backdrop.style.opacity = String(Math.max(0, 1 - progress))
89
+ }
90
+ } else {
91
+ // Opposite direction — iOS behavior:
92
+ // Panel stays anchored (no translate). Show subtle overscroll via box-shadow.
93
+ // Diminishing resistance: asymptotically approaches OPPOSITE_MAX_OVERSCROLL.
94
+ const absMovement = Math.abs(movement)
95
+ const overscroll = (absMovement / (absMovement + size * 0.5)) * OPPOSITE_MAX_OVERSCROLL
96
+ const shadowSpread = Math.round(overscroll)
97
+
98
+ panelEl.style.transform = ''
99
+ panelEl.style.boxShadow =
100
+ shadowSpread > 0
101
+ ? `0 0 ${shadowSpread * 2}px ${shadowSpread}px rgba(0, 0, 0, 0.15)`
102
+ : ''
103
+ }
104
+ },
105
+ [anchor, isHorizontal],
106
+ )
107
+
108
+ /** Reset all inline styles (snap back) */
109
+ const resetStyles = useCallback((panelEl: HTMLDivElement) => {
110
+ panelEl.style.transform = ''
111
+ panelEl.style.boxShadow = ''
112
+
113
+ const backdrop = panelEl.closest('[data-drawer-viewport]')
114
+ ?.previousElementSibling as HTMLElement | null
115
+ if (backdrop) {
116
+ backdrop.style.opacity = ''
117
+ }
118
+ }, [])
119
+
120
+ /** Animate to fully dismissed position, then call onDismiss */
121
+ const animateDismiss = useCallback(
122
+ (panelEl: HTMLDivElement) => {
123
+ const dismissTransform = isHorizontal
124
+ ? `translateX(${anchor === 'right' ? '100%' : '-100%'})`
125
+ : 'translateY(100%)'
126
+
127
+ panelEl.style.transition = `transform ${DISMISS_DURATION}ms ease-in`
128
+ panelEl.style.transform = dismissTransform
129
+
130
+ const backdrop = panelEl.closest('[data-drawer-viewport]')
131
+ ?.previousElementSibling as HTMLElement | null
132
+ if (backdrop) {
133
+ backdrop.style.transition = `opacity ${DISMISS_DURATION}ms ease-in`
134
+ backdrop.style.opacity = '0'
135
+ }
136
+
137
+ // When the slide-out transition finishes, just call onDismiss.
138
+ // Do NOT reset inline styles before unmount — that causes a 1-frame flash
139
+ // where the panel appears at its original position. React will unmount the
140
+ // DOM node entirely, so the inline styles don't need cleanup.
141
+ let dismissed = false
142
+ const done = () => {
143
+ if (dismissed) return
144
+ dismissed = true
145
+ onDismiss()
146
+ }
147
+ panelEl.addEventListener('transitionend', done, {once: true})
148
+ setTimeout(done, DISMISS_DURATION + 50)
149
+ },
150
+ [anchor, isHorizontal, onDismiss],
151
+ )
152
+
153
+ /** Animate snap-back for dismiss-direction undershoot */
154
+ const animateSnapBack = useCallback((panelEl: HTMLDivElement) => {
155
+ panelEl.style.transition = 'transform 200ms cubic-bezier(0.25, 1, 0.5, 1)'
156
+ panelEl.style.transform = ''
157
+
158
+ const backdrop = panelEl.closest('[data-drawer-viewport]')
159
+ ?.previousElementSibling as HTMLElement | null
160
+ if (backdrop) {
161
+ backdrop.style.transition = 'opacity 200ms cubic-bezier(0.25, 1, 0.5, 1)'
162
+ backdrop.style.opacity = ''
163
+ }
164
+
165
+ const cleanup = () => {
166
+ panelEl.style.transition = ''
167
+ if (backdrop) backdrop.style.transition = ''
168
+ }
169
+ panelEl.addEventListener('transitionend', cleanup, {once: true})
170
+ setTimeout(cleanup, 250)
171
+ }, [])
172
+
173
+ useEffect(() => {
174
+ if (!enabled) return
175
+ const panel = panelRef.current
176
+ if (!panel) return
177
+
178
+ function handlePointerDown(e: PointerEvent) {
179
+ if (e.button !== 0) return
180
+ const target = e.target as HTMLElement
181
+ if (target.closest('button, a, input, textarea, select, [role="button"]')) {
182
+ return
183
+ }
184
+
185
+ const rect = panel!.getBoundingClientRect()
186
+ const state = stateRef.current
187
+ state.startX = e.clientX
188
+ state.startY = e.clientY
189
+ state.currentX = e.clientX
190
+ state.currentY = e.clientY
191
+ state.startTime = Date.now()
192
+ state.swiping = false
193
+ state.panelSize = isHorizontal ? rect.width : rect.height
194
+
195
+ panel!.dataset.panelSize = String(state.panelSize)
196
+ panel!.setPointerCapture(e.pointerId)
197
+
198
+ // Freeze CSS animation — we take over positioning.
199
+ // Never restore it; the panel is already at its final resting position.
200
+ panel!.style.animation = 'none'
201
+ }
202
+
203
+ function handlePointerMove(e: PointerEvent) {
204
+ const state = stateRef.current
205
+ if (state.startTime === 0) return
206
+
207
+ state.currentX = e.clientX
208
+ state.currentY = e.clientY
209
+
210
+ const dx = Math.abs(state.currentX - state.startX)
211
+ const dy = Math.abs(state.currentY - state.startY)
212
+
213
+ if (!state.swiping) {
214
+ const totalMovement = Math.sqrt(dx * dx + dy * dy)
215
+ if (totalMovement < 8) return
216
+
217
+ if (isHorizontal && dx > dy) {
218
+ state.swiping = true
219
+ } else if (!isHorizontal && dy > dx) {
220
+ state.swiping = true
221
+ } else {
222
+ // Wrong axis — abort gesture
223
+ state.startTime = 0
224
+ return
225
+ }
226
+ }
227
+
228
+ if (state.swiping) {
229
+ e.preventDefault()
230
+ const movement = getMovement(state)
231
+ applyTransform(movement, panel!)
232
+ }
233
+ }
234
+
235
+ function handlePointerUp(_e: PointerEvent) {
236
+ const state = stateRef.current
237
+ if (state.startTime === 0) return
238
+
239
+ const movement = getMovement(state)
240
+ const elapsed = Date.now() - state.startTime
241
+ const velocity = movement / elapsed
242
+
243
+ state.startTime = 0
244
+
245
+ if (!state.swiping) {
246
+ return
247
+ }
248
+
249
+ state.swiping = false
250
+
251
+ if (movement <= 0) {
252
+ // Was dragging in opposite direction — just reset (no translate was applied)
253
+ resetStyles(panel!)
254
+ return
255
+ }
256
+
257
+ const fraction = movement / state.panelSize
258
+ const shouldDismiss =
259
+ fraction > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD
260
+
261
+ if (shouldDismiss) {
262
+ animateDismiss(panel!)
263
+ } else {
264
+ animateSnapBack(panel!)
265
+ }
266
+ }
267
+
268
+ function handlePointerCancel() {
269
+ const state = stateRef.current
270
+ state.startTime = 0
271
+ state.swiping = false
272
+ resetStyles(panel!)
273
+ }
274
+
275
+ panel.addEventListener('pointerdown', handlePointerDown)
276
+ panel.addEventListener('pointermove', handlePointerMove)
277
+ panel.addEventListener('pointerup', handlePointerUp)
278
+ panel.addEventListener('pointercancel', handlePointerCancel)
279
+
280
+ function handleTouchMove(e: TouchEvent) {
281
+ if (stateRef.current.swiping) {
282
+ e.preventDefault()
283
+ }
284
+ }
285
+ panel.addEventListener('touchmove', handleTouchMove, {passive: false})
286
+
287
+ return () => {
288
+ panel.removeEventListener('pointerdown', handlePointerDown)
289
+ panel.removeEventListener('pointermove', handlePointerMove)
290
+ panel.removeEventListener('pointerup', handlePointerUp)
291
+ panel.removeEventListener('pointercancel', handlePointerCancel)
292
+ panel.removeEventListener('touchmove', handleTouchMove)
293
+ }
294
+ }, [enabled, panelRef, anchor, isHorizontal, getMovement, applyTransform, resetStyles, animateSnapBack, animateDismiss])
295
+ }
@@ -7,9 +7,7 @@ export const meta: ComponentMeta = {
7
7
  'Any form input that needs a label and/or error message',
8
8
  'Standalone labeled input (without Form) with manual error display',
9
9
  ],
10
- whenNotToUse: [
11
- 'Bare input without label — use Input directly (rare)',
12
- ],
10
+ whenNotToUse: ['Bare input without label — use Input directly (rare)'],
13
11
  anatomy: {
14
12
  required: ['Root'],
15
13
  optional: ['Label', 'Description', 'Error'],
@@ -7,9 +7,7 @@ export const meta: ComponentMeta = {
7
7
  'Grouping related fields within a form (e.g., "Personal Info", "Address")',
8
8
  'Applying consistent vertical gap between form fields',
9
9
  ],
10
- whenNotToUse: [
11
- 'Non-form content grouping — use Stack or Card',
12
- ],
10
+ whenNotToUse: ['Non-form content grouping — use Stack or Card'],
13
11
  anatomy: {
14
12
  required: ['Root'],
15
13
  optional: ['Legend'],
@@ -3,10 +3,7 @@ import type {ComponentMeta} from '../component-meta'
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
5
  'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components.',
6
- whenToUse: [
7
- 'Any form that needs validation',
8
- 'Multi-field forms with submit handling',
9
- ],
6
+ whenToUse: ['Any form that needs validation', 'Multi-field forms with submit handling'],
10
7
  whenNotToUse: [
11
8
  'Single standalone field without validation — use Field.Root directly',
12
9
  'Non-form interactive UIs — use individual components',
@@ -11,9 +11,7 @@ export const meta: ComponentMeta = {
11
11
  'Single-column vertical layout — use Stack',
12
12
  'Simple horizontal row — use Inline or Cluster',
13
13
  ],
14
- relatedTo: [
15
- {component: 'Stack', relationship: 'Single-column vertical layout'},
16
- ],
14
+ relatedTo: [{component: 'Stack', relationship: 'Single-column vertical layout'}],
17
15
  example: `// Responsive: columns auto-fit based on min width
18
16
  <Grid minColumnWidth="280px" gap="md">
19
17
  <Card>A</Card>
@@ -1,8 +1,7 @@
1
1
  import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
- description:
5
- 'Horizontal flex layout with NO wrapping. Items stay on one line and may overflow.',
4
+ description: 'Horizontal flex layout with NO wrapping. Items stay on one line and may overflow.',
6
5
  whenToUse: [
7
6
  'Toolbar or button group that must stay on one row',
8
7
  'Horizontal alignment of a known, small number of items',
@@ -3,13 +3,8 @@ import type {ComponentMeta} from '../component-meta'
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
5
  'Text input with automatic Field/Form integration. Variant auto-switches to error when inside an invalid Field.',
6
- whenToUse: [
7
- 'Single-line text entry (text, email, password, url, tel, number, search)',
8
- ],
9
- whenNotToUse: [
10
- 'Multi-line text — use Textarea',
11
- 'Picking from predefined options — use Select',
12
- ],
6
+ whenToUse: ['Single-line text entry (text, email, password, url, tel, number, search)'],
7
+ whenNotToUse: ['Multi-line text — use Textarea', 'Picking from predefined options — use Select'],
13
8
  relatedTo: [
14
9
  {component: 'Field', relationship: 'Wrap in Field.Root for label + error'},
15
10
  {component: 'InputGroup', relationship: 'Wrap in InputGroup.Root for prefix/suffix addons'},
@@ -11,9 +11,7 @@ export const meta: ComponentMeta = {
11
11
  'Triggering in-page actions — use Button',
12
12
  'Plain text links — use a styled <a> tag',
13
13
  ],
14
- relatedTo: [
15
- {component: 'Button', relationship: 'Button for actions; LinkButton for navigation'},
16
- ],
14
+ relatedTo: [{component: 'Button', relationship: 'Button for actions; LinkButton for navigation'}],
17
15
  example: `<LinkButton href="/signup" variant="primary">
18
16
  Get started
19
17
  </LinkButton>
@@ -8,10 +8,7 @@ export const meta: ComponentMeta = {
8
8
  'Navigation links in a dropdown',
9
9
  'Overflow menu for toolbar actions',
10
10
  ],
11
- whenNotToUse: [
12
- 'Picking a value — use Select',
13
- 'Primary navigation — use SideNav or Tabs',
14
- ],
11
+ whenNotToUse: ['Picking a value — use Select', 'Primary navigation — use SideNav or Tabs'],
15
12
  anatomy: {
16
13
  required: ['Root', 'Trigger', 'Popup', 'Item'],
17
14
  optional: ['LinkItem'],
@@ -3,10 +3,7 @@ import type {ComponentMeta} from '../component-meta'
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
5
  'Vertical side navigation with collapsible groups. Supports controlled and uncontrolled active item. Compound component — Root is required.',
6
- whenToUse: [
7
- 'App-level sidebar navigation',
8
- 'Settings navigation with grouped sections',
9
- ],
6
+ whenToUse: ['App-level sidebar navigation', 'Settings navigation with grouped sections'],
10
7
  whenNotToUse: [
11
8
  'In-page content switching — use Tabs',
12
9
  'Top-level horizontal nav — use Inline with LinkButton',
@@ -1,8 +1,7 @@
1
1
  import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
- description:
5
- 'Vertical flex layout. Stacks children top-to-bottom with consistent gap.',
4
+ description: 'Vertical flex layout. Stacks children top-to-bottom with consistent gap.',
6
5
  whenToUse: [
7
6
  'Vertically stacking form fields, cards, or sections',
8
7
  'Any column-direction layout with uniform spacing',
@@ -12,7 +12,10 @@ export const meta: ComponentMeta = {
12
12
  'Selecting between multiple options — use Select or ToggleGroup',
13
13
  ],
14
14
  relatedTo: [
15
- {component: 'Checkbox', relationship: 'Checkbox for selection/opt-in; Switch for on/off settings'},
15
+ {
16
+ component: 'Checkbox',
17
+ relationship: 'Checkbox for selection/opt-in; Switch for on/off settings',
18
+ },
16
19
  {component: 'Toggle', relationship: 'Toggle for toolbar pressed state; Switch for settings'},
17
20
  ],
18
21
  example: `<Switch
@@ -1,8 +1,12 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
+ import {useState} from 'react'
4
5
  import {Table} from './Table'
5
6
  import {Badge} from '../Badge/Badge'
7
+ import {Checkbox} from '../Checkbox/Checkbox'
8
+ import {Button} from '../Button/Button'
9
+ import {ActionBar} from '../ActionBar/ActionBar'
6
10
  import {colors} from '@duro-app/tokens/tokens/colors.css'
7
11
 
8
12
  const meta: Meta = {
@@ -186,6 +190,109 @@ export const Compact: Story = {
186
190
  },
187
191
  }
188
192
 
193
+ // --- WithCheckboxes: demonstrates parent/child cascading selection ---
194
+
195
+ interface ServiceData {
196
+ id: string
197
+ name: string
198
+ status: string
199
+ port: string
200
+ uptime: string
201
+ }
202
+
203
+ function CheckboxTableDemo() {
204
+ const items: ServiceData[] = [
205
+ {id: 's1', name: 'Traefik', status: 'Running', port: '443', uptime: '14d 3h'},
206
+ {id: 's2', name: 'Pi-hole', status: 'Running', port: '80', uptime: '14d 3h'},
207
+ {id: 's3', name: 'Portainer', status: 'Stopped', port: '9000', uptime: '\u2014'},
208
+ {id: 's4', name: 'Grafana', status: 'Running', port: '3000', uptime: '7d 12h'},
209
+ ]
210
+
211
+ const [selected, setSelected] = useState<Set<string>>(new Set())
212
+
213
+ const allSelected = items.length > 0 && items.every((i) => selected.has(i.id))
214
+ const someSelected = items.some((i) => selected.has(i.id))
215
+
216
+ const toggleAll = () => {
217
+ if (allSelected) {
218
+ setSelected(new Set())
219
+ } else {
220
+ setSelected(new Set(items.map((i) => i.id)))
221
+ }
222
+ }
223
+
224
+ const toggleOne = (id: string) => {
225
+ setSelected((prev) => {
226
+ const next = new Set(prev)
227
+ if (next.has(id)) next.delete(id)
228
+ else next.add(id)
229
+ return next
230
+ })
231
+ }
232
+
233
+ return (
234
+ <>
235
+ <Table.Root columns={5}>
236
+ <Table.Header>
237
+ <Table.Row>
238
+ <Table.HeaderCell aria-label="Select all">
239
+ <Checkbox
240
+ checked={allSelected}
241
+ onChange={toggleAll}
242
+ aria-label="Select all services"
243
+ />
244
+ </Table.HeaderCell>
245
+ <Table.HeaderCell>Service</Table.HeaderCell>
246
+ <Table.HeaderCell>Status</Table.HeaderCell>
247
+ <Table.HeaderCell>Port</Table.HeaderCell>
248
+ <Table.HeaderCell>Uptime</Table.HeaderCell>
249
+ </Table.Row>
250
+ </Table.Header>
251
+ <Table.Body>
252
+ {items.map((s) => (
253
+ <Table.Row key={s.id}>
254
+ <Table.Cell>
255
+ <Checkbox
256
+ checked={selected.has(s.id)}
257
+ onChange={() => toggleOne(s.id)}
258
+ aria-label={s.name}
259
+ />
260
+ </Table.Cell>
261
+ <Table.Cell>{s.name}</Table.Cell>
262
+ <Table.Cell>
263
+ <Badge variant={statusVariant(s.status)} size="sm">
264
+ {s.status}
265
+ </Badge>
266
+ </Table.Cell>
267
+ <Table.Cell>{s.port}</Table.Cell>
268
+ <Table.Cell>{s.uptime}</Table.Cell>
269
+ </Table.Row>
270
+ ))}
271
+ </Table.Body>
272
+ </Table.Root>
273
+
274
+ <ActionBar
275
+ selectedItemCount={selected.size}
276
+ onClearSelection={() => setSelected(new Set())}
277
+ >
278
+ <Button variant="danger" size="small">
279
+ Delete
280
+ </Button>
281
+ </ActionBar>
282
+ </>
283
+ )
284
+ }
285
+
286
+ export const WithCheckboxes: Story = {
287
+ render: () => <CheckboxTableDemo />,
288
+ play: async ({canvas}) => {
289
+ await expect(canvas.getByRole('table')).toBeInTheDocument()
290
+ const checkboxes = canvas.getAllByRole('checkbox')
291
+ await expect(checkboxes.length).toBe(5) // 1 select-all + 4 rows
292
+ await expect(canvas.getByLabelText('Select all services')).toBeInTheDocument()
293
+ },
294
+ }
295
+
189
296
  const storyStyles = css.create({
190
297
  stack: {display: 'flex', flexDirection: 'column', gap: 24},
191
298
  label: {color: colors.textMuted, fontSize: '0.875rem', fontWeight: 600},
@@ -94,7 +94,6 @@ function Row({children}: {children: ReactNode}) {
94
94
  const rowIndex = useContext(RowIndexContext)
95
95
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
96
96
  const childArray = Children.toArray(children)
97
- const isLastRow = false // handled by CSS or parent
98
97
 
99
98
  return (
100
99
  <html.div
@@ -3,12 +3,8 @@ import type {ComponentMeta} from '../component-meta'
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
5
  'Multi-line text input with automatic Field/Form integration. Same Field binding behavior as Input.',
6
- whenToUse: [
7
- 'Multi-line text entry (comments, messages, descriptions)',
8
- ],
9
- whenNotToUse: [
10
- 'Single-line input — use Input',
11
- ],
6
+ whenToUse: ['Multi-line text entry (comments, messages, descriptions)'],
7
+ whenNotToUse: ['Single-line input — use Input'],
12
8
  relatedTo: [
13
9
  {component: 'Input', relationship: 'Single-line variant'},
14
10
  {component: 'Field', relationship: 'Wrap in Field.Root for label + error'},
@@ -1,4 +1,4 @@
1
- import {type ReactNode, createContext, useContext, useRef} from 'react'
1
+ import {type ReactNode} from 'react'
2
2
  import {css, html} from 'react-strict-dom'
3
3
  import {colors} from '@duro-app/tokens/tokens/colors.css'
4
4
  import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
@@ -27,26 +27,9 @@ const styles = css.create({
27
27
  // concrete VarGroup lacks the generic index signature. This is a known typing gap.
28
28
  type DivStyle = Parameters<typeof html.div>[0]['style']
29
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
-
39
30
  export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
40
- const overlayRef = useRef<HTMLDivElement>(null)
41
31
  const overrides = themeMap[theme]
42
32
  const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
43
33
 
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
- )
34
+ return <html.div style={themeStyles}>{children}</html.div>
52
35
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import './strict.css'
3
3
 
4
4
  // Components
5
5
  export {ActionBar, type ActionBarProps} from './components/ActionBar/ActionBar'
6
+ export {ActionBarProvider} from './components/ActionBar/ActionBarProvider'
6
7
  export {Alert, type AlertVariant} from './components/Alert/Alert'
7
8
  export {Icon, type IconName} from './components/Icon'
8
9
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
@@ -10,6 +11,8 @@ export {Button, type ButtonVariant, type ButtonSize} from './components/Button/B
10
11
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
11
12
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
12
13
  export {Checkbox} from './components/Checkbox/Checkbox'
14
+ export {Dialog, type DialogSize} from './components/Dialog/Dialog'
15
+ export {Drawer, type DrawerAnchor, type DrawerSize} from './components/Drawer/Drawer'
13
16
  export {EmptyState} from './components/EmptyState/EmptyState'
14
17
  export {Heading, type HeadingVariant} from './components/Heading/Heading'
15
18
  export {Field} from './components/Field/Field'
@@ -35,11 +38,7 @@ export {
35
38
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
36
39
  export {Tabs} from './components/Tabs/Tabs'
37
40
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
38
- export {
39
- ThemeProvider,
40
- useOverlayContainer,
41
- type ThemeName,
42
- } from './components/ThemeProvider/ThemeProvider'
41
+ export {ThemeProvider, type ThemeName} from './components/ThemeProvider/ThemeProvider'
43
42
  export {Text, type TextVariant, type TextColor} from './components/Text/Text'
44
43
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
45
44
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'