@duro-app/ui 5.7.0 → 5.8.1

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 (127) hide show
  1. package/dist/{Table-Dj7PXqO_.js → Table-CAjPFgTX.js} +397 -394
  2. package/dist/Table-CAjPFgTX.js.map +1 -0
  3. package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
  4. package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
  5. package/dist/components/AppShell/AppShell.d.ts +74 -0
  6. package/dist/components/AppShell/AppShell.d.ts.map +1 -0
  7. package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
  8. package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
  9. package/dist/components/AppShell/AppShell.stories.d.ts +192 -0
  10. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
  11. package/dist/components/AppShell/styles.css.d.ts +187 -0
  12. package/dist/components/AppShell/styles.css.d.ts.map +1 -0
  13. package/dist/components/Aside/Aside.d.ts +23 -0
  14. package/dist/components/Aside/Aside.d.ts.map +1 -0
  15. package/dist/components/Aside/Aside.meta.d.ts +3 -0
  16. package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
  17. package/dist/components/Aside/Aside.stories.d.ts +45 -0
  18. package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
  19. package/dist/components/Aside/styles.css.d.ts +44 -0
  20. package/dist/components/Aside/styles.css.d.ts.map +1 -0
  21. package/dist/components/Button/styles.css.d.ts +2 -0
  22. package/dist/components/Button/styles.css.d.ts.map +1 -1
  23. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
  24. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  25. package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
  26. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
  27. package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
  28. package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
  29. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +73 -0
  30. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
  31. package/dist/components/CodeBlock/styles.css.d.ts +41 -0
  32. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
  33. package/dist/components/Grid/Grid.d.ts +13 -4
  34. package/dist/components/Grid/Grid.d.ts.map +1 -1
  35. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  36. package/dist/components/Grid/Grid.stories.d.ts +28 -0
  37. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  38. package/dist/components/Grid/styles.css.d.ts +3 -0
  39. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  40. package/dist/components/Heading/Heading.d.ts +3 -1
  41. package/dist/components/Heading/Heading.d.ts.map +1 -1
  42. package/dist/components/Heading/Heading.stories.d.ts +9 -0
  43. package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
  44. package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
  45. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  46. package/dist/components/LinkButton/styles.css.d.ts +2 -0
  47. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  48. package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
  49. package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
  50. package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
  51. package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
  52. package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
  53. package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
  54. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  55. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  56. package/dist/components/Menu/styles.css.d.ts +2 -0
  57. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  58. package/dist/components/PageNav/styles.css.d.ts +1 -1
  59. package/dist/components/Prose/Prose.d.ts +17 -0
  60. package/dist/components/Prose/Prose.d.ts.map +1 -0
  61. package/dist/components/Prose/Prose.meta.d.ts +3 -0
  62. package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
  63. package/dist/components/Prose/Prose.stories.d.ts +63 -0
  64. package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
  65. package/dist/components/SideNav/SideNav.d.ts +7 -1
  66. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  67. package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
  68. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  69. package/dist/components/Time/Time.d.ts +26 -0
  70. package/dist/components/Time/Time.d.ts.map +1 -0
  71. package/dist/components/Time/Time.meta.d.ts +3 -0
  72. package/dist/components/Time/Time.meta.d.ts.map +1 -0
  73. package/dist/components/Time/Time.stories.d.ts +77 -0
  74. package/dist/components/Time/Time.stories.d.ts.map +1 -0
  75. package/dist/components/Time/relative.d.ts +27 -0
  76. package/dist/components/Time/relative.d.ts.map +1 -0
  77. package/dist/form.js +1 -1
  78. package/dist/hooks/useContainerBelow.d.ts +18 -0
  79. package/dist/hooks/useContainerBelow.d.ts.map +1 -0
  80. package/dist/index.css +1 -1
  81. package/dist/index.d.ts +9 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +4870 -4183
  84. package/dist/index.js.map +1 -1
  85. package/dist/table.js +2 -2
  86. package/package.json +2 -2
  87. package/src/components/AppShell/AppShell.meta.ts +62 -0
  88. package/src/components/AppShell/AppShell.stories.tsx +825 -0
  89. package/src/components/AppShell/AppShell.tsx +346 -0
  90. package/src/components/AppShell/styles.css.ts +219 -0
  91. package/src/components/Aside/Aside.meta.ts +33 -0
  92. package/src/components/Aside/Aside.stories.tsx +106 -0
  93. package/src/components/Aside/Aside.tsx +42 -0
  94. package/src/components/Aside/styles.css.ts +60 -0
  95. package/src/components/Button/styles.css.ts +7 -2
  96. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
  97. package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
  98. package/src/components/CodeBlock/CodeBlock.stories.tsx +192 -0
  99. package/src/components/CodeBlock/CodeBlock.tsx +99 -0
  100. package/src/components/CodeBlock/styles.css.ts +48 -0
  101. package/src/components/Grid/Grid.meta.ts +2 -1
  102. package/src/components/Grid/Grid.stories.tsx +202 -3
  103. package/src/components/Grid/Grid.tsx +35 -6
  104. package/src/components/Grid/styles.css.ts +11 -0
  105. package/src/components/Heading/Heading.stories.tsx +51 -0
  106. package/src/components/Heading/Heading.tsx +38 -7
  107. package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
  108. package/src/components/LinkButton/styles.css.ts +6 -2
  109. package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
  110. package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
  111. package/src/components/LiveRegion/LiveRegion.tsx +48 -0
  112. package/src/components/Menu/Menu.stories.tsx +62 -0
  113. package/src/components/Menu/styles.css.ts +6 -3
  114. package/src/components/Prose/Prose.meta.ts +32 -0
  115. package/src/components/Prose/Prose.stories.tsx +240 -0
  116. package/src/components/Prose/Prose.tsx +25 -0
  117. package/src/components/Prose/prose.css +179 -0
  118. package/src/components/SideNav/SideNav.stories.tsx +29 -0
  119. package/src/components/SideNav/SideNav.tsx +14 -2
  120. package/src/components/Time/Time.meta.ts +17 -0
  121. package/src/components/Time/Time.stories.tsx +173 -0
  122. package/src/components/Time/Time.tsx +33 -0
  123. package/src/components/Time/relative.ts +89 -0
  124. package/src/hooks/useContainerBelow.ts +39 -0
  125. package/src/index.ts +13 -1
  126. package/src/strict.css +4 -1
  127. package/dist/Table-Dj7PXqO_.js.map +0 -1
