@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
@@ -0,0 +1,825 @@
1
+ /// <reference types="vite/client" />
2
+ import {Component, useState, type ReactNode} from 'react'
3
+ import type {Meta, StoryObj} from '@storybook/react'
4
+ import {expect, fn, spyOn, waitFor, within} from 'storybook/test'
5
+ import {css, html} from 'react-strict-dom'
6
+ import {renderToString} from 'react-dom/server'
7
+ import {hydrateRoot} from 'react-dom/client'
8
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
9
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
10
+ import {SIZES_PX, SPACING_PX} from '@duro-app/tokens/keys'
11
+ import {breakpointsPx} from '@duro-app/tokens/tokens/breakpoints.css'
12
+ import {AppShell, type AppShellCollapse} from './AppShell'
13
+ import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
14
+ import {SideNav} from '../SideNav/SideNav'
15
+ import {Heading} from '../Heading/Heading'
16
+ import {Text} from '../Text/Text'
17
+ import {Input} from '../Input/Input'
18
+ import {Menu} from '../Menu/Menu'
19
+ import {TextLink} from '../TextLink/TextLink'
20
+ import {Aside} from '../Aside/Aside'
21
+ import {Stack} from '../Stack/Stack'
22
+ import {Prose} from '../Prose/Prose'
23
+
24
+ // --- the story's frame -----------------------------------------------------
25
+
26
+ // The shell measures its own width, not the window's: the frame stands for a
27
+ // phone, a tablet or a desktop window, whatever the browser's size.
28
+ type FrameWidth = 'phone' | 'tablet' | 'desktop'
29
+
30
+ const PHONE_PX = 375
31
+ const DESKTOP_PX = breakpointsPx.xl
32
+ const FRAME_PX: Record<FrameWidth, number> = {
33
+ phone: PHONE_PX,
34
+ tablet: breakpointsPx.md,
35
+ desktop: DESKTOP_PX,
36
+ }
37
+
38
+ // In the dev Storybook a frame never overflows the canvas (the Controls panel
39
+ // takes room); under Vitest it keeps its exact width, which the checks
40
+ // measure in a narrower test browser.
41
+ const FIT_CANVAS = !import.meta.env.VITEST
42
+
43
+ const styles = css.create({
44
+ // An outline, not a border: it shows the frame's edge without taking any
45
+ // of its width.
46
+ frame: (width: number) => ({
47
+ width,
48
+ outlineWidth: borders.hairline,
49
+ outlineStyle: 'dashed',
50
+ outlineColor: colors.border,
51
+ }),
52
+ fit: {maxWidth: '100%'},
53
+ })
54
+
55
+ function Frame({px, children}: {px: number; children?: ReactNode}) {
56
+ return (
57
+ <html.div style={[styles.frame(px), FIT_CANVAS && styles.fit]} data-frame="">
58
+ {children}
59
+ </html.div>
60
+ )
61
+ }
62
+
63
+ // --- the shell the stories render ------------------------------------------
64
+
65
+ type HeaderContent = 'none' | 'search' | 'search-account'
66
+
67
+ interface ShellArgs {
68
+ /** The story's frame: the width the shell measures. */
69
+ width: FrameWidth
70
+ /** What `AppShell.Header` holds; `none` leaves the Header out. */
71
+ header: HeaderContent
72
+ /** The brand link's text; empty for no brand. */
73
+ brand: string
74
+ /** The rail's footer text; empty for no footer. */
75
+ footer: string
76
+ collapseBelow: AppShellCollapse
77
+ menuLabel: string
78
+ closeLabel: string
79
+ skipLabel: string
80
+ /** A pick in the navigation (SideNav `onValueChange`). */
81
+ onNavigate: (path: string) => void
82
+ /** The search input's value, on every change. */
83
+ onSearchChange: (value: string) => void
84
+ /** The search input's value, on Enter. */
85
+ onSearchSubmit: (value: string) => void
86
+ /** An item of the account menu. */
87
+ onAccountAction: (action: string) => void
88
+ }
89
+
90
+ const BRAND = 'Duro docs'
91
+ const FOOTER = 'Signed in as Ada'
92
+
93
+ const ACCOUNT_ACTIONS = ['Profile', 'Settings', 'Sign out'] as const
94
+
95
+ function Shell({
96
+ header,
97
+ brand,
98
+ footer,
99
+ collapseBelow,
100
+ menuLabel,
101
+ closeLabel,
102
+ skipLabel,
103
+ onNavigate,
104
+ onSearchChange,
105
+ onSearchSubmit,
106
+ onAccountAction,
107
+ children,
108
+ }: Omit<ShellArgs, 'width'> & {children?: ReactNode}) {
109
+ const [path, setPath] = useState('/docs')
110
+ return (
111
+ <AppShell.Root
112
+ menuLabel={menuLabel}
113
+ closeLabel={closeLabel}
114
+ skipLabel={skipLabel}
115
+ brand={brand ? <TextLink href="/">{brand}</TextLink> : undefined}
116
+ collapseBelow={collapseBelow}
117
+ >
118
+ <AppShell.Rail
119
+ aria-label="Navigation"
120
+ footer={footer ? <Text variant="bodySm">{footer}</Text> : undefined}
121
+ >
122
+ {({close}) => (
123
+ <SideNav.Root
124
+ aria-label="Sections"
125
+ value={path}
126
+ onValueChange={(value) => {
127
+ close()
128
+ setPath(value)
129
+ onNavigate(value)
130
+ }}
131
+ >
132
+ <SideNav.Section label="Content">
133
+ <SideNav.Item value="/">Dashboard</SideNav.Item>
134
+ <SideNav.Item value="/docs">Docs</SideNav.Item>
135
+ <SideNav.Item value="/articles">Articles</SideNav.Item>
136
+ </SideNav.Section>
137
+ </SideNav.Root>
138
+ )}
139
+ </AppShell.Rail>
140
+ {header !== 'none' && (
141
+ <AppShell.Header>
142
+ <Input
143
+ type="search"
144
+ aria-label="Search the docs"
145
+ placeholder="Search the docs"
146
+ onChange={(event) => onSearchChange(event.target.value)}
147
+ onKeyDown={(event) => {
148
+ if (event.key === 'Enter') onSearchSubmit(event.currentTarget.value)
149
+ }}
150
+ />
151
+ {header === 'search-account' && (
152
+ <Menu.Root>
153
+ <Menu.Trigger>Account</Menu.Trigger>
154
+ <Menu.Popup align="end">
155
+ {ACCOUNT_ACTIONS.map((action) => (
156
+ <Menu.Item key={action} onClick={() => onAccountAction(action)}>
157
+ {action}
158
+ </Menu.Item>
159
+ ))}
160
+ </Menu.Popup>
161
+ </Menu.Root>
162
+ )}
163
+ </AppShell.Header>
164
+ )}
165
+ <AppShell.Main id="main">
166
+ {children ?? (
167
+ <>
168
+ <Heading level={1}>Docs</Heading>
169
+ <Text>Current page: {path}</Text>
170
+ </>
171
+ )}
172
+ </AppShell.Main>
173
+ </AppShell.Root>
174
+ )
175
+ }
176
+
177
+ const meta = {
178
+ title: 'Layout/AppShell',
179
+ render: ({width, ...args}) => (
180
+ <Frame px={FRAME_PX[width]}>
181
+ <Shell {...args} />
182
+ </Frame>
183
+ ),
184
+ args: {
185
+ width: 'desktop',
186
+ header: 'search',
187
+ brand: BRAND,
188
+ footer: '',
189
+ collapseBelow: 'sm',
190
+ menuLabel: 'Menu',
191
+ closeLabel: 'Close navigation',
192
+ skipLabel: 'Skip to content',
193
+ onNavigate: fn(),
194
+ onSearchChange: fn(),
195
+ onSearchSubmit: fn(),
196
+ onAccountAction: fn(),
197
+ },
198
+ argTypes: {
199
+ width: {
200
+ control: {
201
+ type: 'radio',
202
+ labels: {
203
+ phone: 'phone 375',
204
+ tablet: 'tablet 768',
205
+ desktop: 'desktop 1280 (fits the canvas)',
206
+ },
207
+ },
208
+ options: ['phone', 'tablet', 'desktop'],
209
+ },
210
+ header: {
211
+ control: {
212
+ type: 'radio',
213
+ labels: {none: 'none', search: 'search', 'search-account': 'search + account menu'},
214
+ },
215
+ options: ['none', 'search', 'search-account'],
216
+ },
217
+ brand: {control: 'text'},
218
+ footer: {control: 'text'},
219
+ collapseBelow: {control: 'radio', options: ['sm', 'md', 'lg']},
220
+ menuLabel: {control: 'text'},
221
+ closeLabel: {control: 'text'},
222
+ skipLabel: {control: 'text'},
223
+ },
224
+ parameters: {
225
+ a11y: {
226
+ test: 'error',
227
+ // holds-until: https://github.com/fredericrous/duro-design-system/issues/79
228
+ // — the selected item's accent on bgCardHover is 6.76:1, under AAA's
229
+ // 7:1 (AA passes). Re-enable this rule when that pair reaches 7:1.
230
+ options: {rules: {'color-contrast-enhanced': {enabled: false}}},
231
+ },
232
+ layout: 'fullscreen',
233
+ },
234
+ } satisfies Meta<ShellArgs>
235
+
236
+ export default meta
237
+ type Story = StoryObj<typeof meta>
238
+
239
+ const page = () => within(document.body)
240
+ const setWidth = (element: HTMLElement, px: number) => {
241
+ element.style.width = `${px}px`
242
+ }
243
+ const frameOf = (canvasElement: HTMLElement) =>
244
+ canvasElement.querySelector('[data-frame]') as HTMLElement
245
+ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
246
+
247
+ // Test-only: runs under Vitest, hidden from the dev sidebar and the docs page.
248
+ const TEST_ONLY = ['!dev', '!autodocs']
249
+
250
+ // --- visible stories -------------------------------------------------------
251
+
252
+ /**
253
+ * Every part of the shell, driven by Controls. Try: set `width` to phone and
254
+ * press Menu to open the rail in a drawer; set `collapseBelow` to lg at the
255
+ * tablet width; click in the canvas and press Tab for the skip link. A pick in
256
+ * the navigation, the search box and the account menu show up in Actions.
257
+ * The desktop frame is 1280px wide, but it shrinks to the canvas when the
258
+ * Controls panel takes room, so it can measure less there.
259
+ */
260
+ export const Playground: Story = {
261
+ args: {header: 'search-account', footer: FOOTER},
262
+ }
263
+
264
+ /**
265
+ * Phone: the rail is hidden; one sticky bar holds the Menu button, the brand
266
+ * and the Header's content.
267
+ */
268
+ export const Phone: Story = {
269
+ args: {width: 'phone'},
270
+ }
271
+
272
+ /** No Header: the desktop shell is the rail and the page, nothing above Main. */
273
+ export const NoHeader: Story = {
274
+ args: {header: 'none'},
275
+ }
276
+
277
+ /** `footer` sits at the rail's foot: the signed-in user, a licence line. */
278
+ export const RailFooter: Story = {
279
+ args: {footer: FOOTER},
280
+ }
281
+
282
+ // --- test-only stories -----------------------------------------------------
283
+
284
+ /** Phone's checks, kept off the visible story so a Control change cannot fail them. */
285
+ export const PhoneChecks: Story = {
286
+ ...Phone,
287
+ tags: TEST_ONLY,
288
+ play: async ({args, canvas}) => {
289
+ await expect(canvas.queryByRole('navigation', {name: 'Navigation'})).toBeNull()
290
+ const menu = canvas.getByRole('button', {name: args.menuLabel})
291
+ await expect(menu).toHaveAttribute('aria-expanded', 'false')
292
+ const banner = canvas.getByRole('banner')
293
+ await expect(banner.contains(menu)).toBe(true)
294
+ await expect(banner.contains(canvas.getByRole('link', {name: args.brand}))).toBe(true)
295
+ await expect(banner.contains(canvas.getByRole('searchbox'))).toBe(true)
296
+ await expect(banner.getBoundingClientRect().height).toBe(SIZES_PX.appBarH)
297
+ await expect(getComputedStyle(banner).position).toBe('sticky')
298
+ },
299
+ }
300
+
301
+ /** NoHeader's checks, kept off the visible story so a Control change cannot fail them. */
302
+ export const NoHeaderChecks: Story = {
303
+ ...NoHeader,
304
+ tags: TEST_ONLY,
305
+ play: async ({args, canvas}) => {
306
+ await expect(canvas.getByRole('navigation', {name: 'Navigation'})).toBeVisible()
307
+ await expect(canvas.queryByRole('banner')).toBeNull()
308
+ await expect(canvas.queryByRole('button', {name: args.menuLabel})).toBeNull()
309
+ await expect(canvas.getAllByRole('link', {name: args.brand})).toHaveLength(1)
310
+ },
311
+ }
312
+
313
+ /** RailFooter's checks, kept off the visible story so a Control change cannot fail them. */
314
+ export const RailFooterChecks: Story = {
315
+ ...RailFooter,
316
+ tags: TEST_ONLY,
317
+ play: async ({args, canvas}) => {
318
+ const rail = canvas.getByRole('navigation', {name: 'Navigation'})
319
+ const footer = canvas.getByText(args.footer)
320
+ await expect(rail.contains(footer)).toBe(true)
321
+ // at the foot: its box ends within the rail's last spacing.md
322
+ const gap = rail.getBoundingClientRect().bottom - footer.getBoundingClientRect().bottom
323
+ await expect(gap).toBeGreaterThanOrEqual(0)
324
+ await expect(gap).toBeLessThanOrEqual(SPACING_PX.md)
325
+ },
326
+ }
327
+
328
+ /** Desktop: the brand and the rail beside the header and the page; no Menu button. */
329
+ export const DesktopLandmarks: Story = {
330
+ tags: TEST_ONLY,
331
+ play: async ({args, canvas}) => {
332
+ const rail = canvas.getByRole('navigation', {name: 'Navigation'})
333
+ const banner = canvas.getByRole('banner')
334
+ const main = canvas.getByRole('main')
335
+ await expect(rail).toBeVisible()
336
+ await expect(rail.getBoundingClientRect().width).toBe(SIZES_PX.sidebarW)
337
+ await expect(main.getBoundingClientRect().left).toBeGreaterThanOrEqual(
338
+ rail.getBoundingClientRect().right,
339
+ )
340
+ await expect(banner.getBoundingClientRect().left).toBeGreaterThanOrEqual(
341
+ rail.getBoundingClientRect().right,
342
+ )
343
+ // the rail stays in view; the Header scrolls with the page
344
+ await expect(getComputedStyle(rail).position).toBe('sticky')
345
+ await expect(getComputedStyle(banner).position).toBe('static')
346
+ // landmarks are named and distinct: one main, one banner, each nav its own name
347
+ await expect(canvas.getAllByRole('main')).toHaveLength(1)
348
+ await expect(canvas.getAllByRole('banner')).toHaveLength(1)
349
+ const names = canvas.getAllByRole('navigation').map((nav) => nav.getAttribute('aria-label'))
350
+ await expect(new Set(names).size).toBe(names.length)
351
+ await expect(canvas.queryByRole('button', {name: args.menuLabel})).toBeNull()
352
+ // the brand is the rail's copy
353
+ await expect(rail.contains(canvas.getByRole('link', {name: args.brand}))).toBe(true)
354
+ },
355
+ }
356
+
357
+ /**
358
+ * The Menu button opens the rail in a Drawer. Focus moves into the drawer,
359
+ * and back to the Menu button when it closes — by Escape, and after a pick.
360
+ */
361
+ export const PhoneDrawer: Story = {
362
+ tags: TEST_ONLY,
363
+ args: {width: 'phone', brand: ''},
364
+ play: async ({args, canvas, userEvent}) => {
365
+ const menu = canvas.getByRole('button', {name: args.menuLabel})
366
+ await userEvent.click(menu)
367
+ const dialog = await page().findByRole('dialog', {name: 'Navigation'})
368
+ await expect(menu).toHaveAttribute('aria-expanded', 'true')
369
+ const drawerNav = within(dialog).getByRole('navigation', {name: 'Navigation'})
370
+ await waitFor(() => expect(drawerNav.contains(document.activeElement)).toBe(true))
371
+ // the rail's content is rendered once, in the drawer while it is open:
372
+ // counted in the DOM (a hidden copy would still duplicate a Tree's ids)
373
+ await expect(page().getAllByText('Articles')).toHaveLength(1)
374
+ await expect(document.querySelectorAll('[aria-label="Sections"]')).toHaveLength(1)
375
+
376
+ await userEvent.keyboard('{Escape}')
377
+ await waitFor(() => expect(page().queryByRole('dialog')).toBeNull())
378
+ await waitFor(() => expect(menu).toHaveFocus())
379
+
380
+ // a pick closes the drawer through close(), and focus comes back too
381
+ await userEvent.click(menu)
382
+ const again = await page().findByRole('dialog', {name: 'Navigation'})
383
+ await userEvent.click(within(again).getByRole('button', {name: 'Articles'}))
384
+ await waitFor(() => expect(page().queryByRole('dialog')).toBeNull())
385
+ await waitFor(() => expect(menu).toHaveFocus())
386
+ await expect(canvas.getByText('Current page: /articles')).toBeInTheDocument()
387
+ await expect(args.onNavigate).toHaveBeenCalledWith('/articles')
388
+ },
389
+ }
390
+
391
+ /**
392
+ * The Playground's actions reach their args: a pick in the navigation, the
393
+ * search input's changes and Enter, an account menu item.
394
+ */
395
+ export const PlaygroundActions: Story = {
396
+ tags: TEST_ONLY,
397
+ args: {header: 'search-account'},
398
+ play: async ({args, canvas, userEvent}) => {
399
+ await userEvent.click(canvas.getByRole('button', {name: 'Articles'}))
400
+ await expect(args.onNavigate).toHaveBeenCalledWith('/articles')
401
+
402
+ const search = canvas.getByRole('searchbox', {name: 'Search the docs'})
403
+ await userEvent.type(search, 'flux{Enter}')
404
+ await expect(args.onSearchChange).toHaveBeenLastCalledWith('flux')
405
+ await expect(args.onSearchSubmit).toHaveBeenCalledWith('flux')
406
+
407
+ await userEvent.click(canvas.getByRole('button', {name: 'Account'}))
408
+ await userEvent.click(await page().findByRole('menuitem', {name: 'Sign out'}))
409
+ await expect(args.onAccountAction).toHaveBeenCalledWith('Sign out')
410
+ },
411
+ }
412
+
413
+ /** No Header on a phone still gives the bar: the Menu button and the brand. */
414
+ export const NoHeaderPhone: Story = {
415
+ tags: TEST_ONLY,
416
+ args: {width: 'phone', header: 'none'},
417
+ play: async ({args, canvas}) => {
418
+ const banner = canvas.getByRole('banner')
419
+ const menu = canvas.getByRole('button', {name: args.menuLabel})
420
+ await expect(menu).toBeVisible()
421
+ await expect(banner.contains(menu)).toBe(true)
422
+ await expect(banner.contains(canvas.getByRole('link', {name: args.brand}))).toBe(true)
423
+ await expect(banner.getBoundingClientRect().height).toBe(SIZES_PX.appBarH)
424
+ },
425
+ }
426
+
427
+ /**
428
+ * The brand is in the server's HTML twice (the top of the rail, the bar) and
429
+ * CSS shows one: the accessibility tree holds exactly one link at a phone's
430
+ * width and at a desktop's, and only that one takes focus.
431
+ */
432
+ export const Brand: Story = {
433
+ tags: TEST_ONLY,
434
+ play: async ({args, canvas, canvasElement}) => {
435
+ const frame = frameOf(canvasElement)
436
+ const focusable = () =>
437
+ [...canvasElement.querySelectorAll<HTMLAnchorElement>('a[href="/"]')].filter((a) =>
438
+ a.checkVisibility(),
439
+ )
440
+ await expect(canvasElement.querySelectorAll('a[href="/"]')).toHaveLength(2)
441
+ try {
442
+ for (const width of [PHONE_PX, DESKTOP_PX]) {
443
+ setWidth(frame, width)
444
+ await waitFor(() => expect(canvas.getAllByRole('link', {name: args.brand})).toHaveLength(1))
445
+ await expect(focusable()).toHaveLength(1)
446
+ }
447
+ const link = canvas.getByRole('link', {name: args.brand})
448
+ await expect(canvas.getByRole('navigation', {name: 'Navigation'}).contains(link)).toBe(true)
449
+ } finally {
450
+ frame.style.width = ''
451
+ }
452
+ },
453
+ }
454
+
455
+ /** The footer comes along into the drawer, after the navigation. */
456
+ export const RailFooterInDrawer: Story = {
457
+ tags: TEST_ONLY,
458
+ args: {width: 'phone', brand: '', footer: FOOTER},
459
+ play: async ({args, canvas, userEvent}) => {
460
+ await userEvent.click(canvas.getByRole('button', {name: args.menuLabel}))
461
+ const dialog = await page().findByRole('dialog', {name: 'Navigation'})
462
+ await expect(within(dialog).getByText(args.footer)).toBeVisible()
463
+ // rendered once, in the drawer while it is open
464
+ await expect(page().getAllByText(args.footer)).toHaveLength(1)
465
+ await userEvent.keyboard('{Escape}')
466
+ await waitFor(() => expect(page().queryByRole('dialog')).toBeNull())
467
+ },
468
+ }
469
+
470
+ /**
471
+ * `collapseBelow="md"`: at 680px (between sm and md) the shell already shows
472
+ * the Menu button, on its own width — the window does not change.
473
+ */
474
+ export const CollapseBelowMd: Story = {
475
+ tags: TEST_ONLY,
476
+ args: {brand: '', collapseBelow: 'md'},
477
+ render: ({width: _width, ...args}) => (
478
+ <Frame px={SIZES_PX.dialogLg}>
479
+ <Shell {...args} />
480
+ </Frame>
481
+ ),
482
+ play: async ({args, canvas, canvasElement}) => {
483
+ const frame = frameOf(canvasElement)
484
+ const viewport = window.innerWidth
485
+ await expect(canvas.queryByRole('navigation', {name: 'Navigation'})).toBeNull()
486
+ await expect(canvas.getByRole('button', {name: args.menuLabel})).toBeVisible()
487
+ try {
488
+ setWidth(frame, breakpointsPx.md - 1)
489
+ await nextFrame()
490
+ await expect(canvas.queryByRole('navigation', {name: 'Navigation'})).toBeNull()
491
+ setWidth(frame, breakpointsPx.md)
492
+ await waitFor(() =>
493
+ expect(canvas.getByRole('navigation', {name: 'Navigation'})).toBeVisible(),
494
+ )
495
+ await expect(canvas.queryByRole('button', {name: args.menuLabel})).toBeNull()
496
+ await expect(window.innerWidth).toBe(viewport)
497
+ } finally {
498
+ frame.style.width = ''
499
+ }
500
+ },
501
+ }
502
+
503
+ /** The skip link is the first tab stop and moves focus to Main. */
504
+ export const SkipLink: Story = {
505
+ tags: TEST_ONLY,
506
+ play: async ({args, canvas, userEvent}) => {
507
+ ;(document.activeElement as HTMLElement | null)?.blur()
508
+ await userEvent.tab()
509
+ const skip = canvas.getByRole('link', {name: args.skipLabel})
510
+ await expect(skip).toHaveFocus()
511
+ await expect(skip).toBeVisible()
512
+ await expect(skip).toHaveAttribute('href', '#main')
513
+ await userEvent.keyboard('{Enter}')
514
+ const main = canvas.getByRole('main')
515
+ await waitFor(() => expect(main).toHaveFocus())
516
+ await expect(main).toHaveAttribute('tabindex', '-1')
517
+ },
518
+ }
519
+
520
+ /** Without an id on Main, the skip link targets the one Root generates. */
521
+ export const SkipLinkGeneratedId: Story = {
522
+ tags: TEST_ONLY,
523
+ render: (args) => (
524
+ <Frame px={FRAME_PX[args.width]}>
525
+ <AppShell.Root
526
+ menuLabel={args.menuLabel}
527
+ closeLabel={args.closeLabel}
528
+ skipLabel={args.skipLabel}
529
+ >
530
+ <AppShell.Rail aria-label="Navigation">
531
+ <Text>Links</Text>
532
+ </AppShell.Rail>
533
+ <AppShell.Main>
534
+ <Heading level={1}>Docs</Heading>
535
+ </AppShell.Main>
536
+ </AppShell.Root>
537
+ </Frame>
538
+ ),
539
+ play: async ({args, canvas}) => {
540
+ const main = canvas.getByRole('main')
541
+ const id = main.getAttribute('id')
542
+ await expect(id).toBeTruthy()
543
+ await expect(canvas.getByRole('link', {name: args.skipLabel})).toHaveAttribute('href', `#${id}`)
544
+ },
545
+ }
546
+
547
+ class Caught extends Component<{children: ReactNode}, {error: string | null}> {
548
+ state = {error: null as string | null}
549
+ static getDerivedStateFromError(error: Error) {
550
+ return {error: error.message}
551
+ }
552
+ render() {
553
+ return this.state.error ? <Text>{this.state.error}</Text> : this.props.children
554
+ }
555
+ }
556
+
557
+ /** A Header Root cannot see (inside a fragment) fails loudly, not silently. */
558
+ export const WrappedHeaderThrows: Story = {
559
+ tags: TEST_ONLY,
560
+ // What renders is the test's error fallback, not AppShell: nothing for axe
561
+ // to judge about the component (every other AppShell story keeps it).
562
+ parameters: {a11y: {test: 'off'}},
563
+ render: (args) => (
564
+ <Caught>
565
+ <AppShell.Root
566
+ menuLabel={args.menuLabel}
567
+ closeLabel={args.closeLabel}
568
+ skipLabel={args.skipLabel}
569
+ >
570
+ <AppShell.Rail aria-label="Navigation">
571
+ <Text>Links</Text>
572
+ </AppShell.Rail>
573
+ <>
574
+ <AppShell.Header>
575
+ <Text>Search</Text>
576
+ </AppShell.Header>
577
+ </>
578
+ <AppShell.Main>
579
+ <Heading level={1}>Docs</Heading>
580
+ </AppShell.Main>
581
+ </AppShell.Root>
582
+ </Caught>
583
+ ),
584
+ beforeEach: () => {
585
+ const quiet = spyOn(console, 'error').mockImplementation(() => {})
586
+ return () => quiet.mockRestore()
587
+ },
588
+ play: async ({canvas}) => {
589
+ await expect(
590
+ canvas.getByText(/AppShell\.Header must be a direct child of AppShell\.Root/),
591
+ ).toBeInTheDocument()
592
+ },
593
+ }
594
+
595
+ const PARAGRAPHS = Array.from({length: 80}, (_, i) => `Paragraph ${i + 1} of a long runbook.`)
596
+
597
+ /**
598
+ * Below the collapse point an Aside inside Main sticks below the bar, not
599
+ * under it; above, at its own offset: AppShell sets the bar's height on an
600
+ * element inside its query container, and Aside's top adds it to its offset.
601
+ */
602
+ export const AsideBelowBar: Story = {
603
+ tags: TEST_ONLY,
604
+ args: {width: 'phone', brand: ''},
605
+ render: ({width, ...args}) => (
606
+ <Frame px={FRAME_PX[width]}>
607
+ <Shell {...args}>
608
+ <Aside aria-label="Page outline">
609
+ <Text>On this page</Text>
610
+ </Aside>
611
+ <Stack gap="md">
612
+ {PARAGRAPHS.map((text) => (
613
+ <Text key={text}>{text}</Text>
614
+ ))}
615
+ </Stack>
616
+ </Shell>
617
+ </Frame>
618
+ ),
619
+ play: async ({canvas, canvasElement}) => {
620
+ const frame = frameOf(canvasElement)
621
+ setWidth(frame, PHONE_PX)
622
+ const aside = canvas.getByRole('complementary', {name: 'Page outline'})
623
+ const banner = canvas.getByRole('banner')
624
+ const main = canvas.getByRole('main')
625
+ // set inside the query container, not on it
626
+ const container = main.parentElement?.parentElement as HTMLElement
627
+ await expect(getComputedStyle(main).getPropertyValue('--duro-app-shell-bar').trim()).toBe(
628
+ `${SIZES_PX.appBarH}px`,
629
+ )
630
+ await expect(getComputedStyle(container).getPropertyValue('--duro-app-shell-bar')).toBe('')
631
+
632
+ const scroller = document.scrollingElement as HTMLElement
633
+ const start = scroller.scrollTop
634
+ try {
635
+ scroller.scrollTop = start + 1500
636
+ await waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(start))
637
+ await waitFor(() => expect(Math.round(banner.getBoundingClientRect().top)).toBe(0))
638
+ await expect(Math.round(banner.getBoundingClientRect().bottom)).toBe(SIZES_PX.appBarH)
639
+ await waitFor(() =>
640
+ expect(Math.round(aside.getBoundingClientRect().top)).toBe(
641
+ SIZES_PX.appBarH + SPACING_PX.lg,
642
+ ),
643
+ )
644
+ // above the collapse point the Header scrolls away: only the offset
645
+ setWidth(frame, DESKTOP_PX)
646
+ await waitFor(() => expect(getComputedStyle(aside).top).toBe(`${SPACING_PX.lg}px`))
647
+ await waitFor(() => expect(Math.round(aside.getBoundingClientRect().top)).toBe(SPACING_PX.lg))
648
+ } finally {
649
+ scroller.scrollTop = start
650
+ frame.style.width = ''
651
+ }
652
+ },
653
+ }
654
+
655
+ /**
656
+ * A jump to a heading inside Prose lands below the phone bar, not under it:
657
+ * Prose's scroll margin adds the bar's height that AppShell publishes. Above
658
+ * the collapse point (no sticky bar) it is the spacing offset alone.
659
+ */
660
+ export const ProseAnchorBelowBar: Story = {
661
+ tags: TEST_ONLY,
662
+ args: {width: 'phone', brand: ''},
663
+ render: ({width, ...args}) => (
664
+ <Frame px={FRAME_PX[width]}>
665
+ <Shell {...args}>
666
+ <Prose>
667
+ {PARAGRAPHS.slice(0, 40).map((text) => (
668
+ <html.p key={text}>{text}</html.p>
669
+ ))}
670
+ <html.h2 id="recovery-keys">Recovery keys</html.h2>
671
+ {PARAGRAPHS.slice(40).map((text) => (
672
+ <html.p key={text}>{text}</html.p>
673
+ ))}
674
+ </Prose>
675
+ </Shell>
676
+ </Frame>
677
+ ),
678
+ play: async ({canvas, canvasElement}) => {
679
+ const frame = frameOf(canvasElement)
680
+ setWidth(frame, PHONE_PX)
681
+ const heading = canvas.getByRole('heading', {name: 'Recovery keys'})
682
+ const banner = canvas.getByRole('banner')
683
+ const scroller = document.scrollingElement as HTMLElement
684
+ const start = scroller.scrollTop
685
+ try {
686
+ await waitFor(() =>
687
+ expect(getComputedStyle(heading).scrollMarginTop).toBe(
688
+ `${SIZES_PX.appBarH + SPACING_PX.lg}px`,
689
+ ),
690
+ )
691
+ heading.scrollIntoView({block: 'start'})
692
+ await waitFor(() =>
693
+ expect(Math.round(heading.getBoundingClientRect().top)).toBeGreaterThanOrEqual(
694
+ Math.round(banner.getBoundingClientRect().bottom),
695
+ ),
696
+ )
697
+ // above the collapse point: the spacing offset alone
698
+ setWidth(frame, DESKTOP_PX)
699
+ await waitFor(() =>
700
+ expect(getComputedStyle(heading).scrollMarginTop).toBe(`${SPACING_PX.lg}px`),
701
+ )
702
+ } finally {
703
+ scroller.scrollTop = start
704
+ frame.style.width = ''
705
+ }
706
+ },
707
+ }
708
+
709
+ /**
710
+ * The server renders the shell once, for every width: CSS picks the rail or
711
+ * the Menu button before any script runs, and hydration reports nothing. The
712
+ * brand is in that HTML twice (rail and bar), one of them in the
713
+ * accessibility tree. It replaces the canvas's content with the server's
714
+ * HTML, then hydrates and unmounts it, so it runs as a test only.
715
+ */
716
+ export const ServerRender: Story = {
717
+ tags: TEST_ONLY,
718
+ render: ({width}) => (
719
+ <Frame px={FRAME_PX[width]}>
720
+ <html.div data-ssr-host="" />
721
+ </Frame>
722
+ ),
723
+ play: async ({args: {width: _width, ...args}, canvasElement}) => {
724
+ const host = canvasElement.querySelector('[data-ssr-host]') as HTMLElement
725
+ const markup = renderToString(<Shell {...args} />)
726
+ await expect(markup.split(args.brand).length - 1).toBe(2)
727
+ host.innerHTML = markup
728
+ const shell = within(host)
729
+ // before hydration: the desktop shell, from CSS alone
730
+ await expect(shell.getByRole('navigation', {name: 'Navigation'})).toBeVisible()
731
+ await expect(shell.getByRole('button', {name: args.menuLabel, hidden: true})).not.toBeVisible()
732
+ await expect(shell.getAllByRole('link', {name: args.brand})).toHaveLength(1)
733
+
734
+ const errors: unknown[] = []
735
+ const consoleError = console.error
736
+ console.error = (...rest: unknown[]) => {
737
+ errors.push(rest)
738
+ consoleError(...rest)
739
+ }
740
+ const root = hydrateRoot(host, <Shell {...args} />, {
741
+ onRecoverableError: (error) => errors.push(error),
742
+ })
743
+ try {
744
+ await new Promise((resolve) => setTimeout(resolve, 100))
745
+ await expect(shell.getByRole('navigation', {name: 'Navigation'})).toBeVisible()
746
+ await expect(shell.getAllByRole('link', {name: args.brand})).toHaveLength(1)
747
+ await expect(errors).toEqual([])
748
+ } finally {
749
+ console.error = consoleError
750
+ root.unmount()
751
+ }
752
+ },
753
+ }
754
+
755
+ type LayoutShiftEntry = PerformanceEntry & {value: number; hadRecentInput: boolean}
756
+
757
+ /**
758
+ * Puts the server's HTML for `node` in `host` and hydrates it, summing the
759
+ * layout-shift entries from the moment the HTML lands until 500ms after.
760
+ */
761
+ async function layoutShift(host: HTMLElement, node: ReactNode): Promise<number> {
762
+ const shifts: number[] = []
763
+ const observer = new PerformanceObserver((list) => {
764
+ for (const entry of list.getEntries() as LayoutShiftEntry[]) {
765
+ if (!entry.hadRecentInput) shifts.push(entry.value)
766
+ }
767
+ })
768
+ observer.observe({type: 'layout-shift'})
769
+ host.innerHTML = renderToString(node)
770
+ await nextFrame()
771
+ const root = hydrateRoot(host, node)
772
+ try {
773
+ await new Promise((resolve) => setTimeout(resolve, 500))
774
+ await nextFrame()
775
+ for (const entry of observer.takeRecords() as LayoutShiftEntry[]) {
776
+ if (!entry.hadRecentInput) shifts.push(entry.value)
777
+ }
778
+ } finally {
779
+ observer.disconnect()
780
+ root.unmount()
781
+ host.innerHTML = ''
782
+ }
783
+ return shifts.reduce((sum, value) => sum + value, 0)
784
+ }
785
+
786
+ /**
787
+ * At a phone's width nothing moves after hydration (layout shift 0), with a
788
+ * Header and without: the brand and the Menu button are in the server's HTML.
789
+ */
790
+ export const NoLayoutShift: Story = {
791
+ tags: TEST_ONLY,
792
+ args: {width: 'phone'},
793
+ render: ({width}) => <Frame px={FRAME_PX[width]} />,
794
+ play: async ({args: {width: _width, ...args}, canvasElement}) => {
795
+ const host = frameOf(canvasElement)
796
+ setWidth(host, PHONE_PX)
797
+ await expect(PerformanceObserver.supportedEntryTypes).toContain('layout-shift')
798
+ await expect(await layoutShift(host, <Shell {...args} />)).toBe(0)
799
+ await expect(await layoutShift(host, <Shell {...args} header="none" />)).toBe(0)
800
+ },
801
+ }
802
+
803
+ /**
804
+ * A touch screen gets a 44px Menu button (the small Button's coarse-pointer
805
+ * target); a mouse keeps the compact control.
806
+ * It drives Chrome's touch emulation through Vitest's CDP session, so it runs
807
+ * as a test and stays out of the dev sidebar (there is no Vitest there).
808
+ */
809
+ export const MenuButtonTouchTarget: Story = {
810
+ tags: TEST_ONLY,
811
+ args: {width: 'phone', brand: ''},
812
+ play: async ({args, canvas}) => {
813
+ const menu = () => canvas.getByRole('button', {name: args.menuLabel})
814
+ await withFinePointer()
815
+ await expect(menu().getBoundingClientRect().height).toBeLessThan(SIZES_PX.touchTarget)
816
+ await withCoarsePointer(async () => {
817
+ await waitFor(() =>
818
+ expect(menu().getBoundingClientRect().height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget),
819
+ )
820
+ await expect(menu().getBoundingClientRect().width).toBeGreaterThanOrEqual(
821
+ SIZES_PX.touchTarget,
822
+ )
823
+ })
824
+ },
825
+ }