@duro-app/ui 3.0.0 → 3.2.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 (33) hide show
  1. package/dist/components/DragDrop/DragDrop.d.ts +67 -0
  2. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -0
  3. package/dist/components/DragDrop/DragDrop.meta.d.ts +3 -0
  4. package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -0
  5. package/dist/components/DragDrop/DragDrop.stories.d.ts +21 -0
  6. package/dist/components/DragDrop/DragDrop.stories.d.ts.map +1 -0
  7. package/dist/components/DragDrop/styles.css.d.ts +52 -0
  8. package/dist/components/DragDrop/styles.css.d.ts.map +1 -0
  9. package/dist/components/Grid/Grid.d.ts +13 -3
  10. package/dist/components/Grid/Grid.d.ts.map +1 -1
  11. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  12. package/dist/components/Grid/Grid.stories.d.ts +15 -0
  13. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  14. package/dist/components/Grid/columns.d.ts +27 -0
  15. package/dist/components/Grid/columns.d.ts.map +1 -0
  16. package/dist/components/Grid/styles.css.d.ts +71 -0
  17. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  18. package/dist/index.css +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2961 -2489
  22. package/dist/index.js.map +1 -1
  23. package/package.json +2 -2
  24. package/src/components/DragDrop/DragDrop.meta.ts +49 -0
  25. package/src/components/DragDrop/DragDrop.stories.tsx +249 -0
  26. package/src/components/DragDrop/DragDrop.tsx +502 -0
  27. package/src/components/DragDrop/styles.css.ts +62 -0
  28. package/src/components/Grid/Grid.meta.ts +8 -2
  29. package/src/components/Grid/Grid.stories.tsx +83 -1
  30. package/src/components/Grid/Grid.tsx +117 -7
  31. package/src/components/Grid/columns.ts +58 -0
  32. package/src/components/Grid/styles.css.ts +72 -8
  33. package/src/index.ts +7 -0
