@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.
- 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 +192 -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 +73 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +41 -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 +4870 -4183
- 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 +825 -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 +192 -0
- package/src/components/CodeBlock/CodeBlock.tsx +99 -0
- package/src/components/CodeBlock/styles.css.ts +48 -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 +240 -0
- package/src/components/Prose/Prose.tsx +25 -0
- package/src/components/Prose/prose.css +179 -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,346 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
type Ref,
|
|
14
|
+
type RefObject,
|
|
15
|
+
} from 'react'
|
|
16
|
+
import {html} from 'react-strict-dom'
|
|
17
|
+
import {Button} from '../Button/Button'
|
|
18
|
+
import {Drawer} from '../Drawer/Drawer'
|
|
19
|
+
import {Icon} from '../Icon'
|
|
20
|
+
import {TextLink} from '../TextLink/TextLink'
|
|
21
|
+
import {useContainerBelow} from '../../hooks/useContainerBelow'
|
|
22
|
+
import type {OnNavigate} from '../../shared/navigate'
|
|
23
|
+
import {styles} from './styles.css'
|
|
24
|
+
|
|
25
|
+
/* AppShell — an application's frame: a navigation rail beside the main
|
|
26
|
+
* content, with an optional Header. At and above `collapseBelow` (the
|
|
27
|
+
* shell's own width, a container query) the rail sits on the left, the brand
|
|
28
|
+
* at its top, and the Header is a row above Main that scrolls with the page.
|
|
29
|
+
* Below it the rail is hidden and ONE sticky bar takes the top: a Menu button
|
|
30
|
+
* that opens the same navigation in a left Drawer, the brand, then the
|
|
31
|
+
* Header's content when there is a Header — so a shell without a Header still
|
|
32
|
+
* has its navigation on a phone. CSS makes the switch, so the server renders
|
|
33
|
+
* one HTML for every width and nothing moves after hydration: the brand is in
|
|
34
|
+
* the HTML twice (rail and bar) and `display: none` hides the inactive copy.
|
|
35
|
+
*
|
|
36
|
+
* Landmarks: the rail is a `nav` named by its `aria-label`, the bar a
|
|
37
|
+
* `header` (banner), the content a `main`, which a skip link (the first tab
|
|
38
|
+
* stop) focuses. */
|
|
39
|
+
|
|
40
|
+
export type AppShellCollapse = 'sm' | 'md' | 'lg'
|
|
41
|
+
|
|
42
|
+
interface AppShellContextValue {
|
|
43
|
+
open: boolean
|
|
44
|
+
setOpen: (open: boolean) => void
|
|
45
|
+
closeLabel: string
|
|
46
|
+
brand: ReactNode
|
|
47
|
+
collapseBelow: AppShellCollapse
|
|
48
|
+
mainId: string
|
|
49
|
+
mainRef: RefObject<HTMLElement | null>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const AppShellContext = createContext<AppShellContextValue | null>(null)
|
|
53
|
+
|
|
54
|
+
// True only where Root placed a Header itself: Root finds Header among its
|
|
55
|
+
// direct children, so a Header wrapped in a fragment or a component would
|
|
56
|
+
// otherwise render as a stray grid item and never reach the bar.
|
|
57
|
+
const HeaderSlotContext = createContext(false)
|
|
58
|
+
|
|
59
|
+
function useAppShell(): AppShellContextValue {
|
|
60
|
+
const ctx = useContext(AppShellContext)
|
|
61
|
+
if (!ctx) throw new Error('AppShell compound components must be used within AppShell.Root')
|
|
62
|
+
return ctx
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const variants = {
|
|
66
|
+
sm: {
|
|
67
|
+
grid: styles.gridSm,
|
|
68
|
+
rail: styles.railSm,
|
|
69
|
+
bar: styles.barSm,
|
|
70
|
+
barEmpty: styles.barEmptySm,
|
|
71
|
+
narrow: styles.narrowSm,
|
|
72
|
+
main: styles.mainSm,
|
|
73
|
+
},
|
|
74
|
+
md: {
|
|
75
|
+
grid: styles.gridMd,
|
|
76
|
+
rail: styles.railMd,
|
|
77
|
+
bar: styles.barMd,
|
|
78
|
+
barEmpty: styles.barEmptyMd,
|
|
79
|
+
narrow: styles.narrowMd,
|
|
80
|
+
main: styles.mainMd,
|
|
81
|
+
},
|
|
82
|
+
lg: {
|
|
83
|
+
grid: styles.gridLg,
|
|
84
|
+
rail: styles.railLg,
|
|
85
|
+
bar: styles.barLg,
|
|
86
|
+
barEmpty: styles.barEmptyLg,
|
|
87
|
+
narrow: styles.narrowLg,
|
|
88
|
+
main: styles.mainLg,
|
|
89
|
+
},
|
|
90
|
+
} as const satisfies Record<AppShellCollapse, unknown>
|
|
91
|
+
|
|
92
|
+
// --- Root ---
|
|
93
|
+
|
|
94
|
+
interface RootProps {
|
|
95
|
+
/** The narrow-width button that opens the navigation ("Menu"), from your text catalog. */
|
|
96
|
+
menuLabel: string
|
|
97
|
+
/** The accessible name of the drawer's close button ("Close navigation"). */
|
|
98
|
+
closeLabel: string
|
|
99
|
+
/** The skip link's text ("Skip to content"): the first tab stop, it moves focus to Main. */
|
|
100
|
+
skipLabel: string
|
|
101
|
+
/**
|
|
102
|
+
* The app's name or logo — usually a link home. Shown at the top of the
|
|
103
|
+
* rail and, below `collapseBelow`, in the bar beside the Menu button.
|
|
104
|
+
*/
|
|
105
|
+
brand?: ReactNode
|
|
106
|
+
/**
|
|
107
|
+
* Below this breakpoint, measured on the shell's own width, the rail
|
|
108
|
+
* becomes a Drawer behind the Menu button. Default `sm`.
|
|
109
|
+
*/
|
|
110
|
+
collapseBelow?: AppShellCollapse
|
|
111
|
+
/**
|
|
112
|
+
* `AppShell.Rail`, `AppShell.Main` and an optional `AppShell.Header`, as
|
|
113
|
+
* direct children (Root places the Header's content in its bar).
|
|
114
|
+
*/
|
|
115
|
+
children: ReactNode
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Root({
|
|
119
|
+
menuLabel,
|
|
120
|
+
closeLabel,
|
|
121
|
+
skipLabel,
|
|
122
|
+
brand,
|
|
123
|
+
collapseBelow = 'sm',
|
|
124
|
+
children,
|
|
125
|
+
}: RootProps) {
|
|
126
|
+
const [open, setOpen] = useState(false)
|
|
127
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
128
|
+
const menuButtonRef = useRef<HTMLButtonElement | null>(null)
|
|
129
|
+
const mainRef = useRef<HTMLElement | null>(null)
|
|
130
|
+
const narrow = useContainerBelow(containerRef, collapseBelow)
|
|
131
|
+
const generatedId = useId()
|
|
132
|
+
const variant = variants[collapseBelow]
|
|
133
|
+
|
|
134
|
+
// The Header's content goes in the bar, after the Menu button and the
|
|
135
|
+
// brand; Main may name its own id, which the skip link then targets.
|
|
136
|
+
let header: ReactNode = null
|
|
137
|
+
let hasHeader = false
|
|
138
|
+
let mainId = `${generatedId}main`
|
|
139
|
+
const rest: ReactNode[] = []
|
|
140
|
+
Children.forEach(children, (child) => {
|
|
141
|
+
if (isValidElement<HeaderProps>(child) && child.type === Header) {
|
|
142
|
+
hasHeader = true
|
|
143
|
+
header = child
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (isValidElement<MainProps>(child) && child.type === Main && child.props.id) {
|
|
147
|
+
mainId = child.props.id
|
|
148
|
+
}
|
|
149
|
+
rest.push(child)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Widening past the collapse point while the drawer is open puts the rail
|
|
153
|
+
// back: close it.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!narrow) setOpen(false)
|
|
156
|
+
}, [narrow])
|
|
157
|
+
|
|
158
|
+
// Focus goes back to the Menu button when the drawer closes (Escape, the
|
|
159
|
+
// close button, the backdrop, a navigation).
|
|
160
|
+
const wasOpen = useRef(false)
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (wasOpen.current && !open) menuButtonRef.current?.focus()
|
|
163
|
+
wasOpen.current = open
|
|
164
|
+
}, [open])
|
|
165
|
+
|
|
166
|
+
const value = useMemo(
|
|
167
|
+
() => ({open, setOpen, closeLabel, brand, collapseBelow, mainId, mainRef}),
|
|
168
|
+
[open, closeLabel, brand, collapseBelow, mainId],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
// Focus Main itself (tabIndex -1), not just scroll to it, and leave the
|
|
172
|
+
// URL alone: a router may read the hash.
|
|
173
|
+
const skip: OnNavigate = (_href, event) => {
|
|
174
|
+
event.preventDefault()
|
|
175
|
+
mainRef.current?.focus()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<AppShellContext.Provider value={value}>
|
|
180
|
+
<html.div ref={containerRef} style={styles.container}>
|
|
181
|
+
<html.div style={[styles.grid, variant.grid]}>
|
|
182
|
+
<html.div style={styles.skip}>
|
|
183
|
+
<TextLink href={`#${mainId}`} onNavigate={skip}>
|
|
184
|
+
{skipLabel}
|
|
185
|
+
</TextLink>
|
|
186
|
+
</html.div>
|
|
187
|
+
<html.header style={[styles.bar, variant.bar, !hasHeader && variant.barEmpty]}>
|
|
188
|
+
<html.div style={[styles.narrowItem, variant.narrow]}>
|
|
189
|
+
<Button
|
|
190
|
+
ref={menuButtonRef}
|
|
191
|
+
variant="secondary"
|
|
192
|
+
size="small"
|
|
193
|
+
aria-expanded={open}
|
|
194
|
+
onClick={() => setOpen(true)}
|
|
195
|
+
>
|
|
196
|
+
<Icon name="menu" size="sm" />
|
|
197
|
+
{menuLabel}
|
|
198
|
+
</Button>
|
|
199
|
+
</html.div>
|
|
200
|
+
{brand != null && (
|
|
201
|
+
<html.div style={[styles.narrowItem, variant.narrow]}>{brand}</html.div>
|
|
202
|
+
)}
|
|
203
|
+
<HeaderSlotContext.Provider value={true}>{header}</HeaderSlotContext.Provider>
|
|
204
|
+
</html.header>
|
|
205
|
+
{rest}
|
|
206
|
+
</html.div>
|
|
207
|
+
</html.div>
|
|
208
|
+
</AppShellContext.Provider>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Rail ---
|
|
213
|
+
|
|
214
|
+
export interface AppShellRailState {
|
|
215
|
+
/**
|
|
216
|
+
* Closes the drawer. Call it after any selection in the navigation — the
|
|
217
|
+
* current page included, where no route change would close it.
|
|
218
|
+
*/
|
|
219
|
+
close: () => void
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type RailContent = ReactNode | ((state: AppShellRailState) => ReactNode)
|
|
223
|
+
|
|
224
|
+
interface RailProps {
|
|
225
|
+
/** Names the navigation landmark and the drawer ("Navigation"); required. */
|
|
226
|
+
'aria-label': string
|
|
227
|
+
/**
|
|
228
|
+
* The navigation (a SideNav, a Tree). Rendered in the rail, or in the
|
|
229
|
+
* drawer while it is open (never both); a function receives `close`.
|
|
230
|
+
*/
|
|
231
|
+
children: RailContent
|
|
232
|
+
/**
|
|
233
|
+
* What sits at the rail's foot — the signed-in user, a licence line —
|
|
234
|
+
* and at the end of the drawer. A function receives `close`.
|
|
235
|
+
*/
|
|
236
|
+
footer?: RailContent
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
240
|
+
|
|
241
|
+
function Rail({'aria-label': ariaLabel, children, footer}: RailProps) {
|
|
242
|
+
const {open, setOpen, closeLabel, brand, collapseBelow} = useAppShell()
|
|
243
|
+
const close = useCallback(() => setOpen(false), [setOpen])
|
|
244
|
+
const drawerNavRef = useRef<HTMLElement | null>(null)
|
|
245
|
+
const content = typeof children === 'function' ? children({close}) : children
|
|
246
|
+
const foot = typeof footer === 'function' ? footer({close}) : footer
|
|
247
|
+
const variant = variants[collapseBelow]
|
|
248
|
+
|
|
249
|
+
// The drawer is modal: move focus into it, onto the first control.
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!open) return
|
|
252
|
+
drawerNavRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus()
|
|
253
|
+
}, [open])
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<>
|
|
257
|
+
{/* Rendered in one place at a time, so ids (a Tree's) and the landmark
|
|
258
|
+
name are never duplicated: the drawer only opens below the collapse
|
|
259
|
+
point, where the rail is hidden. The brand stays: the bar holds the
|
|
260
|
+
narrow copy, and display: none hides this one there. */}
|
|
261
|
+
<html.nav
|
|
262
|
+
aria-label={ariaLabel}
|
|
263
|
+
style={[styles.rail, variant.rail]}
|
|
264
|
+
hidden={open || undefined}
|
|
265
|
+
>
|
|
266
|
+
{brand != null && <html.div style={styles.railBrand}>{brand}</html.div>}
|
|
267
|
+
<html.div style={styles.railBody}>{open ? null : content}</html.div>
|
|
268
|
+
{foot != null && <html.div style={styles.railFooter}>{open ? null : foot}</html.div>}
|
|
269
|
+
</html.nav>
|
|
270
|
+
<Drawer.Root anchor="left" open={open} onOpenChange={setOpen}>
|
|
271
|
+
<Drawer.Portal size="sm">
|
|
272
|
+
<Drawer.Header>
|
|
273
|
+
<Drawer.Title>{ariaLabel}</Drawer.Title>
|
|
274
|
+
<Drawer.Close aria-label={closeLabel} />
|
|
275
|
+
</Drawer.Header>
|
|
276
|
+
<Drawer.Body>
|
|
277
|
+
<html.nav ref={drawerNavRef} aria-label={ariaLabel}>
|
|
278
|
+
{content}
|
|
279
|
+
{foot != null && <html.div style={styles.drawerFooter}>{foot}</html.div>}
|
|
280
|
+
</html.nav>
|
|
281
|
+
</Drawer.Body>
|
|
282
|
+
</Drawer.Portal>
|
|
283
|
+
</Drawer.Root>
|
|
284
|
+
</>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- Header ---
|
|
289
|
+
|
|
290
|
+
interface HeaderProps {
|
|
291
|
+
/**
|
|
292
|
+
* The header's content: a search form, the account menu. Below
|
|
293
|
+
* `collapseBelow` it joins the Menu button and the brand in the sticky
|
|
294
|
+
* bar; above, it is a row over Main that scrolls with the page.
|
|
295
|
+
*/
|
|
296
|
+
children?: ReactNode
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function Header({children}: HeaderProps) {
|
|
300
|
+
useAppShell()
|
|
301
|
+
if (!useContext(HeaderSlotContext)) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
'AppShell.Header must be a direct child of AppShell.Root (not inside a fragment or another component)',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
return <html.div style={styles.header}>{children}</html.div>
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- Main ---
|
|
310
|
+
|
|
311
|
+
interface MainProps {
|
|
312
|
+
children: ReactNode
|
|
313
|
+
/** The skip link's target; Root makes one with `useId` when it is left out. */
|
|
314
|
+
id?: string
|
|
315
|
+
/** The `main` element: the container to hand `useContainerBelow`. */
|
|
316
|
+
ref?: Ref<HTMLElement>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function Main({children, id, ref}: MainProps) {
|
|
320
|
+
const {mainId, mainRef, collapseBelow} = useAppShell()
|
|
321
|
+
const setRef = useCallback(
|
|
322
|
+
(node: HTMLElement | null) => {
|
|
323
|
+
mainRef.current = node
|
|
324
|
+
if (typeof ref === 'function') ref(node)
|
|
325
|
+
else if (ref) ref.current = node
|
|
326
|
+
},
|
|
327
|
+
[mainRef, ref],
|
|
328
|
+
)
|
|
329
|
+
return (
|
|
330
|
+
<html.main
|
|
331
|
+
ref={setRef}
|
|
332
|
+
id={id ?? mainId}
|
|
333
|
+
tabIndex={-1}
|
|
334
|
+
style={[styles.main, variants[collapseBelow].main]}
|
|
335
|
+
>
|
|
336
|
+
{children}
|
|
337
|
+
</html.main>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const AppShell = {
|
|
342
|
+
Root,
|
|
343
|
+
Rail,
|
|
344
|
+
Header,
|
|
345
|
+
Main,
|
|
346
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
5
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
6
|
+
import {layers} from '@duro-app/tokens/tokens/layers.css'
|
|
7
|
+
import {breakpoints} from '@duro-app/tokens/tokens/breakpoints.css'
|
|
8
|
+
|
|
9
|
+
// At and above the collapse point (measured on the shell's own width, not
|
|
10
|
+
// the window) the rail sits beside the page and the Header is a row that
|
|
11
|
+
// scrolls with it; below it the rail gives way to one sticky bar — the Menu
|
|
12
|
+
// button, the brand and the Header's content — and its Drawer. CSS makes the
|
|
13
|
+
// switch, so the server's HTML is the same at every width and the first
|
|
14
|
+
// paint is already right.
|
|
15
|
+
//
|
|
16
|
+
// `collapseBelow` picks one of three static sets (sm, md, lg): StyleX needs
|
|
17
|
+
// every query written out, so each breakpoint-dependent style comes three
|
|
18
|
+
// times, identical but for the query.
|
|
19
|
+
const WIDE_SM = `@container (min-width: ${breakpoints.sm})`
|
|
20
|
+
const WIDE_MD = `@container (min-width: ${breakpoints.md})`
|
|
21
|
+
const WIDE_LG = `@container (min-width: ${breakpoints.lg})`
|
|
22
|
+
|
|
23
|
+
// The custom property an Aside inside the shell adds to its sticky `top`
|
|
24
|
+
// (Aside/styles.css.ts spells the same name). It is set on the grid, inside
|
|
25
|
+
// the query container (a container query cannot style the container itself)
|
|
26
|
+
// and an ancestor of Main. Above the collapse point it is `initial` (unset),
|
|
27
|
+
// so the Aside's fallback 0px applies: StyleX would write a `0px` as a
|
|
28
|
+
// unitless 0, which makes the Aside's calc() invalid.
|
|
29
|
+
// '--duro-app-shell-bar'
|
|
30
|
+
|
|
31
|
+
const RAIL_COLUMNS = `${sizes.sidebarW} minmax(0, 1fr)`
|
|
32
|
+
const NARROW_AREAS = '"bar" "main"'
|
|
33
|
+
const WIDE_AREAS = '"rail bar" "rail main"'
|
|
34
|
+
|
|
35
|
+
export const styles = css.create({
|
|
36
|
+
// Hosts the container query: a container cannot query itself.
|
|
37
|
+
container: {
|
|
38
|
+
containerType: 'inline-size',
|
|
39
|
+
position: 'relative',
|
|
40
|
+
minWidth: 0,
|
|
41
|
+
},
|
|
42
|
+
// The page canvas of the app, in the theme.
|
|
43
|
+
grid: {
|
|
44
|
+
display: 'grid',
|
|
45
|
+
minHeight: '100dvh',
|
|
46
|
+
color: colors.text,
|
|
47
|
+
backgroundColor: colors.bg,
|
|
48
|
+
gridTemplateRows: 'auto 1fr',
|
|
49
|
+
},
|
|
50
|
+
gridSm: {
|
|
51
|
+
gridTemplateColumns: {default: 'minmax(0, 1fr)', [WIDE_SM]: RAIL_COLUMNS},
|
|
52
|
+
gridTemplateAreas: {default: NARROW_AREAS, [WIDE_SM]: WIDE_AREAS},
|
|
53
|
+
'--duro-app-shell-bar': {default: sizes.appBarH, [WIDE_SM]: 'initial'},
|
|
54
|
+
},
|
|
55
|
+
gridMd: {
|
|
56
|
+
gridTemplateColumns: {default: 'minmax(0, 1fr)', [WIDE_MD]: RAIL_COLUMNS},
|
|
57
|
+
gridTemplateAreas: {default: NARROW_AREAS, [WIDE_MD]: WIDE_AREAS},
|
|
58
|
+
'--duro-app-shell-bar': {default: sizes.appBarH, [WIDE_MD]: 'initial'},
|
|
59
|
+
},
|
|
60
|
+
gridLg: {
|
|
61
|
+
gridTemplateColumns: {default: 'minmax(0, 1fr)', [WIDE_LG]: RAIL_COLUMNS},
|
|
62
|
+
gridTemplateAreas: {default: NARROW_AREAS, [WIDE_LG]: WIDE_AREAS},
|
|
63
|
+
'--duro-app-shell-bar': {default: sizes.appBarH, [WIDE_LG]: 'initial'},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Holds the skip link, the first tab stop: off screen until the link
|
|
67
|
+
// has focus.
|
|
68
|
+
skip: {
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
top: spacing.sm,
|
|
71
|
+
left: spacing.sm,
|
|
72
|
+
zIndex: layers.floatingRaised,
|
|
73
|
+
paddingTop: spacing.xs,
|
|
74
|
+
paddingBottom: spacing.xs,
|
|
75
|
+
paddingLeft: spacing.sm,
|
|
76
|
+
paddingRight: spacing.sm,
|
|
77
|
+
borderRadius: radii.sm,
|
|
78
|
+
backgroundColor: colors.bgCard,
|
|
79
|
+
transform: {default: 'translateY(-200%)', ':focus-within': 'none'},
|
|
80
|
+
opacity: {default: 0, ':focus-within': 1},
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// The rail scrolls on its own beside the page: the brand at the top, the
|
|
84
|
+
// navigation in between, the footer at its foot.
|
|
85
|
+
rail: {
|
|
86
|
+
gridArea: 'rail',
|
|
87
|
+
alignSelf: 'start',
|
|
88
|
+
position: 'sticky',
|
|
89
|
+
top: 0,
|
|
90
|
+
height: '100dvh',
|
|
91
|
+
flexDirection: 'column',
|
|
92
|
+
minWidth: 0,
|
|
93
|
+
backgroundColor: colors.bgCard,
|
|
94
|
+
borderRightWidth: borders.hairline,
|
|
95
|
+
borderRightStyle: 'solid',
|
|
96
|
+
borderRightColor: colors.border,
|
|
97
|
+
},
|
|
98
|
+
railSm: {display: {default: 'none', [WIDE_SM]: 'flex'}},
|
|
99
|
+
railMd: {display: {default: 'none', [WIDE_MD]: 'flex'}},
|
|
100
|
+
railLg: {display: {default: 'none', [WIDE_LG]: 'flex'}},
|
|
101
|
+
railBrand: {
|
|
102
|
+
flexShrink: 0,
|
|
103
|
+
paddingTop: spacing.md,
|
|
104
|
+
paddingBottom: spacing.sm,
|
|
105
|
+
paddingLeft: spacing.md,
|
|
106
|
+
paddingRight: spacing.md,
|
|
107
|
+
},
|
|
108
|
+
railBody: {
|
|
109
|
+
flexGrow: 1,
|
|
110
|
+
minHeight: 0,
|
|
111
|
+
overflowY: 'auto',
|
|
112
|
+
overscrollBehavior: 'contain',
|
|
113
|
+
},
|
|
114
|
+
railFooter: {
|
|
115
|
+
flexShrink: 0,
|
|
116
|
+
paddingTop: spacing.sm,
|
|
117
|
+
paddingBottom: spacing.sm,
|
|
118
|
+
paddingLeft: spacing.md,
|
|
119
|
+
paddingRight: spacing.md,
|
|
120
|
+
borderTopWidth: borders.hairline,
|
|
121
|
+
borderTopStyle: 'solid',
|
|
122
|
+
borderTopColor: colors.border,
|
|
123
|
+
},
|
|
124
|
+
drawerFooter: {
|
|
125
|
+
marginTop: spacing.md,
|
|
126
|
+
paddingTop: spacing.md,
|
|
127
|
+
borderTopWidth: borders.hairline,
|
|
128
|
+
borderTopStyle: 'solid',
|
|
129
|
+
borderTopColor: colors.border,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// The bar: the banner. Below the collapse point it is sticky and exactly
|
|
133
|
+
// appBarH tall, so the page never moves under it and an Aside can stick
|
|
134
|
+
// right below it; above, it is the Header's row and scrolls with the page.
|
|
135
|
+
bar: {
|
|
136
|
+
gridArea: 'bar',
|
|
137
|
+
display: 'flex',
|
|
138
|
+
alignItems: 'center',
|
|
139
|
+
gap: spacing.sm,
|
|
140
|
+
minWidth: 0,
|
|
141
|
+
backgroundColor: colors.bgCard,
|
|
142
|
+
borderBottomWidth: borders.hairline,
|
|
143
|
+
borderBottomStyle: 'solid',
|
|
144
|
+
borderBottomColor: colors.border,
|
|
145
|
+
},
|
|
146
|
+
barSm: {
|
|
147
|
+
position: {default: 'sticky', [WIDE_SM]: 'static'},
|
|
148
|
+
top: 0,
|
|
149
|
+
zIndex: {default: layers.floating, [WIDE_SM]: 'auto'},
|
|
150
|
+
height: {default: sizes.appBarH, [WIDE_SM]: 'auto'},
|
|
151
|
+
paddingTop: {default: spacing.sm, [WIDE_SM]: spacing.ms},
|
|
152
|
+
paddingBottom: {default: spacing.sm, [WIDE_SM]: spacing.ms},
|
|
153
|
+
paddingLeft: {default: spacing.md, [WIDE_SM]: spacing.lg},
|
|
154
|
+
paddingRight: {default: spacing.md, [WIDE_SM]: spacing.lg},
|
|
155
|
+
},
|
|
156
|
+
barMd: {
|
|
157
|
+
position: {default: 'sticky', [WIDE_MD]: 'static'},
|
|
158
|
+
top: 0,
|
|
159
|
+
zIndex: {default: layers.floating, [WIDE_MD]: 'auto'},
|
|
160
|
+
height: {default: sizes.appBarH, [WIDE_MD]: 'auto'},
|
|
161
|
+
paddingTop: {default: spacing.sm, [WIDE_MD]: spacing.ms},
|
|
162
|
+
paddingBottom: {default: spacing.sm, [WIDE_MD]: spacing.ms},
|
|
163
|
+
paddingLeft: {default: spacing.md, [WIDE_MD]: spacing.lg},
|
|
164
|
+
paddingRight: {default: spacing.md, [WIDE_MD]: spacing.lg},
|
|
165
|
+
},
|
|
166
|
+
barLg: {
|
|
167
|
+
position: {default: 'sticky', [WIDE_LG]: 'static'},
|
|
168
|
+
top: 0,
|
|
169
|
+
zIndex: {default: layers.floating, [WIDE_LG]: 'auto'},
|
|
170
|
+
height: {default: sizes.appBarH, [WIDE_LG]: 'auto'},
|
|
171
|
+
paddingTop: {default: spacing.sm, [WIDE_LG]: spacing.ms},
|
|
172
|
+
paddingBottom: {default: spacing.sm, [WIDE_LG]: spacing.ms},
|
|
173
|
+
paddingLeft: {default: spacing.md, [WIDE_LG]: spacing.lg},
|
|
174
|
+
paddingRight: {default: spacing.md, [WIDE_LG]: spacing.lg},
|
|
175
|
+
},
|
|
176
|
+
// Without a Header there is nothing to show above the collapse point.
|
|
177
|
+
barEmptySm: {display: {default: 'flex', [WIDE_SM]: 'none'}},
|
|
178
|
+
barEmptyMd: {display: {default: 'flex', [WIDE_MD]: 'none'}},
|
|
179
|
+
barEmptyLg: {display: {default: 'flex', [WIDE_LG]: 'none'}},
|
|
180
|
+
// The Menu button and the bar's copy of the brand: below the collapse
|
|
181
|
+
// point only (display: none takes the hidden copy out of the a11y tree).
|
|
182
|
+
narrowSm: {display: {default: 'flex', [WIDE_SM]: 'none'}},
|
|
183
|
+
narrowMd: {display: {default: 'flex', [WIDE_MD]: 'none'}},
|
|
184
|
+
narrowLg: {display: {default: 'flex', [WIDE_LG]: 'none'}},
|
|
185
|
+
narrowItem: {
|
|
186
|
+
flexShrink: 0,
|
|
187
|
+
alignItems: 'center',
|
|
188
|
+
minWidth: 0,
|
|
189
|
+
},
|
|
190
|
+
// The Header's content, after the Menu button and the brand.
|
|
191
|
+
header: {
|
|
192
|
+
display: 'flex',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
gap: spacing.sm,
|
|
195
|
+
flexGrow: 1,
|
|
196
|
+
minWidth: 0,
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
main: {
|
|
200
|
+
gridArea: 'main',
|
|
201
|
+
minWidth: 0,
|
|
202
|
+
paddingTop: spacing.lg,
|
|
203
|
+
paddingBottom: spacing.lg,
|
|
204
|
+
// Focused by the skip link: no ring on a region that is not a control.
|
|
205
|
+
outlineStyle: 'none',
|
|
206
|
+
},
|
|
207
|
+
mainSm: {
|
|
208
|
+
paddingLeft: {default: spacing.md, [WIDE_SM]: spacing.lg},
|
|
209
|
+
paddingRight: {default: spacing.md, [WIDE_SM]: spacing.lg},
|
|
210
|
+
},
|
|
211
|
+
mainMd: {
|
|
212
|
+
paddingLeft: {default: spacing.md, [WIDE_MD]: spacing.lg},
|
|
213
|
+
paddingRight: {default: spacing.md, [WIDE_MD]: spacing.lg},
|
|
214
|
+
},
|
|
215
|
+
mainLg: {
|
|
216
|
+
paddingLeft: {default: spacing.md, [WIDE_LG]: spacing.lg},
|
|
217
|
+
paddingRight: {default: spacing.md, [WIDE_LG]: spacing.lg},
|
|
218
|
+
},
|
|
219
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Content beside the main reading column — a table of contents, related pages: a labelled aside landmark that sticks within the viewport while the page scrolls and scrolls on its own when taller (offset from a spacing token, the raised layer). Forwards ref to the aside element.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'The second track of Grid layout="content-aside": an "On this page" list, related links',
|
|
8
|
+
'Secondary content that should stay in view beside a long page',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Inspecting a selected record — use DetailPanel',
|
|
12
|
+
'A navigation rail for the app — use AppShell.Rail',
|
|
13
|
+
],
|
|
14
|
+
relatedTo: [
|
|
15
|
+
{
|
|
16
|
+
component: 'Grid',
|
|
17
|
+
kind: 'composition',
|
|
18
|
+
relationship:
|
|
19
|
+
'Grid layout="content-aside" gives Aside its asideW track and stacks it below md',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
component: 'TableOfContents',
|
|
23
|
+
kind: 'composition',
|
|
24
|
+
relationship: 'The usual content of an Aside on a docs page',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
example: `<Grid layout="content-aside" gap="xl">
|
|
28
|
+
<Prose>{body}</Prose>
|
|
29
|
+
<Aside aria-label="Page outline">
|
|
30
|
+
<TableOfContents.Root aria-label="On this page" title="On this page">…</TableOfContents.Root>
|
|
31
|
+
</Aside>
|
|
32
|
+
</Grid>`,
|
|
33
|
+
}
|