@@ -1,13 +1,18 @@
1
+ import {useRef} from 'react'
1
2
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect} from 'storybook/test'
3
+ import {expect, waitFor} from 'storybook/test'
3
4
  import {css, html} from 'react-strict-dom'
4
5
  import {Grid} from './Grid'
5
6
  import {Stack} from '../Stack/Stack'
6
7
  import {colors} from '@duro-app/tokens/tokens/colors.css'
7
8
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
8
- import {SIZES_PX, SPACING_KEYS} from '@duro-app/tokens/keys'
9
+ import {SIZES_PX, SPACING_KEYS, type SpacingToken} from '@duro-app/tokens/keys'
10
+ import {breakpointsPx} from '@duro-app/tokens/tokens/breakpoints.css'
9
11
  import {sizes} from '@duro-app/tokens/tokens/sizes.css'
10
12
  import {useContainerQuery} from '../../hooks/useContainerQuery'
13
+ import {useContainerBelow} from '../../hooks/useContainerBelow'
14
+ import {Aside} from '../Aside/Aside'
15
+ import {onThemeSurface} from '../../docs/themedSurface'
11
16
  import {typography} from '@duro-app/tokens/tokens/typography.css'
12
17
  import {borders} from '@duro-app/tokens/tokens/borders.css'
13
18
 
@@ -25,7 +30,7 @@ const meta: Meta<typeof Grid> = {
25
30
  description: 'A count, or weights such as [1, 2] for a one-third / two-thirds split',
26
31
  },
27
32
  minColumnWidth: {control: 'text'},
28
- layout: {control: 'select', options: [undefined, 'split', 'split-wide']},
33
+ layout: {control: 'select', options: [undefined, 'split', 'split-wide', 'content-aside']},
29
34
  },
