@stealthscale/component-navigation 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @stealthscale/component-navigation
2
2
 
3
- Draws the ways a person moves between places: the link, and the trail of crumbs from the front of a
4
- site. Every component binds a recipe and draws nothing of its own, so a theme restyles all of them
5
- by extending the recipe. The preset under `./theme` registers the recipes with an application's
6
- compiler.
3
+ React components for moving between pages and between sections of a page: `Link`, `Breadcrumb`,
4
+ `NavList`, `NavigationMenu`, `Pagination` and `Toc`. Each component renders through a recipe, so a
5
+ theme restyles it by extending the recipe. The preset under `./theme` registers the recipes with an
6
+ application's compiler.
7
7
 
8
- Every value a theme can change on a component is an axis of its recipe, so a caller sets it as a
9
- prop and writes no style. A caller changes the element a component draws with `as`.
8
+ Every value a theme can change is a recipe axis, and a caller sets it as a prop. A caller changes
9
+ the rendered element with `as`.
10
10
 
11
11
  ## Install
12
12
 
@@ -14,19 +14,21 @@ prop and writes no style. A caller changes the element a component draws with `a
14
14
  pnpm add @stealthscale/component-navigation
15
15
  ```
16
16
 
17
- The package peers on `react` and `@stealthscale/theme`. An application lists the preset under
18
- `./theme` among the presets its compiler installs.
17
+ The package peers on `react`, `@stealthscale/theme`, `@stealthscale/hooks`,
18
+ `@stealthscale/component-actions`, `@stealthscale/component-disclosure` and
19
+ `@stealthscale/component-primitives`. Add the preset under `./theme` to the presets the
20
+ application's compiler installs.
19
21
 
20
22
  ## Link
21
23
 
22
- Draws words a person follows to somewhere else. The ink, the visited ink, the cursor and the focus
23
- ring come from the theme, so a theme decides what a link looks like once for every link.
24
+ Renders an anchor in the theme's link ink. `fg.link`, the visited ink, the cursor and the focus ring
25
+ come from the theme's `link()` fragment, so a theme sets them once for every link.
24
26
 
25
27
  ```tsx
26
28
  import { Link } from "@stealthscale/component-navigation";
27
29
 
28
30
  <Link href="/invoices">Invoices</Link>;
29
- <Link href="/terms" variant="underline">
31
+ <Link href="/terms" variant="plain">
30
32
  Terms
31
33
  </Link>;
32
34
  <Link as={RouterLink} to="/invoices">
@@ -34,23 +36,32 @@ import { Link } from "@stealthscale/component-navigation";
34
36
  </Link>;
35
37
  ```
36
38
 
37
- | Axis | Values | Default |
38
- | --------- | -------------------- | ------- |
39
- | `variant` | `plain`, `underline` | `plain` |
39
+ | Axis | Values | Default |
40
+ | --------- | ------------------------------------------------------------------ | ----------- |
41
+ | `variant` | `plain`, `underline` | `underline` |
42
+ | `palette` | `primary`, `secondary`, `accent`, `neutral`, and the four statuses | `fg.link` |
43
+ | `inherit` | `true` | off |
40
44
 
41
- Both looks underline under a pointer. The axis decides whether the underline is there at rest as
42
- well. A link inside a paragraph is found by its underline as much as by its colour, so a reader who
43
- cannot tell the two inks apart has nothing else to go on.
45
+ Both looks underline on hover, and `underline` also underlines at rest. Keep the default for a link
46
+ in running text. Without an underline the link differs from the text only by ink, and the link ink
47
+ measured 1.64:1 against body text on the ink theme. Use `plain` where the context marks the link: a
48
+ card title, a navigation row, a brand name.
44
49
 
45
- The element is `a` and takes an `href`. A link with no address is not a link to anything, and a
46
- browser gives it no focus, no Enter and no offer to open elsewhere, so a control that acts rather
47
- than navigates is a button. A router's own link goes in through `as`, which keeps the routing and
48
- leaves the drawing here.
50
+ `palette` sets the ink from the palette's `fg` role and the focus ring from its `focusRing` role.
51
+ Without a palette the link reads `fg.link`. The recipe lists every palette in `staticCss`, so a
52
+ value set through `LinkPropsProvider` has a rule.
53
+
54
+ `inherit` makes the link take the text color of its parent, visited state included. The hover
55
+ underline and the focus ring still apply. A `palette` on the same link takes precedence.
56
+
57
+ The element is `a` and needs an `href`. A browser gives an anchor without an `href` no focus and no
58
+ Enter key, so use a button for a control that runs an action. Pass a router's link component through
59
+ `as` to keep its routing and take the recipe's classes.
49
60
 
