@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.
Files changed (91) hide show
  1. package/dist/components/Checkbox/Checkbox.meta.d.ts.map +1 -1
  2. package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -1
  3. package/dist/components/DetailPanel/DetailPanel.d.ts +53 -0
  4. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -0
  5. package/dist/components/DetailPanel/DetailPanel.meta.d.ts +3 -0
  6. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -0
  7. package/dist/components/DetailPanel/DetailPanel.stories.d.ts +9 -0
  8. package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -0
  9. package/dist/components/DetailPanel/styles.css.d.ts +126 -0
  10. package/dist/components/DetailPanel/styles.css.d.ts.map +1 -0
  11. package/dist/components/Dialog/Dialog.d.ts +58 -0
  12. package/dist/components/Dialog/Dialog.d.ts.map +1 -0
  13. package/dist/components/Dialog/Dialog.meta.d.ts +3 -0
  14. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -0
  15. package/dist/components/Dialog/Dialog.stories.d.ts +13 -0
  16. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -0
  17. package/dist/components/Dialog/styles.css.d.ts +140 -0
  18. package/dist/components/Dialog/styles.css.d.ts.map +1 -0
  19. package/dist/components/Drawer/Drawer.d.ts +62 -0
  20. package/dist/components/Drawer/Drawer.d.ts.map +1 -0
  21. package/dist/components/Drawer/Drawer.meta.d.ts +3 -0
  22. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -0
  23. package/dist/components/Drawer/Drawer.stories.d.ts +15 -0
  24. package/dist/components/Drawer/Drawer.stories.d.ts.map +1 -0
  25. package/dist/components/Drawer/styles.css.d.ts +184 -0
  26. package/dist/components/Drawer/styles.css.d.ts.map +1 -0
  27. package/dist/components/Drawer/useSwipeDismiss.d.ts +17 -0
  28. package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -0
  29. package/dist/components/Field/Field.meta.d.ts.map +1 -1
  30. package/dist/components/Fieldset/Fieldset.meta.d.ts.map +1 -1
  31. package/dist/components/Form/Form.meta.d.ts.map +1 -1
  32. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  33. package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
  34. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  35. package/dist/components/LinkButton/LinkButton.meta.d.ts.map +1 -1
  36. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  37. package/dist/components/Panel/Panel.d.ts +27 -0
  38. package/dist/components/Panel/Panel.d.ts.map +1 -0
  39. package/dist/components/Panel/Panel.meta.d.ts +3 -0
  40. package/dist/components/Panel/Panel.meta.d.ts.map +1 -0
  41. package/dist/components/Panel/Panel.stories.d.ts +10 -0
  42. package/dist/components/Panel/Panel.stories.d.ts.map +1 -0
  43. package/dist/components/Panel/styles.css.d.ts +50 -0
  44. package/dist/components/Panel/styles.css.d.ts.map +1 -0
  45. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  46. package/dist/components/Stack/Stack.meta.d.ts.map +1 -1
  47. package/dist/components/Switch/Switch.meta.d.ts.map +1 -1
  48. package/dist/components/Table/Table.d.ts.map +1 -1
  49. package/dist/components/Table/Table.stories.d.ts +1 -0
  50. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  51. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  52. package/dist/index.css +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +3286 -2007
  56. package/dist/index.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/components/Checkbox/Checkbox.meta.ts +4 -1
  59. package/src/components/Cluster/Cluster.meta.ts +2 -6
  60. package/src/components/DetailPanel/DetailPanel.meta.ts +41 -0
  61. package/src/components/DetailPanel/DetailPanel.stories.tsx +270 -0
  62. package/src/components/DetailPanel/DetailPanel.tsx +312 -0
  63. package/src/components/DetailPanel/styles.css.ts +187 -0
  64. package/src/components/Dialog/Dialog.meta.ts +44 -0
  65. package/src/components/Dialog/Dialog.stories.tsx +325 -0
  66. package/src/components/Dialog/Dialog.tsx +310 -0
  67. package/src/components/Dialog/styles.css.ts +197 -0
  68. package/src/components/Drawer/Drawer.meta.ts +45 -0
  69. package/src/components/Drawer/Drawer.stories.tsx +364 -0
  70. package/src/components/Drawer/Drawer.tsx +370 -0
  71. package/src/components/Drawer/styles.css.ts +242 -0
  72. package/src/components/Drawer/useSwipeDismiss.ts +295 -0
  73. package/src/components/Field/Field.meta.ts +1 -3
  74. package/src/components/Fieldset/Fieldset.meta.ts +1 -3
  75. package/src/components/Form/Form.meta.ts +1 -4
  76. package/src/components/Grid/Grid.meta.ts +1 -3
  77. package/src/components/Inline/Inline.meta.ts +1 -2
  78. package/src/components/Input/Input.meta.ts +2 -7
  79. package/src/components/LinkButton/LinkButton.meta.ts +1 -3
  80. package/src/components/Menu/Menu.meta.ts +1 -4
  81. package/src/components/Panel/Panel.meta.ts +37 -0
  82. package/src/components/Panel/Panel.stories.tsx +124 -0
  83. package/src/components/Panel/Panel.tsx +56 -0
  84. package/src/components/Panel/styles.css.ts +55 -0
  85. package/src/components/SideNav/SideNav.meta.ts +1 -4
  86. package/src/components/Stack/Stack.meta.ts +1 -2
  87. package/src/components/Switch/Switch.meta.ts +4 -1
  88. package/src/components/Table/Table.stories.tsx +107 -0
  89. package/src/components/Table/Table.tsx +0 -1
  90. package/src/components/Textarea/Textarea.meta.ts +2 -6
  91. 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
- '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'],
@@ -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',