@duro-app/ui 5.7.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.
- package/dist/{Table-Dj7PXqO_.js → Table-CAjPFgTX.js} +397 -394
- package/dist/Table-CAjPFgTX.js.map +1 -0
- package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
- package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
- package/dist/components/AppShell/AppShell.d.ts +74 -0
- package/dist/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
- package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.stories.d.ts +186 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
- package/dist/components/AppShell/styles.css.d.ts +187 -0
- package/dist/components/AppShell/styles.css.d.ts.map +1 -0
- package/dist/components/Aside/Aside.d.ts +23 -0
- package/dist/components/Aside/Aside.d.ts.map +1 -0
- package/dist/components/Aside/Aside.meta.d.ts +3 -0
- package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
- package/dist/components/Aside/Aside.stories.d.ts +45 -0
- package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
- package/dist/components/Aside/styles.css.d.ts +44 -0
- package/dist/components/Aside/styles.css.d.ts.map +1 -0
- package/dist/components/Button/styles.css.d.ts +2 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +67 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +32 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
- package/dist/components/Grid/Grid.d.ts +13 -4
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +28 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/styles.css.d.ts +3 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Heading/Heading.d.ts +3 -1
- package/dist/components/Heading/Heading.d.ts.map +1 -1
- package/dist/components/Heading/Heading.stories.d.ts +9 -0
- package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +2 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
- package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +2 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/PageNav/styles.css.d.ts +1 -1
- package/dist/components/Prose/Prose.d.ts +17 -0
- package/dist/components/Prose/Prose.d.ts.map +1 -0
- package/dist/components/Prose/Prose.meta.d.ts +3 -0
- package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
- package/dist/components/Prose/Prose.stories.d.ts +63 -0
- package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts +7 -1
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
- package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
- package/dist/components/Time/Time.d.ts +26 -0
- package/dist/components/Time/Time.d.ts.map +1 -0
- package/dist/components/Time/Time.meta.d.ts +3 -0
- package/dist/components/Time/Time.meta.d.ts.map +1 -0
- package/dist/components/Time/Time.stories.d.ts +77 -0
- package/dist/components/Time/Time.stories.d.ts.map +1 -0
- package/dist/components/Time/relative.d.ts +27 -0
- package/dist/components/Time/relative.d.ts.map +1 -0
- package/dist/form.js +1 -1
- package/dist/hooks/useContainerBelow.d.ts +18 -0
- package/dist/hooks/useContainerBelow.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4766 -4088
- package/dist/index.js.map +1 -1
- package/dist/table.js +2 -2
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +62 -0
- package/src/components/AppShell/AppShell.stories.tsx +770 -0
- package/src/components/AppShell/AppShell.tsx +346 -0
- package/src/components/AppShell/styles.css.ts +219 -0
- package/src/components/Aside/Aside.meta.ts +33 -0
- package/src/components/Aside/Aside.stories.tsx +106 -0
- package/src/components/Aside/Aside.tsx +42 -0
- package/src/components/Aside/styles.css.ts +60 -0
- package/src/components/Button/styles.css.ts +7 -2
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
- package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
- package/src/components/CodeBlock/CodeBlock.stories.tsx +162 -0
- package/src/components/CodeBlock/CodeBlock.tsx +99 -0
- package/src/components/CodeBlock/styles.css.ts +36 -0
- package/src/components/Grid/Grid.meta.ts +2 -1
- package/src/components/Grid/Grid.stories.tsx +202 -3
- package/src/components/Grid/Grid.tsx +35 -6
- package/src/components/Grid/styles.css.ts +11 -0
- package/src/components/Heading/Heading.stories.tsx +51 -0
- package/src/components/Heading/Heading.tsx +38 -7
- package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
- package/src/components/LinkButton/styles.css.ts +6 -2
- package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
- package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
- package/src/components/LiveRegion/LiveRegion.tsx +48 -0
- package/src/components/Menu/Menu.stories.tsx +62 -0
- package/src/components/Menu/styles.css.ts +6 -3
- package/src/components/Prose/Prose.meta.ts +32 -0
- package/src/components/Prose/Prose.stories.tsx +233 -0
- package/src/components/Prose/Prose.tsx +25 -0
- package/src/components/Prose/prose.css +177 -0
- package/src/components/SideNav/SideNav.stories.tsx +29 -0
- package/src/components/SideNav/SideNav.tsx +14 -2
- package/src/components/Time/Time.meta.ts +17 -0
- package/src/components/Time/Time.stories.tsx +173 -0
- package/src/components/Time/Time.tsx +33 -0
- package/src/components/Time/relative.ts +89 -0
- package/src/hooks/useContainerBelow.ts +39 -0
- package/src/index.ts +13 -1
- package/src/strict.css +4 -1
- package/dist/Table-Dj7PXqO_.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
|
+
}
|