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