50
61
  ## Breadcrumb
51
62
 
52
- Draws the path from the front of a site to the page a person is on, composed as `Breadcrumb.Root`
53
- holding a list of crumbs.
63
+ Renders an ordered list of links from the site root to the current page. Compose it as
64
+ `Breadcrumb.Root` around a `Breadcrumb.List` of items.
54
65
 
55
66
  ```tsx
56
67
  import { Breadcrumb } from "@stealthscale/component-navigation";
@@ -77,31 +88,386 @@ import { Breadcrumb } from "@stealthscale/component-navigation";
77
88
  | `size` | `xs`, `sm`, `md`, `lg`, `xl` | `md` |
78
89
  | `variant` | `plain`, `underline` | `plain` |
79
90
 
80
- The size sets the text on the root and the gap on the list, so every part reads at one size by
81
- inheriting it. It stops at `xl` because it reads the body role, a trail being read at the size of
82
- the page around it rather than as a heading.
83
-
84
- The last crumb is `Breadcrumb.CurrentLink` and not a link. It draws a `span` carrying
85
- `aria-current="page"`, which is what tells a screen reader which crumb is where the reader is, and a
86
- link to the page already open would be a control that does nothing. It is the one crumb at full
87
- strength and the crumbs above it are muted, because the crumb naming where you are is the one worth
88
- reading first.
89
-
90
- The trail does four more things for accessibility:
91
-
92
- - **The landmark is named.** The root is a `nav` carrying `aria-label="Breadcrumb"` by default,
93
- because a page usually holds more than one navigation landmark and an unnamed one is announced
94
- with nothing to tell it from the others. State your own to override it.
95
- - **The list keeps its role.** The list is an `ol` stating `role="list"`, because a list drawn with
96
- no marker loses its role in Safari and a reader is then told neither how many crumbs there are nor
97
- which one they are on.
98
- - **The separator is a row, not a crumb.** It sits between two items as a row of the list rather
99
- than inside one, so a screen reader counting the list counts the crumbs.
100
- - **The separator is silent.** It carries `aria-hidden` and a presentation role, because the list
101
- already carries the order and a mark read out between every pair adds nothing.
102
-
103
- The separator turns around where the line runs right to left, so a chevron pointing forwards keeps
104
- pointing forwards.
91
+ | Part | Element | What it renders |
92
+ | ------------- | ------- | -------------------------------------------------- |
93
+ | `Root` | `nav` | The landmark, which receives the variants |
94
+ | `List` | `ol` | The crumbs in order, with `role="list"` |
95
+ | `Item` | `li` | One crumb |
96
+ | `Link` | `a` | The link of a crumb above the current page |
97
+ | `CurrentLink` | `span` | The current page, with `aria-current="page"` |
98
+ | `Separator` | `li` | The `aria-hidden` mark between two crumbs |
99
+ | `Ellipsis` | `li` | The labelled row that replaces the crumbs left out |
100
+
101
+ `size` sets the body text style on the root and the gap on the list, and every part inherits the
102
+ text size. The links are muted and darken on hover. The current page renders in the default ink. The
103
+ recipe has no `palette` axis, because a trail has no color of its own.
104
+
105
+ The root's `aria-label` defaults to `Breadcrumb`, because a page with a site navigation and a
106
+ breadcrumb has two navigation landmarks. Pass a translated label through `aria-label`.
107
+
108
+ The list sets `role="list"`, because Safari drops the list role from a list with `list-style: none`.
109
+ The separator is a list row between two items, with `aria-hidden` and `role="presentation"`, so a
110
+ screen reader counts only the crumbs. The recipe rotates the separator by 180 degrees in a
111
+ right-to-left document.
112
+
113
+ For a long trail, keep the first crumb and the current page and render `Breadcrumb.Ellipsis` in
114
+ place of the crumbs between them. Give it an `aria-label` with the number of crumbs it replaces,
115
+ such as `4 more steps`, so a screen reader announces the full depth.
116
+
117
+ ## NavList
118
+
119
+ Renders a list of links a page is reached from: rows in a column, and branches that expand a nested
120
+ list.
121
+
122
+ ```tsx
123
+ import { NavList } from "@stealthscale/component-navigation";
124
+
125
+ <nav aria-label="Main">
126
+ <NavList.Root iconic={collapsed} palette="primary">
127
+ <NavList.Item>
128
+ <NavList.Link aria-current="page" href="/">
129
+ Overview
130
+ </NavList.Link>
131
+ <NavList.Badge>3</NavList.Badge>
132
+ </NavList.Item>
133
+ <NavList.Branch defaultOpen>
134
+ <NavList.Trigger>
135
+ Settings
136
+ <NavList.Indicator>
137
+ <ChevronIcon />
138
+ </NavList.Indicator>
139
+ </NavList.Trigger>
140
+ <NavList.Content>
141
+ <NavList.Item>
142
+ <NavList.Link href="/settings/team">Team</NavList.Link>
143
+ </NavList.Item>
144
+ </NavList.Content>
145
+ </NavList.Branch>
146
+ </NavList.Root>
147
+ </nav>;
148
+ ```
149
+
150
+ | Axis | Values | Default |
151
+ | ----------- | ------------------------------------------------------------------ | --------- |
152
+ | `size` | `sm`, `md`, `lg` | `md` |
153
+ | `variant` | `list`, `dock` | `list` |
154
+ | `highlight` | `tint`, `fill`, `bar` | `tint` |
155
+ | `palette` | `primary`, `secondary`, `accent`, `neutral`, and the four statuses | inherited |
156
+ | `radius` | `l1`, `l2`, `l3`, `full` | `l2` |
157
+ | `guide` | `solid`, `dashed`, `dotted`, `none` | `solid` |
158
+ | `iconic` | `true` | off |
159
+ | `reveal` | `always`, `hover` | `always` |
160
+ | `effect` | `glow` | none |
161
+
162
+ | Part | Element | What it renders |
163
+ | --------------- | -------- | -------------------------------------------------- |
164
+ | `Root` | `ul` | The list, which receives the variants |
165
+ | `Item` | `li` | One row |
166
+ | `Link` | `a` | A row's link |
167
+ | `Mark` | `span` | A leading mark that is not an icon, an avatar |
168
+ | `Action` | `button` | A control at the end of a row |
169
+ | `Badge` | `span` | A count at the end of a row |
170
+ | `Branch` | `li` | A row that expands, and its state |
171
+ | `Trigger` | `button` | The row that expands and collapses |
172
+ | `Indicator` | `span` | The icon that rotates as a branch opens |
173
+ | `Content` | `ul` | The nested list of a branch |
174
+ | `Skeleton` | `li` | A placeholder row while the list loads |
175
+ | `PropsProvider` | none | The default `iconic` and `size` of the lists below |
176
+
177
+ Set `aria-current="page"` on the link to the current page. A screen reader announces the attribute
178
+ and `highlight` styles it, so the two cannot disagree. A row is `fg.muted`, and the current row is
179
+ `fg` and semibold. A row's label, inline inset and gap are two sizes below the row's size: 12.6px,
180
+ 8px and 4px at `md`. The rows of an `md` list are 4px apart and those of an `lg` list 6px. An `sm`
181
+ list sets its 24px rows with no gap between them, for a dense list such as a catalogue's.
182
+
183
+ The list does not set a landmark, because a page renders more than one list. Render a `nav` with an
184
+ `aria-label` around the list. Do not pass `as="nav"` to the root: the rows are `li` elements, and a
185
+ `nav` holding them directly is not read as a list.
186
+
187
+ `palette` sets the palette the highlight, the hover fill and the branch rows read. Without it the
188
+ list inherits the palette of the surrounding element. `effect="glow"` adds a glow around the current
189
+ row.
190
+
191
+ `NavList.Branch` takes `open`, `defaultOpen`, `onOpenChange` and `id`. Pass `open` to keep the
192
+ branch that contains the current page open across navigations. The machine sets `aria-expanded` and
193
+ `aria-controls` on the trigger, so do not set them yourself.
194
+
195
+ `NavList.Action` is a `button` with `type="button"`. Pass the icon as its child and an `aria-label`
196
+ that includes the row, such as `Rename Invoices`. A screen reader has no other text that ties the
197
+ control to its row.
198
+
199
+ Set `iconic` for a list collapsed to a rail. Each row becomes a square that contains its icon. The
200
+ counts, the controls, the indicators and the nested rows are hidden. The text stays in the
201
+ accessibility tree, so a screen reader still reads the name of every row. The rail centres its
202
+ squares, so render it in a container as wide as the rail. Pass `iconic` from the component that
203
+ collapses. The list does not measure anything itself.
204
+
205
+ A row without an icon leads with `NavList.Mark`, such as an avatar with the row's initials. The mark
206
+ is `aria-hidden`, and an iconic list keeps it visible in place of the icon.
207
+
208
+ `NavList.PropsProvider` sets `iconic` and `size` once for every list below it, and a list's own
209
+ props apply over it. The screen package's sidebar renders one. On an iconic list, a link with
210
+ `tooltip` shows those words in a tooltip beside its icon on hover and on focus. The tooltip is
211
+ portalled to the document, because a scrolling column clips what overflows it.
212
+
213
+ Inside a filter scope from `@stealthscale/hooks`, such as a sidebar's search, an `Item` or a
214
+ `Branch` whose words do not contain the query is hidden, and a branch is open while the scope has a
215
+ query. While the query is active, a press on a branch's trigger leaves the branch open. A cleared
216
+ query returns each branch to the state it had before the query, so the branch of the current page is
217
+ open again.
218
+
219
+ `reveal="hover"` hides each control until its row is hovered. The control on the current row stays
220
+ visible. The others also appear while any element in their row has focus and under a coarse pointer,
221
+ so a keyboard and a touch screen both reach them.
222
+
223
+ Set `variant="dock"` for a few links across the foot of a screen. Each link takes an equal share of
224
+ the row and shows its icon over its label. The bottom padding includes the safe area a device
225
+ reserves for a home indicator. Do not put a branch in a dock, because it has no room to open.
226
+
227
+ The list sizes an `svg` that is a direct child of a link or a trigger. In a list, the icon takes the
228
+ icon size one smaller than the row's size. In a dock, it takes `icon.lg`. Pass the icon without a
229
+ size.
230
+
231
+ Nested rows use the same `Item` and `Link`. `Content` sets `fg.subtle`, and the nested rows inherit
232
+ it. A line runs down the start of a nested list. When the trigger leads with an icon, the line is
233
+ aligned with the icon's centre. `guide` sets the line's style, and `none` removes it.
234
+
235
+ Set `aria-busy` on the root while `NavList.Skeleton` rows render in place of the loading rows, and
236
+ put the feedback package's `Skeleton` inside each one.
237
+
238
+ ## NavigationMenu
239
+
240
+ Renders a site's navigation landmark: a bar of links and of buttons that open panels of links.
241
+ Compose it as `NavigationMenu.Root` around a `NavigationMenu.List` of items. An item contains a
242
+ trigger and its panel, or a link.
243
+
244
+ ```tsx
245
+ import { NavigationMenu } from "@stealthscale/component-navigation";
246
+
247
+ <NavigationMenu.Root aria-label="Site">
248
+ <NavigationMenu.List>
249
+ <NavigationMenu.Item value="products">
250
+ <NavigationMenu.Trigger>
251
+ Products
252
+ <ChevronDownIcon />
253
+ </NavigationMenu.Trigger>
254
+ <NavigationMenu.Content>
255
+ <NavigationMenu.Link href="/payments">
256
+ <CreditCardIcon />
257
+ <Strong weight="medium">Payments</Strong>
258
+ <Span tone="muted">Accept cards and bank debits in one checkout.</Span>
259
+ </NavigationMenu.Link>
260
+ </NavigationMenu.Content>
261
+ </NavigationMenu.Item>
262
+ <NavigationMenu.Item value="pricing">
263
+ <NavigationMenu.Link current href="/pricing">
264
+ Pricing
265
+ </NavigationMenu.Link>
266
+ </NavigationMenu.Item>
267
+ <NavigationMenu.Indicator />
268
+ </NavigationMenu.List>
269
+ <NavigationMenu.ViewportPositioner align="start">
270
+ <NavigationMenu.Viewport />
271
+ </NavigationMenu.ViewportPositioner>
272
+ </NavigationMenu.Root>;
273
+ ```
274
+
275
+ | Axis | Values | Default |
276
+ | --------- | ------------------------------------------------------------------ | --------- |
277
+ | `size` | `sm`, `md`, `lg` | `md` |
278
+ | `palette` | `primary`, `secondary`, `accent`, `neutral`, and the four statuses | `primary` |
279
+
280
+ | Part | Element | What it renders |
281
+ | -------------------- | -------- | ------------------------------------------------------- |
282
+ | `Root` | `nav` | The landmark, which starts the machine |
283
+ | `List` | `ul` | The bar of items |
284
+ | `Item` | `li` | A trigger and its panel, or a link |
285
+ | `Trigger` | `button` | The button that opens its item's panel |
286
+ | `Content` | `div` | The panel of an item, `hidden` while the item is closed |
287
+ | `Link` | `a` | A link in the bar or in a panel |
288
+ | `Indicator` | `li` | The `aria-hidden` bar under the open trigger |
289
+ | `ViewportPositioner` | `div` | The box that places the viewport under the bar |
290
+ | `Viewport` | `div` | The surface every panel shows in |
291
+
292
+ Each trigger follows the WAI-ARIA disclosure navigation pattern: a `button` with `aria-expanded` and
293
+ `aria-controls`, and no `menu` role. A mouse over a trigger opens its panel after `openDelay`, 200ms
294
+ by default, and a press toggles it. A mouse that leaves the trigger and the panel closes it after
295
+ `closeDelay`, 300ms by default. The arrow keys move along the bar, Home and End move to its ends,
296
+ and ArrowDown moves into an open panel, or ArrowRight in a vertical menu. The arrow keys move
297
+ between a panel's links. Escape and a press outside close the panel and return focus to its trigger.
298
+
299
+ `NavigationMenu.Root` takes the machine's options: `value` or `defaultValue`, `onValueChange`,
300
+ `openDelay`, `closeDelay`, `orientation`, `dir`, `disableHoverTrigger`, `disableClickTrigger` and
301
+ `disablePointerLeaveClose`. Name the landmark with `aria-label`. The machine's `translations` are
302
+ not accepted. `disablePointerLeaveClose` keeps a panel open when the pointer leaves the panel or the
303
+ viewport. The root sets the machine's value to an item open at mount after the first commit, because
304
+ the machine measures a trigger and tracks Escape and presses outside only when its value changes.
305
+ `onValueChange` receives each change the menu makes, and never the value the menu mounts with.
306
+
307
+ Without a viewport, each panel opens one gap under its trigger. A panel is as wide as its content,
308
+ at most `sizes.2xl` and 20px narrower than the window. With `ViewportPositioner` and `Viewport`,
309
+ every panel shows inside the viewport, which moves to the open trigger and resizes to the panel. The
310
+ panel that closes fades over the panel that opens. `align` on the positioner lines the viewport up
311
+ with the open trigger: `start` joins their start edges, `center` their centres and `end` their end
312
+ edges. The default is `center`. The machine keeps the viewport 10px inside the window. Render the
313
+ viewport on the first render, because the machine looks for it once, as it starts.
314
+
315
+ A closed panel is in the document with `hidden`, so a crawler reads its links. While the menu
316
+ renders a viewport, the machine renders a visually hidden proxy after each open trigger, and an
317
+ element whose `aria-owns` references the panel. The proxy is focusable and `aria-hidden`. Tab on the
318
+ trigger focuses the proxy, which moves focus to the panel's first link, and Tab on the panel's last
319
+ link moves focus past the proxy to the next item. axe reports `aria-hidden-focus` for the proxy
320
+ while a panel is open.
321
+
322
+ `NavigationMenu.Link` takes `current`, which sets `aria-current="page"`, and `closeOnClick`, true by
323
+ default. `onSelect` runs on a press before the menu closes, and a call to `preventDefault` on its
324
+ event keeps the menu open. A press with the meta key keeps the menu open, because the link opens in
325
+ another tab. Pass a router's link through `as`.
326
+
327
+ A panel link places a leading icon in a column of its own and every other child in the column beside
328
+ it, so a title and a description start at one edge. Pass `Strong` and `Span tone="muted"` from the
329
+ typography package for the two. The recipe sizes the icon from the icon scale at the menu's size. A
330
+ trigger's trailing icon is one size smaller and turns over while the panel is open.
331
+
332
+ `NavigationMenu.Indicator` is a bar along the open trigger's bottom edge. In a list with an
333
+ indicator the items are not positioned, because the machine measures a trigger against its
334
+ positioned ancestor and the indicator reads that place against the list. A panel in place then opens
335
+ under the trigger's measured place, and a panel that closes while another opens hides at once.
336
+ `palette` colors the indicator and a link to the current page. Under forced colors the indicator
337
+ paints `CanvasText` and an open trigger fills with `Highlight`.
338
+
339
+ The bar wraps onto a second row when its items do not fit. The recipe offers no `Arrow` part,
340
+ because the indicator is a bar, and no item indicator, because the trigger turns its own trailing
341
+ icon.
342
+
343
+ ## Pagination
344
+
345
+ Renders a navigation landmark of page buttons between the buttons that move a page back and forward.
346
+ Compose it as `Pagination.Root` around the triggers and `Pagination.Items`, or around the triggers
347
+ and `Pagination.PageText` for a row without page buttons.
348
+
349
+ ```tsx
350
+ import { Pagination } from "@stealthscale/component-navigation";
351
+
352
+ <Pagination.Root count={240} onPageChange={({ page }) => setPage(page)} page={page} pageSize={10}>
353
+ <Pagination.PrevTrigger>
354
+ <ChevronLeftIcon />
355
+ </Pagination.PrevTrigger>
356
+ <Pagination.Items />
357
+ <Pagination.NextTrigger>
358
+ <ChevronRightIcon />
359
+ </Pagination.NextTrigger>
360
+ </Pagination.Root>;
361
+ ```
362
+
363
+ | Axis | Values | Default |
364
+ | ------ | ---------------- | ------- |
365
+ | `size` | `sm`, `md`, `lg` | `md` |
366
+
367
+ | Part | Element | What it renders |
368
+ | -------------- | --------------- | --------------------------------------------------------- |
369
+ | `Root` | `nav` | The landmark, which starts the machine |
370
+ | `Items` | none | A page per page shown, a mark per run left out, a summary |
371
+ | `Item` | `button` or `a` | One page |
372
+ | `Ellipsis` | `span` | The `aria-hidden` mark for a run of pages left out |
373
+ | `PrevTrigger` | `button` or `a` | The button that moves one page back |
374
+ | `NextTrigger` | `button` or `a` | The button that moves one page forward |
375
+ | `FirstTrigger` | `button` or `a` | The button that moves to the first page |
376
+ | `LastTrigger` | `button` or `a` | The button that moves to the last page |
377
+ | `PageText` | `output` | The current page in words |
378
+
379
+ Every page and trigger is the actions package's square `Button`. The root passes `size`, `variant`
380
+ and `palette` to every button inside it. `variant` takes any look of the button and defaults to
381
+ `ghost`, and `palette` takes any palette. The current page takes `aria-current="page"`, which every
382
+ look of the button marks as on.
383
+
384
+ `Pagination.Root` takes the machine's options: `count`, `page` or `defaultPage`, `pageSize` or
385
+ `defaultPageSize`, `siblingCount` and `boundaryCount` (both 1 by default), `onPageChange`,
386
+ `onPageSizeChange`, `type` and `getPageUrl`. Its `aria-label` defaults to `Pagination`. The
387
+ machine's `translations` are not accepted, because every word is a prop with an English default. A
388
+ page is named `Page N` unless `Items` takes a `label` function, and each trigger takes `label` in
389
+ place of `Previous page`, `Next page`, `First page` or `Last page`. Pass each trigger's glyph as its
390
+ child.
391
+
392
+ A trigger at an end sets `aria-disabled` in place of `disabled`, so a press that moves to the last
393
+ page leaves focus on the trigger. The machine ignores a press on a trigger at an end.
394
+
395
+ While its children overflow its row at their natural width, the root sets `data-crowded`. The recipe
396
+ then hides the pages and the marks and shows a summary of the current page between the triggers,
397
+ such as `Page 12 of 24`. `summary` on `Items` sets its format. The summary is an `output`, so a
398
+ screen reader reads the new page after a press while the summary shows. The root takes no `ref`,
399
+ because it attaches its own.
400
+
401
+ `PageText` shows the current page in the format `format` names: `compact` renders `Page 12 of 24`,
402
+ `short` renders `12 / 24`, and `long` renders the range of items, `111–120 of 240`. A count of zero
403
+ renders `Page 0 of 0`, `0 / 0` and `0–0 of 0`. A function receives `count`, `page`, `pageRange` and
404
+ `totalPages` and returns the words in any language. `summary` takes the same values.
405
+
406
+ With `type="link"`, every page and trigger is a link to the address `getPageUrl` returns, so a
407
+ reader can open a page in a new tab. A press follows the link and leaves the page unchanged: pass
408
+ `page` from the address, as an application reads it from its router. A trigger at an end has no
409
+ address, so it takes the link role and a tab stop and keeps focus.
410
+
411
+ ## Toc
412
+
413
+ Renders a list of links to the headings on a page and marks the headings in view. Compose it as
414
+ `Toc.Root` around a `Toc.Title` and a `Toc.List` whose first child is `Toc.Indicator`.
415
+
416
+ ```tsx
417
+ import { Toc } from "@stealthscale/component-navigation";
418
+
419
+ const items = [
420
+ { depth: 2, value: "install" },
421
+ { depth: 3, value: "peers" },
422
+ { depth: 2, value: "usage" },
423
+ ];
424
+
425
+ <Toc.Root items={items} placement="aside" rootMargin="0px">
426
+ <Toc.Title>On this page</Toc.Title>
427
+ <Toc.List>
428
+ <Toc.Indicator />
429
+ {items.map((item) => (
430
+ <Toc.Item item={item} key={item.value}>
431
+ <Toc.Link href={`#${item.value}`} item={item}>
432
+ {titles[item.value]}
433
+ </Toc.Link>
434
+ </Toc.Item>
435
+ ))}
436
+ </Toc.List>
437
+ </Toc.Root>;
438
+ ```
439
+
440
+ | Axis | Values | Default |
441
+ | ----------- | ------------------------------------------------------------------ | --------- |
442
+ | `size` | `sm`, `md`, `lg` | `md` |
443
+ | `placement` | `inline`, `aside` | `inline` |
444
+ | `palette` | `primary`, `secondary`, `accent`, `neutral`, and the four statuses | `primary` |
445
+
446
+ | Part | Element | What it renders |
447
+ | ----------- | ------- | ---------------------------------------------------- |
448
+ | `Root` | `nav` | The landmark, named by the title |
449
+ | `Title` | `div` | The text the landmark's `aria-labelledby` references |
450
+ | `List` | `ul` | One row per heading |
451
+ | `Item` | `li` | One row, indented by its heading's depth |
452
+ | `Link` | `a` | The link to one heading |
453
+ | `Indicator` | `li` | The bar next to the rows whose heading is in view |
454
+
455
+ Each item names a heading by `value`, the element ID of the heading, and `depth`, the heading level.
456
+ The root observes those elements with an `IntersectionObserver` and sets `aria-current="location"`
457
+ on the links of the headings in view. A click scrolls the page to the heading. When the page scrolls
458
+ inside an element, pass `scrollEl` so the root observes and scrolls that element.
459
+
460
+ `Toc.Root` takes the machine options next to `items`: `rootMargin` and `threshold` for the observer
461
+ band, `autoScroll` to keep the active row visible in a scrolling list, `scrollBehavior`,
462
+ `onActiveChange`, and `activeIds` or `defaultActiveIds`. The machine's default band excludes the
463
+ bottom of the viewport, so a short last section is never marked. Pass `rootMargin="0px"` for a page
464
+ of sections.
465
+
466
+ `placement="aside"` makes the root sticky below the application shell's pinned bars and caps it at
467
+ the viewport height. A longer list scrolls in the primitives package's scroll area inside the root,
468
+ whose viewport takes no tab stop. The root is at least 11rem wide and never wider than its
469
+ container. `palette` sets the indicator and the focus ring. Under forced colors the indicator paints
470
+ `CanvasText`.
105
471
 
106
472
  ## Licence
107
473