@duro-app/ui 0.18.0 → 0.20.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/Checkbox/Checkbox.meta.d.ts.map +1 -1
- package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.d.ts +53 -0
- package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -0
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts +3 -0
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -0
- package/dist/components/DetailPanel/DetailPanel.stories.d.ts +9 -0
- package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -0
- package/dist/components/DetailPanel/styles.css.d.ts +126 -0
- package/dist/components/DetailPanel/styles.css.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.d.ts +58 -0
- package/dist/components/Dialog/Dialog.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.meta.d.ts +3 -0
- package/dist/components/Dialog/Dialog.meta.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.meta.d.ts +3 -0
- package/dist/components/Drawer/Drawer.meta.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/Panel/Panel.d.ts +27 -0
- package/dist/components/Panel/Panel.d.ts.map +1 -0
- package/dist/components/Panel/Panel.meta.d.ts +3 -0
- package/dist/components/Panel/Panel.meta.d.ts.map +1 -0
- package/dist/components/Panel/Panel.stories.d.ts +10 -0
- package/dist/components/Panel/Panel.stories.d.ts.map +1 -0
- package/dist/components/Panel/styles.css.d.ts +50 -0
- package/dist/components/Panel/styles.css.d.ts.map +1 -0
- 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/index.css +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3286 -2007
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Checkbox/Checkbox.meta.ts +4 -1
- package/src/components/Cluster/Cluster.meta.ts +2 -6
- package/src/components/DetailPanel/DetailPanel.meta.ts +41 -0
- package/src/components/DetailPanel/DetailPanel.stories.tsx +270 -0
- package/src/components/DetailPanel/DetailPanel.tsx +312 -0
- package/src/components/DetailPanel/styles.css.ts +187 -0
- package/src/components/Dialog/Dialog.meta.ts +44 -0
- 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.meta.ts +45 -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/Panel/Panel.meta.ts +37 -0
- package/src/components/Panel/Panel.stories.tsx +124 -0
- package/src/components/Panel/Panel.tsx +56 -0
- package/src/components/Panel/styles.css.ts +55 -0
- 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/index.ts +4 -0
|
@@ -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'],
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Structural primitive for grouping content with header, body, and footer slots. A "workspace surface" for forms, settings sections, and inspectors. Sub-components are purely structural — no context required.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Organizing content with distinct header/body/footer sections',
|
|
8
|
+
'Settings panels, form containers, inspector views',
|
|
9
|
+
'When you need more structure than Card but less than a full page layout',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Repeating item cards — use Card',
|
|
13
|
+
'Modal/overlay content — use Dialog or Drawer',
|
|
14
|
+
'Page-level layout — use PageShell',
|
|
15
|
+
],
|
|
16
|
+
anatomy: {
|
|
17
|
+
required: [],
|
|
18
|
+
optional: ['Root', 'Header', 'Body', 'Footer'],
|
|
19
|
+
},
|
|
20
|
+
relatedTo: [
|
|
21
|
+
{component: 'Card', relationship: 'Card is for shallow, repeated items; Panel is for deep, organized content'},
|
|
22
|
+
{component: 'DetailPanel', relationship: 'DetailPanel uses Panel internally for its structural layout'},
|
|
23
|
+
{component: 'PageShell', relationship: 'PageShell is for page-level layout; Panel is for section-level'},
|
|
24
|
+
],
|
|
25
|
+
example: `<Panel.Root bordered>
|
|
26
|
+
<Panel.Header>
|
|
27
|
+
<Heading level={3}>Section Title</Heading>
|
|
28
|
+
<Button variant="secondary" size="small">Action</Button>
|
|
29
|
+
</Panel.Header>
|
|
30
|
+
<Panel.Body>
|
|
31
|
+
{/* Content */}
|
|
32
|
+
</Panel.Body>
|
|
33
|
+
<Panel.Footer>
|
|
34
|
+
<Button>Save</Button>
|
|
35
|
+
</Panel.Footer>
|
|
36
|
+
</Panel.Root>`,
|
|
37
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {Panel} from './Panel'
|
|
3
|
+
import {Button} from '../Button/Button'
|
|
4
|
+
import {Text} from '../Text/Text'
|
|
5
|
+
import {Heading} from '../Heading/Heading'
|
|
6
|
+
import {Stack} from '../Stack/Stack'
|
|
7
|
+
import {Inline} from '../Inline/Inline'
|
|
8
|
+
import {Field} from '../Field/Field'
|
|
9
|
+
import {Input} from '../Input/Input'
|
|
10
|
+
|
|
11
|
+
const meta: Meta = {
|
|
12
|
+
title: 'Components/Panel',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj
|
|
17
|
+
|
|
18
|
+
// --- Default ---
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<Panel.Root>
|
|
23
|
+
<Panel.Header>
|
|
24
|
+
<Heading level={3}>Section Title</Heading>
|
|
25
|
+
</Panel.Header>
|
|
26
|
+
<Panel.Body>
|
|
27
|
+
<Text>
|
|
28
|
+
Panel is a structural primitive for grouping content with
|
|
29
|
+
header, body, and footer slots. It fills the gap between Card
|
|
30
|
+
and page-level layout.
|
|
31
|
+
</Text>
|
|
32
|
+
</Panel.Body>
|
|
33
|
+
<Panel.Footer>
|
|
34
|
+
<Button variant="secondary">Cancel</Button>
|
|
35
|
+
<Button>Save</Button>
|
|
36
|
+
</Panel.Footer>
|
|
37
|
+
</Panel.Root>
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Bordered ---
|
|
42
|
+
|
|
43
|
+
export const Bordered: Story = {
|
|
44
|
+
render: () => (
|
|
45
|
+
<Panel.Root bordered>
|
|
46
|
+
<Panel.Header>
|
|
47
|
+
<Heading level={3}>Bordered Panel</Heading>
|
|
48
|
+
<Button variant="secondary" size="small">Action</Button>
|
|
49
|
+
</Panel.Header>
|
|
50
|
+
<Panel.Body>
|
|
51
|
+
<Text>
|
|
52
|
+
The bordered variant adds an outer border, useful when the panel
|
|
53
|
+
needs visual separation from its surroundings.
|
|
54
|
+
</Text>
|
|
55
|
+
</Panel.Body>
|
|
56
|
+
<Panel.Footer>
|
|
57
|
+
<Button>Confirm</Button>
|
|
58
|
+
</Panel.Footer>
|
|
59
|
+
</Panel.Root>
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Body Only ---
|
|
64
|
+
|
|
65
|
+
export const BodyOnly: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<Panel.Root bordered>
|
|
68
|
+
<Panel.Body>
|
|
69
|
+
<Text>
|
|
70
|
+
Panel sub-components can be used independently. This panel only
|
|
71
|
+
has a Body — no Header or Footer.
|
|
72
|
+
</Text>
|
|
73
|
+
</Panel.Body>
|
|
74
|
+
</Panel.Root>
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Unpadded Body ---
|
|
79
|
+
|
|
80
|
+
export const UnpaddedBody: Story = {
|
|
81
|
+
render: () => (
|
|
82
|
+
<Panel.Root bordered>
|
|
83
|
+
<Panel.Header>
|
|
84
|
+
<Heading level={3}>Full-bleed Content</Heading>
|
|
85
|
+
</Panel.Header>
|
|
86
|
+
<Panel.Body padded={false}>
|
|
87
|
+
<Text>
|
|
88
|
+
Setting padded=false removes body padding, useful for content
|
|
89
|
+
that needs to extend edge-to-edge (tables, images, etc).
|
|
90
|
+
</Text>
|
|
91
|
+
</Panel.Body>
|
|
92
|
+
</Panel.Root>
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- With Form ---
|
|
97
|
+
|
|
98
|
+
export const WithForm: Story = {
|
|
99
|
+
render: () => (
|
|
100
|
+
<Panel.Root bordered>
|
|
101
|
+
<Panel.Header>
|
|
102
|
+
<Heading level={3}>Profile Settings</Heading>
|
|
103
|
+
</Panel.Header>
|
|
104
|
+
<Panel.Body>
|
|
105
|
+
<Stack gap="md">
|
|
106
|
+
<Field.Root>
|
|
107
|
+
<Field.Label>Display name</Field.Label>
|
|
108
|
+
<Input placeholder="Enter your name" />
|
|
109
|
+
</Field.Root>
|
|
110
|
+
<Field.Root>
|
|
111
|
+
<Field.Label>Email</Field.Label>
|
|
112
|
+
<Input type="email" placeholder="you@example.com" />
|
|
113
|
+
</Field.Root>
|
|
114
|
+
</Stack>
|
|
115
|
+
</Panel.Body>
|
|
116
|
+
<Panel.Footer>
|
|
117
|
+
<Inline gap="sm">
|
|
118
|
+
<Button variant="secondary">Cancel</Button>
|
|
119
|
+
<Button>Save changes</Button>
|
|
120
|
+
</Inline>
|
|
121
|
+
</Panel.Footer>
|
|
122
|
+
</Panel.Root>
|
|
123
|
+
),
|
|
124
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {styles} from './styles.css'
|
|
4
|
+
|
|
5
|
+
// --- Root ---
|
|
6
|
+
|
|
7
|
+
interface RootProps {
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** Adds an outer border. Default: false */
|
|
10
|
+
bordered?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Root({children, bordered = false}: RootProps) {
|
|
14
|
+
return (
|
|
15
|
+
<html.div style={[styles.root, bordered && styles.bordered]}>
|
|
16
|
+
{children}
|
|
17
|
+
</html.div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Header ---
|
|
22
|
+
|
|
23
|
+
function Header({children}: {children: ReactNode}) {
|
|
24
|
+
return <html.div style={styles.header}>{children}</html.div>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Body ---
|
|
28
|
+
|
|
29
|
+
interface BodyProps {
|
|
30
|
+
children: ReactNode
|
|
31
|
+
/** Adds inner padding. Default: true */
|
|
32
|
+
padded?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Body({children, padded = true}: BodyProps) {
|
|
36
|
+
return (
|
|
37
|
+
<html.div style={[styles.body, padded && styles.bodyPadded]}>
|
|
38
|
+
{children}
|
|
39
|
+
</html.div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Footer ---
|
|
44
|
+
|
|
45
|
+
function Footer({children}: {children: ReactNode}) {
|
|
46
|
+
return <html.div style={styles.footer}>{children}</html.div>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Export ---
|
|
50
|
+
|
|
51
|
+
export const Panel = {
|
|
52
|
+
Root,
|
|
53
|
+
Header,
|
|
54
|
+
Body,
|
|
55
|
+
Footer,
|
|
56
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
|
|
5
|
+
export const styles = css.create({
|
|
6
|
+
root: {
|
|
7
|
+
display: 'flex',
|
|
8
|
+
flexDirection: 'column',
|
|
9
|
+
backgroundColor: colors.bgCard,
|
|
10
|
+
},
|
|
11
|
+
bordered: {
|
|
12
|
+
borderWidth: 1,
|
|
13
|
+
borderStyle: 'solid',
|
|
14
|
+
borderColor: colors.border,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
header: {
|
|
18
|
+
display: 'flex',
|
|
19
|
+
alignItems: 'center',
|
|
20
|
+
justifyContent: 'space-between',
|
|
21
|
+
paddingTop: spacing.lg,
|
|
22
|
+
paddingLeft: spacing.lg,
|
|
23
|
+
paddingRight: spacing.lg,
|
|
24
|
+
paddingBottom: spacing.lg,
|
|
25
|
+
gap: spacing.md,
|
|
26
|
+
borderBottomWidth: 1,
|
|
27
|
+
borderBottomStyle: 'solid',
|
|
28
|
+
borderBottomColor: colors.border,
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
body: {
|
|
32
|
+
flex: 1,
|
|
33
|
+
overflowY: 'auto',
|
|
34
|
+
},
|
|
35
|
+
bodyPadded: {
|
|
36
|
+
paddingTop: spacing.lg,
|
|
37
|
+
paddingLeft: spacing.lg,
|
|
38
|
+
paddingRight: spacing.lg,
|
|
39
|
+
paddingBottom: spacing.lg,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
footer: {
|
|
43
|
+
display: 'flex',
|
|
44
|
+
alignItems: 'center',
|
|
45
|
+
justifyContent: 'flex-end',
|
|
46
|
+
gap: spacing.sm,
|
|
47
|
+
paddingTop: spacing.md,
|
|
48
|
+
paddingBottom: spacing.md,
|
|
49
|
+
paddingLeft: spacing.lg,
|
|
50
|
+
paddingRight: spacing.lg,
|
|
51
|
+
borderTopWidth: 1,
|
|
52
|
+
borderTopStyle: 'solid',
|
|
53
|
+
borderTopColor: colors.border,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
@@ -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',
|