@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.
- package/dist/components/ActionBar/ActionBar.d.ts +1 -1
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBarProvider.d.ts +12 -0
- package/dist/components/ActionBar/ActionBarProvider.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.meta.d.ts.map +1 -1
- package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +58 -0
- package/dist/components/Dialog/Dialog.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.stories.d.ts +13 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -0
- package/dist/components/Dialog/styles.css.d.ts +140 -0
- package/dist/components/Dialog/styles.css.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.d.ts +62 -0
- package/dist/components/Drawer/Drawer.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.stories.d.ts +15 -0
- package/dist/components/Drawer/Drawer.stories.d.ts.map +1 -0
- package/dist/components/Drawer/styles.css.d.ts +184 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +17 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -0
- package/dist/components/Field/Field.meta.d.ts.map +1 -1
- package/dist/components/Fieldset/Fieldset.meta.d.ts.map +1 -1
- package/dist/components/Form/Form.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
- package/dist/components/Input/Input.meta.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
- package/dist/components/Stack/Stack.meta.d.ts.map +1 -1
- package/dist/components/Switch/Switch.meta.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +1 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +0 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2869 -1904
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.tsx +28 -12
- package/src/components/ActionBar/ActionBarProvider.tsx +100 -0
- package/src/components/Checkbox/Checkbox.meta.ts +4 -1
- package/src/components/Cluster/Cluster.meta.ts +2 -6
- package/src/components/Dialog/Dialog.stories.tsx +325 -0
- package/src/components/Dialog/Dialog.tsx +310 -0
- package/src/components/Dialog/styles.css.ts +197 -0
- package/src/components/Drawer/Drawer.stories.tsx +364 -0
- package/src/components/Drawer/Drawer.tsx +370 -0
- package/src/components/Drawer/styles.css.ts +242 -0
- package/src/components/Drawer/useSwipeDismiss.ts +295 -0
- package/src/components/Field/Field.meta.ts +1 -3
- package/src/components/Fieldset/Fieldset.meta.ts +1 -3
- package/src/components/Form/Form.meta.ts +1 -4
- package/src/components/Grid/Grid.meta.ts +1 -3
- package/src/components/Inline/Inline.meta.ts +1 -2
- package/src/components/Input/Input.meta.ts +2 -7
- package/src/components/LinkButton/LinkButton.meta.ts +1 -3
- package/src/components/Menu/Menu.meta.ts +1 -4
- package/src/components/SideNav/SideNav.meta.ts +1 -4
- package/src/components/Stack/Stack.meta.ts +1 -2
- package/src/components/Switch/Switch.meta.ts +4 -1
- package/src/components/Table/Table.stories.tsx +107 -0
- package/src/components/Table/Table.tsx +0 -1
- package/src/components/Textarea/Textarea.meta.ts +2 -6
- package/src/components/ThemeProvider/ThemeProvider.tsx +2 -19
- 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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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'
|