@stealthscale/component-screen 0.0.0-stage → 0.0.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/README.md CHANGED
@@ -1,3 +1,809 @@
1
- # Temporary Holding Version
1
+ # @stealthscale/component-screen
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lays out an application's screen: the shell around everything, the page inside it, the sections of a
4
+ page, the sidebar of destinations, the switcher at the head of a sidebar, the toolbar over a table,
5
+ the action bar over a selection, the splitter between panels a person resizes and the floating panel
6
+ a person moves over the page. Every component takes its styles from a recipe, so a theme restyles
7
+ all nine by extending the recipes. The preset under `./theme` registers the recipes with an
8
+ application's compiler.
9
+
10
+ Every value a theme can change is an axis of a recipe, so a caller sets it as a prop and writes no
11
+ style. A caller changes the element a component renders with `as`.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pnpm add @stealthscale/component-screen
17
+ ```
18
+
19
+ The package peers on `react`, `@stealthscale/theme`, `@stealthscale/hooks`,
20
+ `@stealthscale/provider-viewport` and the component packages it renders: `a11y`, `actions`, `data`,
21
+ `disclosure`, `forms`, `layout`, `modals`, `navigation`, `primitives` and `typography`. An
22
+ application lists the preset under `./theme` among the presets its compiler installs.
23
+
24
+ ## Folding
25
+
26
+ A component folds on its own width, not on the window's. It compares its element's width with the
27
+ width a breakpoint starts at and writes `data-narrow`, which the recipes select on. A page beside an
28
+ open sidebar folds on the width the sidebar leaves it, and a caller does not write a breakpoint or a
29
+ media query. Before the element is laid out, and where the browser does not report a size, the
30
+ viewport's width decides, so a phone never lays out wide first.
31
+
32
+ `Toolbar.Action`, `Page.Action` and `Section.Action` fold by priority. An action states `priority`,
33
+ or the action's props imply one:
34
+
35
+ | Props | Priority | On a narrow row |
36
+ | ---------------------------- | ----------- | --------------------------------------- |
37
+ | `primary`, no `icon` | `primary` | Unchanged |
38
+ | `icon` | `secondary` | Icon alone, the label hidden visually |
39
+ | neither `primary` nor `icon` | `tertiary` | Removed from the row, a row in its menu |
40
+
41
+ - A secondary action keeps its label for screen readers.
42
+ - A tertiary action leaves the row and the tab order. The row renders a menu with one row per folded
43
+ action.
44
+ - `more` names the menu's trigger, `More actions` by default. `moreIcon` replaces the trigger's
45
+ words with a mark.
46
+ - A folded link keeps its `href` in its menu row. A menu row calls the action's `onClick`.
47
+ - A control that opens its own overlay cannot run from a menu row. Give it an icon or `primary`.
48
+ - An action writes `data-narrow` on itself from its own row. An action in a wide row inside a narrow
49
+ component keeps its words, such as the toolbar in an action bar, which is fixed to the window and
50
+ wider than the page around it in the document.
51
+
52
+ ## ActionBar
53
+
54
+ Renders a bar fixed to the bottom of the window with the actions for what a person has selected. The
55
+ bar contains a toolbar, so its actions have one tab stop, the arrow keys and folding.
56
+
57
+ ```tsx
58
+ import { ActionBar, Toolbar } from "@stealthscale/component-screen";
59
+
60
+ <ActionBar.Root onOpenChange={() => setSelected([])} open={selected.length > 0}>
61
+ <ActionBar.Positioner>
62
+ <ActionBar.Content>
63
+ <Toolbar.Root aria-label="Actions for the selected invoices" size="sm">
64
+ <Toolbar.Start>
65
+ <ActionBar.CloseTrigger aria-label="Clear selection">
66
+ <XIcon />
67
+ </ActionBar.CloseTrigger>
68
+ <Text size="sm">{selected.length} selected</Text>
69
+ </Toolbar.Start>
70
+ <Toolbar.End>
71
+ <Toolbar.Action icon={<DownloadIcon />}>Download</Toolbar.Action>
72
+ <Toolbar.Action icon={<ReceiptIcon />} primary priority="primary">
73
+ Mark as paid
74
+ </Toolbar.Action>
75
+ </Toolbar.End>
76
+ </Toolbar.Root>
77
+ </ActionBar.Content>
78
+ </ActionBar.Positioner>
79
+ </ActionBar.Root>;
80
+ ```
81
+
82
+ | Axis | Values | Default |
83
+ | ----------- | -------------------------------------- | -------- |
84
+ | `placement` | `bottom-start`, `bottom`, `bottom-end` | `bottom` |
85
+
86
+ - The caller keeps the selection. `open` is true while anything is selected. `onOpenChange` receives
87
+ `{ open: false }` on Escape inside the bar and on a press of `ActionBar.CloseTrigger`, and the
88
+ caller clears the selection, which closes the bar.
89
+ - Render the root after the list it acts on, so Tab from the list moves into the bar.
90
+ - Focus remains on the selection when the bar opens. A polite live region announces `announcement`,
91
+ `Actions available` by default.
92
+ - When the bar closes with focus inside it, focus returns to the element that had it when the bar
93
+ opened. Focus that a person moved elsewhere remains there.
94
+ - `closeOnEscape={false}` leaves Escape to the caller. An Escape that a control inside the bar
95
+ handles with `preventDefault`, or that a menu portalled out of the bar receives, does not close
96
+ it.
97
+ - `ActionBar.CloseTrigger` renders the actions package's `IconButton`, ghost, as an item of the
98
+ toolbar. Name it with `aria-label`. An `onClick` that calls `preventDefault` keeps the bar open.
99
+ - Put the close trigger and the count in `Toolbar.Start` and the actions in `Toolbar.End`. The
100
+ toolbar renders its menu of folded actions as its last control, beside the actions. Give the main
101
+ action `priority="primary"` to keep its words in a narrow window.
102
+ - The bar is as wide as its content, at least `sizes.2xl`, 672px, and at most the window inside the
103
+ inset. Its toolbar folds in a narrow window and not because of its own content.
104
+ - The positioner is fixed above the safe area at the `banner` layer and takes no pointer events, so
105
+ the page beside the bar remains pressable. `placement` sets the bar at the start, in the middle or
106
+ at the end of the bottom edge, and start and end follow the writing direction.
107
+ - The bar is not in the document before it first opens, and leaves once its exit motion ends.
108
+ `lazyMount` and `unmountOnExit` default to `true`. It rises from the bottom edge as it fades in,
109
+ and is inert while it leaves.
110
+ - The bar has no `palette` and no `effect` axis, because it is a `bg.panel` surface for controls
111
+ that have their own.
112
+
113
+ | Part | Element |
114
+ | ------------------------ | -------------------------- |
115
+ | `ActionBar.Root` | `div`, `display: contents` |
116
+ | `ActionBar.Positioner` | `div` |
117
+ | `ActionBar.Content` | `div` |
118
+ | `ActionBar.CloseTrigger` | `button` |
119
+
120
+ The bar does not offer these:
121
+
122
+ - A selection trigger. The count is text the caller renders in the toolbar.
123
+ - A bar placed inside a container. The positioner is fixed to the window.
124
+ - Escape outside the bar. A list clears its selection with its own keys.
125
+
126
+ ## AppShell
127
+
128
+ Lays out an application: bars across the top and the bottom, a body between them with a panel on
129
+ either side of the main region, and a status bar at the foot.
130
+
131
+ ```tsx
132
+ import { AppShell, Sidebar } from "@stealthscale/component-screen";
133
+
134
+ <AppShell.Root>
135
+ <AppShell.Header sticky>
136
+ <AppShell.Trigger aria-label="Navigation">…</AppShell.Trigger>
137
+ </AppShell.Header>
138
+ <AppShell.Body>
139
+ <AppShell.Navbar collapse="icons" shortcut="b">
140
+ <Sidebar.Root>…</Sidebar.Root>
141
+ </AppShell.Navbar>
142
+ <AppShell.Rail closeLabel="Close navigation" openLabel="Open navigation" />
143
+ <AppShell.Main>…</AppShell.Main>
144
+ <AppShell.Aside aria-label="Invoice detail" folds="under" width="20rem">
145
+ <AppShell.Section grows scrolls>
146
+ …
147
+ </AppShell.Section>
148
+ </AppShell.Aside>
149
+ </AppShell.Body>
150
+ <AppShell.Footer when="narrow">…</AppShell.Footer>
151
+ <AppShell.Status>…</AppShell.Status>
152
+ </AppShell.Root>;
153
+ ```
154
+
155
+ | Axis | Values | Default |
156
+ | --------- | ---------------------------- | ------- |
157
+ | `divided` | `true`, `false` | `true` |
158
+ | `scroll` | `page`, `window` | `page` |
159
+ | `variant` | `floating`, `inset`, `plain` | `plain` |
160
+
161
+ - `divided` renders a hairline on the inner edge of each bar and each panel. A sidebar inside a
162
+ panel sets its ground and no line.
163
+ - `scroll="page"` sets the shell to the window's height and scrolls the main region. While a panel
164
+ is under the page, the body scrolls, so the page and the panel scroll together.
165
+ - `scroll="window"` scrolls the document, pins each `sticky` bar under the bars before it, and
166
+ sticks a panel beside the page under the pinned bars. A pinned bar is filled with `bg.panel`.
167
+ - The body, the main region, a panel's content and a section that scrolls are the primitives
168
+ package's scroll areas, so each bar in the shell is the theme's thin bar, shown under the pointer
169
+ and while the region scrolls. The main region's viewport is the `main` element, and
170
+ `AppShell.Main` passes its props to it. Under `scroll="window"` neither viewport scrolls, and the
171
+ browser's bar scrolls the document.
172
+ - `variant="plain"` fills no region, so the shell shows the ground it is placed on.
173
+ `variant="inset"` gives the main region the panel ground, a hairline and a shadow over a muted
174
+ root. `variant="floating"` gives each panel's content the same instead. A sidebar in a panel
175
+ paints its own ground.
176
+ - The header and the footer pad by `spacing.gap.md`. `AppShell.Footer when="narrow"` renders below
177
+ the `md` breakpoint alone, and `when="wide"` from it.
178
+ - The shell reads its window's height from `WINDOW_HEIGHT`, the custom property
179
+ `--app-shell-window-height`, and `100dvh` without it. A shell drawn in a box of fixed height sets
180
+ it on the box.
181
+
182
+ The shell has no `palette` and no `effect` axis, because its parts are containers.
183
+
184
+ ### Parts
185
+
186
+ | Part | Element | Landmark |
187
+ | ------------------ | -------- | --------------- |
188
+ | `AppShell.Root` | `div` | none |
189
+ | `AppShell.Header` | `header` | `banner` |
190
+ | `AppShell.Body` | `div` | none |
191
+ | `AppShell.Navbar` | `div` | none |
192
+ | `AppShell.Rail` | `button` | none |
193
+ | `AppShell.Main` | `main` | `main` |
194
+ | `AppShell.Aside` | `aside` | `complementary` |
195
+ | `AppShell.Section` | `div` | none |
196
+ | `AppShell.Footer` | `footer` | `contentinfo` |
197
+ | `AppShell.Status` | `div` | none |
198
+ | `AppShell.Trigger` | `button` | none |
199
+
200
+ `AppShell.Navbar` has no landmark, because a sidebar inside it renders its own navigation landmarks.
201
+ Name `AppShell.Aside` with `aria-label` when an application renders more than one.
202
+
203
+ Render `AppShell.Status` after the footer, for the state of the application: the connection, a sync,
204
+ work that runs in the background. It lays its entries out in a row that wraps, padded by
205
+ `spacing.gap.xs` above and below and `spacing.gap.md` at the sides, with a hairline above it under
206
+ `divided`. It is not a live region, so an entry that reports a change renders its own `output`.
207
+ Under `scroll="window"` the bar is at the end of the document.
208
+
209
+ `AppShell.Section` is a padded band in a panel or in the main region. `grows` gives it the room its
210
+ siblings leave, and `scrolls` makes it a scroll area, so the bands around it remain in place. A band
211
+ that scrolls is for content whose every item takes focus, such as a list of links or a navigation,
212
+ so its viewport is outside the tab order.
213
+
214
+ A page in the main region grows to the region's height, and a sidebar in a panel takes the panel's
215
+ height exactly and scrolls its own list.
216
+
217
+ ### Panels
218
+
219
+ `AppShell.Navbar` and `AppShell.Aside` are the same panel on the two sides.
220
+
221
+ | Prop | Values | Default |
222
+ | -------------- | --------------------- | ---------------------------------------------- |
223
+ | `collapse` | `hide`, `icons` | `hide` |
224
+ | `folds` | `over`, `under` | `over` |
225
+ | `foldsBelow` | a breakpoint, `never` | `md` on the start side, `lg` on the end side |
226
+ | `width` | a CSS length | `sizes.sidebar` or `sizes.aside` |
227
+ | `railWidth` | a CSS length | `sizes.rail` |
228
+ | `defaultOpen` | `true`, `false` | `true` |
229
+ | `open` | `true`, `false` | uncontrolled |
230
+ | `onOpenChange` | a function | none |
231
+ | `name` | a string | `navbar` on the start side, `aside` on the end |
232
+ | `shortcut` | a key | none |
233
+
234
+ - `collapse` sets what closing the panel beside the page leaves: `hide` hides it, and `icons` leaves
235
+ a rail of `railWidth`. A sidebar inside the panel renders as a rail on its own.
236
+ - `folds` sets where the panel goes when the shell is narrower than `foldsBelow`: `over` lays it
237
+ over the page behind a backdrop, and `under` drops it under the main region as a block that is
238
+ always shown. `foldsBelow="never"` keeps the panel in the body at every width.
239
+ - `shortcut` toggles the panel with Control or Command held: `shortcut="b"` for ⌘B and Ctrl+B.
240
+ - A panel over the page starts closed, and closes again every time the shell narrows. It is a raised
241
+ surface, `bg.panel` with the `lg` shadow, and keeps its width while it slides closed. An
242
+ application that passes `open` controls the panel at every width.
243
+ - While a panel is over the page, the bars, the main region and the other panels are inert, focus
244
+ moves into the panel, and Escape or a press on the backdrop closes it and returns focus to the
245
+ control that opened it. The panel has no dialog role, because the inert page and the returned
246
+ focus give a reader the same behaviour.
247
+
248
+ ### Triggers, the rail and hooks
249
+
250
+ `AppShell.Trigger` renders the library's button, a neutral ghost unless the caller states another
251
+ look. It finds its panel by the name passed as `panel`, `navbar` by default, and sets
252
+ `aria-controls`, `aria-expanded` and `data-state` from it, with no pressed fill. It does not render
253
+ while its panel is under the page, because that panel is always shown. Name the trigger for the
254
+ panel, such as `Navigation`, so a screen reader announces "Navigation, collapsed, button".
255
+
256
+ `AppShell.Rail` is a 24px strip on the edge between a panel and the page, with a 2px line under the
257
+ pointer and on focus. Render it after the navbar or before an aside, and name it with `openLabel`
258
+ and `closeLabel`. It is not in the tab order, because the trigger and the shortcut open and close
259
+ the same panel. It does not render while its panel is over or under the page, and the recipe hides
260
+ it under a coarse pointer.
261
+
262
+ `useAppShellPanel(name)` reads a panel from an application's own code, for example to close the
263
+ navigation when a destination is pressed:
264
+
265
+ ```tsx
266
+ const navbar = useAppShellPanel("navbar");
267
+
268
+ <Link href="/invoices" onClick={() => navbar?.setOpen(false)}>
269
+ Invoices
270
+ </Link>;
271
+ ```
272
+
273
+ `useNearestPanel()` reads the panel a part is inside, and `useOverlaid()` returns the panels that
274
+ are open over the page.
275
+
276
+ ## FloatingPanel
277
+
278
+ Renders a window over the page that a person opens from a trigger, moves by its header and resizes
279
+ from its edges and corners. The page around the window remains in use.
280
+
281
+ ```tsx
282
+ import { FloatingPanel } from "@stealthscale/component-screen";
283
+
284
+ <FloatingPanel.Root>
285
+ <FloatingPanel.Trigger as={Button}>Notes</FloatingPanel.Trigger>
286
+ <Portal>
287
+ <FloatingPanel.Positioner>
288
+ <FloatingPanel.Content>
289
+ <FloatingPanel.Header>
290
+ <FloatingPanel.DragTrigger>
291
+ <GripHorizontalIcon aria-hidden />
292
+ <FloatingPanel.Title>Launch notes</FloatingPanel.Title>
293
+ </FloatingPanel.DragTrigger>
294
+ <FloatingPanel.Control>
295
+ <FloatingPanel.StageTrigger stage="minimized">
296
+ <MinusIcon />
297
+ </FloatingPanel.StageTrigger>
298
+ <FloatingPanel.StageTrigger stage="maximized">
299
+ <Maximize2Icon />
300
+ </FloatingPanel.StageTrigger>
301
+ <FloatingPanel.StageTrigger stage="default">
302
+ <Minimize2Icon />
303
+ </FloatingPanel.StageTrigger>
304
+ <FloatingPanel.CloseTrigger>
305
+ <XIcon />
306
+ </FloatingPanel.CloseTrigger>
307
+ </FloatingPanel.Control>
308
+ </FloatingPanel.Header>
309
+ <FloatingPanel.Body>…</FloatingPanel.Body>
310
+ <FloatingPanel.ResizeTriggers />
311
+ </FloatingPanel.Content>
312
+ </FloatingPanel.Positioner>
313
+ </Portal>
314
+ </FloatingPanel.Root>;
315
+ ```
316
+
317
+ The recipe has no axes.
318
+
319
+ - The root takes the machine's options: `open` or `defaultOpen` with `onOpenChange`, `size` or
320
+ `defaultSize` with `onSizeChange` and `onSizeChangeEnd`, `position` or `defaultPosition` with
321
+ `onPositionChange` and `onPositionChangeEnd`, `minSize`, `maxSize`, `getAnchorPosition`,
322
+ `getBoundaryEl`, `allowOverflow`, `draggable`, `resizable`, `disabled`, `lockAspectRatio`,
323
+ `gridSize`, `persistRect`, `closeOnEscape`, `initialFocusEl`, `finalFocusEl`, `restoreFocus` and
324
+ `onStageChange`. The panel is 320 by 240 pixels unless stated, at least 240 by 100, and closes on
325
+ Escape unless `closeOnEscape` is false.
326
+ - The panel opens in the middle of the window, or where `getAnchorPosition` places it from the
327
+ trigger's and the window's rectangles. A drag can take the panel past the window's edge by the
328
+ distance from the pointer to the panel's edge, and `allowOverflow={false}` keeps it inside.
329
+ - A controlled panel remains open until `open` turns false. The trigger, the close trigger and
330
+ Escape call `onOpenChange` with `{ open: false }`.
331
+ - The positioner is fixed to the window at the `banner` layer plus the panel's place among the open
332
+ panels: above the page's sticky bands, and under a backdrop, a dialog, a popover, a toast and a
333
+ tooltip. Render it in a portal. A press or focus inside a panel brings it in front, and Escape
334
+ closes the panel in front.
335
+ - The trigger opens and closes the panel and sets `aria-expanded` and `aria-haspopup="dialog"`.
336
+ Focus moves to the panel as it opens, or to the element `initialFocusEl` returns, and returns to
337
+ the trigger as it closes.
338
+ - The panel is a `dialog` that is not modal, named by its title. A panel without a title takes
339
+ `aria-label`. Tab moves from the trigger into the panel and from the panel's last control to the
340
+ control after the trigger, wherever the portal puts the panel.
341
+ - While the panel itself has focus, the arrow keys move it in the arrow's direction under both
342
+ writing directions: 1px at a time and 10px with Shift, times `gridSize`. Alt with an arrow resizes
343
+ it from its end and bottom edges and keeps its aspect ratio under `lockAspectRatio`.
344
+ - A drag on `FloatingPanel.DragTrigger` moves the panel, and a double click maximizes it and
345
+ restores it. A press on a button inside the drag trigger does not start a drag. The cursor is
346
+ `move` while the panel can move.
347
+ - The resize triggers are 8px strips inside the panel's edges and 8px squares at its corners. Shift
348
+ keeps the aspect ratio during a drag and Alt resizes from the centre. They are hidden while the
349
+ panel is minimized or maximized, disabled or not resizable. `FloatingPanel.ResizeTriggers` renders
350
+ all eight, or those `axes` lists.
351
+ - `FloatingPanel.StageTrigger` sets `minimized`, the header alone, `maximized`, the whole window, or
352
+ `default`, the size and place before. Minimize and Maximize show while the panel is at its size,
353
+ and Restore while it is minimized or maximized. A stage trigger hidden with focus moves focus to
354
+ the trigger that reverses it. The stage triggers are hidden while the panel is not resizable.
355
+ - `label` names each trigger: "Minimize", "Maximize" and "Restore" by stage, and "Close" for
356
+ `FloatingPanel.CloseTrigger`. Both render the actions `Button` as a ghost `xs` square around the
357
+ caller's glyph.
358
+ - `FloatingPanel.Body` renders the primitives' scroll area, whose viewport is a region named by the
359
+ title while its content overflows. It is hidden while the panel is minimized.
360
+ - The panel has the `bg.panel` surface, the `lg` shadow, a transparent hairline edge that forced
361
+ colours paint in `CanvasText`, and a ring outside it under keyboard focus. It is not in the
362
+ document before it first opens, and leaves once its exit motion ends. `lazyMount` and
363
+ `unmountOnExit` default to `true`.
364
+
365
+ | Part | Element |
366
+ | ------------------------------ | ------------------------------- |
367
+ | `FloatingPanel.Root` | `div`, `display: contents` |
368
+ | `FloatingPanel.Trigger` | `button` |
369
+ | `FloatingPanel.Positioner` | `div` |
370
+ | `FloatingPanel.Content` | `div`, role `dialog` |
371
+ | `FloatingPanel.Header` | `div` |
372
+ | `FloatingPanel.DragTrigger` | `div` |
373
+ | `FloatingPanel.Title` | `h2` |
374
+ | `FloatingPanel.Control` | `div` |
375
+ | `FloatingPanel.StageTrigger` | `button` |
376
+ | `FloatingPanel.CloseTrigger` | `button` |
377
+ | `FloatingPanel.Body` | `div` inside a scroll area |
378
+ | `FloatingPanel.ResizeTrigger` | `div` |
379
+ | `FloatingPanel.ResizeTriggers` | a `ResizeTrigger` for each axis |
380
+
381
+ The panel does not offer these:
382
+
383
+ - `strategy`. The machine computes the panel's place in window coordinates, so the panel is fixed to
384
+ the window.
385
+ - `translations`. The triggers take `label`.
386
+ - The machine's api. A caller controls `open`, `size` and `position`, and the stage triggers set the
387
+ stage.
388
+ - A boundary that scrolls with the page. The machine measures `getBoundaryEl` when it resizes, so
389
+ the panel remains inside a boundary that does not scroll with the page, such as an app shell's
390
+ main region, and leaves one that does.
391
+
392
+ A menu, a select or a combobox opened inside the panel stacks at the `dropdown` layer, under the
393
+ panel.
394
+
395
+ ## Page
396
+
397
+ Lays out a page as a column of bands: a banner, a header, a navigation, a toolbar, a body with an
398
+ optional aside, and a footer.
399
+
400
+ ```tsx
401
+ import { Page } from "@stealthscale/component-screen";
402
+
403
+ <Page.Root measure="wide">
404
+ <Page.Header>
405
+ <Page.Breadcrumbs backIcon={<ArrowLeftIcon />} items={[{ href: "/", label: "Ledger" }]} />
406
+ <Page.Title>Users</Page.Title>
407
+ <Page.Meta when="wide">…</Page.Meta>
408
+ <Page.Description>Everyone with access to this ledger.</Page.Description>
409
+ <Page.Actions more="More actions" moreIcon={<EllipsisIcon />}>
410
+ <Page.Action icon={<SettingsIcon />}>Settings</Page.Action>
411
+ <Page.Action>Preview</Page.Action>
412
+ <Page.Action icon={<PlusIcon />} primary>
413
+ Add user
414
+ </Page.Action>
415
+ </Page.Actions>
416
+ </Page.Header>
417
+ <Page.Nav aria-label="User lists">
418
+ <Page.TabList
419
+ aria-label="Views"
420
+ emptyLabel="No view matches"
421
+ filterLabel="Filter views"
422
+ onValueChange={setView}
423
+ value={view}
424
+ >
425
+ <Page.Tab count={412} value="all">
426
+ All
427
+ </Page.Tab>
428
+ <Page.Tab value="suspended">Suspended</Page.Tab>
429
+ </Page.TabList>
430
+ </Page.Nav>
431
+ <Page.Body>…</Page.Body>
432
+ </Page.Root>;
433
+ ```
434
+
435
+ | Axis | Values | Default |
436
+ | --------- | ------------------------ | ------- |
437
+ | `align` | `center`, `start` | `start` |
438
+ | `divided` | `true`, `false` | `true` |
439
+ | `gutter` | `xs` to `4xl` | `xl` |
440
+ | `measure` | `full`, `narrow`, `wide` | `full` |
441
+ | `size` | `sm`, `md`, `lg` | `md` |
442
+
443
+ - Every band runs edge to edge, and its content starts at the gutter. The root sets the gutter and
444
+ the measure as custom properties that every band reads.
445
+ - A page folds below 40rem. A narrow page sets its title one heading size smaller and its actions
446
+ one button size smaller.
447
+ - `size` sets the title one heading size larger than a section's title at the same size.
448
+ - The root has no landmark, because `AppShell.Main` is the `main` landmark. The header takes its
449
+ name from `Page.Title`.
450
+ - `Page.Action` renders the library's button, `outline`, or `solid` with `primary`. `as` renders
451
+ another control in its place, such as a menu's trigger, and the caller sets its look.
452
+ - `when="narrow"` renders a part on a folded page alone, and `when="wide"` on an unfolded page
453
+ alone. `Page.Context`, `Page.Meta`, `Page.Leading`, `Page.Actions`, `Page.Action`, `Page.Nav` and
454
+ `Page.Footer` take it, and `<Page.When when="narrow">` wraps anything else.
455
+ - Each sticky band sticks under the sticky bands before it.
456
+ - `Page.Aside` is a complementary landmark beside the body from the `lg` breakpoint. Name it with
457
+ `aria-label`. Below `lg` it stacks under the body, or leaves the page with `folds="hide"`.
458
+ `sticky` keeps it in view under the shell's pinned bars.
459
+ - A sticky aside renders its content in the primitives package's scroll area. Beside the body the
460
+ aside is at most as tall as the area that scrolls the page, less the pinned bars, the sticky bands
461
+ and a gap at each end. A longer aside scrolls on its own, and its viewport is a region named like
462
+ the aside, with a tab stop.
463
+ - A section scrolled to by its id stops a gap under the shell's pinned bars.
464
+ - The page has no `palette` and no `effect` axis, because it is a layout.
465
+
466
+ ### Breadcrumbs
467
+
468
+ `Page.Breadcrumbs` takes the pages above this one as `items`, the nearest last, each with `href`,
469
+ `label` and an optional `onClick`. The navigation package's breadcrumb renders them in the context
470
+ row, named by `label`, with `separator` between the items. On a narrow page one link back to the
471
+ nearest item replaces the trail, with `backIcon` before its words.
472
+
473
+ ### Tabs
474
+
475
+ `Page.TabList` selects one of its `Page.Tab` children by value. The caller controls the value with
476
+ `value` and `onValueChange`, or sets `defaultValue`, and the first tab is selected until then.
477
+ `Page.Tab` takes its words as a string and an optional `count`, which renders as a badge.
478
+
479
+ - A wide page renders the disclosure package's tabs on the navigation band, 48px tall at `md`, and
480
+ `Page.Body` is the selected tab's panel.
481
+ - On a narrow page a button with `pickerIcon` replaces the strip and opens a list of the tabs. The
482
+ list filters as the reader types into a field named by `filterLabel`, and shows `emptyLabel` when
483
+ the query matches no tab.
484
+ - `Page.Picker` and `Page.Palette` are that button and its panel, for a caller that composes its own
485
+ picker: `<Menu.Trigger as={Page.Picker}>`, and the menu's content rendered as `Page.Palette`.
486
+
487
+ ## Section
488
+
489
+ Renders one part of a page under its own heading, with the actions that apply to it.
490
+
491
+ ```tsx
492
+ import { Section } from "@stealthscale/component-screen";
493
+
494
+ <Section.Root variant="surface">
495
+ <Section.Header>
496
+ <Section.Title>Payment methods</Section.Title>
497
+ <Section.Description>Cards this account can be charged on.</Section.Description>
498
+ <Section.Actions>
499
+ <Section.Action icon={<PlusIcon />}>Add a card</Section.Action>
500
+ </Section.Actions>
501
+ </Section.Header>
502
+ <Section.Body bleed>…</Section.Body>
503
+ </Section.Root>;
504
+ ```
505
+
506
+ | Axis | Values | Default |
507
+ | ----------- | ------------------ | ------- |
508
+ | `annotated` | `true` | none |
509
+ | `size` | `sm`, `md`, `lg` | `md` |
510
+ | `variant` | `plain`, `surface` | `plain` |
511
+
512
+ - The element is `section`. While a `Section.Title` is mounted, the title names it, so it is a
513
+ region landmark with the title as its name.
514
+ - A section without `size` takes the page's size, so one `size` on `Page.Root` sets every section in
515
+ it.
516
+ - `Section.Action` renders the library's button one size below the section, `outline`, or `solid`
517
+ with `primary`. `Section.Actions` takes `more` and `moreIcon` for its menu of folded actions.
518
+ - A section folds when it is narrower than 40rem. An annotated section folds when it is narrower
519
+ than 48rem, because its header and body share the row.
520
+ - A plain section after another renders a hairline above itself with a gap on either side.
521
+ - `annotated` lays out the header and the body as two columns on a wide section, the layout of a
522
+ settings page.
523
+ - `Section.Body bleed` drops the body's padding in a card and runs it to the card's edges under a
524
+ hairline, for a table or a list whose rows pad themselves.
525
+ - The section has no `palette` and no `effect` axis, because it is a layout.
526
+
527
+ ## Sidebar
528
+
529
+ Lays out an application's navigation as a column: a fixed header, scrolling content with nav blocks,
530
+ and a fixed footer.
531
+
532
+ ```tsx
533
+ import { NavList } from "@stealthscale/component-navigation";
534
+ import { Sidebar } from "@stealthscale/component-screen";
535
+
536
+ <Sidebar.Root variant="subtle">
537
+ <Sidebar.Header>
538
+ <Sidebar.Search
539
+ aria-label="Find a page"
540
+ clearIndicator={<XIcon />}
541
+ clearLabel="Clear the filter"
542
+ placeholder="Filter…"
543
+ searchIndicator={<SearchIcon />}
544
+ shortcut="k"
545
+ />
546
+ </Sidebar.Header>
547
+ <Sidebar.Content>
548
+ <Sidebar.Nav>
549
+ <Sidebar.NavLabel>Platform</Sidebar.NavLabel>
550
+ <NavList.Root>…</NavList.Root>
551
+ </Sidebar.Nav>
552
+ <Sidebar.Separator />
553
+ <Sidebar.Nav>
554
+ <Sidebar.NavLabel>Projects</Sidebar.NavLabel>
555
+ <Sidebar.NavAction aria-label="New project">
556
+ <PlusIcon />
557
+ </Sidebar.NavAction>
558
+ <NavList.Root>…</NavList.Root>
559
+ </Sidebar.Nav>
560
+ <Sidebar.Empty>No pages match</Sidebar.Empty>
561
+ </Sidebar.Content>
562
+ <Sidebar.Footer>…</Sidebar.Footer>
563
+ </Sidebar.Root>;
564
+ ```
565
+
566
+ | Axis | Values | Default |
567
+ | --------- | --------------------------------------- | ------- |
568
+ | `size` | `sm`, `md`, `lg` | `md` |
569
+ | `variant` | `outline`, `plain`, `subtle`, `surface` | `plain` |
570
+
571
+ - `subtle` is a muted ground without an edge, for a sidebar inside a shell panel, where the shell
572
+ renders the hairline.
573
+ - The content scrolls and the root does not, so the header and the footer remain visible. The
574
+ content is the primitives package's scroll area, with the theme's thin bar. Its viewport is
575
+ outside the tab order, because a link that takes focus scrolls into view. The header and the
576
+ footer pad like the rows, so a search, a switcher or a list in them is as wide as the rows' fills.
577
+ - The size sets the rows of every list inside: 24, 32 and 40px at `sm`, `md` and `lg`. A block's
578
+ label is one row tall.
579
+ - The content's blocks are 12, 16 and 24px apart at `sm`, `md` and `lg`, three to four times the gap
580
+ between rows. A `Sidebar.Separator` between two blocks sets no margin of its own.
581
+ - Each `Sidebar.Nav` is a `nav` landmark named by its `Sidebar.NavLabel` through `aria-labelledby`.
582
+ A block without a label takes `aria-label`, and a caller's `aria-label` replaces the label's name.
583
+ - A block whose rows a query matches none of is hidden, its label included, unless it renders an
584
+ empty message.
585
+ - `Sidebar.NavHeading` renders a heading over one list inside a block. Pass it an `id`, and pass the
586
+ same identifier to the list's `aria-labelledby`.
587
+ - `Sidebar.NavAction` is a 24px ghost square in the navigation list's end column, with the rows' end
588
+ inset. Pass the icon as its child and name it with `aria-label`.
589
+ - The sidebar has no `palette` and no `effect` axis. Its looks are neutral grounds, and the
590
+ navigation list offers its own palette and effect for the current row.
591
+
592
+ ### Search
593
+
594
+ `Sidebar.Search` renders the forms package's search input and takes its props, named by
595
+ `aria-label`, `Search` by default. It filters the rows of its scope: every block from the header,
596
+ one block from inside a `Sidebar.Nav`. It hides a row whose words do not contain the query and opens
597
+ a branch that contains a match. When the query clears, every branch returns to the state it had
598
+ before the query.
599
+
600
+ - The down arrow moves focus from the field to the first row its scope shows.
601
+ - `shortcut` moves focus to the field with the platform's modifier held, `k` for ⌘K and Ctrl+K, and
602
+ sets `aria-keyshortcuts` on the field. Inside a closed app shell panel, the shortcut opens the
603
+ panel first.
604
+ - `Sidebar.Empty` renders while its scope shows zero rows, and a polite live region announces it
605
+ while a query is active. Put it in the scope the search filters, and name what was searched:
606
+ `No pages match`.
607
+ - With children, `Sidebar.Search` contains the caller's field, and the caller filters the rows.
608
+
609
+ ### In an app shell
610
+
611
+ - Inside a panel that closes to icons, the sidebar is a rail while the panel is closed. `iconic`
612
+ overrides the panel. The labels, the headings and the words in the header and footer are hidden
613
+ visually and kept for screen readers.
614
+ - On a rail, a search with `searchIndicator` renders a square button with its name in a tooltip. The
615
+ button opens the panel and moves focus to the field. A rail removes a search without
616
+ `searchIndicator` or outside a panel, the empty message and the block controls.
617
+ - In a panel over the page the sidebar renders at `lg`, and a click on a link inside it closes the
618
+ panel.
619
+ - The sidebar provides its size and its rail to every `NavList.Root` inside it through
620
+ `NavList.PropsProvider`. A list's own props apply over them.
621
+
622
+ ## Splitter
623
+
624
+ Lays panels out in a row or a column with a resize trigger between each two. An application starts
625
+ the machine with `Splitter.useSplitter` and passes its api to `Splitter.Root`, so a control outside
626
+ the root collapses or resizes a panel.
627
+
628
+ ```tsx
629
+ import { Splitter } from "@stealthscale/component-screen";
630
+
631
+ const splitter = Splitter.useSplitter({
632
+ defaultSize: [30, 70],
633
+ panels: [
634
+ { collapsedSize: 0, collapsible: true, id: "notes", maxSize: 50, minSize: 20 },
635
+ { id: "note", minSize: 40 },
636
+ ],
637
+ });
638
+
639
+ <Splitter.Root splitter={splitter}>
640
+ <Splitter.Panel id="notes">…</Splitter.Panel>
641
+ <Splitter.ResizeTrigger id="notes:note" label="Resize the notes list" />
642
+ <Splitter.Panel id="note">…</Splitter.Panel>
643
+ </Splitter.Root>;
644
+ ```
645
+
646
+ | Axis | Values | Default |
647
+ | --------- | ------------------------------------------------------------------ | --------- |
648
+ | `palette` | `primary`, `secondary`, `accent`, `neutral`, and the four statuses | `primary` |
649
+
650
+ - `useSplitter` takes the machine's options: `panels`, each with `id`, `minSize`, `maxSize`,
651
+ `collapsible`, `collapsedSize` and `resizeBehavior`, and `orientation`, `defaultSize` or `size`,
652
+ `onResize`, `onResizeStart`, `onResizeEnd`, `onCollapse`, `onExpand` and `keyboardResizeBy`. A
653
+ number is a percentage of the root, and a string with a unit, such as `"200px"`, resolves against
654
+ the root.
655
+ - The api reads and changes the sizes: `getSizes`, `setSizes`, `resizePanel`, `collapsePanel`,
656
+ `expandPanel`, `isPanelCollapsed`, `isPanelExpanded` and `resetSizes`. With `size`, the
657
+ application resets by setting its sizes, because the machine records a controlled size as its
658
+ first layout.
659
+ - The root is a flex row or column that fills its container, and grows in a flex container, such as
660
+ a column under a toolbar. A panel is a column its content can shrink in. Render content that
661
+ scrolls in the primitives package's scroll area.
662
+ - `Splitter.ResizeTrigger` sits between the two panels its `id` names, `before:after`. It is a
663
+ `separator` named by `label`, "Resize" by default. Its value is the before panel's share of the
664
+ root, `aria-controls` points at that panel, and its orientation is its line's: vertical between
665
+ panels in a row. `disabled` removes it from the tab order and hides its pill.
666
+ - The arrows move a focused trigger by 1%, or by `keyboardResizeBy`, and by 10% with Shift. Home and
667
+ End move it to the before panel's minimum and maximum. Enter collapses a collapsible before panel
668
+ and restores the size it had before the collapse. F6 moves focus to the next trigger.
669
+ - The trigger is 8px across, 24px under a coarse pointer, and stacks above the panels' content. It
670
+ renders a hairline and a pill, which children replace. The palette colours the line and the pill
671
+ while the pointer is over the trigger, while it has focus and while it drags. Under forced colours
672
+ the line is `CanvasText`, an active pill `Highlight` and its ring `CanvasText`.
673
+
674
+ | Part | Element |
675
+ | --------------------------------- | ----------------------- |
676
+ | `Splitter.Root` | `div` |
677
+ | `Splitter.Panel` | `div` |
678
+ | `Splitter.ResizeTrigger` | `div`, role `separator` |
679
+ | `Splitter.ResizeTriggerSeparator` | `div` |
680
+ | `Splitter.ResizeTriggerIndicator` | `div` |
681
+
682
+ The splitter does not offer the machine's `ids`: the hook builds each id from the encoded panel id,
683
+ so an id with a space makes a valid `aria-controls`.
684
+
685
+ ## Switcher
686
+
687
+ Renders the control that shows the current workspace, project or environment and opens a menu of the
688
+ others. `Switcher.Root` is the disclosure package's `Menu.Root`, so the roles, the keyboard and the
689
+ positioning are the menu's.
690
+
691
+ ```tsx
692
+ import { Switcher } from "@stealthscale/component-screen";
693
+
694
+ <Switcher.Root
695
+ checkIcon={<CheckIcon />}
696
+ indicator={<ChevronsUpDownIcon />}
697
+ items={[
698
+ { detail: "Pro plan", label: "Ledger", value: "ledger" },
699
+ { detail: "Free plan", label: "Atlas", value: "atlas" },
700
+ ]}
701
+ label="Workspace"
702
+ onValueChange={setWorkspace}
703
+ value={workspace}
704
+ >
705
+ <Switcher.Action icon={<PlusIcon />} onClick={create}>
706
+ New workspace
707
+ </Switcher.Action>
708
+ </Switcher.Root>;
709
+ ```
710
+
711
+ | Axis | Values | Default |
712
+ | ----------- | ---------------------------------------------------------------------------------- | ------- |
713
+ | `palette` | `primary`, `secondary`, `accent`, `neutral`, `info`, `success`, `warning`, `error` | none |
714
+ | `placement` | `alone`, `sidebar`, `toolbar` | `alone` |
715
+ | `size` | `sm`, `md`, `lg` | `md` |
716
+ | `variant` | `solid`, `subtle`, `surface`, `outline`, `ghost`, `plain` | `ghost` |
717
+
718
+ - `items` lists the choices, each with `value`, `label` and an optional `detail`, `mark`, `disabled`
719
+ and `href`. A choice without `mark` shows its initials. A choice with `href` is a link row.
720
+ - The value is the caller's with `value` and `onValueChange`, and the root's own otherwise, starting
721
+ at `defaultValue` or the first choice.
722
+ - `label` names what the switcher switches. The trigger renders it as visually hidden text before
723
+ the name, so a screen reader announces `Workspace Ledger`, and the visible name stays in the
724
+ accessible name.
725
+ - `Switcher.Action` renders a menu row with `icon` after a separator under the choices.
726
+ - `Switcher.Root` reads the sidebar or the toolbar around it for `placement` and `size`, unless the
727
+ caller states them.
728
+ - In a sidebar the switcher fills the column and opens a menu as wide as the control. In a sidebar
729
+ closed to icons it is its mark alone, a 32px square at `md`, with its name in a tooltip, and its
730
+ menu opens beside the rail.
731
+ - Alone and in a toolbar the switcher is one line as wide as its words, at least as tall as a button
732
+ of its size. In a narrow toolbar a switcher with a mark hides its name.
733
+ - The looks and the palettes are the button's. The outline look has the button's light edge.
734
+ - Render the menu's positioner in a `Portal` when the switcher is inside an element that clips, such
735
+ as an app shell panel, so the menu renders over the page.
736
+
737
+ Without `items`, the children are the trigger and the menu, composed from the switcher's parts and
738
+ the menu's own:
739
+
740
+ ```tsx
741
+ <Switcher.Root placement="toolbar">
742
+ <Toolbar.Item as={Switcher.Trigger} label="Environment">
743
+ <Switcher.Label>
744
+ <Switcher.Name>Production</Switcher.Name>
745
+ </Switcher.Label>
746
+ <Switcher.Indicator>
747
+ <ChevronsUpDownIcon />
748
+ </Switcher.Indicator>
749
+ </Toolbar.Item>
750
+ <Portal>
751
+ <Menu.Positioner>…</Menu.Positioner>
752
+ </Portal>
753
+ </Switcher.Root>
754
+ ```
755
+
756
+ ## Toolbar
757
+
758
+ Renders a row of controls over a table or a list: a band at each end, a band in the middle, and a
759
+ search that folds to a button on a narrow row.
760
+
761
+ ```tsx
762
+ import { Toolbar } from "@stealthscale/component-screen";
763
+
764
+ <Toolbar.Root aria-label="Invoices" more="More actions" moreIcon={<EllipsisIcon />}>
765
+ <Toolbar.Start>
766
+ <Toolbar.Link current href="/invoices/drafts">
767
+ Drafts
768
+ </Toolbar.Link>
769
+ <Toolbar.Link href="/invoices/sent">Sent</Toolbar.Link>
770
+ <Toolbar.Separator />
771
+ <Toolbar.Group>
772
+ <Toolbar.Action icon={<BoldIcon />}>Bold</Toolbar.Action>
773
+ <Toolbar.Action icon={<ItalicIcon />}>Italic</Toolbar.Action>
774
+ </Toolbar.Group>
775
+ </Toolbar.Start>
776
+ <Toolbar.End>
777
+ <Toolbar.Search aria-label="Search invoices" searchIndicator={<SearchIcon />} />
778
+ <Toolbar.Action>Export</Toolbar.Action>
779
+ <Toolbar.Action primary>New invoice</Toolbar.Action>
780
+ </Toolbar.End>
781
+ </Toolbar.Root>;
782
+ ```
783
+
784
+ | Axis | Values | Default |
785
+ | --------- | ----------------------------- | ------- |
786
+ | `radius` | `l1`, `l2`, `l3` | `l2` |
787
+ | `size` | `sm`, `md`, `lg` | `md` |
788
+ | `variant` | `outline`, `plain`, `surface` | `plain` |
789
+
790
+ - The row has `role="toolbar"`, and the arrow keys move focus between its controls, so the row is
791
+ one tab stop. Name it with `aria-label`.
792
+ - The row measures its own width and writes `data-narrow` below `sm`. Its gap is the gap token of
793
+ its size, and every library button in it renders at its size.
794
+ - `Toolbar.Action` renders the library's button, `ghost`, or `solid` with `primary`, and folds by
795
+ its priority.
796
+ - `Toolbar.Link` renders an `a` in the same look with `href`, `current` and `icon`. `current` sets
797
+ `aria-current="page"`. Its `onClick` takes a router's anchor handler, and its menu row calls the
798
+ same handler.
799
+ - `Toolbar.Group` joins controls pressed together, such as bold, italic and underline.
800
+ - `Toolbar.Item` renders a link when it has an `href` and a button otherwise, and both stay in the
801
+ arrow-key group. Pass `as` to render another component as the item: `as={Switcher.Trigger}` or a
802
+ menu's trigger.
803
+ - `Toolbar.Search` renders the forms package's search input and takes its props, named by
804
+ `aria-label`, `Search` by default. On a narrow row it renders a button with `searchIndicator`, or
805
+ with its name without one, that opens the field over the whole row, the padding and the edge of an
806
+ outline or surface row included. Escape on the empty field, or focus leaving it, closes the field
807
+ and returns focus to the button.
808
+ - The toolbar has no `palette` and no `effect` axis, because it is a layout for controls that have
809
+ their own.