30
35
  }
31
36
 
@@ -261,3 +266,197 @@ const trackStyles = css.create({
261
266
  backgroundColor: colors.bgCard,
262
267
  },
263
268
  })
269
+
270
+ // --- content-aside --------------------------------------------------------
271
+
272
+ const asideStyles = css.create({
273
+ // pageLg (1200px) and pageSm (600px): either side of md (768px)
274
+ wide: {width: sizes.pageLg},
275
+ narrow: {width: sizes.pageSm},
276
+ cell: {
277
+ padding: spacing.sm,
278
+ borderWidth: borders.hairline,
279
+ borderStyle: 'solid',
280
+ borderColor: colors.border,
281
+ },
282
+ // An outline, not a border: the frame's edge without taking its width.
283
+ frame: (width: number) => ({
284
+ width,
285
+ outlineWidth: borders.hairline,
286
+ outlineStyle: 'dashed',
287
+ outlineColor: colors.border,
288
+ }),
289
+ })
290
+
291
+ // Each page's Grid ref, for the play functions to compare with the DOM.
292
+ const gridRefs = new Map<string, {current: HTMLDivElement | null}>()
293
+
294
+ /** Reports, beside the grid, what `useContainerBelow` sees on the Grid's ref. */
295
+ function ReadingPage({name, gap = 'xl'}: {name: string; gap?: SpacingToken}) {
296
+ const ref = useRef<HTMLDivElement | null>(null)
297
+ gridRefs.set(name, ref)
298
+ const below = useContainerBelow(ref, 'md')
299
+ return (
300
+ <html.div role="group" aria-label={name}>
301
+ <Grid ref={ref} layout="content-aside" gap={gap}>
302
+ <html.div role="region" aria-label={`${name} content`} style={asideStyles.cell}>
303
+ {below ? 'below md' : 'md or wider'}
304
+ </html.div>
305
+ <Aside aria-label={`${name} outline`}>
306
+ <html.div style={asideStyles.cell}>On this page</html.div>
307
+ </Aside>
308
+ </Grid>
309
+ </html.div>
310
+ )
311
+ }
312
+
313
+ interface ContentAsideArgs {
314
+ /** The gap between the reading column and the Aside. */
315
+ gap: SpacingToken
316
+ /** The frame's width in px: the container the Grid measures. */
317
+ width: number
318
+ }
319
+
320
+ /**
321
+ * A reading column beside an Aside (asideW); one column below md (768px),
322
+ * measured on the Grid's own container. Drag `width` across 768 to see it
323
+ * collapse: the column reports what `useContainerBelow` sees.
324
+ */
325
+ export const ContentAside: StoryObj<ContentAsideArgs> = {
326
+ args: {gap: 'xl', width: SIZES_PX.pageLg},
327
+ argTypes: {
328
+ gap: {control: 'select', options: [...SPACING_KEYS]},
329
+ width: {control: {type: 'range', min: 320, max: 1440, step: 8}},
330
+ },
331
+ parameters: {a11y: {test: 'error'}, controls: {include: ['gap', 'width']}},
332
+ decorators: [onThemeSurface],
333
+ render: ({gap, width}) => (
334
+ <html.div style={asideStyles.frame(width)}>
335
+ <ReadingPage name="Page" gap={gap} />
336
+ </html.div>
337
+ ),
338
+ }
339
+
340
+ /** ContentAside's checks, kept off the visible story so a Control change cannot fail them. */
341
+ export const ContentAsideChecks: StoryObj<ContentAsideArgs> = {
342
+ ...ContentAside,
343
+ tags: ['!dev', '!autodocs'],
344
+ play: async ({args, canvas}) => {
345
+ const content = canvas.getByRole('region', {name: 'Page content'})
346
+ const aside = canvas.getByRole('complementary', {name: 'Page outline'})
347
+ if (args.width >= breakpointsPx.md) {
348
+ await expect(aside.getBoundingClientRect().width).toBe(SIZES_PX.asideW)
349
+ await expect(aside.getBoundingClientRect().left).toBeGreaterThan(
350
+ content.getBoundingClientRect().right,
351
+ )
352
+ } else {
353
+ await expect(aside.getBoundingClientRect().top).toBeGreaterThanOrEqual(
354
+ content.getBoundingClientRect().bottom,
355
+ )
356
+ }
357
+ },
358
+ }
359
+
360
+ /** ContentAside's checks below md (pageSm, 600px): the Aside stacks under the column. */
361
+ export const ContentAsideStackedChecks: StoryObj<ContentAsideArgs> = {
362
+ ...ContentAsideChecks,
363
+ args: {...ContentAsideChecks.args, width: SIZES_PX.pageSm},
364
+ }
365
+
366
+ /** A wide page and a narrow one, side by side: beside, then stacked. */
367
+ export const ContentAsideWideAndNarrow: Story = {
368
+ tags: ['!dev', '!autodocs'],
369
+ parameters: {a11y: {test: 'error'}},
370
+ decorators: [onThemeSurface],
371
+ render: () => (
372
+ <Stack gap="lg">
373
+ <html.div style={asideStyles.wide}>
374
+ <ReadingPage name="Wide" />
375
+ </html.div>
376
+ <html.div style={asideStyles.narrow}>
377
+ <ReadingPage name="Narrow" />
378
+ </html.div>
379
+ </Stack>
380
+ ),
381
+ play: async ({canvas}) => {
382
+ const wideContent = canvas.getByRole('region', {name: 'Wide content'})
383
+ const wideAside = canvas.getByRole('complementary', {name: 'Wide outline'})
384
+ await expect(wideAside.getBoundingClientRect().width).toBe(SIZES_PX.asideW)
385
+ await expect(wideAside.getBoundingClientRect().left).toBeGreaterThan(
386
+ wideContent.getBoundingClientRect().right,
387
+ )
388
+ const narrowContent = canvas.getByRole('region', {name: 'Narrow content'})
389
+ const narrowAside = canvas.getByRole('complementary', {name: 'Narrow outline'})
390
+ await expect(narrowAside.getBoundingClientRect().top).toBeGreaterThanOrEqual(
391
+ narrowContent.getBoundingClientRect().bottom,
392
+ )
393
+ },
394
+ }
395
+
396
+ /**
397
+ * The collapse and `useContainerBelow` follow the Grid's container, not the
398
+ * window: in a 1440px viewport the 600px page stacks and reports "below md",
399
+ * the 1200px one does not. The forwarded ref is the container the query
400
+ * measures. Sizes the browser through Vitest, so it runs as a test only.
401
+ */
402
+ export const ContentAsideFollowsItsContainer: Story = {
403
+ tags: ['!dev', '!autodocs'],
404
+ parameters: {a11y: {test: 'error'}},
405
+ decorators: [onThemeSurface],
406
+ render: () => (
407
+ <Stack gap="lg">
408
+ <html.div style={asideStyles.wide}>
409
+ <ReadingPage name="Wide" />
410
+ </html.div>
411
+ <html.div style={asideStyles.narrow}>
412
+ <ReadingPage name="Narrow" />
413
+ </html.div>
414
+ </Stack>
415
+ ),
416
+ play: async ({canvas}) => {
417
+ const {page} = await import('vitest/browser')
418
+ const before = {width: window.innerWidth, height: window.innerHeight}
419
+ await page.viewport(1440, 900)
420
+ try {
421
+ await waitFor(() => expect(window.innerWidth).toBe(1440))
422
+ const narrow = canvas.getByRole('group', {name: 'Narrow'})
423
+ const wide = canvas.getByRole('group', {name: 'Wide'})
424
+ // the ref resolves to the container element the query measures
425
+ const narrowContainer = narrow.firstElementChild as HTMLElement
426
+ await expect(gridRefs.get('Narrow')?.current).toBe(narrowContainer)
427
+ await expect(gridRefs.get('Wide')?.current).toBe(wide.firstElementChild)
428
+ await expect(getComputedStyle(narrowContainer).containerType).toBe('inline-size')
429
+ await expect(narrowContainer.getBoundingClientRect().width).toBe(SIZES_PX.pageSm)
430
+ await waitFor(() =>
431
+ expect(canvas.getByRole('region', {name: 'Narrow content'})).toHaveTextContent('below md'),
432
+ )
433
+ await expect(canvas.getByRole('region', {name: 'Wide content'})).toHaveTextContent(
434
+ 'md or wider',
435
+ )
436
+ // and the CSS collapse agrees with it
437
+ const narrowAside = canvas.getByRole('complementary', {name: 'Narrow outline'})
438
+ await expect(narrowAside.getBoundingClientRect().width).toBe(SIZES_PX.pageSm)
439
+ const wideAside = canvas.getByRole('complementary', {name: 'Wide outline'})
440
+ await expect(wideAside.getBoundingClientRect().width).toBe(SIZES_PX.asideW)
441
+ } finally {
442
+ await page.viewport(before.width, before.height)
443
+ }
444
+ },
445
+ }
446
+
447
+ const plainGrid: {current: HTMLDivElement | null} = {current: null}
448
+
449
+ /** `ref` reaches the grid element itself when there is no named layout. */
450
+ export const RefWithoutLayout: Story = {
451
+ render: () => (
452
+ <Grid ref={plainGrid} columns={2} gap="sm">
453
+ <Cell>One</Cell>
454
+ <Cell>Two</Cell>
455
+ </Grid>
456
+ ),
457
+ play: async ({canvas}) => {
458
+ const grid = canvas.getByText('One').parentElement as HTMLElement
459
+ await expect(getComputedStyle(grid).display).toBe('grid')
460
+ await expect(plainGrid.current).toBe(grid)
461
+ },
462
+ }
@@ -1,4 +1,4 @@
1
- import {Children, type ReactNode} from 'react'
1
+ import {Children, type ReactNode, type Ref} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
4
  import {isNative} from '../../platform'
