@stealthscale/component-navigation 0.1.0 → 0.3.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.
@@ -0,0 +1,1548 @@
1
+ import { HIGHLIGHTS, PALETTES, axis, below, controlSizes, cornerVariants, defineRecipe, defineSlotRecipe, dense, floating, gapSizes, highlightVariants, interactive, link, motion, onSlot, onSlots, paletteVariants, row, sizeVariants, textSizes, truncate } from "@stealthscale/theme/authoring";
2
+ //#region src/breadcrumb/recipe.ts
3
+ /**
4
+ * Declares the breadcrumb slot recipe for an ordered list of links from the site root to the
5
+ * current page, with a separator between each pair.
6
+ *
7
+ * @remarks
8
+ * The separator is a list row between two items, so a screen reader counts the crumbs without
9
+ * the separators. The size axis sets the text style on the root and the gap on the list, and
10
+ * every part inherits the text size. The axis stops at `xl`, because it reads the body text
11
+ * style. The links are muted and the current page is in the default ink. The recipe has no
12
+ * `palette` axis, because a trail has no color of its own. It has no `effect` axis, because it
13
+ * has no box.
14
+ */
15
+ /**
16
+ * Breadcrumb slot recipe, at the md size in the plain look by default.
17
+ */
18
+ const recipe$5 = defineSlotRecipe({
19
+ base: {
20
+ currentLink: {
21
+ alignItems: "center",
22
+ display: "inline-flex",
23
+ gap: dense("{spacing.gap.xs}")
24
+ },
25
+ ellipsis: {
26
+ alignItems: "center",
27
+ color: "fg.muted",
28
+ display: "inline-flex"
29
+ },
30
+ item: {
31
+ alignItems: "center",
32
+ display: "inline-flex"
33
+ },
34
+ link: {
35
+ focusVisibleRing: "outside",
36
+ alignItems: "center",
37
+ borderRadius: "l1",
38
+ cursor: "button",
39
+ display: "inline-flex",
40
+ focusRingColor: "colorPalette.focusRing",
41
+ gap: dense("{spacing.gap.xs}"),
42
+ textDecoration: "none"
43
+ },
44
+ list: {
45
+ alignItems: "center",
46
+ display: "flex",
47
+ flexWrap: "wrap",
48
+ listStyle: "none",
49
+ margin: "0",
50
+ padding: "0",
51
+ wordBreak: "break-word"
52
+ },
53
+ separator: {
54
+ _rtl: { rotate: "180deg" },
55
+ color: "fg.muted"
56
+ }
57
+ },
58
+ className: "breadcrumb",
59
+ defaultVariants: {
60
+ size: "md",
61
+ variant: "plain"
62
+ },
63
+ jsx: [/^Breadcrumb(\.\w+)?$/u],
64
+ slots: [
65
+ "root",
66
+ "list",
67
+ "item",
68
+ "link",
69
+ "currentLink",
70
+ "ellipsis",
71
+ "separator"
72
+ ],
73
+ variants: {
74
+ /**
75
+ * The text style of the root and the gap between list rows.
76
+ */
77
+ size: onSlots({
78
+ list: gapSizes([
79
+ "xs",
80
+ "sm",
81
+ "md",
82
+ "lg",
83
+ "xl"
84
+ ]),
85
+ root: textSizes("body")
86
+ }),
87
+ /**
88
+ * Whether the links show an underline at rest or only on hover.
89
+ */
90
+ variant: {
91
+ plain: {
92
+ currentLink: { color: "fg" },
93
+ link: {
94
+ _hover: {
95
+ color: "fg",
96
+ textDecoration: "underline"
97
+ },
98
+ color: "fg.muted"
99
+ }
100
+ },
101
+ underline: {
102
+ currentLink: { color: "fg" },
103
+ link: {
104
+ _hover: { color: "fg" },
105
+ color: "fg.muted",
106
+ textDecoration: "underline"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ });
112
+ //#endregion
113
+ //#region src/link/recipe.ts
114
+ /**
115
+ * Declares the link recipe, which renders an inline anchor in the theme's link ink with an
116
+ * underline at rest or on hover.
117
+ *
118
+ * @remarks
119
+ * The ink, the visited ink, the cursor and the focus ring come from `link()`, so a theme that
120
+ * restates `fg.link` restyles every link. The root is `inline-flex` with centred items, so an
121
+ * icon next to the text centres on the text instead of standing on the baseline. The recipe has
122
+ * no `effect` axis, because a link has no box for a glow or a pulse to surround. `staticCss`
123
+ * lists every palette, because `LinkPropsProvider` can set the value at run time. It lists
124
+ * `inherit` and every variant as well. A router link renders the link through `createLink`, whose
125
+ * result has another name, so an application's stylesheet compiler does not read the values a
126
+ * router link passes.
127
+ */
128
+ /**
129
+ * Link recipe, underlined at rest by default.
130
+ */
131
+ const recipe$4 = defineRecipe({
132
+ base: {
133
+ ...link(),
134
+ alignItems: "center",
135
+ borderRadius: "l1",
136
+ display: "inline-flex",
137
+ gap: dense("{spacing.gap.xs}")
138
+ },
139
+ className: "link",
140
+ defaultVariants: { variant: "underline" },
141
+ jsx: [/^Link$/u],
142
+ staticCss: [
143
+ { inherit: ["true"] },
144
+ { variant: ["plain", "underline"] },
145
+ { palette: [...PALETTES] }
146
+ ],
147
+ variants: {
148
+ /**
149
+ * Whether the link inherits the text color in place of the link ink.
150
+ *
151
+ * @remarks
152
+ * Use it for a card title or a brand name, where the context already marks the text as a
153
+ * link. The hover underline and the focus ring still apply. A `palette` set on the same link
154
+ * takes precedence, because the compiler emits the palette axis after this one.
155
+ */
156
+ inherit: { true: {
157
+ _visited: { color: "inherit" },
158
+ color: "inherit"
159
+ } },
160
+ /**
161
+ * The semantic palette the ink and the focus ring read from.
162
+ *
163
+ * @remarks
164
+ * Each value sets `colorPalette` and writes the ink from the palette's `fg` role, visited
165
+ * state included. Without a palette the link keeps `fg.link`, which a theme can restate
166
+ * apart from `accent.fg`.
167
+ */
168
+ palette: axis(PALETTES, (palette) => ({
169
+ _visited: { color: "colorPalette.fg" },
170
+ color: "colorPalette.fg",
171
+ colorPalette: palette
172
+ }))(),
173
+ /**
174
+ * Whether the underline shows at rest or only on hover.
175
+ *
176
+ * @remarks
177
+ * `underline` is the default. Without an underline a link in running text differs from the
178
+ * text only by ink, and the link ink measured 1.64:1 against body text on the ink theme,
179
+ * under the 3:1 that WCAG technique G183 asks for. `plain` is for a link whose context marks
180
+ * it: a card title, a navigation row, a brand name. `plain` restates the hover underline,
181
+ * because its `none` is in the variants layer and overrides the base `_hover` rule.
182
+ */
183
+ variant: {
184
+ plain: {
185
+ _hover: { textDecoration: "underline" },
186
+ textDecoration: "none"
187
+ },
188
+ underline: { textDecoration: "underline" }
189
+ }
190
+ }
191
+ });
192
+ //#endregion
193
+ //#region src/nav-list/metrics.ts
194
+ /**
195
+ * Computes the geometry the navigation list's size axis is written from.
196
+ *
197
+ * @remarks
198
+ * The module covers the row, its leading icon, the end column and the nested list's margin. It is
199
+ * a separate module so the recipe is under the 300-line limit.
200
+ */
201
+ /**
202
+ * Maps each row size to the size token of its height: 24px, 32px and 40px at the foundation's
203
+ * metrics.
204
+ *
205
+ * @remarks
206
+ * The `md` and `lg` rows read the control scale, so a sidebar's rows are as tall as its
207
+ * controls. The `sm` row reads the tag scale's `md` step, the WCAG 2.5.8 target size, for a
208
+ * dense list such as a catalogue's: with no gap between them its rows are 24px apart.
209
+ */
210
+ const ROW = {
211
+ lg: "control.md",
212
+ md: "control.xs",
213
+ sm: "tag.md"
214
+ };
215
+ /**
216
+ * Returns the width and height of a row's leading icon, one size smaller than the row on the icon
217
+ * scale.
218
+ *
219
+ * @param size - The row's size.
220
+ * @returns The icon's width and height.
221
+ */
222
+ function glyph(size) {
223
+ return dense(`{sizes.icon.${below(size)}}`);
224
+ }
225
+ /**
226
+ * Returns the scale step a row's label, inset and gap read: two steps below the row, and never
227
+ * below `xs`.
228
+ *
229
+ * @remarks
230
+ * A middle row reads `xs`: a 12.6px label, an 8px inset and a 4px gap in a 32px row. A large row
231
+ * reads `sm`, and a small row reads `xs` like a middle one.
232
+ * @param size - The row's size.
233
+ * @returns The step.
234
+ */
235
+ function lighter(size) {
236
+ return below(below(size));
237
+ }
238
+ /**
239
+ * Returns the gap between a list's rows. An `sm` list sets none, so the rows of a dense list
240
+ * touch, and an `md` or `lg` list sets the gap two sizes smaller than the row.
241
+ *
242
+ * @param size - The row's size.
243
+ * @returns The gap, multiplied by the density.
244
+ */
245
+ function between(size) {
246
+ return size === "sm" ? "0" : dense(`{spacing.gap.${lighter(size)}}`);
247
+ }
248
+ /**
249
+ * Returns the inline inset of a row: the space before its icon and after its end column.
250
+ *
251
+ * @param size - The row's size.
252
+ * @returns The inset, multiplied by the density.
253
+ */
254
+ function inset(size) {
255
+ return dense(`{spacing.inset.${lighter(size)}}`);
256
+ }
257
+ /**
258
+ * Returns the size styles of a link or a trigger.
259
+ *
260
+ * @remarks
261
+ * The label, the inset and the gap are two sizes smaller than the row, so a row is lighter than
262
+ * a button of the same height. The current row is semibold here and not in the base, because the
263
+ * label text style sets its own weight and the compiler emits variants after the base. The
264
+ * `highlight` axis sets the current row's ink, and the compiler emits `size` after `highlight`,
265
+ * so this function sets no ink. The row is at least `sizes.6` (24px) tall at any density, the
266
+ * WCAG 2.5.8 target size. The recipe sizes the icon, so the line down a nested list is aligned
267
+ * with the icon's centre.
268
+ * @param size - The row's size.
269
+ * @returns The styles of a link or a trigger at that size.
270
+ */
271
+ function rowed(size) {
272
+ return {
273
+ textStyle: `label.${lighter(size)}`,
274
+ _currentPage: { fontWeight: "semibold" },
275
+ "& > svg": {
276
+ boxSize: glyph(size),
277
+ flexShrink: "0"
278
+ },
279
+ blockSize: `max({sizes.6}, ${dense(`{sizes.${ROW[size]}}`)})`,
280
+ gap: dense(`{spacing.gap.${lighter(size)}}`),
281
+ paddingInline: inset(size)
282
+ };
283
+ }
284
+ /**
285
+ * Returns the side of the end column's square: the tag size one step below the row, and never
286
+ * under `sizes.6`.
287
+ *
288
+ * @remarks
289
+ * The control is positioned over the row's link, so the WCAG 2.5.8 spacing exception does not
290
+ * apply and the control needs a 24px target. The square is at least 24px at every size and
291
+ * density.
292
+ */
293
+ function square(size) {
294
+ return `max({sizes.6}, ${dense(`{sizes.tag.${below(size)}}`)})`;
295
+ }
296
+ /**
297
+ * Returns the styles that make the end column a square with its content centred.
298
+ *
299
+ * @remarks
300
+ * The count, the control and the indicator share this square, so their centres are at one
301
+ * distance from the row's end. The square sets a minimum width, so a three-digit count extends
302
+ * it towards the row's start.
303
+ * @param size - The row's size.
304
+ * @returns The styles of the count, the control or the indicator at that size.
305
+ */
306
+ function trailing(size) {
307
+ return {
308
+ alignItems: "center",
309
+ blockSize: square(size),
310
+ display: "flex",
311
+ justifyContent: "center",
312
+ minInlineSize: square(size)
313
+ };
314
+ }
315
+ /**
316
+ * Returns the space between the end column and the row's edge, equal to the row's inset at the
317
+ * start.
318
+ *
319
+ * @param size - The row's size.
320
+ * @returns The end margin of the count or the control.
321
+ */
322
+ function tucked(size) {
323
+ return { marginInlineEnd: inset(size) };
324
+ }
325
+ /**
326
+ * Returns the end padding a row keeps for a part positioned over it: the inset, the gap and the
327
+ * square.
328
+ *
329
+ * @remarks
330
+ * The count and the control are positioned over the row, and the padding keeps the row's text
331
+ * clear of them.
332
+ * @param size - The row's size.
333
+ * @returns The row's end padding.
334
+ */
335
+ function reserved(size) {
336
+ return `calc(${inset(size)} + ${dense(`{spacing.gap.${lighter(size)}}`)} + ${square(size)})`;
337
+ }
338
+ /**
339
+ * Returns the start margin that puts the line down a nested list on the centre of the trigger's
340
+ * icon.
341
+ *
342
+ * @remarks
343
+ * The margin is the trigger's inset plus half the icon, less half the line. At the middle size
344
+ * the line is 16px from the list's start, on the centre of a 16px icon that starts at 8px. A
345
+ * trigger without a leading icon puts the line at its inset, under the start of its text.
346
+ * @param size - The row's size.
347
+ * @returns The nested list's start margin.
348
+ */
349
+ function centred(size) {
350
+ return `calc(${inset(size)} + ${glyph(size)} / 2 - {borderWidths.hairline} / 2)`;
351
+ }
352
+ //#endregion
353
+ //#region src/nav-list/recipe.ts
354
+ /**
355
+ * Declares the navigation list's slot recipe: rows of links, branches that expand a nested list,
356
+ * and the count, control and indicator at the end of a row.
357
+ *
358
+ * @remarks
359
+ * A row reads the theme's `row` fragment and no second recipe, so one rule sets its height. A
360
+ * nested list reuses `Item` and `Link`, and `Content` sets the subtle ink the nested rows
361
+ * inherit. `Content` animates between the heights the collapsible machine measures, and the
362
+ * indicator rotates a quarter turn as the branch opens. `iconic` renders every row as a square
363
+ * containing its icon, with the text kept for screen readers, and removes the end padding, so a
364
+ * row with a control is as wide as a row without one. The count, the control and the indicator
365
+ * share one column at the end of a row.
366
+ */
367
+ /**
368
+ * The recipe's class name, used to build the selectors that reach from one part to another.
369
+ *
370
+ * @remarks
371
+ * The binding writes one class per part, such as `nav-list__action`, and no part attribute, so a
372
+ * selector across parts targets the class, built from this constant. The keyboard module reads
373
+ * the same constant to find the rows.
374
+ */
375
+ const CLASS$1 = "nav-list";
376
+ /**
377
+ * Selects the control at the end of a row, from a rule on the row.
378
+ */
379
+ const ACTION = `.${CLASS$1}__action`;
380
+ /**
381
+ * Selects either the count or the control at the end of a row.
382
+ */
383
+ const TRAILED = `:is(${ACTION}, ${`.${CLASS$1}__badge`})`;
384
+ /**
385
+ * Styles a row of the iconic list: a square containing the icon, with the text hidden visually.
386
+ *
387
+ * @remarks
388
+ * The text remains in the accessibility tree through `srOnly`, because a link without a name is
389
+ * announced as `link` alone. `srOnly` also takes the text out of the flow, where clipping by
390
+ * overflow would leave it inside the square and squeeze the icon. Only an `svg` child and a mark
391
+ * remain visible. The square has no inline padding, so the icon is centred in it.
392
+ */
393
+ const SQUARED = {
394
+ [`& > :not(svg, ${`.${CLASS$1}__mark`})`]: { srOnly: true },
395
+ aspectRatio: "square",
396
+ inlineSize: "auto",
397
+ justifyContent: "center",
398
+ paddingInline: "0"
399
+ };
400
+ /**
401
+ * Styles every row a person presses: the theme's row fragment at the full width of the list, with
402
+ * a minimum width of zero so the text can truncate.
403
+ *
404
+ * @remarks
405
+ * A row takes the list's muted ink, and the current row takes `fg`. The `highlight` axis writes
406
+ * the current row's ink again in a later layer, so the fill highlight's contrast ink applies over
407
+ * `fg`. The bar highlight writes no ink and keeps `fg`.
408
+ */
409
+ const PRESSABLE = {
410
+ ...row(),
411
+ ...interactive(),
412
+ _currentPage: { color: "fg" },
413
+ _hover: { background: "colorPalette.subtle" },
414
+ cursor: "button",
415
+ inlineSize: "full",
416
+ justifyContent: "flex-start",
417
+ minInlineSize: "0"
418
+ };
419
+ /**
420
+ * Positions a part at the end of a row: absolutely, over the row, centred on the block axis.
421
+ *
422
+ * @remarks
423
+ * The part is out of the flow so the highlight fill spans the whole row. In the flow, the fill
424
+ * would stop where the control begins and leave a notch at the end of the row.
425
+ */
426
+ const BESIDE = {
427
+ insetInlineEnd: "0",
428
+ position: "absolute",
429
+ top: "50%",
430
+ translate: "0 -50%"
431
+ };
432
+ /**
433
+ * Styles the control at the end of a row as an unfilled square button that fills on hover.
434
+ *
435
+ * @remarks
436
+ * The end column sizes the action, so it fits inside the row at every size. The hover fill is
437
+ * `emphasized`, two steps darker than the row's `subtle` hover and one step darker than the tint
438
+ * highlight, so a hovered control shows an edge on its hovered row.
439
+ */
440
+ const ACTING = {
441
+ ...BESIDE,
442
+ ...interactive(),
443
+ _hover: {
444
+ background: "colorPalette.emphasized",
445
+ color: "fg"
446
+ },
447
+ appearance: "none",
448
+ background: "transparent",
449
+ borderRadius: "l1",
450
+ borderStyle: "none",
451
+ color: "fg.muted",
452
+ padding: "0"
453
+ };
454
+ /**
455
+ * Selects the row a person presses, a link or a trigger, from a rule on the item.
456
+ */
457
+ const PRESSED = `:is(.${CLASS$1}__link, .${CLASS$1}__trigger)`;
458
+ /**
459
+ * Selects the row for the current page by its `aria-current` attribute.
460
+ */
461
+ const CURRENT = "[aria-current=\"page\"]";
462
+ /**
463
+ * Selects a branch's nested list whose trigger leads with an icon, from a rule on the branch.
464
+ */
465
+ const UNDER_ICON = `&:has(> .${CLASS$1}__trigger > svg:first-child) > .${CLASS$1}__content`;
466
+ /**
467
+ * The glow the `effect` axis puts around the current row.
468
+ */
469
+ const GLOWING = { _currentPage: { layerStyle: "glow.sm" } };
470
+ /**
471
+ * Styles a column of rows at the md size in muted ink, tinting the current row.
472
+ *
473
+ * @remarks
474
+ * The rows take `fg.muted` from the root, a nested list's rows take `fg.subtle`, and the current
475
+ * row takes `fg`. The count sets its own ink. The count is a sibling of the link, so it does not
476
+ * inherit the ink of the link's highlight, and the compound for the fill highlight sets the
477
+ * fill's contrast ink on it. `staticCss` lists every size and the iconic list, which a sidebar
478
+ * passes to its lists while it runs.
479
+ */
480
+ const recipe$3 = defineSlotRecipe({
481
+ base: {
482
+ action: ACTING,
483
+ badge: {
484
+ ...BESIDE,
485
+ color: "fg.muted",
486
+ pointerEvents: "none"
487
+ },
488
+ branch: {
489
+ listStyle: "none",
490
+ minInlineSize: "0"
491
+ },
492
+ content: {
493
+ _closed: { animationStyle: "collapse.out" },
494
+ _open: { animationStyle: "collapse.in" },
495
+ "&[hidden]": { display: "none" },
496
+ borderColor: "border",
497
+ borderInlineStartWidth: "hairline",
498
+ color: "fg.subtle",
499
+ display: "flex",
500
+ flexDirection: "column",
501
+ listStyle: "none",
502
+ margin: "0",
503
+ minInlineSize: "0",
504
+ overflow: "hidden",
505
+ padding: "0"
506
+ },
507
+ indicator: {
508
+ _motionReduce: { transitionDuration: "0s" },
509
+ _open: { rotate: "90deg" },
510
+ _rtl: {
511
+ _open: { rotate: "90deg" },
512
+ rotate: "180deg"
513
+ },
514
+ color: "fg.muted",
515
+ display: "flex",
516
+ flexShrink: "0",
517
+ marginInlineStart: "auto",
518
+ transitionDuration: "press",
519
+ transitionProperty: "rotate",
520
+ transitionTimingFunction: "press"
521
+ },
522
+ item: {
523
+ /**
524
+ * The row keeps its hover fill while the pointer is on its control. The control is a sibling
525
+ * of the link, so the link's own hover ends when the pointer moves onto the control.
526
+ */
527
+ [`&:has(> ${ACTION}:hover) > ${PRESSED}`]: { background: "colorPalette.subtle" },
528
+ listStyle: "none",
529
+ minInlineSize: "0",
530
+ position: "relative"
531
+ },
532
+ link: PRESSABLE,
533
+ mark: {
534
+ alignItems: "center",
535
+ display: "inline-flex",
536
+ flexShrink: "0",
537
+ justifyContent: "center"
538
+ },
539
+ root: {
540
+ color: "fg.muted",
541
+ display: "flex",
542
+ flexDirection: "column",
543
+ listStyle: "none",
544
+ margin: "0",
545
+ minInlineSize: "0",
546
+ padding: "0"
547
+ },
548
+ skeleton: {
549
+ alignItems: "center",
550
+ display: "flex"
551
+ },
552
+ trigger: {
553
+ ...PRESSABLE,
554
+ appearance: "none",
555
+ background: "transparent",
556
+ borderStyle: "none",
557
+ color: "inherit"
558
+ }
559
+ },
560
+ className: CLASS$1,
561
+ compoundVariants: [{
562
+ css: {
563
+ action: { display: "none" },
564
+ badge: { srOnly: true },
565
+ content: { display: "none" },
566
+ indicator: { display: "none" },
567
+ item: { [`&:has(> ${TRAILED}) > ${PRESSED}`]: { paddingInlineEnd: "0" } },
568
+ link: { ...SQUARED },
569
+ trigger: { ...SQUARED }
570
+ },
571
+ iconic: true,
572
+ name: "squared",
573
+ variant: "list"
574
+ }, (
575
+ /**
576
+ * The count and the control next to the current row take the fill's contrast ink when the
577
+ * highlight is a fill.
578
+ *
579
+ * @remarks
580
+ * The count is positioned over the filled row, where the muted ink is 3.1:1 against the
581
+ * fill, under the 4.5:1 WCAG 1.4.3 requires for text. The control's icon is under the same
582
+ * fill. The tint and bar highlights leave the row on the page surface, where the muted ink
583
+ * meets the ratio. Both parts follow the link in the DOM, so the rule selects them as
584
+ * following siblings of the current row. The control hovers to the fill's own hover role,
585
+ * because `emphasized` is lighter than the solid fill.
586
+ */
587
+ {
588
+ css: {
589
+ action: { [`${PRESSED}${CURRENT} ~ &`]: {
590
+ _hover: {
591
+ background: "colorPalette.solid.hover",
592
+ color: "colorPalette.contrast"
593
+ },
594
+ color: "colorPalette.contrast"
595
+ } },
596
+ badge: { [`${PRESSED}${CURRENT} ~ &`]: { color: "colorPalette.contrast" } }
597
+ },
598
+ highlight: "fill",
599
+ name: "inked",
600
+ variant: "list"
601
+ })],
602
+ defaultVariants: {
603
+ guide: "solid",
604
+ highlight: "tint",
605
+ radius: "l2",
606
+ reveal: "always",
607
+ size: "md",
608
+ variant: "list"
609
+ },
610
+ jsx: [/^NavList(\.\w+)?$/u],
611
+ slots: [
612
+ "root",
613
+ "item",
614
+ "link",
615
+ "mark",
616
+ "action",
617
+ "badge",
618
+ "branch",
619
+ "trigger",
620
+ "indicator",
621
+ "content",
622
+ "skeleton"
623
+ ],
624
+ staticCss: [{
625
+ iconic: ["true"],
626
+ size: [
627
+ "sm",
628
+ "md",
629
+ "lg"
630
+ ]
631
+ }],
632
+ variants: {
633
+ /**
634
+ * The halo around the current row, in the palette's solid at half opacity.
635
+ */
636
+ effect: onSlots({
637
+ link: { glow: GLOWING },
638
+ trigger: { glow: GLOWING }
639
+ }),
640
+ /**
641
+ * The style of the line down the start of a nested list.
642
+ *
643
+ * @remarks
644
+ * `none` removes the line and keeps the indent.
645
+ */
646
+ guide: onSlot("content", {
647
+ dashed: { borderInlineStartStyle: "dashed" },
648
+ dotted: { borderInlineStartStyle: "dotted" },
649
+ none: { borderInlineStartStyle: "none" },
650
+ solid: { borderInlineStartStyle: "solid" }
651
+ }),
652
+ /**
653
+ * How the current row is marked.
654
+ */
655
+ highlight: onSlots({
656
+ link: highlightVariants(HIGHLIGHTS, "_currentPage"),
657
+ trigger: highlightVariants(HIGHLIGHTS, "_currentPage")
658
+ }),
659
+ /**
660
+ * Whether the rows render as squares containing their icons, with the text kept for screen
661
+ * readers.
662
+ *
663
+ * @remarks
664
+ * A sidebar that collapses to a rail sets it. The list measures nothing itself.
665
+ */
666
+ iconic: { true: { root: { alignItems: "center" } } },
667
+ /**
668
+ * The palette the highlight, the hover fill and the branch rows read.
669
+ *
670
+ * @remarks
671
+ * The palette is set on the root, and every part inherits the palette's custom properties.
672
+ */
673
+ palette: onSlot("root", paletteVariants()),
674
+ radius: onSlots({
675
+ link: cornerVariants([
676
+ "l1",
677
+ "l2",
678
+ "l3",
679
+ "full"
680
+ ]),
681
+ trigger: cornerVariants([
682
+ "l1",
683
+ "l2",
684
+ "l3",
685
+ "full"
686
+ ])
687
+ }),
688
+ /**
689
+ * When the control at the end of a row is visible.
690
+ *
691
+ * @remarks
692
+ * With `hover`, the control is hidden until its row is hovered. It is visible on the current
693
+ * row and under a coarse pointer, which cannot hover. It is also visible while any element in
694
+ * its row has focus, so a keyboard user can operate it.
695
+ */
696
+ reveal: {
697
+ always: { action: { opacity: "1" } },
698
+ hover: {
699
+ action: {
700
+ _touch: { opacity: "1" },
701
+ opacity: "0",
702
+ transitionDuration: "press",
703
+ transitionProperty: "common",
704
+ transitionTimingFunction: "press"
705
+ },
706
+ item: { [`&:focus-within ${ACTION}, &:hover ${ACTION}, &:has(> ${PRESSED}${CURRENT}) ${ACTION}`]: { opacity: "1" } }
707
+ }
708
+ },
709
+ size: onSlots({
710
+ action: sizeVariants((size) => ({
711
+ ...trailing(size),
712
+ ...tucked(size)
713
+ }), [
714
+ "sm",
715
+ "md",
716
+ "lg"
717
+ ]),
718
+ /**
719
+ * The count's text style is the row label's.
720
+ */
721
+ badge: sizeVariants((size) => ({
722
+ ...trailing(size),
723
+ ...tucked(size),
724
+ textStyle: `label.${lighter(size)}`
725
+ }), [
726
+ "sm",
727
+ "md",
728
+ "lg"
729
+ ]),
730
+ branch: sizeVariants((size) => ({ [UNDER_ICON]: { marginInlineStart: centred(size) } }), [
731
+ "sm",
732
+ "md",
733
+ "lg"
734
+ ]),
735
+ /**
736
+ * A nested list's rows are as far apart as the list's own. Without an icon on the trigger,
737
+ * the line is at the trigger's inset, under the start of its text.
738
+ */
739
+ content: sizeVariants((size) => ({
740
+ gap: between(size),
741
+ marginInlineStart: inset(size),
742
+ paddingBlock: "0.5",
743
+ paddingInlineStart: dense(`{spacing.inset.${below(size)}}`)
744
+ }), [
745
+ "sm",
746
+ "md",
747
+ "lg"
748
+ ]),
749
+ indicator: sizeVariants(trailing, [
750
+ "sm",
751
+ "md",
752
+ "lg"
753
+ ]),
754
+ /**
755
+ * The end padding a row keeps for the count or the control positioned over it.
756
+ *
757
+ * @remarks
758
+ * The rule is on this axis, not in the item's base, because the compiler puts variants in a
759
+ * layer over the base, and the row's own `paddingInline` comes from this axis. A later
760
+ * layer applies over any specificity, so the same rule in the base, three classes deep,
761
+ * would lose to a variant one class deep.
762
+ */
763
+ item: sizeVariants((size) => ({ [`&:has(> ${TRAILED}) > ${PRESSED}`]: { paddingInlineEnd: reserved(size) } }), [
764
+ "sm",
765
+ "md",
766
+ "lg"
767
+ ]),
768
+ link: sizeVariants(rowed, [
769
+ "sm",
770
+ "md",
771
+ "lg"
772
+ ]),
773
+ root: sizeVariants((size) => ({ gap: between(size) }), [
774
+ "sm",
775
+ "md",
776
+ "lg"
777
+ ]),
778
+ skeleton: sizeVariants((size) => ({
779
+ blockSize: dense(`{sizes.tag.${size}}`),
780
+ gap: dense(`{spacing.gap.${lighter(size)}}`),
781
+ paddingInline: inset(size)
782
+ }), [
783
+ "sm",
784
+ "md",
785
+ "lg"
786
+ ]),
787
+ trigger: sizeVariants(rowed, [
788
+ "sm",
789
+ "md",
790
+ "lg"
791
+ ])
792
+ }),
793
+ /**
794
+ * Whether the rows run down the side of a page or across the foot of a screen.
795
+ *
796
+ * @remarks
797
+ * `dock` lays a few links out in equal columns, each an `icon.lg` icon over a `label.xs`
798
+ * caption, and pads the root by the safe area a device reserves at the foot of the screen.
799
+ * The current destination is marked by `fg` and the semibold weight, with no fill. It is
800
+ * named `dock` because the `highlight` axis already offers `bar`, and two values with one
801
+ * name on a slot compile to one class.
802
+ */
803
+ variant: {
804
+ dock: {
805
+ item: {
806
+ flex: "1",
807
+ minInlineSize: "0"
808
+ },
809
+ link: {
810
+ textStyle: "label.xs",
811
+ _currentPage: {
812
+ background: "transparent",
813
+ color: "fg"
814
+ },
815
+ "& > svg": { boxSize: dense("{sizes.icon.lg}") },
816
+ blockSize: "auto",
817
+ flexDirection: "column",
818
+ gap: dense("{spacing.gap.xs}"),
819
+ justifyContent: "center",
820
+ paddingBlock: dense("{spacing.gap.sm}"),
821
+ paddingInline: dense("{spacing.gap.xs}")
822
+ },
823
+ root: {
824
+ alignItems: "stretch",
825
+ flexDirection: "row",
826
+ gap: dense("{spacing.gap.xs}"),
827
+ paddingBlockEnd: "safe.bottom"
828
+ }
829
+ },
830
+ list: {
831
+ link: truncate(),
832
+ trigger: truncate()
833
+ }
834
+ }
835
+ }
836
+ });
837
+ //#endregion
838
+ //#region src/navigation-menu/metrics.ts
839
+ /**
840
+ * Writes the selectors and the size geometry the navigation menu's recipe is written from.
841
+ *
842
+ * @remarks
843
+ * The module covers the bar's controls, the place a panel opens at and a panel's links. It is a
844
+ * separate module so the recipe is under the 300-line limit.
845
+ */
846
+ /**
847
+ * Class name of the recipe, from which the binding writes each part's class.
848
+ */
849
+ const CLASS = "navigation-menu";
850
+ /**
851
+ * Lists the sizes the menu offers, in reading order.
852
+ */
853
+ const SIZES$1 = [
854
+ "sm",
855
+ "md",
856
+ "lg"
857
+ ];
858
+ /**
859
+ * Selects a link in the bar, beside the triggers, from a rule on the link.
860
+ */
861
+ const BARRED = `.${CLASS}__item > &`;
862
+ /**
863
+ * Fill and ink of a trigger or a link in the bar under the pointer.
864
+ */
865
+ const HOVERED = {
866
+ background: "bg.subtle",
867
+ color: "fg"
868
+ };
869
+ /**
870
+ * Selects a panel shown inside the viewport, from a rule on the panel.
871
+ */
872
+ const VIEWED = `.${CLASS}__viewport > &`;
873
+ /**
874
+ * Selects a trigger in a vertical menu from its item, because the machine writes the orientation
875
+ * on the item and not on the trigger.
876
+ */
877
+ const UPRIGHT = "[data-orientation=vertical] > &";
878
+ /**
879
+ * Selects a list that renders an indicator.
880
+ */
881
+ const INDICATED = `.${CLASS}__list:has(> .${CLASS}__indicator)`;
882
+ /**
883
+ * Selects an item in a list that renders an indicator, which is not positioned, because the machine
884
+ * measures a trigger against its positioned ancestor and the indicator reads that place against
885
+ * the list.
886
+ */
887
+ const UNANCHORED = `${INDICATED} > &`;
888
+ /**
889
+ * Selects a panel in place in a list that renders an indicator, which reads its trigger's measured
890
+ * place, because its item is not positioned.
891
+ */
892
+ const MEASURED = `${INDICATED} &`;
893
+ /**
894
+ * Selects a panel in place in a horizontal list that renders an indicator, which reads its
895
+ * trigger's measured inline place.
896
+ */
897
+ const ACROSS = `.${CLASS}__list[data-orientation=horizontal]:has(> .${CLASS}__indicator) &`;
898
+ /**
899
+ * Selects a panel in place in a list that renders an indicator while another item is open, which
900
+ * hides at once, because the measured place it reads is the open trigger's.
901
+ */
902
+ const REPLACED = `${INDICATED}:has(> .${CLASS}__item[data-state=open]) &`;
903
+ /**
904
+ * Selects an element whose first element is an icon: a panel link with a leading icon, and the
905
+ * control that `controlSizes` gives one step less inset at its start, which a trigger leaves out,
906
+ * because its icon trails its words.
907
+ */
908
+ const LEADING = "&:has(> svg:first-child)";
909
+ /**
910
+ * Moves an element back by one hairline.
911
+ */
912
+ const HAIRLINE_BACK = "calc({borderWidths.hairline} * -1)";
913
+ /**
914
+ * Moves an element back by two hairlines.
915
+ */
916
+ const HAIRLINES_BACK = "calc({borderWidths.hairline} * -2)";
917
+ /**
918
+ * Returns the viewport's correction for its two hairline edges, which the machine leaves out when
919
+ * it aligns the panel's width to the open trigger.
920
+ *
921
+ * @remarks
922
+ * The viewport's content box is the panel's size, so its edges make it two hairlines wider and
923
+ * taller than the box the machine places. A centred viewport moves back by one hairline and an
924
+ * end-aligned one by two, along the bar's axis. The machine's place is physical, so the
925
+ * correction is too.
926
+ */
927
+ function aligned() {
928
+ return {
929
+ "&[data-align=center]": {
930
+ _vertical: {
931
+ marginLeft: "0",
932
+ marginTop: HAIRLINE_BACK
933
+ },
934
+ marginLeft: HAIRLINE_BACK
935
+ },
936
+ "&[data-align=end]": {
937
+ _vertical: {
938
+ marginLeft: "0",
939
+ marginTop: HAIRLINES_BACK
940
+ },
941
+ marginLeft: HAIRLINES_BACK
942
+ }
943
+ };
944
+ }
945
+ /**
946
+ * Returns what a trigger and a link in the bar share: a control in the muted ink, laid out in a
947
+ * row, that fills under the pointer.
948
+ */
949
+ function barred() {
950
+ return {
951
+ ...interactive(),
952
+ _hover: HOVERED,
953
+ alignItems: "center",
954
+ borderRadius: "l2",
955
+ color: "fg.muted",
956
+ display: "inline-flex",
957
+ flexDirection: "row",
958
+ fontWeight: "medium",
959
+ textDecoration: "none",
960
+ whiteSpace: "nowrap"
961
+ };
962
+ }
963
+ /**
964
+ * Returns a trigger's size: a control's height, text and insets, the end inset one step less
965
+ * before a trailing icon, and the icon one step smaller on the icon scale.
966
+ *
967
+ * @param size - The size of the menu.
968
+ */
969
+ function triggered(size) {
970
+ const { [LEADING]: _leading, ...control } = controlSizes()[size];
971
+ return {
972
+ ...control,
973
+ "&:has(> svg:last-child)": { paddingInlineEnd: dense(`{spacing.inset.${below(size)}}`) },
974
+ "& > svg": { boxSize: dense(`{sizes.icon.${below(size)}}`) }
975
+ };
976
+ }
977
+ /**
978
+ * Returns where a panel in place opens: one gap under its trigger, or one gap beside it in a
979
+ * vertical menu.
980
+ *
981
+ * @remarks
982
+ * A panel is placed against its item. In a list that renders an indicator it reads the trigger's
983
+ * measured place against the list instead. Inside the viewport a panel is not positioned, so no
984
+ * offset here applies to it.
985
+ * @param size - The size of the menu.
986
+ */
987
+ function placed(size) {
988
+ const gap = dense(`{spacing.gap.${size}}`);
989
+ const beside = `calc(100% + ${gap})`;
990
+ return {
991
+ _vertical: {
992
+ insetBlockStart: "0",
993
+ insetInlineStart: beside
994
+ },
995
+ [ACROSS]: {
996
+ left: "var(--trigger-x, 0px)",
997
+ right: "auto"
998
+ },
999
+ insetBlockStart: beside,
1000
+ [MEASURED]: {
1001
+ _vertical: { insetBlockStart: "var(--trigger-y, 0px)" },
1002
+ insetBlockStart: `calc(var(--trigger-y, 0px) + var(--trigger-height, 0px) + ${gap})`
1003
+ }
1004
+ };
1005
+ }
1006
+ /**
1007
+ * Returns a link's size: a row of the panel with its icon on the icon scale, or a trigger's size
1008
+ * for a link in the bar.
1009
+ *
1010
+ * @param size - The size of the menu.
1011
+ */
1012
+ function linked(size) {
1013
+ return {
1014
+ textStyle: `body.${below(size)}`,
1015
+ "& > svg": { boxSize: dense(`{sizes.icon.${size}}`) },
1016
+ [BARRED]: {
1017
+ ...triggered(size),
1018
+ paddingBlock: "0"
1019
+ },
1020
+ columnGap: dense(`{spacing.gap.${size}}`),
1021
+ paddingBlock: dense(`{spacing.gap.${size}}`),
1022
+ paddingInline: dense(`{spacing.inset.${below(size)}}`),
1023
+ rowGap: dense("{spacing.gap.xs}")
1024
+ };
1025
+ }
1026
+ //#endregion
1027
+ //#region src/navigation-menu/recipe.ts
1028
+ /**
1029
+ * Recipe for the navigation menu: a bar of triggers and links, the panel each trigger opens, the
1030
+ * links in a panel, the bar under the open trigger, and the viewport the panels share.
1031
+ *
1032
+ * @remarks
1033
+ * A trigger and a link in the bar are controls of the size's height in the muted ink, and take
1034
+ * `fg` under the pointer and while open. The bar wraps onto a second row when its items do not
1035
+ * fit. A panel in place opens one gap under its trigger and is as wide as its content, at most
1036
+ * `sizes.2xl` and 20px narrower than the window, because the machine keeps the viewport 10px
1037
+ * inside it. The indicator is a bar along the open trigger's bottom edge, placed from the
1038
+ * machine's `--trigger-x`, `--trigger-y`, `--trigger-width` and `--trigger-height`. Inside the
1039
+ * viewport a panel drops its surface and every panel takes the viewport's one grid cell, so the
1040
+ * panel that leaves fades over the panel that enters. The viewport is one floating surface that
1041
+ * moves to `--viewport-x`, and its content box takes the open panel's `--viewport-width` and
1042
+ * `--viewport-height`, so its hairline edge clips none of the panel. A panel link places a
1043
+ * leading icon in a column of its own, beside every other child, and packs its rows at its top
1044
+ * when a taller link beside it stretches it. The palette colors the indicator and a link to the
1045
+ * current page.
1046
+ */
1047
+ /**
1048
+ * Forced colors of a state that fills a control: the system highlight, which the browser keeps.
1049
+ *
1050
+ * @remarks
1051
+ * The compiler emits the hover rule after the forced-colors rule, so an open trigger under the
1052
+ * pointer restates these inside its hover rule, where the open state's selector raises the
1053
+ * specificity over the hover fill's.
1054
+ */
1055
+ const FORCED = { _highContrast: {
1056
+ background: "Highlight",
1057
+ color: "HighlightText",
1058
+ forcedColorAdjust: "none"
1059
+ } };
1060
+ /**
1061
+ * Defines the navigation menu recipe, at size `md` in the primary palette by default.
1062
+ */
1063
+ const recipe$2 = defineSlotRecipe({
1064
+ base: {
1065
+ content: {
1066
+ ...floating(),
1067
+ display: "flex",
1068
+ flexDirection: "column",
1069
+ inlineSize: "max-content",
1070
+ insetInlineStart: "0",
1071
+ maxInlineSize: "min({sizes.2xl}, calc(100vw - {spacing.5}))",
1072
+ position: "absolute",
1073
+ [REPLACED]: { _closed: { animation: "none" } },
1074
+ [VIEWED]: {
1075
+ ...motion("fade.in", "fade.out"),
1076
+ alignSelf: "start",
1077
+ background: "transparent",
1078
+ borderWidth: "0",
1079
+ boxShadow: "none",
1080
+ gridArea: "1 / 1 / 2 / 2",
1081
+ position: "static"
1082
+ }
1083
+ },
1084
+ indicator: {
1085
+ ...motion("fade.in", "fade.out"),
1086
+ _highContrast: {
1087
+ background: "CanvasText",
1088
+ forcedColorAdjust: "none"
1089
+ },
1090
+ _motionReduce: { transitionDuration: "0s" },
1091
+ _vertical: {
1092
+ _rtl: {
1093
+ left: "auto",
1094
+ right: "0"
1095
+ },
1096
+ blockSize: "var(--trigger-height)",
1097
+ inlineSize: "{borderWidths.indicator}",
1098
+ translate: "0 var(--trigger-y)"
1099
+ },
1100
+ background: "colorPalette.solid",
1101
+ blockSize: "{borderWidths.indicator}",
1102
+ borderRadius: "full",
1103
+ inlineSize: "var(--trigger-width)",
1104
+ insetBlockStart: "0",
1105
+ left: "0",
1106
+ listStyle: "none",
1107
+ pointerEvents: "none",
1108
+ transitionDuration: "move",
1109
+ transitionProperty: "translate, width, height",
1110
+ transitionTimingFunction: "move",
1111
+ translate: "var(--trigger-x) calc(var(--trigger-y) + var(--trigger-height) - {borderWidths.indicator})"
1112
+ },
1113
+ item: {
1114
+ _vertical: { flexDirection: "column" },
1115
+ display: "flex",
1116
+ position: "relative",
1117
+ [UNANCHORED]: { position: "static" }
1118
+ },
1119
+ link: {
1120
+ ...interactive(),
1121
+ _currentPage: {
1122
+ color: "colorPalette.fg",
1123
+ fontWeight: "medium"
1124
+ },
1125
+ _hover: { background: "bg.subtle" },
1126
+ "& > :not(svg)": { gridColumn: "-2" },
1127
+ "& > svg": { color: "fg.muted" },
1128
+ alignContent: "start",
1129
+ alignItems: "center",
1130
+ [BARRED]: {
1131
+ ...barred(),
1132
+ _currentPage: { color: "colorPalette.fg" }
1133
+ },
1134
+ borderRadius: "l2",
1135
+ color: "fg",
1136
+ display: "grid",
1137
+ gridTemplateColumns: "1fr",
1138
+ [LEADING]: { gridTemplateColumns: "auto 1fr" },
1139
+ textDecoration: "none"
1140
+ },
1141
+ list: {
1142
+ _vertical: {
1143
+ alignItems: "stretch",
1144
+ flexDirection: "column",
1145
+ flexWrap: "nowrap"
1146
+ },
1147
+ alignItems: "center",
1148
+ display: "flex",
1149
+ flexWrap: "wrap",
1150
+ listStyle: "none",
1151
+ margin: "0",
1152
+ padding: "0",
1153
+ position: "relative"
1154
+ },
1155
+ root: { position: "relative" },
1156
+ trigger: {
1157
+ ...barred(),
1158
+ _hover: {
1159
+ ...HOVERED,
1160
+ _open: FORCED
1161
+ },
1162
+ _open: {
1163
+ ...FORCED,
1164
+ "& > svg": { rotate: "180deg" },
1165
+ background: "bg.muted",
1166
+ color: "fg"
1167
+ },
1168
+ "& > svg": {
1169
+ _motionReduce: { transitionDuration: "0s" },
1170
+ flexShrink: "0",
1171
+ transitionDuration: "press",
1172
+ transitionProperty: "rotate"
1173
+ },
1174
+ appearance: "none",
1175
+ background: "transparent",
1176
+ borderWidth: "0",
1177
+ [UPRIGHT]: {
1178
+ _open: { "& > svg": { rotate: "0deg" } },
1179
+ justifyContent: "space-between"
1180
+ }
1181
+ },
1182
+ viewport: {
1183
+ ...floating(),
1184
+ ...aligned(),
1185
+ _motionReduce: { transitionDuration: "0s" },
1186
+ _vertical: { translate: "0 var(--viewport-y)" },
1187
+ blockSize: "var(--viewport-height)",
1188
+ boxSizing: "content-box",
1189
+ display: "grid",
1190
+ inlineSize: "var(--viewport-width)",
1191
+ overflow: "hidden",
1192
+ transitionDuration: "move",
1193
+ transitionProperty: "translate, width, height",
1194
+ transitionTimingFunction: "move",
1195
+ translate: "var(--viewport-x) 0"
1196
+ },
1197
+ viewportPositioner: {
1198
+ _vertical: {
1199
+ _rtl: {
1200
+ left: "auto",
1201
+ right: "100%"
1202
+ },
1203
+ insetBlockStart: "0",
1204
+ left: "100%"
1205
+ },
1206
+ insetBlockStart: "100%",
1207
+ left: "0",
1208
+ position: "absolute",
1209
+ zIndex: "popover"
1210
+ }
1211
+ },
1212
+ className: CLASS,
1213
+ defaultVariants: {
1214
+ palette: "primary",
1215
+ size: "md"
1216
+ },
1217
+ jsx: [/^NavigationMenu(\.\w+)?$/u],
1218
+ slots: [
1219
+ "root",
1220
+ "list",
1221
+ "item",
1222
+ "trigger",
1223
+ "content",
1224
+ "link",
1225
+ "indicator",
1226
+ "viewportPositioner",
1227
+ "viewport"
1228
+ ],
1229
+ variants: {
1230
+ /**
1231
+ * Palette of the indicator and of a link to the current page.
1232
+ */
1233
+ palette: onSlot("root", paletteVariants()),
1234
+ /**
1235
+ * Height, insets and text of the bar's triggers and links, the gaps between them, the gap
1236
+ * between the bar and a panel, and the padding, gaps and icons of a panel and its links.
1237
+ */
1238
+ size: onSlots({
1239
+ content: sizeVariants((size) => ({
1240
+ ...placed(size),
1241
+ gap: dense(`{spacing.gap.${below(size)}}`),
1242
+ padding: dense(`{spacing.inset.${below(size)}}`)
1243
+ }), SIZES$1),
1244
+ link: sizeVariants(linked, SIZES$1),
1245
+ list: sizeVariants((size) => ({ gap: dense(`{spacing.gap.${below(size)}}`) }), SIZES$1),
1246
+ trigger: sizeVariants(triggered, SIZES$1),
1247
+ viewportPositioner: sizeVariants((size) => ({
1248
+ _vertical: {
1249
+ paddingBlockStart: "0",
1250
+ paddingInlineStart: dense(`{spacing.gap.${size}}`)
1251
+ },
1252
+ paddingBlockStart: dense(`{spacing.gap.${size}}`)
1253
+ }), SIZES$1)
1254
+ })
1255
+ }
1256
+ });
1257
+ //#endregion
1258
+ //#region src/pagination/recipe.ts
1259
+ /**
1260
+ * Recipe for the pagination: a row of page buttons between the buttons that move a page back and
1261
+ * forward, the marks between distant pages, and the text that states the current page.
1262
+ *
1263
+ * @remarks
1264
+ * Every page and every trigger is the actions package's square `Button`, at the pagination's size
1265
+ * and in the look and palette the root passes it. The current page is the button with
1266
+ * `aria-current="page"`, which the button's looks mark as on. The recipe lays the row out, sizes
1267
+ * the gaps, the ellipsis and the page text, and sets the page numbers in figures of one width.
1268
+ * While the root marks the row `data-crowded`, the pages and the marks are hidden and the summary
1269
+ * in their place states the current page. The recipe has no palette or effect axis of its own,
1270
+ * because the buttons take the palette from the root.
1271
+ */
1272
+ /**
1273
+ * Sizes the recipe offers, the sizes it passes to the buttons.
1274
+ */
1275
+ const SIZES = [
1276
+ "sm",
1277
+ "md",
1278
+ "lg"
1279
+ ];
1280
+ /**
1281
+ * Selects a page, a mark or the summary inside a root whose pages do not fit its row.
1282
+ */
1283
+ const CROWDED = "[data-crowded] > &";
1284
+ /**
1285
+ * Sets text that states the current page in muted figures of one width, on one line.
1286
+ */
1287
+ const STATED = {
1288
+ color: "fg.muted",
1289
+ fontVariantNumeric: "tabular-nums",
1290
+ whiteSpace: "nowrap"
1291
+ };
1292
+ /**
1293
+ * Pads text that states the current page and sets its type, per size.
1294
+ */
1295
+ const STATED_SIZES = sizeVariants((size) => ({
1296
+ textStyle: `label.${size}`,
1297
+ paddingInline: dense(`{spacing.gap.${size}}`)
1298
+ }), SIZES);
1299
+ /**
1300
+ * Defines the pagination recipe, at size `md` by default.
1301
+ */
1302
+ const recipe$1 = defineSlotRecipe({
1303
+ base: {
1304
+ ellipsis: {
1305
+ alignItems: "center",
1306
+ color: "fg.muted",
1307
+ [CROWDED]: { display: "none" },
1308
+ display: "inline-flex",
1309
+ flexShrink: "0",
1310
+ justifyContent: "center",
1311
+ userSelect: "none"
1312
+ },
1313
+ item: {
1314
+ [CROWDED]: { display: "none" },
1315
+ fontVariantNumeric: "tabular-nums"
1316
+ },
1317
+ pageText: STATED,
1318
+ root: {
1319
+ alignItems: "center",
1320
+ display: "flex",
1321
+ minInlineSize: "0"
1322
+ },
1323
+ summary: {
1324
+ ...STATED,
1325
+ [CROWDED]: { display: "block" },
1326
+ display: "none"
1327
+ }
1328
+ },
1329
+ className: "pagination",
1330
+ defaultVariants: { size: "md" },
1331
+ jsx: [/^Pagination(\.\w+)?$/u],
1332
+ slots: [
1333
+ "root",
1334
+ "item",
1335
+ "ellipsis",
1336
+ "summary",
1337
+ "trigger",
1338
+ "pageText"
1339
+ ],
1340
+ variants: {
1341
+ /**
1342
+ * Size of the gaps, the ellipsis, the summary and the page text, beside the buttons' own size.
1343
+ *
1344
+ * @remarks
1345
+ * The gap between buttons is one step below the size, so a row of squares reads as one group.
1346
+ * The ellipsis is a square of the control's side, so the row keeps one rhythm.
1347
+ */
1348
+ size: onSlots({
1349
+ ellipsis: sizeVariants((size) => ({
1350
+ textStyle: `label.${size}`,
1351
+ boxSize: dense(`{sizes.control.${size}}`)
1352
+ }), SIZES),
1353
+ pageText: STATED_SIZES,
1354
+ root: sizeVariants((size) => ({ gap: dense(`{spacing.gap.${below(size)}}`) }), SIZES),
1355
+ summary: STATED_SIZES
1356
+ }) }
1357
+ });
1358
+ //#endregion
1359
+ //#region src/toc/recipe.ts
1360
+ /**
1361
+ * Declares the table of contents slot recipe for a list of links to the headings on a page, with
1362
+ * an indicator next to the links whose headings are visible.
1363
+ *
1364
+ * @remarks
1365
+ * The machine measures the active rows and writes their offset and height to the indicator as
1366
+ * `--top` and `--height`, so the recipe sets only the indicator's width and color. Each item
1367
+ * carries its heading's depth as `--depth`, and the recipe indents it by one gap per level below
1368
+ * `h2`. Active links take the default ink at medium weight, and the others are muted. Links
1369
+ * truncate to one line. The root is at least 11rem wide, so a page of short headings gets the
1370
+ * same width as a page of long ones, and never wider than its container. The title and the
1371
+ * links share their inline start padding, so the title text and the link text start at the same
1372
+ * offset. Under forced colors the indicator paints `CanvasText`, because the browser replaces
1373
+ * its background with Canvas. The recipe has no `effect` axis, because the rail has no box.
1374
+ */
1375
+ /**
1376
+ * Custom property with the height of the pinned bars above the page, set by the application
1377
+ * shell.
1378
+ *
1379
+ * @remarks
1380
+ * The aside placement adds it to its sticky offset, so the rail sticks below the bars. Without
1381
+ * a shell the property is unset and the offset falls back to 0px.
1382
+ */
1383
+ const STUCK = "--app-shell-sticky-top";
1384
+ /**
1385
+ * Sizes the recipe offers. Links read the body text style one size smaller, and the title reads
1386
+ * the label text style two sizes smaller.
1387
+ */
1388
+ const STEPS = [
1389
+ "sm",
1390
+ "md",
1391
+ "lg"
1392
+ ];
1393
+ /**
1394
+ * Room a focus ring takes outside an element: its offset and its width.
1395
+ */
1396
+ const RING_ROOM = "calc(var(--focus-ring-offset, 0px) + var(--focus-ring-width, 0px))";
1397
+ /**
1398
+ * Returns the gap between the title and the list at a size, one gap step smaller than the size.
1399
+ *
1400
+ * @param size - The recipe's size.
1401
+ */
1402
+ function spaced(size) {
1403
+ return { gap: dense(`{spacing.gap.${below(size)}}`) };
1404
+ }
1405
+ /**
1406
+ * Table of contents slot recipe, inline at the md size by default.
1407
+ */
1408
+ const recipe = defineSlotRecipe({
1409
+ base: {
1410
+ /**
1411
+ * The column of the title and the list inside the aside placement's scroll area, padded by
1412
+ * the room a focus ring takes at its edges.
1413
+ */
1414
+ content: {
1415
+ display: "flex",
1416
+ flexDirection: "column",
1417
+ padding: RING_ROOM
1418
+ },
1419
+ indicator: {
1420
+ _highContrast: {
1421
+ background: "CanvasText",
1422
+ forcedColorAdjust: "none"
1423
+ },
1424
+ _motionReduce: { transitionDuration: "0s" },
1425
+ background: "colorPalette.solid",
1426
+ blockSize: "var(--height)",
1427
+ borderRadius: "full",
1428
+ inlineSize: "{borderWidths.indicator}",
1429
+ insetBlockStart: "var(--top)",
1430
+ insetInlineStart: "0",
1431
+ position: "absolute",
1432
+ transitionDuration: "move",
1433
+ transitionProperty: "top, height",
1434
+ transitionTimingFunction: "move"
1435
+ },
1436
+ item: { paddingInlineStart: "calc((var(--depth) - 2) * {spacing.gap.md})" },
1437
+ link: {
1438
+ ...truncate(),
1439
+ focusVisibleRing: "outside",
1440
+ _hover: { color: "fg" },
1441
+ "&[data-active]": {
1442
+ color: "fg",
1443
+ fontWeight: "medium"
1444
+ },
1445
+ borderRadius: "l2",
1446
+ color: "fg.muted",
1447
+ cursor: "button",
1448
+ display: "block",
1449
+ focusRingColor: "colorPalette.focusRing",
1450
+ textDecoration: "none",
1451
+ transitionDuration: "press",
1452
+ transitionProperty: "common"
1453
+ },
1454
+ list: {
1455
+ display: "flex",
1456
+ flexDirection: "column",
1457
+ listStyle: "none",
1458
+ margin: "0",
1459
+ padding: "0",
1460
+ position: "relative"
1461
+ },
1462
+ root: {
1463
+ colorPalette: "primary",
1464
+ display: "flex",
1465
+ flexDirection: "column",
1466
+ minInlineSize: "min({sizes.44}, 100%)"
1467
+ },
1468
+ /**
1469
+ * The aside placement's scroll area, pulled out at its start edges by the room its content's
1470
+ * padding takes, so the title and the links start where the root starts.
1471
+ *
1472
+ * @remarks
1473
+ * The end edges are inside the root, because a box past the root's end edges is overflow a
1474
+ * scrolling container around the root scrolls to.
1475
+ */
1476
+ scroller: {
1477
+ marginBlockStart: `calc(${RING_ROOM} * -1)`,
1478
+ marginInlineStart: `calc(${RING_ROOM} * -1)`
1479
+ },
1480
+ title: {
1481
+ color: "fg.muted",
1482
+ fontWeight: "semibold",
1483
+ letterSpacing: "wider",
1484
+ textTransform: "uppercase"
1485
+ }
1486
+ },
1487
+ className: "toc",
1488
+ defaultVariants: {
1489
+ placement: "inline",
1490
+ size: "md"
1491
+ },
1492
+ jsx: [/^Toc(\.\w+)?$/u],
1493
+ slots: [
1494
+ "root",
1495
+ "scroller",
1496
+ "content",
1497
+ "title",
1498
+ "list",
1499
+ "item",
1500
+ "link",
1501
+ "indicator"
1502
+ ],
1503
+ variants: {
1504
+ /**
1505
+ * The semantic palette of the indicator and the focus ring. Without a value the root reads
1506
+ * `primary`.
1507
+ */
1508
+ palette: onSlot("root", paletteVariants()),
1509
+ /**
1510
+ * Whether the root sticks to the top of the viewport or stays in the document flow.
1511
+ *
1512
+ * @remarks
1513
+ * `aside` sets `position: sticky` with an offset below the application shell's pinned bars,
1514
+ * and caps the root at the viewport height. The root renders its title and list in the
1515
+ * primitives package's scroll area, which fills the root. `inline` is the default and leaves
1516
+ * placement to the container, such as a drawer or a column on a narrow screen.
1517
+ */
1518
+ placement: onSlot("root", {
1519
+ aside: {
1520
+ alignSelf: "start",
1521
+ insetBlockStart: `calc(var(${STUCK}, 0px) + ${dense("{spacing.gap.lg}")})`,
1522
+ maxBlockSize: `calc(100dvh - var(${STUCK}, 0px) - ${dense("{spacing.gap.lg}")} * 2)`,
1523
+ position: "sticky"
1524
+ },
1525
+ inline: { position: "static" }
1526
+ }),
1527
+ /**
1528
+ * The text styles, the row padding and the gap between the title and the list.
1529
+ */
1530
+ size: onSlots({
1531
+ content: sizeVariants(spaced, STEPS),
1532
+ link: sizeVariants((size) => ({
1533
+ textStyle: `body.${below(size)}`,
1534
+ paddingBlock: dense(`{spacing.gap.${below(below(size))}}`),
1535
+ paddingInlineEnd: dense(`{spacing.inset.${below(size)}}`),
1536
+ paddingInlineStart: dense(`{spacing.inset.${size}}`)
1537
+ }), STEPS),
1538
+ root: sizeVariants(spaced, STEPS),
1539
+ title: sizeVariants((size) => ({
1540
+ textStyle: `label.${below(below(size))}`,
1541
+ paddingInlineEnd: dense(`{spacing.inset.${below(size)}}`),
1542
+ paddingInlineStart: dense(`{spacing.inset.${size}}`)
1543
+ }), STEPS)
1544
+ })
1545
+ }
1546
+ });
1547
+ //#endregion
1548
+ export { recipe$3 as a, CLASS$1 as i, recipe$1 as n, recipe$4 as o, recipe$2 as r, recipe$5 as s, recipe as t };