@stealthscale/component-navigation 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +414 -48
- package/dist/cyclonedx/bom.json +1 -1
- package/dist/index.d.ts +900 -38
- package/dist/index.js +2250 -32
- package/dist/recipe-wIXGUbM7.js +1541 -0
- package/dist/theme.js +8 -2
- package/package.json +41 -23
- package/dist/recipe-TfPpeGkM.js +0 -150
|
@@ -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 };
|