@@ -0,0 +1,502 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react'
11
+ import {html} from 'react-strict-dom'
12
+ import {styles} from './styles.css'
13
+ import {isNative} from '../../platform'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // DragDrop — move items between zones with one pointer, on mouse, pen and
17
+ // touch alike.
18
+ //
19
+ // Pointer events are the single input path: a press arms a drag, movement
20
+ // past a small threshold (or, on touch, a short hold) starts it, pointer
21
+ // capture keeps the stream on the item, and release resolves the zone under
22
+ // the pointer. The dragged item stays in the DOM (faded) while a ghost copy
23
+ // follows the pointer, so layout never shifts mid-drag and a cancelled drag
24
+ // costs nothing.
25
+ //
26
+ // Dragging is a pointer gesture only. Every consumer MUST keep a non-drag way
27
+ // to do the same thing (a button that adds, a remove affordance) — that is
28
+ // the keyboard and assistive path, and WCAG 2.5.7 requires it. The Root's
29
+ // live region announces picks and drops so screen readers follow along.
30
+ //
31
+ // Native: react-strict-dom does not route pointer capture on React Native,
32
+ // so Item renders its children without drag behaviour there and the consumer's
33
+ // non-drag path is the only path. Not exported from the native barrel.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface DragDropItemData<T = unknown> {
37
+ readonly id: string
38
+ readonly zone: string
39
+ readonly data: T
40
+ }
41
+
42
+ export interface DragDropTarget {
43
+ readonly zone: string
44
+ /** Insertion index among the target zone's items, from the pointer's
45
+ * position along the zone's axis. Excludes the dragged item itself. */
46
+ readonly index: number
47
+ }
48
+
49
+ export interface DragDropEvent<T = unknown> {
50
+ readonly item: DragDropItemData<T>
51
+ readonly target: DragDropTarget
52
+ }
53
+
54
+ interface ZoneRecord {
55
+ readonly el: HTMLElement
56
+ readonly orientation: 'horizontal' | 'vertical'
57
+ readonly accepts?: (item: DragDropItemData) => boolean
58
+ readonly label: string
59
+ }
60
+
61
+ interface ItemRecord {
62
+ readonly el: HTMLElement
63
+ readonly zone: string
64
+ readonly data: unknown
65
+ readonly label: string
66
+ readonly preview: ReactNode
67
+ }
68
+
69
+ interface DragSession {
70
+ readonly id: string
71
+ readonly pointerId: number
72
+ /** Event time of the press, for judging the touch hold by timestamps. */
73
+ readonly downAt: number
74
+ readonly startX: number
75
+ readonly startY: number
76
+ readonly offsetX: number
77
+ readonly offsetY: number
78
+ readonly width: number
79
+ readonly height: number
80
+ readonly source: HTMLElement
81
+ active: boolean
82
+ holdTimer: ReturnType<typeof setTimeout> | null
83
+ }
84
+
85
+ interface DragState {
86
+ readonly id: string
87
+ readonly preview: ReactNode
88
+ readonly width: number
89
+ readonly height: number
90
+ readonly x: number
91
+ readonly y: number
92
+ readonly over: DragDropTarget | null
93
+ }
94
+
95
+ interface RootContextValue {
96
+ registerZone: (id: string, record: ZoneRecord) => () => void
97
+ registerItem: (id: string, record: ItemRecord) => () => void
98
+ begin: (id: string, e: React.PointerEvent) => void
99
+ drag: DragState | null
100
+ }
101
+
102
+ const RootContext = createContext<RootContextValue | null>(null)
103
+
104
+ const useRoot = (part: string): RootContextValue => {
105
+ const ctx = useContext(RootContext)
106
+ if (!ctx) throw new Error(`DragDrop.${part} must be used within DragDrop.Root`)
107
+ return ctx
108
+ }
109
+
110
+ /** How far the pointer moves before a press becomes a drag (mouse/pen), so a
111
+ * click stays a click. */
112
+ const MOVE_THRESHOLD_PX = 6
113
+ /** How long a touch holds still before it becomes a drag. Below this a touch
114
+ * that moves is a scroll, and the browser gets it back via pointercancel. */
115
+ const TOUCH_HOLD_MS = 180
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Root
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export interface DragDropRootProps<T = unknown> {
122
+ /** Called when an item is released over a zone that accepts it. Reordering
123
+ * inside the item's own zone arrives here too, with the new index. */
124
+ onDrop: (event: DragDropEvent<T>) => void
125
+ /** Overrides the default screen-reader announcements. */
126
+ announce?: (event: {kind: 'pick' | 'drop' | 'cancel'; item: string; zone?: string}) => string
127
+ children: ReactNode
128
+ }
129
+
130
+ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
131
+ const zones = useRef(new Map<string, ZoneRecord>())
132
+ const items = useRef(new Map<string, ItemRecord>())
133
+ const session = useRef<DragSession | null>(null)
134
+ const frame = useRef<number | null>(null)
135
+ const [drag, setDrag] = useState<DragState | null>(null)
136
+ const [announcement, setAnnouncement] = useState('')
137
+
138
+ const say = useCallback(
139
+ (kind: 'pick' | 'drop' | 'cancel', itemId: string, zoneId?: string) => {
140
+ const item = items.current.get(itemId)?.label ?? itemId
141
+ const zone = zoneId ? (zones.current.get(zoneId)?.label ?? zoneId) : undefined
142
+ const text = announce
143
+ ? announce({kind, item, zone})
144
+ : kind === 'pick'
145
+ ? `Picked up ${item}.`
146
+ : kind === 'drop'
147
+ ? `Dropped ${item} in ${zone ?? 'place'}.`
148
+ : `Cancelled moving ${item}.`
149
+ setAnnouncement(text)
150
+ },
151
+ [announce],
152
+ )
153
+
154
+ const registerZone = useCallback((id: string, record: ZoneRecord) => {
155
+ zones.current.set(id, record)
156
+ return () => {
157
+ zones.current.delete(id)
158
+ }
159
+ }, [])
160
+
161
+ const registerItem = useCallback((id: string, record: ItemRecord) => {
162
+ items.current.set(id, record)
163
+ return () => {
164
+ items.current.delete(id)
165
+ }
166
+ }, [])
167
+
168
+ /** The zone under the pointer that accepts the dragged item, and where in
169
+ * it the item would land. */
170
+ const locate = useCallback((itemId: string, x: number, y: number): DragDropTarget | null => {
171
+ const item = items.current.get(itemId)
172
+ if (!item) return null
173
+ const payload: DragDropItemData = {id: itemId, zone: item.zone, data: item.data}
174
+ for (const [zoneId, zone] of zones.current) {
175
+ const r = zone.el.getBoundingClientRect()
176
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
177
+ if (zone.accepts && !zone.accepts(payload)) return null
178
+ const horizontal = zone.orientation === 'horizontal'
179
+ let index = 0
180
+ for (const [otherId, other] of items.current) {
181
+ if (otherId === itemId || other.zone !== zoneId) continue
182
+ const or = other.el.getBoundingClientRect()
183
+ const mid = horizontal ? (or.left + or.right) / 2 : (or.top + or.bottom) / 2
184
+ if ((horizontal ? x : y) > mid) index += 1
185
+ }
186
+ return {zone: zoneId, index}
187
+ }
188
+ return null
189
+ }, [])
190
+
191
+ const finish = useCallback(
192
+ (dropped: boolean) => {
193
+ const s = session.current
194
+ if (!s) return
195
+ if (s.holdTimer) clearTimeout(s.holdTimer)
196
+ if (frame.current !== null) cancelAnimationFrame(frame.current)
197
+ frame.current = null
198
+ session.current = null
199
+ try {
200
+ if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
201
+ } catch {
202
+ // already released
203
+ }
204
+ if (!s.active) return
205
+ setDrag((current) => {
206
+ const target = current?.over ?? null
207
+ const item = items.current.get(s.id)
208
+ if (dropped && target && item) {
209
+ onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
210
+ say('drop', s.id, target.zone)
211
+ } else {
212
+ say('cancel', s.id)
213
+ }
214
+ return null
215
+ })
216
+ },
217
+ [onDrop, say],
218
+ )
219
+
220
+ const activate = useCallback(
221
+ (s: DragSession, x: number, y: number) => {
222
+ s.active = true
223
+ const item = items.current.get(s.id)
224
+ setDrag({
225
+ id: s.id,
226
+ preview: item?.preview ?? null,
227
+ width: s.width,
228
+ height: s.height,
229
+ x: x - s.offsetX,
230
+ y: y - s.offsetY,
231
+ over: locate(s.id, x, y),
232
+ })
233
+ say('pick', s.id)
234
+ },
235
+ [locate, say],
236
+ )
237
+
238
+ const begin = useCallback(
239
+ (id: string, e: React.PointerEvent) => {
240
+ if (session.current || e.button !== 0) return
241
+ const source = e.currentTarget as HTMLElement
242
+ const rect = source.getBoundingClientRect()
243
+ const s: DragSession = {
244
+ id,
245
+ pointerId: e.pointerId,
246
+ downAt: e.timeStamp,
247
+ startX: e.clientX,
248
+ startY: e.clientY,
249
+ offsetX: e.clientX - rect.left,
250
+ offsetY: e.clientY - rect.top,
251
+ width: rect.width,
252
+ height: rect.height,
253
+ source,
254
+ active: false,
255
+ holdTimer: null,
256
+ }
257
+ session.current = s
258
+ // Capture keeps the stream on the item if the pointer leaves the window;
259
+ // the document listeners below carry the drag either way, so a browser
260
+ // (or a synthetic event) without an active pointer is not an error.
261
+ try {
262
+ source.setPointerCapture(e.pointerId)
263
+ } catch {
264
+ // no active pointer to capture
265
+ }
266
+ if (e.pointerType === 'touch') {
267
+ s.holdTimer = setTimeout(() => {
268
+ s.holdTimer = null
269
+ if (session.current === s && !s.active) activate(s, s.startX, s.startY)
270
+ }, TOUCH_HOLD_MS)
271
+ }
272
+ },
273
+ [activate],
274
+ )
275
+
276
+ // Move/up/cancel listen on the document: pointer capture keeps the events
277
+ // flowing to the source element, but a single document listener survives
278
+ // the item re-rendering under the pointer.
279
+ useEffect(() => {
280
+ const onMove = (e: PointerEvent) => {
281
+ const s = session.current
282
+ if (!s || e.pointerId !== s.pointerId) return
283
+ if (!s.active) {
284
+ const moved = Math.hypot(e.clientX - s.startX, e.clientY - s.startY)
285
+ if (e.pointerType === 'touch') {
286
+ // The hold is judged by event time, not by the timer having fired:
287
+ // a busy main thread (seen on iOS Safari) can withhold the timer
288
+ // AND every pointermove until the finger lifts, then deliver them
289
+ // in one burst. Since touch-action:none keeps the browser from
290
+ // panning on the item, a move stream that arrives at all is ours;
291
+ // the hold only separates a drag from a quick flick.
292
+ if (e.timeStamp - s.downAt >= TOUCH_HOLD_MS) {
293
+ if (s.holdTimer) clearTimeout(s.holdTimer)
294
+ s.holdTimer = null
295
+ activate(s, s.startX, s.startY)
296
+ } else {
297
+ // Moved before the hold elapsed: a flick, not a drag.
298
+ if (moved > MOVE_THRESHOLD_PX) finish(false)
299
+ return
300
+ }
301
+ } else {
302
+ if (moved < MOVE_THRESHOLD_PX) return
303
+ activate(s, e.clientX, e.clientY)
304
+ }
305
+ }
306
+ if (frame.current !== null) return
307
+ frame.current = requestAnimationFrame(() => {
308
+ frame.current = null
309
+ const x = e.clientX
310
+ const y = e.clientY
311
+ setDrag((current) =>
312
+ current
313
+ ? {...current, x: x - s.offsetX, y: y - s.offsetY, over: locate(s.id, x, y)}
314
+ : current,
315
+ )
316
+ })
317
+ }
318
+ const onUp = (e: PointerEvent) => {
319
+ const s = session.current
320
+ if (!s || e.pointerId !== s.pointerId) return
321
+ // Resolve the target from the release point, not the last frame.
322
+ if (s.active) {
323
+ const over = locate(s.id, e.clientX, e.clientY)
324
+ setDrag((current) => (current ? {...current, over} : current))
325
+ }
326
+ finish(true)
327
+ }
328
+ const onCancel = (e: PointerEvent) => {
329
+ const s = session.current
330
+ if (!s || e.pointerId !== s.pointerId) return
331
+ finish(false)
332
+ }
333
+ const onKey = (e: KeyboardEvent) => {
334
+ if (e.key === 'Escape' && session.current) finish(false)
335
+ }
336
+ document.addEventListener('pointermove', onMove)
337
+ document.addEventListener('pointerup', onUp)
338
+ document.addEventListener('pointercancel', onCancel)
339
+ document.addEventListener('keydown', onKey)
340
+ return () => {
341
+ document.removeEventListener('pointermove', onMove)
342
+ document.removeEventListener('pointerup', onUp)
343
+ document.removeEventListener('pointercancel', onCancel)
344
+ document.removeEventListener('keydown', onKey)
345
+ }
346
+ }, [activate, finish, locate])
347
+
348
+ const value = useMemo<RootContextValue>(
349
+ () => ({registerZone, registerItem, begin, drag}),
350
+ [registerZone, registerItem, begin, drag],
351
+ )
352
+
353
+ return (
354
+ <RootContext.Provider value={value}>
355
+ {children}
356
+ {drag && (
357
+ <html.div
358
+ aria-hidden
359
+ style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
360
+ >
361
+ {drag.preview}
362
+ </html.div>
363
+ )}
364
+ <html.div role="status" aria-live="polite" aria-atomic style={styles.liveRegion}>
365
+ {announcement}
366
+ </html.div>
367
+ </RootContext.Provider>
368
+ )
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Zone
373
+ // ---------------------------------------------------------------------------
374
+
375
+ export interface DragDropZoneProps<T = unknown> {
376
+ id: string
377
+ /** Read to screen readers when an item is dropped here. */
378
+ label: string
379
+ /** Which axis items flow along; decides how the insertion index is read
380
+ * from the pointer. */
381
+ orientation?: 'horizontal' | 'vertical'
382
+ /** Refuse an item; the zone then never lights up for it. */
383
+ accepts?: (item: DragDropItemData<T>) => boolean
384
+ children: ReactNode
385
+ }
386
+
387
+ function Zone<T = unknown>({
388
+ id,
389
+ label,
390
+ orientation = 'horizontal',
391
+ accepts,
392
+ children,
393
+ }: DragDropZoneProps<T>) {
394
+ const {registerZone, drag} = useRoot('Zone')
395
+ const ref = useRef<HTMLDivElement>(null)
396
+
397
+ useEffect(() => {
398
+ const el = ref.current
399
+ if (!el || isNative) return
400
+ return registerZone(id, {
401
+ el,
402
+ orientation,
403
+ label,
404
+ accepts: accepts as ZoneRecord['accepts'],
405
+ })
406
+ }, [id, label, orientation, accepts, registerZone])
407
+
408
+ const over = drag?.over?.zone === id
409
+ const dragging = drag !== null
410
+
411
+ return (
412
+ <html.div
413
+ ref={ref}
414
+ style={[styles.zone, dragging && styles.zoneReady, over && styles.zoneOver]}
415
+ >
416
+ {children}
417
+ </html.div>
418
+ )
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Item
423
+ // ---------------------------------------------------------------------------
424
+
425
+ export interface DragDropItemProps<T = unknown> {
426
+ id: string
427
+ /** The zone this item currently lives in. */
428
+ zone: string
429
+ /** Read to screen readers when picked up or dropped. */
430
+ label: string
431
+ /** Carried to onDrop untouched. */
432
+ data?: T
433
+ /** What follows the pointer; defaults to the children. */
434
+ preview?: ReactNode
435
+ disabled?: boolean
436
+ children: ReactNode
437
+ }
438
+
439
+ function Item<T = unknown>({
440
+ id,
441
+ zone,
442
+ label,
443
+ data,
444
+ preview,
445
+ disabled = false,
446
+ children,
447
+ }: DragDropItemProps<T>) {
448
+ const {registerItem, begin, drag} = useRoot('Item')
449
+ const ref = useRef<HTMLDivElement>(null)
450
+
451
+ useEffect(() => {
452
+ const el = ref.current
453
+ if (!el || isNative) return
454
+ return registerItem(id, {el, zone, data, label, preview: preview ?? children})
455
+ }, [id, zone, data, label, preview, children, registerItem])
456
+
457
+ // WebKit only honours the prefixed user-select, and a touch held on
458
+ // selectable text becomes iOS's selection gesture (loupe and handles)
459
+ // before our hold can turn it into a drag; the callout is the same race
460
+ // for links and images. StyleX emits neither prefixed property and
461
+ // react-strict-dom's types have no vendor keys, so they are set on the
462
+ // element directly. Both inherit into the item's children.
463
+ useEffect(() => {
464
+ const el = ref.current
465
+ if (!el || isNative) return
466
+ const style = el.style as CSSStyleDeclaration & {webkitTouchCallout?: string}
467
+ style.webkitUserSelect = 'none'
468
+ style.webkitTouchCallout = 'none'
469
+ }, [])
470
+
471
+ const onPointerDown = useCallback(
472
+ (e: React.PointerEvent) => {
473
+ if (disabled) return
474
+ begin(id, e)
475
+ },
476
+ [begin, disabled, id],
477
+ )
478
+
479
+ if (isNative) return <>{children}</>
480
+
481
+ const lifted = drag?.id === id
482
+ return (
483
+ <html.div
484
+ ref={ref}
485
+ onPointerDown={onPointerDown}
486
+ style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
487
+ >
488
+ {children}
489
+ </html.div>
490
+ )
491
+ }
492
+
493
+ /** Current drag, for consumers that render their own placeholder or ghost. */
494
+ export function useDragDrop(): {
495
+ readonly dragging: string | null
496
+ readonly over: DragDropTarget | null
497
+ } {
498
+ const ctx = useContext(RootContext)
499
+ return {dragging: ctx?.drag?.id ?? null, over: ctx?.drag?.over ?? null}
500
+ }
501
+
502
+ export const DragDrop = {Root, Zone, Item}
@@ -0,0 +1,62 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
+
6
+ export const styles = css.create({
7
+ // The item is the drag handle: no browser panning starts on it, so a touch
8
+ // hold becomes a drag instead of a scroll, and text inside never selects
9
+ // mid-gesture.
10
+ item: {
11
+ touchAction: 'none',
12
+ userSelect: 'none',
13
+ cursor: 'grab',
14
+ transitionProperty: 'opacity',
15
+ transitionDuration: duration.fast,
16
+ transitionTimingFunction: easing.standard,
17
+ },
18
+ itemDisabled: {
19
+ cursor: 'default',
20
+ },
21
+ // The source stays in flow while its ghost travels, so nothing reflows.
22
+ itemLifted: {
23
+ opacity: 0.4,
24
+ cursor: 'grabbing',
25
+ },
26
+ zone: {
27
+ borderRadius: radii.sm,
28
+ transitionProperty: 'box-shadow, background-color',
29
+ transitionDuration: duration.fast,
30
+ transitionTimingFunction: easing.standard,
31
+ },
32
+ // Every zone shows it can receive while something is in the air; the one
33
+ // under the pointer lights up.
34
+ zoneReady: {
35
+ boxShadow: `inset 0 0 0 1px ${colors.border}`,
36
+ },
37
+ zoneOver: {
38
+ boxShadow: `inset 0 0 0 2px ${colors.accent}`,
39
+ backgroundColor: colors.infoBg,
40
+ },
41
+ ghost: {
42
+ position: 'fixed',
43
+ top: 0,
44
+ left: 0,
45
+ pointerEvents: 'none',
46
+ zIndex: 1000,
47
+ opacity: 0.9,
48
+ cursor: 'grabbing',
49
+ },
50
+ ghostAt: (x: number, y: number, width: number, height: number) => ({
51
+ width,
52
+ height,
53
+ transform: `translate3d(${x}px, ${y}px, 0)`,
54
+ }),
55
+ // Same visually-hidden recipe as TagGroup's live region.
56
+ liveRegion: {
57
+ position: 'absolute',
58
+ width: 1,
59
+ height: 1,
60
+ overflow: 'hidden',
61
+ },
62
+ })
@@ -2,11 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'CSS grid layout. Fixed column count (1-6), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses below the md breakpoint.',
5
+ 'Grid layout. Columns are a count (1-6), a list of weights ([1, 2] for one-third / two-thirds), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses to one column on the width of its own container, so it is safe to nest and to place beside a DetailPanel. Weights rather than CSS template strings, so the same props render as CSS grid on web and as a wrapping flex row on native.',
6
6
  whenToUse: [
7
7
  'Card grids, dashboard layouts, multi-column forms',
8
8
  'Responsive layouts that should auto-adjust column count',
9
- 'A list/detail or nav/content screen — layout="split" | "split-wide", never a hand-rolled gridTemplateColumns with its own @media',
9
+ 'A list/detail or nav/content screen — layout="split" | "split-wide", never a hand-rolled gridTemplateColumns with its own @media; a split inside a split is fine, each collapses on its own room',
10
10
  ],
11
11
  whenNotToUse: [
12
12
  'Single-column vertical layout — use Stack',
@@ -42,5 +42,11 @@ export const meta: ComponentMeta = {
42
42
  <Card>A</Card>
43
43
  <Card>B</Card>
44
44
  <Card>C</Card>
45
+ </Grid>
46
+
47
+ // Weighted: a one-third / two-thirds split (works on native too)
48
+ <Grid columns={[1, 2]} gap="md">
49
+ <Card>Sidebar</Card>
50
+ <Card>Content</Card>
45
51
  </Grid>`,
46
52
  }
@@ -1,4 +1,5 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
2
3
  import {css, html} from 'react-strict-dom'
3
4
  import {Grid} from './Grid'
4
5
  import {Stack} from '../Stack/Stack'
@@ -19,6 +20,7 @@ const meta: Meta<typeof Grid> = {
19
20
  columns: {
20
21
  control: 'select',
21
22
  options: [1, 2, 3, 4, 5, 6],
23
+ description: 'A count, or weights such as [1, 2] for a one-third / two-thirds split',
22
24
  },
23
25
  minColumnWidth: {control: 'text'},
24
26
  layout: {control: 'select', options: [undefined, 'split', 'split-wide']},
@@ -41,6 +43,12 @@ const localStyles = css.create({
41
43
  fontSize: typography.fontSizeXs,
42
44
  color: colors.textMuted,
43
45
  },
46
+ frame: (width: number) => ({
47
+ width,
48
+ borderWidth: 1,
49
+ borderStyle: 'dashed',
50
+ borderColor: colors.border,
51
+ }),
44
52
  })
45
53
 
46
54
  const Cell = ({children}: {children: string}) => (
@@ -89,7 +97,8 @@ export const Split: Story = {
89
97
  render: () => (
90
98
  <Stack gap="sm">
91
99
  <html.span style={localStyles.label}>
92
- layout=&quot;split&quot; — list ≥ 240px beside the detail, one column below md
100
+ layout=&quot;split&quot; — list ≥ 240px beside the detail, one column when the container is
101
+ narrower than sm; split-wide collapses below md
93
102
  </html.span>
94
103
  <Grid layout="split" gap="lg">
95
104
  <Cell>list</Cell>
@@ -103,6 +112,79 @@ export const Split: Story = {
103
112
  ),
104
113
  }
105
114
 
115
+ /**
116
+ * A split inside a split: a list/detail board in the content column of a
117
+ * nav/content shell. Each collapses on its OWN container: at 1120 both split;
118
+ * at 900 the shell still splits (≥ md) while the board — left 588px, under
119
+ * sm — stacks; at 700 the shell stacks and the board, now given the whole
120
+ * width, splits again; at 600 both stack. Keyed on the viewport, both would
121
+ * open at 768 and leave the detail pane ~150px.
122
+ */
123
+ export const NestedSplits: Story = {
124
+ render: () => (
125
+ <Stack gap="lg">
126
+ {[1120, 900, 700, 600].map((width) => (
127
+ <Stack key={width} gap="sm">
128
+ <html.span style={localStyles.label}>frame {width}px</html.span>
129
+ <html.div style={localStyles.frame(width)} data-testid={`frame-${width}`}>
130
+ <Grid layout="split-wide" gap="xl">
131
+ <Cell>nav</Cell>
132
+ <Grid layout="split" gap="md">
133
+ <Cell>list</Cell>
134
+ <Cell>detail</Cell>
135
+ </Grid>
136
+ </Grid>
137
+ </html.div>
138
+ </Stack>
139
+ ))}
140
+ </Stack>
141
+ ),
142
+ play: async ({canvas}) => {
143
+ const tracks = (frame: HTMLElement, text: string) => {
144
+ const grid = canvas.getAllByText(text).find((el) => frame.contains(el))
145
+ ?.parentElement as HTMLElement
146
+ return getComputedStyle(grid).gridTemplateColumns.split(' ').length
147
+ }
148
+ const expected: Record<number, [number, number]> = {
149
+ 1120: [2, 2],
150
+ 900: [2, 1],
151
+ 700: [1, 2],
152
+ 600: [1, 1],
153
+ }
154
+ for (const [width, [outer, inner]] of Object.entries(expected)) {
155
+ const frame = canvas.getByTestId(`frame-${width}`)
156
+ await expect(tracks(frame, 'nav'), `shell at ${width}`).toBe(outer)
157
+ await expect(tracks(frame, 'list'), `board at ${width}`).toBe(inner)
158
+ }
159
+ },
160
+ }
161
+
162
+ /**
163
+ * Weighted columns: the portable form of `grid-template-columns: 1fr 2fr`.
164
+ * Weights are what the native Grid can honour too (as flex bases), where a
165
+ * CSS template string would be dropped.
166
+ */
167
+ export const WeightedColumns: Story = {
168
+ render: () => (
169
+ <Grid gap="md" columns={[1, 2]}>
170
+ <Cell>Sidebar (1)</Cell>
171
+ <Cell>Content (2)</Cell>
172
+ <Cell>Sidebar (1)</Cell>
173
+ <Cell>Content (2)</Cell>
174
+ </Grid>
175
+ ),
176
+ play: async ({canvas}) => {
177
+ // The grid is the cells' parent (Storybook wraps the story in its own
178
+ // divs). The browser resolves `1fr 2fr` to pixel tracks, so assert the
179
+ // ratio rather than the string.
180
+ const root = canvas.getAllByText('Sidebar (1)')[0].parentElement as HTMLElement
181
+ const tracks = getComputedStyle(root).gridTemplateColumns.split(' ').map(Number.parseFloat)
182
+ await expect(tracks).toHaveLength(2)
183
+ // Second track is twice the first, within a pixel of rounding.
184
+ await expect(Math.abs(tracks[1] - 2 * tracks[0])).toBeLessThan(1.5)
185
+ },
186
+ }
187
+
106
188
  export const WithContainerQuery: Story = {
107
189
  render: function Render() {
108
190
  const {ref, size} = useContainerQuery<HTMLDivElement>()