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