@@ -6,7 +6,7 @@ import {cellBasis, cellPosition, columnWeights, gridTemplate, type GridColumns}
6
6
  import type {SpacingToken} from '@duro-app/tokens/keys'
7
7
  import {resolveLength, resolveTrack, type GridTrack, type Length} from '../../shared/length'
8
8
 
9
- export type GridLayout = 'split' | 'split-wide'
9
+ export type GridLayout = 'split' | 'split-wide' | 'content-aside'
10
10
 
11
11
  interface GridProps {
12
12
  gap?: SpacingToken
@@ -25,7 +25,9 @@ interface GridProps {
25
25
  * column on the width of its OWN container — `split` below `sm` (640px),
26
26
  * `split-wide` below `md` (768px) — so a split nested inside another
27
27
  * split, or beside an open DetailPanel, collapses when it runs out of room
28
- * rather than when the window does. Wins over `columns` / `minColumnWidth`.
28
+ * rather than when the window does. `content-aside` is a reading column
29
+ * beside an `Aside` (`asideW`, 320px), one column below `md`. Wins over
30
+ * `columns` / `minColumnWidth`.
29
31
  */
30
32
  layout?: GridLayout
31
33
  /**
@@ -37,6 +39,13 @@ interface GridProps {
37
39
  * wins over it.
38
40
  */
39
41
  tracks?: readonly GridTrack[]
42
+ /**
43
+ * The Grid's outer element: with a `layout`, the wrapper its container
44
+ * query measures (hand it to `useContainerBelow` to follow the same
45
+ * collapse); otherwise the grid itself. Web only: on native the ref is not
46
+ * attached.
47
+ */
48
+ ref?: Ref<HTMLDivElement>
40
49
  children: ReactNode
41
50
  }
42
51
 
@@ -65,6 +74,7 @@ const columnsMap = {
65
74
  const layoutMap = {
66
75
  split: styles.split,
67
76
  'split-wide': styles.splitWide,
77
+ 'content-aside': styles.contentAside,
68
78
  } as const satisfies Record<GridLayout, unknown>
69
79
 
70
80
  // On native the named layouts are their weights; there is no media query to
@@ -72,6 +82,7 @@ const layoutMap = {
72
82
  const layoutWeights = {
73
83
  split: [1, 2],
74
84
  'split-wide': [1, 3],
85
+ 'content-aside': [2, 1],
75
86
  } as const satisfies Record<GridLayout, readonly number[]>
76
87
 
77
88
  const cellGapLeftMap = {
@@ -96,7 +107,15 @@ const cellGapTopMap = {
96
107
  xxxl: styles.cellGapTopXxxl,
97
108
  } as const satisfies Record<SpacingToken, unknown>
98
109
 
99
- export function Grid({gap = 'md', columns, minColumnWidth, layout, tracks, children}: GridProps) {
110
+ export function Grid({
111
+ gap = 'md',
112
+ columns,
113
+ minColumnWidth,
114
+ layout,
115
+ tracks,
116
+ ref,
117
+ children,
118
+ }: GridProps) {
100
119
  const hasTracks = tracks !== undefined && tracks.length > 0
101
120
  if (isNative) {
102
121
  return (
@@ -122,10 +141,20 @@ export function Grid({gap = 'md', columns, minColumnWidth, layout, tracks, child
122
141
  ? styles.template(gridTemplate(columnWeights(columns) ?? [1]))
123
142
  : undefined
124
143
 
125
- const grid = <html.div style={[styles.base, gapMap[gap], columnStyle]}>{children}</html.div>
144
+ const grid = (
145
+ <html.div ref={layout ? undefined : ref} style={[styles.base, gapMap[gap], columnStyle]}>
146
+ {children}
147
+ </html.div>
148
+ )
126
149
  // A named layout answers its container query from a wrapper: an element
127
150
  // cannot query its own size.
128
- return layout ? <html.div style={styles.splitContainer}>{grid}</html.div> : grid
151
+ return layout ? (
152
+ <html.div ref={ref} style={styles.splitContainer}>
153
+ {grid}
154
+ </html.div>
155
+ ) : (
156
+ grid
157
+ )
129
158
  }
130
159
 
131
160
  /**
@@ -12,6 +12,7 @@ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
12
12
  // has. defineConsts strings, inlined into the @container text at build time.
13
13
  const SPLIT_WIDE_BP = breakpoints.md // 280px rail + real content
14
14
  const SPLIT_BP = breakpoints.sm // 240px list + ≥ 384px detail
15
+ const CONTENT_ASIDE_BP = breakpoints.md // reading column + 320px aside
15
16
 
16
17
  export const styles = css.create({
17
18
  base: {
@@ -50,6 +51,16 @@ export const styles = css.create({
50
51
  [`@container (min-width: ${SPLIT_WIDE_BP})`]: `minmax(${sizes.gridColMd}, 1fr) minmax(0, 3fr)`,
51
52
  },
52
53
  },
54
+ // A reading column beside an Aside (table of contents, related links): the
55
+ // aside keeps its asideW track, the content takes the rest and may shrink
56
+ // (minmax(0, …) so a wide code block scrolls instead of widening the
57
+ // grid); one column below md.
58
+ contentAside: {
59
+ gridTemplateColumns: {
60
+ default: 'minmax(0, 1fr)',
61
+ [`@container (min-width: ${CONTENT_ASIDE_BP})`]: `minmax(0, 1fr) ${sizes.asideW}`,
62
+ },
63
+ },
53
64
  // Weighted columns: `[1, 2]` → `1fr 2fr` (see columns.ts).
54
65
  template: (tracks: string) => ({
55
66
  gridTemplateColumns: tracks,
@@ -0,0 +1,51 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {Heading} from './Heading'
4
+ import {Stack} from '../Stack/Stack'
5
+ import {TextLink} from '../TextLink/TextLink'
6
+ import {onThemeSurface} from '../../docs/themedSurface'
7
+
8
+ const meta: Meta = {
9
+ title: 'Components/Heading',
10
+ parameters: {a11y: {test: 'error'}},
11
+ decorators: [onThemeSurface],
12
+ }
13
+
14
+ export default meta
15
+ type Story = StoryObj
16
+
17
+ export const Levels: Story = {
18
+ render: () => (
19
+ <Stack gap="sm">
20
+ <Heading level={1}>Page title</Heading>
21
+ <Heading level={2}>Section</Heading>
22
+ <Heading level={3}>Subsection</Heading>
23
+ <Heading level={4} color="muted">
24
+ Group
25
+ </Heading>
26
+ </Stack>
27
+ ),
28
+ play: async ({canvas}) => {
29
+ await expect(canvas.getByRole('heading', {level: 1, name: 'Page title'})).toBeInTheDocument()
30
+ await expect(canvas.getByRole('heading', {level: 4, name: 'Group'})).toBeInTheDocument()
31
+ },
32
+ }
33
+
34
+ /** `id` makes a heading the target of an in-page link (a table of contents)
35
+ * or of `aria-labelledby`. */
36
+ export const WithId: Story = {
37
+ render: () => (
38
+ <Stack gap="sm">
39
+ <TextLink href="#install">Install</TextLink>
40
+ <Heading level={2} id="install">
41
+ Install
42
+ </Heading>
43
+ </Stack>
44
+ ),
45
+ play: async ({canvas}) => {
46
+ const heading = canvas.getByRole('heading', {level: 2, name: 'Install'})
47
+ await expect(heading).toHaveAttribute('id', 'install')
48
+ await expect(document.getElementById('install')).toBe(heading)
49
+ await expect(canvas.getByRole('link', {name: 'Install'})).toHaveAttribute('href', '#install')
50
+ },
51
+ }
@@ -17,6 +17,8 @@ interface HeadingProps {
17
17
  variant?: HeadingVariant
18
18
  color?: 'default' | 'muted' | 'accent'
19
19
  align?: 'start' | 'center' | 'end'
20
+ /** The heading's id: an in-page link's target (`href="#id"`), an `aria-labelledby` reference. */
21
+ id?: string
20
22
  children: ReactNode
21
23
  }
22
24
 
@@ -35,14 +37,43 @@ const alignMap = {
35
37
  end: styles.alignEnd,
36
38
  } as const
37
39
 
38
- export function Heading({level, variant, color = 'default', align, children}: HeadingProps) {
40
+ export function Heading({level, variant, color = 'default', align, id, children}: HeadingProps) {
39
41
  const resolvedVariant = variant ?? defaultVariantMap[level]
40
42
  const style = [typePresets[resolvedVariant], styles[color], align && alignMap[align]]
41
43
 
42
- if (level === 1) return <html.h1 style={style}>{children}</html.h1>
43
- if (level === 2) return <html.h2 style={style}>{children}</html.h2>
44
- if (level === 3) return <html.h3 style={style}>{children}</html.h3>
45
- if (level === 4) return <html.h4 style={style}>{children}</html.h4>
46
- if (level === 5) return <html.h5 style={style}>{children}</html.h5>
47
- return <html.h6 style={style}>{children}</html.h6>
44
+ if (level === 1)
45
+ return (
46
+ <html.h1 id={id} style={style}>
47
+ {children}
48
+ </html.h1>
49
+ )
50
+ if (level === 2)
51
+ return (
52
+ <html.h2 id={id} style={style}>
53
+ {children}
54
+ </html.h2>
55
+ )
56
+ if (level === 3)
57
+ return (
58
+ <html.h3 id={id} style={style}>
59
+ {children}
60
+ </html.h3>
61
+ )
62
+ if (level === 4)
63
+ return (
64
+ <html.h4 id={id} style={style}>
65
+ {children}
66
+ </html.h4>
67
+ )
68
+ if (level === 5)
69
+ return (
70
+ <html.h5 id={id} style={style}>
71
+ {children}
72
+ </html.h5>
73
+ )
74
+ return (
75
+ <html.h6 id={id} style={style}>
76
+ {children}
77
+ </html.h6>
78
+ )
48
79
  }
@@ -1,11 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn} from 'storybook/test'
2
+ import {expect, fn, waitFor} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {LinkButton} from './LinkButton'
5
5
  import {clickLink} from '../../docs/clickLink'
6
6
  import {onThemeSurface} from '../../docs/themedSurface'
7
7
  import type {OnNavigate} from '../../shared/navigate'
8
8
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
9
+ import {SIZES_PX} from '@duro-app/tokens/keys'
10
+ import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
9
11
 
10
12
  const meta: Meta<typeof LinkButton> = {
11
13
  title: 'Components/LinkButton',
@@ -131,3 +133,41 @@ export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
131
133
  await expect(onNavigate).toHaveBeenCalledTimes(1)
132
134
  },
133
135
  }
136
+
137
+ /**
138
+ * A touch screen gets 44px links, default and small alike (5.6, as Button);
139
+ * a mouse keeps the compact control.
140
+ * It drives Chrome's touch emulation through Vitest's CDP session, so it runs
141
+ * as a test and stays out of the dev sidebar (there is no Vitest there).
142
+ */
143
+ export const TouchTargets: Story = {
144
+ tags: ['!dev', '!autodocs'],
145
+ render: () => (
146
+ <html.div>
147
+ <LinkButton href="#a">Open runbook</LinkButton>
148
+ <LinkButton href="#b" size="small">
149
+ Edit
150
+ </LinkButton>
151
+ </html.div>
152
+ ),
153
+ play: async ({canvas}) => {
154
+ const links = () => [
155
+ canvas.getByRole('link', {name: 'Open runbook'}),
156
+ canvas.getByRole('link', {name: 'Edit'}),
157
+ ]
158
+ await withFinePointer()
159
+ for (const link of links()) {
160
+ await expect(link.getBoundingClientRect().height).toBeLessThan(SIZES_PX.touchTarget)
161
+ }
162
+ await withCoarsePointer(async () => {
163
+ for (const link of links()) {
164
+ await waitFor(() =>
165
+ expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget),
166
+ )
167
+ await expect(link.getBoundingClientRect().width).toBeGreaterThanOrEqual(
168
+ SIZES_PX.touchTarget,
169
+ )
170
+ }
171
+ })
172
+ },
173
+ }
@@ -22,12 +22,15 @@ export const styles = css.create({
22
22
  transitionProperty: 'background-color, border-color, color',
23
23
  transitionDuration: duration.fast,
24
24
  transitionTimingFunction: easing.standard,
25
+ // Matches Button: the 44px target under a coarse pointer (5.6).
26
+ minWidth: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
25
27
  },
26
28
  sizeDefault: {
27
29
  paddingTop: spacing.sm,
28
30
  paddingBottom: spacing.sm,
29
31
  paddingLeft: spacing.md,
30
32
  paddingRight: spacing.md,
33
+ minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
31
34
  },
32
35
  sizeSmall: {
33
36
  paddingTop: spacing.xs,
@@ -36,8 +39,9 @@ export const styles = css.create({
36
39
  paddingRight: spacing.sm,
37
40
  fontSize: typography.fontSizeXs,
38
41
 
39
- // Matches Button: every small control is at least controlSm tall (5.6).
40
- minHeight: sizes.controlSm,
42
+ // Matches Button: every small control is at least controlSm tall, and
43
+ // the touch target under a coarse pointer (5.6).
44
+ minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
41
45
  },
42
46
  primary: {
43
47
  backgroundColor: {
@@ -0,0 +1,29 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Tells assistive tech about a change that has no focus move: a polite (role="status") or assertive (role="alert") region. Always mounted — render it empty and change its content to announce; visuallyHidden keeps it off screen.',
6
+ whenToUse: [
7
+ 'Confirming an action that leaves focus where it is ("Link copied", "Saved")',
8
+ 'A result that arrives later ("12 results", "Answer ready")',
9
+ 'An error the person must hear now — politeness="assertive"',
10
+ ],
11
+ whenNotToUse: [
12
+ 'A message that also needs to be seen and dismissed — use Toast (it announces itself)',
13
+ 'An inline field error — use Field.Error',
14
+ ],
15
+ relatedTo: [
16
+ {
17
+ component: 'VisuallyHidden',
18
+ kind: 'contrast',
19
+ relationship:
20
+ 'VisuallyHidden hides static text; LiveRegion visuallyHidden announces changing text',
21
+ },
22
+ {
23
+ component: 'CodeBlock',
24
+ kind: 'composition',
25
+ relationship: 'CodeBlock announces "copied" through a LiveRegion',
26
+ },
27
+ ],
28
+ example: `<LiveRegion visuallyHidden>{copied ? 'Link copied' : ''}</LiveRegion>`,
29
+ }