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