rei-kit 2.1.0 → 2.2.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 +40 -22
- package/dist/index.js +1 -1
- package/dist/mobile.css +27 -0
- package/dist/styles.css +8 -8
- package/dist/web.css +27 -0
- package/dist/web.js +3 -3
- package/dist/web.js.map +1 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -86,17 +86,17 @@ styles included and Vue left out. Minified, gzip -9:
|
|
|
86
86
|
|
|
87
87
|
| Kit | JS | CSS | Total |
|
|
88
88
|
| -------------------- | ---------: | ----------: | ----------: |
|
|
89
|
-
| **rei-kit 2.
|
|
89
|
+
| **rei-kit 2.2.0** | **3.1 KB** | **14.5 KB** | **17.6 KB** |
|
|
90
90
|
| element-plus 2.14.6 | 27.3 KB | 6.0 KB | 33.3 KB |
|
|
91
91
|
| naive-ui 2.45.3 | 51.2 KB | — | 51.2 KB |
|
|
92
92
|
| primevue 5.0.1 | 53.6 KB | — | 53.6 KB |
|
|
93
93
|
| ant-design-vue 4.2.6 | 70.3 KB | — | 70.3 KB |
|
|
94
94
|
| vuetify 4.2.1 | 44.0 KB | 34.3 KB | 78.4 KB |
|
|
95
95
|
|
|
96
|
-
rei-kit's CSS column is the
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
dependencies. Naive UI, PrimeVue and Ant Design put their styles in the
|
|
96
|
+
rei-kit's CSS column is the whole `mobile.css` preset: the stylesheet for
|
|
97
|
+
**every** component, all four materials and all ten palettes. The JavaScript
|
|
98
|
+
is only what the three need, because each component is its own
|
|
99
|
+
tree-shakeable module and there are no runtime dependencies. Naive UI, PrimeVue and Ant Design put their styles in the
|
|
100
100
|
JavaScript. Run it yourself: `cd bench && npm install && npm run bench`.
|
|
101
101
|
Nuxt UI is left out because it builds through its own Nuxt or Vite module and
|
|
102
102
|
cannot be bundled the same way.
|
|
@@ -123,14 +123,14 @@ Each claim here is enforced by something that fails, not by a promise.
|
|
|
123
123
|
|
|
124
124
|
## Status
|
|
125
125
|
|
|
126
|
-
**v2.
|
|
126
|
+
**v2.2.0 — three consumers.**
|
|
127
127
|
|
|
128
128
|
| | |
|
|
129
129
|
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
130
130
|
| Components | 53 (`AuthForm`, `BaseTable`, `BaseCombobox`, `BaseSlider`, `TabShell`, `BaseModal`, `BaseTabs`, `BaseTooltip`, `BasePagination`, `BaseBreadcrumb`, `BaseDisclosure`, `BaseAccordion`, `NavLinks`, `OfflineBanner`, `FabButton`, `BaseButton`, `BaseCard`, `BaseInput`, `BaseSelect`, `BaseTextarea`, `BaseCheckbox`, `BaseSwitch`, `BaseRadioGroup`, `BaseMenu`, `BaseAvatar`, `BaseSpinner`, `BaseAlert`, `BaseBadge`, `BaseSheet`, `ProgressBar`, `PriceCard`, `ToastHost`, `TabBar`, `GoogleButton`, `LocaleLinks`, `LocaleSheet`, `AuthShell`, `TourShell`, `InstallPrompt`, `UpdatePrompt`, `InstallSettings`, `SkeletonList`, `PageContainer`, `ErrorBoundary`, etc.) |
|
|
131
131
|
| Composables | 16 (`useToast`, `useTheme`, `useMaterial`, `usePalette`, `useToday`, `useMediaQuery`, `useInstall`, `watchInstallability`, `createTabTransition`, `useThemeSync`, `useVisualViewport`, etc.) |
|
|
132
132
|
| Utilities | 33 (`applyTheme`, `applyMaterial`, `applyPalette`, `MATERIALS`, `PALETTES`, `formatDate`, `fieldErrors`, `toAuthMessageKey`, `createAuthGuard`, `createQueryDefaults`, `createWriteReport`, `toRedirectPath`, `Supabase error mapper`, i18n runtime, etc.) |
|
|
133
|
-
| Entry Points | `rei-kit`, `rei-kit/app`, `rei-kit/web`, `rei-kit/pwa`, `rei-kit/supabase`, `rei-kit/
|
|
133
|
+
| Entry Points | `rei-kit`, `rei-kit/app`, `rei-kit/web`, `rei-kit/pwa`, `rei-kit/supabase`, `rei-kit/mobile.css`, `rei-kit/web.css`, and each stylesheet on its own |
|
|
134
134
|
|
|
135
135
|
| Consumers | [Hibi](https://github.com/ramazandogna/hibi) · [Kakei](https://github.com/ramazandogna/kakei) · [Kakehashi](https://github.com/ramazandogna/kakehashi-nihongo) |
|
|
136
136
|
|
|
@@ -178,35 +178,53 @@ import { createSupabaseClient } from 'rei-kit/supabase'
|
|
|
178
178
|
|
|
179
179
|
### Wiring the styles
|
|
180
180
|
|
|
181
|
-
|
|
182
|
-
materials and palettes:
|
|
181
|
+
Two lines, after Tailwind's own:
|
|
183
182
|
|
|
184
183
|
```css
|
|
185
184
|
/* your app's main.css */
|
|
186
185
|
@import 'tailwindcss';
|
|
186
|
+
@import 'rei-kit/mobile.css'; /* a phone-shaped app — or rei-kit/web.css for a site */
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
That is the whole kit: the colour roles and dark variant, the shell, the four
|
|
190
|
+
materials, the ten palettes and the compiled component styles. The preset
|
|
191
|
+
also tells Tailwind where the components are, which used to be the step
|
|
192
|
+
people got wrong: Tailwind only builds classes it has seen and never looks in
|
|
193
|
+
`node_modules` on its own, and a missing or mis-counted `@source` path failed
|
|
194
|
+
silently — the components mounted and came out unstyled. The preset's
|
|
195
|
+
`@source` sits next to the files it points at, so it is right wherever your
|
|
196
|
+
stylesheet is.
|
|
197
|
+
|
|
198
|
+
Put your brand after it, and you are done:
|
|
199
|
+
|
|
200
|
+
```css
|
|
201
|
+
@theme {
|
|
202
|
+
--color-primary: #6b4de6;
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
#### The parts, one by one
|
|
207
|
+
|
|
208
|
+
The preset is only these, and an app that wants less can import them itself:
|
|
209
|
+
|
|
210
|
+
```css
|
|
211
|
+
@import 'tailwindcss';
|
|
187
212
|
@import 'rei-kit/tokens.css'; /* colour roles, depth, motion, the dark variant */
|
|
188
213
|
@import 'rei-kit/shell/mobile.css'; /* or shell/web.css — see The shell, below */
|
|
189
214
|
@import 'rei-kit/materials.css'; /* optional: glass, brutal, soft */
|
|
190
215
|
@import 'rei-kit/palettes.css'; /* optional: the ten palettes */
|
|
191
216
|
@import 'rei-kit/styles.css'; /* compiled component styles */
|
|
192
217
|
|
|
193
|
-
/*
|
|
194
|
-
not walk node_modules on its own. Without this the kit's components render
|
|
195
|
-
with every class present in the markup and absent from the stylesheet. */
|
|
218
|
+
/* Relative to this file: adjust the ../ depth to where it sits. */
|
|
196
219
|
@source '../../node_modules/rei-kit/dist';
|
|
197
220
|
```
|
|
198
221
|
|
|
199
|
-
The path is relative to the CSS file, so adjust the `../` depth to where your
|
|
200
|
-
`main.css` sits.
|
|
201
|
-
|
|
202
222
|
**Leaving any of these out fails quietly:** the build succeeds, the
|
|
203
|
-
components mount, and they come out unstyled. Nothing type-checks
|
|
204
|
-
is worth a test
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
invisible once, and separately spent three versions restating `tokens.css`
|
|
209
|
-
locally instead of importing it, which nothing noticed.
|
|
223
|
+
components mount, and they come out unstyled. Nothing type-checks CSS, so an
|
|
224
|
+
app on this path is worth a test that reads its own stylesheet and asserts
|
|
225
|
+
them — Hibi's `kit-styling.spec.ts` does, at unit-test speed. The kit holds
|
|
226
|
+
its presets to the same line: `pnpm size` builds each one with Tailwind's own
|
|
227
|
+
scanning switched off and fails if a component's classes are missing.
|
|
210
228
|
|
|
211
229
|
### Materials
|
|
212
230
|
|
package/dist/index.js
CHANGED
|
@@ -2106,7 +2106,7 @@ function createI18nRuntime(options) {
|
|
|
2106
2106
|
*
|
|
2107
2107
|
* The fallback keeps `vitest` and `vite dev` honest, where no define runs.
|
|
2108
2108
|
*/
|
|
2109
|
-
var VERSION = "2.
|
|
2109
|
+
var VERSION = "2.2.0";
|
|
2110
2110
|
//#endregion
|
|
2111
2111
|
export { AppError, BaseAlert_default as BaseAlert, BaseAvatar_default as BaseAvatar, BaseBadge_default as BaseBadge, BaseButton_default as BaseButton, BaseCard_default as BaseCard, BaseCheckbox_default as BaseCheckbox, BaseCombobox_default as BaseCombobox, BaseInput_default as BaseInput, BaseMenu_default as BaseMenu, BaseRadioGroup_default as BaseRadioGroup, BaseSelect_default as BaseSelect, BaseSheet_default as BaseSheet, BaseSlider_default as BaseSlider, BaseSpinner_default as BaseSpinner, BaseSwitch_default as BaseSwitch, BaseTable_default as BaseTable, BaseTextarea_default as BaseTextarea, EmptyState_default as EmptyState, ErrorBoundary_default as ErrorBoundary, FormField_default as FormField, GoogleButton_default as GoogleButton, LocaleLinks_default as LocaleLinks, MATERIALS, PALETTES, PageContainer_default as PageContainer, PageHeader_default as PageHeader, PriceCard_default as PriceCard, ProgressBar_default as ProgressBar, SHEET_ROOT_ID, SectionHeading_default as SectionHeading, SegmentedControl_default as SegmentedControl, SettingsGroup_default as SettingsGroup, SettingsRow_default as SettingsRow, SkeletonList_default as SkeletonList, StatCard_default as StatCard, TabBar_default as TabBar, ToastHost_default as ToastHost, ToneDot_default as ToneDot, VERSION, addDays, applyMaterial, applyPalette, applyTheme, createI18nRuntime, downloadJson, eachDayOfYear, ensureSheetRoot, formatDate, fromDateKey, isApplePortable, isInstalled, isMaterial, isPaletteName, isThemePreference, lastNDays, leadingBlanks, needsIosInstall, readStoredTheme, registerErrorMapper, relativeDayLabel, safeRedirect, setFormatLocale, setMaterialStorageKey, setPaletteStorageKey, setThemeStorageKey, startOfWeek, tapFeedback, toAppError, toDateKey, toRedirectPath, todayKey, useDebouncedCallback, useDragScroll, useMaterial, useMediaQuery, useOnline, usePalette, useTheme, useToast, useToday, useVisualViewport };
|
|
2112
2112
|
|
package/dist/mobile.css
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* rei-kit, everything, for a phone-shaped app: the 430px column and the tab slides.
|
|
2
|
+
*
|
|
3
|
+
* @import 'tailwindcss';
|
|
4
|
+
* @import 'rei-kit/mobile.css';
|
|
5
|
+
*
|
|
6
|
+
* Two lines instead of seven, and the seven had a trap in them: Tailwind only
|
|
7
|
+
* builds classes it has seen, and it never looks in node_modules on its own,
|
|
8
|
+
* so every app had to write an `@source` with the right number of `../` for
|
|
9
|
+
* wherever its stylesheet happened to sit. Get it wrong and nothing fails —
|
|
10
|
+
* the components mount and render unstyled.
|
|
11
|
+
*
|
|
12
|
+
* Here the `@source` sits next to the files it points at, and Tailwind
|
|
13
|
+
* resolves it from this file, so it is right wherever the app is.
|
|
14
|
+
*
|
|
15
|
+
* Only the compiled JavaScript is scanned. The source maps and the type
|
|
16
|
+
* declarations mention classes too — in examples, in comments — and scanning
|
|
17
|
+
* them built rules no component uses.
|
|
18
|
+
*
|
|
19
|
+
* Want less? The parts are still importable one by one, and `web.css` is
|
|
20
|
+
* the same for a web app. Import one preset, never both. */
|
|
21
|
+
@import './tokens.css';
|
|
22
|
+
@import './shell/mobile.css';
|
|
23
|
+
@import './materials.css';
|
|
24
|
+
@import './palettes.css';
|
|
25
|
+
@import './styles.css';
|
|
26
|
+
|
|
27
|
+
@source './**/*.js';
|
package/dist/styles.css
CHANGED
|
@@ -1237,32 +1237,32 @@ to {
|
|
|
1237
1237
|
color: var(--color-ink-soft);
|
|
1238
1238
|
}
|
|
1239
1239
|
|
|
1240
|
-
.rk-tabs[data-v-
|
|
1240
|
+
.rk-tabs[data-v-8135ecab] {
|
|
1241
1241
|
display: flex;
|
|
1242
1242
|
align-items: center;
|
|
1243
1243
|
gap: 0.25rem;
|
|
1244
1244
|
border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);
|
|
1245
1245
|
}
|
|
1246
|
-
.rk-tab[data-v-
|
|
1246
|
+
.rk-tab[data-v-8135ecab] {
|
|
1247
1247
|
position: relative;
|
|
1248
1248
|
padding: 0.625rem 0.875rem;
|
|
1249
1249
|
font-size: 0.875rem;
|
|
1250
1250
|
color: var(--color-ink-soft);
|
|
1251
1251
|
transition: color var(--duration-fast);
|
|
1252
1252
|
}
|
|
1253
|
-
.rk-tab[data-v-
|
|
1253
|
+
.rk-tab[data-v-8135ecab]:hover {
|
|
1254
1254
|
color: var(--color-ink);
|
|
1255
1255
|
}
|
|
1256
|
-
.rk-tab[data-v-
|
|
1256
|
+
.rk-tab[data-v-8135ecab]:focus-visible {
|
|
1257
1257
|
outline: 2px solid var(--color-primary);
|
|
1258
1258
|
outline-offset: -2px;
|
|
1259
1259
|
border-radius: var(--radius-cell);
|
|
1260
1260
|
}
|
|
1261
|
-
.rk-tab.is-active[data-v-
|
|
1261
|
+
.rk-tab.is-active[data-v-8135ecab] {
|
|
1262
1262
|
color: var(--color-ink);
|
|
1263
1263
|
font-weight: 500;
|
|
1264
1264
|
}
|
|
1265
|
-
.rk-tab.is-active[data-v-
|
|
1265
|
+
.rk-tab.is-active[data-v-8135ecab]::after {
|
|
1266
1266
|
content: '';
|
|
1267
1267
|
position: absolute;
|
|
1268
1268
|
inset-inline: 0.5rem;
|
|
@@ -1271,10 +1271,10 @@ to {
|
|
|
1271
1271
|
border-radius: 9999px;
|
|
1272
1272
|
background: var(--color-primary);
|
|
1273
1273
|
}
|
|
1274
|
-
.rk-tabpanel[data-v-
|
|
1274
|
+
.rk-tabpanel[data-v-8135ecab] {
|
|
1275
1275
|
padding-top: 1rem;
|
|
1276
1276
|
}
|
|
1277
|
-
.rk-tabpanel[data-v-
|
|
1277
|
+
.rk-tabpanel[data-v-8135ecab]:focus-visible {
|
|
1278
1278
|
outline: 2px solid var(--color-primary);
|
|
1279
1279
|
outline-offset: 2px;
|
|
1280
1280
|
}
|
package/dist/web.css
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* rei-kit, everything, for a wide site: a column with gutters and a page fade.
|
|
2
|
+
*
|
|
3
|
+
* @import 'tailwindcss';
|
|
4
|
+
* @import 'rei-kit/web.css';
|
|
5
|
+
*
|
|
6
|
+
* Two lines instead of seven, and the seven had a trap in them: Tailwind only
|
|
7
|
+
* builds classes it has seen, and it never looks in node_modules on its own,
|
|
8
|
+
* so every app had to write an `@source` with the right number of `../` for
|
|
9
|
+
* wherever its stylesheet happened to sit. Get it wrong and nothing fails —
|
|
10
|
+
* the components mount and render unstyled.
|
|
11
|
+
*
|
|
12
|
+
* Here the `@source` sits next to the files it points at, and Tailwind
|
|
13
|
+
* resolves it from this file, so it is right wherever the app is.
|
|
14
|
+
*
|
|
15
|
+
* Only the compiled JavaScript is scanned. The source maps and the type
|
|
16
|
+
* declarations mention classes too — in examples, in comments — and scanning
|
|
17
|
+
* them built rules no component uses.
|
|
18
|
+
*
|
|
19
|
+
* Want less? The parts are still importable one by one, and `mobile.css` is
|
|
20
|
+
* the same for a mobile app. Import one preset, never both. */
|
|
21
|
+
@import './tokens.css';
|
|
22
|
+
@import './shell/web.css';
|
|
23
|
+
@import './materials.css';
|
|
24
|
+
@import './palettes.css';
|
|
25
|
+
@import './styles.css';
|
|
26
|
+
|
|
27
|
+
@source './**/*.js';
|
package/dist/web.js
CHANGED
|
@@ -450,6 +450,7 @@ var BaseTabs_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
450
450
|
onKeydown
|
|
451
451
|
}, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.items, (item) => {
|
|
452
452
|
return openBlock(), createElementBlock("button", {
|
|
453
|
+
id: tabId(item.key),
|
|
453
454
|
key: item.key,
|
|
454
455
|
ref_for: true,
|
|
455
456
|
ref_key: "tabs",
|
|
@@ -457,7 +458,6 @@ var BaseTabs_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
457
458
|
type: "button",
|
|
458
459
|
role: "tab",
|
|
459
460
|
class: normalizeClass(["rk-tab", { "is-active": item.key === active.value }]),
|
|
460
|
-
id: tabId(item.key),
|
|
461
461
|
"aria-selected": item.key === active.value,
|
|
462
462
|
"aria-controls": panelId(item.key),
|
|
463
463
|
tabindex: item.key === active.value ? 0 : -1,
|
|
@@ -465,10 +465,10 @@ var BaseTabs_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
465
465
|
}, toDisplayString(item.label), 11, _hoisted_2$2);
|
|
466
466
|
}), 128))], 40, _hoisted_1$2), (openBlock(true), createElementBlock(Fragment, null, renderList(__props.items, (item) => {
|
|
467
467
|
return withDirectives((openBlock(), createElementBlock("div", {
|
|
468
|
+
id: panelId(item.key),
|
|
468
469
|
key: item.key,
|
|
469
470
|
role: "tabpanel",
|
|
470
471
|
class: "rk-tabpanel",
|
|
471
|
-
id: panelId(item.key),
|
|
472
472
|
"aria-labelledby": tabId(item.key),
|
|
473
473
|
tabindex: "0"
|
|
474
474
|
}, [renderSlot(_ctx.$slots, "default", {
|
|
@@ -478,7 +478,7 @@ var BaseTabs_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
478
478
|
}), 128))]);
|
|
479
479
|
};
|
|
480
480
|
}
|
|
481
|
-
}), [["__scopeId", "data-v-
|
|
481
|
+
}), [["__scopeId", "data-v-8135ecab"]]);
|
|
482
482
|
//#endregion
|
|
483
483
|
//#region src/web/BaseTooltip.vue?vue&type=script&setup=true&lang.ts
|
|
484
484
|
var _hoisted_1$1 = { class: "rk-tip" };
|
package/dist/web.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web.js","names":["$slots"],"sources":["../src/web/BaseAccordion.vue","../src/web/BaseAccordion.vue","../src/web/BaseBreadcrumb.vue","../src/web/BaseBreadcrumb.vue","../src/web/BaseDisclosure.vue","../src/web/BaseDisclosure.vue","../src/web/BaseModal.vue","../src/web/BaseModal.vue","../src/web/BasePagination.vue","../src/web/BasePagination.vue","../src/web/BaseTabs.vue","../src/web/BaseTabs.vue","../src/web/BaseTooltip.vue","../src/web/BaseTooltip.vue","../src/web/NavLinks.vue","../src/web/NavLinks.vue"],"sourcesContent":["<script setup lang=\"ts\" generic=\"K extends string\">\nimport { shallowRef, useId } from 'vue'\n\nexport interface AccordionItem<K extends string> {\n key: K\n /** The question. Already translated. */\n title: string\n}\n\n/**\n * A list that opens one section at a time.\n *\n * ## Why not `<details>`\n *\n * `<details>` is the honest element for this and it was the first choice. A\n * browser removes its content from layout entirely when closed, so there is\n * nothing to animate between — the panel can only appear. Faking it by leaving\n * `open` set and hiding with CSS tells a screen reader the section is expanded\n * while a sighted reader sees it shut, which is worse than no animation.\n *\n * So: a button carrying `aria-expanded`, the answer always in the markup — the\n * prerendered HTML carries every answer, which is what a crawler and a reader\n * without JavaScript get — and a grid row that travels from `0fr` to `1fr`,\n * which is the one way to animate to a height nobody has measured.\n *\n * The content is always present, so anything expensive in it is paid for on\n * first paint. That is the trade: a section a crawler can read costs a section\n * that is never lazy.\n */\nconst {\n items,\n multiple = false,\n headingLevel = 3,\n} = defineProps<{\n items: readonly AccordionItem<K>[]\n /** Allow several open at once. Off by default, which is what makes it an accordion. */\n multiple?: boolean | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level is a prop because it depends on what\n * is above it on the page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\ndefineSlots<{\n /** The answer, per item. */\n default: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n /** The question, when the item's `title` is not enough. */\n title?: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n}>()\n\nconst uid = useId()\n/* `shallowRef` because the list is replaced wholesale rather than mutated, and\n because a deep ref unwraps a generic key type into something TypeScript can\n no longer match against `K`. */\nconst open = shallowRef<K[]>([])\n\nconst isOpen = (key: K) => open.value.includes(key)\n\nfunction toggle(key: K) {\n if (isOpen(key)) {\n open.value = open.value.filter((k) => k !== key)\n } else {\n open.value = multiple ? [...open.value, key] : [key]\n }\n}\n</script>\n\n<template>\n <ul class=\"rk-accordion\">\n <li v-for=\"item in items\" :key=\"item.key\" class=\"rk-accordion-item\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-accordion-control\"\n :aria-expanded=\"isOpen(item.key)\"\n :aria-controls=\"`${uid}-${item.key}`\"\n @click=\"toggle(item.key)\"\n >\n <span class=\"rk-accordion-title\">\n <slot name=\"title\" :item=\"item\" :open=\"isOpen(item.key)\">{{ item.title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control\n keeps its place while it changes meaning. -->\n <span class=\"rk-accordion-mark\" aria-hidden=\"true\">\n <span class=\"rk-accordion-bar\" />\n <span\n class=\"rk-accordion-bar rk-accordion-bar-turn\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n />\n </span>\n </button>\n </component>\n\n <div\n :id=\"`${uid}-${item.key}`\"\n class=\"rk-accordion-panel\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n >\n <div class=\"rk-accordion-clip\">\n <div class=\"rk-accordion-body\" :class=\"{ 'is-open': isOpen(item.key) }\">\n <slot :item=\"item\" :open=\"isOpen(item.key)\" />\n </div>\n </div>\n </div>\n </li>\n </ul>\n</template>\n\n<style scoped>\n.rk-accordion {\n border-top: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-item {\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-accordion-control:hover {\n color: var(--color-primary);\n}\n\n.rk-accordion-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-accordion-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-accordion-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-accordion-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-accordion-bar-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-accordion-bar-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which is what `height: auto` cannot do. */\n.rk-accordion-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-accordion-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-accordion-clip {\n overflow: hidden;\n}\n\n.rk-accordion-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-accordion-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-accordion-panel,\n .rk-accordion-bar-turn,\n .rk-accordion-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { shallowRef, useId } from 'vue'\n\nexport interface AccordionItem<K extends string> {\n key: K\n /** The question. Already translated. */\n title: string\n}\n\n/**\n * A list that opens one section at a time.\n *\n * ## Why not `<details>`\n *\n * `<details>` is the honest element for this and it was the first choice. A\n * browser removes its content from layout entirely when closed, so there is\n * nothing to animate between — the panel can only appear. Faking it by leaving\n * `open` set and hiding with CSS tells a screen reader the section is expanded\n * while a sighted reader sees it shut, which is worse than no animation.\n *\n * So: a button carrying `aria-expanded`, the answer always in the markup — the\n * prerendered HTML carries every answer, which is what a crawler and a reader\n * without JavaScript get — and a grid row that travels from `0fr` to `1fr`,\n * which is the one way to animate to a height nobody has measured.\n *\n * The content is always present, so anything expensive in it is paid for on\n * first paint. That is the trade: a section a crawler can read costs a section\n * that is never lazy.\n */\nconst {\n items,\n multiple = false,\n headingLevel = 3,\n} = defineProps<{\n items: readonly AccordionItem<K>[]\n /** Allow several open at once. Off by default, which is what makes it an accordion. */\n multiple?: boolean | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level is a prop because it depends on what\n * is above it on the page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\ndefineSlots<{\n /** The answer, per item. */\n default: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n /** The question, when the item's `title` is not enough. */\n title?: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n}>()\n\nconst uid = useId()\n/* `shallowRef` because the list is replaced wholesale rather than mutated, and\n because a deep ref unwraps a generic key type into something TypeScript can\n no longer match against `K`. */\nconst open = shallowRef<K[]>([])\n\nconst isOpen = (key: K) => open.value.includes(key)\n\nfunction toggle(key: K) {\n if (isOpen(key)) {\n open.value = open.value.filter((k) => k !== key)\n } else {\n open.value = multiple ? [...open.value, key] : [key]\n }\n}\n</script>\n\n<template>\n <ul class=\"rk-accordion\">\n <li v-for=\"item in items\" :key=\"item.key\" class=\"rk-accordion-item\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-accordion-control\"\n :aria-expanded=\"isOpen(item.key)\"\n :aria-controls=\"`${uid}-${item.key}`\"\n @click=\"toggle(item.key)\"\n >\n <span class=\"rk-accordion-title\">\n <slot name=\"title\" :item=\"item\" :open=\"isOpen(item.key)\">{{ item.title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control\n keeps its place while it changes meaning. -->\n <span class=\"rk-accordion-mark\" aria-hidden=\"true\">\n <span class=\"rk-accordion-bar\" />\n <span\n class=\"rk-accordion-bar rk-accordion-bar-turn\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n />\n </span>\n </button>\n </component>\n\n <div\n :id=\"`${uid}-${item.key}`\"\n class=\"rk-accordion-panel\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n >\n <div class=\"rk-accordion-clip\">\n <div class=\"rk-accordion-body\" :class=\"{ 'is-open': isOpen(item.key) }\">\n <slot :item=\"item\" :open=\"isOpen(item.key)\" />\n </div>\n </div>\n </div>\n </li>\n </ul>\n</template>\n\n<style scoped>\n.rk-accordion {\n border-top: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-item {\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-accordion-control:hover {\n color: var(--color-primary);\n}\n\n.rk-accordion-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-accordion-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-accordion-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-accordion-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-accordion-bar-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-accordion-bar-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which is what `height: auto` cannot do. */\n.rk-accordion-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-accordion-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-accordion-clip {\n overflow: hidden;\n}\n\n.rk-accordion-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-accordion-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-accordion-panel,\n .rk-accordion-bar-turn,\n .rk-accordion-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { RouterLink } from 'vue-router'\n\nexport interface Crumb {\n /** Already translated. */\n label: string\n /** Omitted for the current page, which is not a link to itself. */\n to?: string | undefined\n}\n\n/**\n * Where this page sits, and the way back up.\n *\n * An ordered list inside a `nav`, because the order is the meaning. The last\n * crumb is the current page and is deliberately not a link: a link to the page\n * you are on is a control that does nothing, and `aria-current=\"page\"` is what\n * says so to everyone else.\n *\n * The separators are `aria-hidden`. A screen reader announces a list and its\n * position in it; reading \"slash\" between every item is noise on top of\n * information the reader already has.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly Crumb[]\n /** Accessible name for the landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <ol class=\"rk-crumbs\">\n <li v-for=\"(item, index) in items\" :key=\"item.label\" class=\"rk-crumb\">\n <RouterLink v-if=\"item.to\" :to=\"item.to\" class=\"rk-crumb-link\">\n {{ item.label }}\n </RouterLink>\n <span v-else class=\"rk-crumb-current\" aria-current=\"page\">{{ item.label }}</span>\n\n <span v-if=\"index < items.length - 1\" class=\"rk-crumb-sep\" aria-hidden=\"true\">/</span>\n </li>\n </ol>\n </nav>\n</template>\n\n<style scoped>\n.rk-crumbs {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.8125rem;\n}\n\n.rk-crumb {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.rk-crumb-link {\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-crumb-link:hover {\n color: var(--color-ink);\n}\n\n.rk-crumb-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\n.rk-crumb-current {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-crumb-sep {\n color: var(--color-hair);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { RouterLink } from 'vue-router'\n\nexport interface Crumb {\n /** Already translated. */\n label: string\n /** Omitted for the current page, which is not a link to itself. */\n to?: string | undefined\n}\n\n/**\n * Where this page sits, and the way back up.\n *\n * An ordered list inside a `nav`, because the order is the meaning. The last\n * crumb is the current page and is deliberately not a link: a link to the page\n * you are on is a control that does nothing, and `aria-current=\"page\"` is what\n * says so to everyone else.\n *\n * The separators are `aria-hidden`. A screen reader announces a list and its\n * position in it; reading \"slash\" between every item is noise on top of\n * information the reader already has.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly Crumb[]\n /** Accessible name for the landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <ol class=\"rk-crumbs\">\n <li v-for=\"(item, index) in items\" :key=\"item.label\" class=\"rk-crumb\">\n <RouterLink v-if=\"item.to\" :to=\"item.to\" class=\"rk-crumb-link\">\n {{ item.label }}\n </RouterLink>\n <span v-else class=\"rk-crumb-current\" aria-current=\"page\">{{ item.label }}</span>\n\n <span v-if=\"index < items.length - 1\" class=\"rk-crumb-sep\" aria-hidden=\"true\">/</span>\n </li>\n </ol>\n </nav>\n</template>\n\n<style scoped>\n.rk-crumbs {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.8125rem;\n}\n\n.rk-crumb {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.rk-crumb-link {\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-crumb-link:hover {\n color: var(--color-ink);\n}\n\n.rk-crumb-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\n.rk-crumb-current {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-crumb-sep {\n color: var(--color-hair);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * One section that opens and closes.\n *\n * `BaseAccordion` is a list of these with the rule that only one stays open.\n * This is the same row on its own, for the common case where the list belongs\n * to the app — because it is staggered as it scrolls in, or interleaved with\n * something else, or rendered from a source the accordion cannot know about.\n *\n * That case is not hypothetical: the first list this kit met decorated every\n * row with a reveal directive, which has to sit on the element the accordion\n * would have owned. Shipping only the list would have meant choosing between\n * the component and the design.\n *\n * ## Why not `<details>`\n *\n * A browser removes `<details>` content from layout entirely when closed, so\n * there is nothing to animate between — the panel can only appear. Faking it by\n * leaving `open` set and hiding with CSS tells a screen reader the section is\n * expanded while a sighted reader sees it shut, which is worse than no\n * animation.\n *\n * So: a button carrying `aria-expanded`, the content always in the markup —\n * which is what a crawler and a reader without JavaScript get — and a grid row\n * travelling from `0fr` to `1fr`, the one way to animate to a height nobody has\n * measured.\n */\nconst { title = '', headingLevel = 3 } = defineProps<{\n /**\n * The question. Already translated.\n *\n * Optional only because the `title` slot is the other way to give one — pass\n * exactly one of them. A disclosure with neither has a control nobody can\n * read, which is why this is documented rather than defaulted to something.\n */\n title?: string | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level depends on what is above it on the\n * page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The answer. */\n default: () => unknown\n /**\n * The question, when the prop is not enough.\n *\n * A slot as well as a prop because the size of a heading is a decision about\n * the page it is on, not about disclosure: the first list to use this wanted\n * the question a step larger on a wide screen, and a component that hard-codes\n * one size makes that an override against its own stylesheet.\n */\n title?: () => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <div class=\"rk-disclosure\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-disclosure-control\"\n :aria-expanded=\"open\"\n :aria-controls=\"id\"\n @click=\"open = !open\"\n >\n <span class=\"rk-disclosure-title\">\n <slot name=\"title\">{{ title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control keeps\n its place while it changes meaning. -->\n <span class=\"rk-disclosure-mark\" aria-hidden=\"true\">\n <span class=\"rk-disclosure-bar\" />\n <span class=\"rk-disclosure-bar rk-disclosure-turn\" :class=\"{ 'is-open': open }\" />\n </span>\n </button>\n </component>\n\n <div :id=\"id\" class=\"rk-disclosure-panel\" :class=\"{ 'is-open': open }\">\n <div class=\"rk-disclosure-clip\">\n <div class=\"rk-disclosure-body\" :class=\"{ 'is-open': open }\"><slot /></div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-disclosure-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-disclosure-control:hover {\n color: var(--color-primary);\n}\n\n.rk-disclosure-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-disclosure-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-disclosure-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-disclosure-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-disclosure-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which `height: auto` cannot do. */\n.rk-disclosure-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-disclosure-clip {\n overflow: hidden;\n}\n\n.rk-disclosure-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-disclosure-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-disclosure-panel,\n .rk-disclosure-turn,\n .rk-disclosure-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * One section that opens and closes.\n *\n * `BaseAccordion` is a list of these with the rule that only one stays open.\n * This is the same row on its own, for the common case where the list belongs\n * to the app — because it is staggered as it scrolls in, or interleaved with\n * something else, or rendered from a source the accordion cannot know about.\n *\n * That case is not hypothetical: the first list this kit met decorated every\n * row with a reveal directive, which has to sit on the element the accordion\n * would have owned. Shipping only the list would have meant choosing between\n * the component and the design.\n *\n * ## Why not `<details>`\n *\n * A browser removes `<details>` content from layout entirely when closed, so\n * there is nothing to animate between — the panel can only appear. Faking it by\n * leaving `open` set and hiding with CSS tells a screen reader the section is\n * expanded while a sighted reader sees it shut, which is worse than no\n * animation.\n *\n * So: a button carrying `aria-expanded`, the content always in the markup —\n * which is what a crawler and a reader without JavaScript get — and a grid row\n * travelling from `0fr` to `1fr`, the one way to animate to a height nobody has\n * measured.\n */\nconst { title = '', headingLevel = 3 } = defineProps<{\n /**\n * The question. Already translated.\n *\n * Optional only because the `title` slot is the other way to give one — pass\n * exactly one of them. A disclosure with neither has a control nobody can\n * read, which is why this is documented rather than defaulted to something.\n */\n title?: string | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level depends on what is above it on the\n * page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The answer. */\n default: () => unknown\n /**\n * The question, when the prop is not enough.\n *\n * A slot as well as a prop because the size of a heading is a decision about\n * the page it is on, not about disclosure: the first list to use this wanted\n * the question a step larger on a wide screen, and a component that hard-codes\n * one size makes that an override against its own stylesheet.\n */\n title?: () => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <div class=\"rk-disclosure\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-disclosure-control\"\n :aria-expanded=\"open\"\n :aria-controls=\"id\"\n @click=\"open = !open\"\n >\n <span class=\"rk-disclosure-title\">\n <slot name=\"title\">{{ title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control keeps\n its place while it changes meaning. -->\n <span class=\"rk-disclosure-mark\" aria-hidden=\"true\">\n <span class=\"rk-disclosure-bar\" />\n <span class=\"rk-disclosure-bar rk-disclosure-turn\" :class=\"{ 'is-open': open }\" />\n </span>\n </button>\n </component>\n\n <div :id=\"id\" class=\"rk-disclosure-panel\" :class=\"{ 'is-open': open }\">\n <div class=\"rk-disclosure-clip\">\n <div class=\"rk-disclosure-body\" :class=\"{ 'is-open': open }\"><slot /></div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-disclosure-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-disclosure-control:hover {\n color: var(--color-primary);\n}\n\n.rk-disclosure-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-disclosure-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-disclosure-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-disclosure-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-disclosure-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which `height: auto` cannot do. */\n.rk-disclosure-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-disclosure-clip {\n overflow: hidden;\n}\n\n.rk-disclosure-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-disclosure-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-disclosure-panel,\n .rk-disclosure-turn,\n .rk-disclosure-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { nextTick, onBeforeUnmount, ref, watch } from 'vue'\n\nimport { inertOutside } from '../utils/inert'\n\n/**\n * A dialog that arrives from nowhere.\n *\n * Not `BaseSheet` with different padding. A sheet slides up from the bottom\n * edge, is dismissed by dragging it back down, and belongs to a thumb; a modal\n * appears in the middle of what you were reading and is dismissed by leaving\n * it. They are two answers to two questions and merging them would give a\n * component that is wrong on a phone and wrong on a desktop.\n *\n * ## The parts that are easy to get wrong\n *\n * This is why it exists rather than being written per dialog, which is what the\n * one wide consumer was doing in two places. None of the following is visible\n * on screen when it is missing:\n *\n * - **Focus moves in and comes back.** Opening without moving focus leaves a\n * keyboard user behind the dialog, operating a page they cannot see. Closing\n * without restoring it drops them at the top of the document.\n * - **Tab does not leave.** A dialog you can Tab out of is a dialog that is\n * modal only to somebody using a mouse.\n * - **Escape closes.** Every dialog in every application does this.\n * - **The page underneath does not scroll.** Otherwise dismissing the dialog\n * returns you somewhere else.\n * - **The backdrop closes it, the panel does not.** A click that starts inside\n * the panel and ends on the backdrop is not a click on the backdrop, which is\n * why this listens for a press on the backdrop itself rather than any click\n * that reaches it.\n */\nconst {\n title,\n closeLabel,\n tone = 'default',\n dismissible = true,\n} = defineProps<{\n /** Names the dialog for assistive tech. Required; an unnamed dialog is a box. */\n title: string\n /** Accessible name for the close button. */\n closeLabel: string\n /**\n * `alert` marks it as a decision that interrupts — `role=\"alertdialog\"`,\n * which a screen reader announces immediately rather than on arrival.\n */\n tone?: 'default' | 'alert' | undefined\n /**\n * Whether Escape and the backdrop close it.\n *\n * Off for a dialog that must be answered — but never off as a way of forcing\n * a reader to a decision they have not been given the information for.\n */\n dismissible?: boolean | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The body. */\n default: () => unknown\n /** Buttons, at the foot. */\n actions?: () => unknown\n}>()\n\nconst panel = ref<HTMLElement | null>(null)\n/** Who had focus before this opened, so it can be given back. */\nlet restoreTo: HTMLElement | null = null\n/** Gives the page behind back; set while open. */\nlet releaseInert: (() => void) | null = null\n\nconst FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nfunction focusable(): HTMLElement[] {\n if (!panel.value) return []\n\n return Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'Escape' && dismissible) {\n event.stopPropagation()\n open.value = false\n\n return\n }\n\n if (event.key !== 'Tab') return\n\n const items = focusable()\n if (items.length === 0) return event.preventDefault()\n\n const first = items[0]!\n const last = items[items.length - 1]!\n const active = document.activeElement\n\n // The wrap has to be done by hand: the browser's own Tab order is the whole\n // document, and the dialog is only part of it.\n if (event.shiftKey && (active === first || !panel.value?.contains(active))) {\n event.preventDefault()\n last.focus()\n } else if (!event.shiftKey && active === last) {\n event.preventDefault()\n first.focus()\n }\n}\n\nwatch(open, async (isOpen) => {\n if (isOpen) {\n restoreTo = document.activeElement as HTMLElement | null\n document.body.style.overflow = 'hidden'\n await nextTick()\n // The Tab wrap above keeps focus cycling; `inert` is what also stops a\n // screen reader's virtual cursor, which walks past keydown handlers.\n if (panel.value) releaseInert = inertOutside(panel.value)\n // The panel itself when it holds nothing focusable, so focus is at least\n // inside the dialog rather than behind it.\n ;(focusable()[0] ?? panel.value)?.focus()\n } else {\n document.body.style.overflow = ''\n // Before focus goes back: an inert element cannot take it.\n releaseInert?.()\n releaseInert = null\n restoreTo?.focus()\n restoreTo = null\n }\n})\n\n// A dialog unmounted while open would otherwise leave the page unscrollable,\n// with nothing on screen to explain why.\nonBeforeUnmount(() => {\n if (open.value) document.body.style.overflow = ''\n releaseInert?.()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <Transition name=\"rk-modal\">\n <div\n v-if=\"open\"\n class=\"rk-modal-scrim\"\n @keydown=\"onKeydown\"\n @mousedown.self=\"dismissible && (open = false)\"\n >\n <div\n ref=\"panel\"\n class=\"rk-modal-panel surface-overlay\"\n :role=\"tone === 'alert' ? 'alertdialog' : 'dialog'\"\n aria-modal=\"true\"\n :aria-label=\"title\"\n tabindex=\"-1\"\n >\n <div class=\"rk-modal-head\">\n <h2 class=\"rk-modal-title\">{{ title }}</h2>\n\n <button\n v-if=\"dismissible\"\n type=\"button\"\n class=\"rk-modal-close\"\n :aria-label=\"closeLabel\"\n @click=\"open = false\"\n >\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M18 6 6 18M6 6l12 12\" stroke-linecap=\"round\" />\n </svg>\n </button>\n </div>\n\n <div class=\"rk-modal-body\"><slot /></div>\n\n <div v-if=\"$slots.actions\" class=\"rk-modal-actions\"><slot name=\"actions\" /></div>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n.rk-modal-scrim {\n position: fixed;\n inset: 0;\n z-index: 50;\n display: grid;\n place-items: center;\n padding: 1.5rem;\n background: color-mix(in srgb, var(--color-ink) 70%, transparent);\n backdrop-filter: blur(4px);\n}\n\n.rk-modal-panel {\n width: 100%;\n max-width: 28rem;\n max-height: 85vh;\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 1.5rem;\n}\n\n.rk-modal-panel:focus {\n outline: none;\n}\n\n.rk-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n}\n\n.rk-modal-title {\n font-size: 1.0625rem;\n font-weight: 600;\n color: var(--color-ink);\n}\n\n.rk-modal-close {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 2rem;\n height: 2rem;\n border-radius: 9999px;\n color: var(--color-ink-soft);\n transition: background-color var(--duration-fast);\n}\n\n.rk-modal-close:hover {\n background: var(--color-muted);\n}\n\n.rk-modal-close:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-modal-close svg {\n width: 1rem;\n height: 1rem;\n}\n\n.rk-modal-body {\n margin-top: 0.75rem;\n color: var(--color-ink-soft);\n font-size: 0.875rem;\n line-height: 1.625;\n}\n\n.rk-modal-actions {\n margin-top: 1.5rem;\n display: flex;\n justify-content: flex-end;\n gap: 0.5rem;\n}\n\n.rk-modal-enter-active,\n.rk-modal-leave-active {\n transition: opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-active .rk-modal-panel,\n.rk-modal-leave-active .rk-modal-panel {\n transition:\n transform var(--duration-base) var(--ease-sheet),\n opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-from,\n.rk-modal-leave-to {\n opacity: 0;\n}\n\n/* From nowhere, not from an edge: a scale is what separates this from a sheet. */\n.rk-modal-enter-from .rk-modal-panel,\n.rk-modal-leave-to .rk-modal-panel {\n transform: scale(0.96);\n opacity: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-modal-enter-active .rk-modal-panel,\n .rk-modal-leave-active .rk-modal-panel {\n transition: opacity var(--duration-base) ease;\n transform: none;\n }\n\n .rk-modal-enter-from .rk-modal-panel,\n .rk-modal-leave-to .rk-modal-panel {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { nextTick, onBeforeUnmount, ref, watch } from 'vue'\n\nimport { inertOutside } from '../utils/inert'\n\n/**\n * A dialog that arrives from nowhere.\n *\n * Not `BaseSheet` with different padding. A sheet slides up from the bottom\n * edge, is dismissed by dragging it back down, and belongs to a thumb; a modal\n * appears in the middle of what you were reading and is dismissed by leaving\n * it. They are two answers to two questions and merging them would give a\n * component that is wrong on a phone and wrong on a desktop.\n *\n * ## The parts that are easy to get wrong\n *\n * This is why it exists rather than being written per dialog, which is what the\n * one wide consumer was doing in two places. None of the following is visible\n * on screen when it is missing:\n *\n * - **Focus moves in and comes back.** Opening without moving focus leaves a\n * keyboard user behind the dialog, operating a page they cannot see. Closing\n * without restoring it drops them at the top of the document.\n * - **Tab does not leave.** A dialog you can Tab out of is a dialog that is\n * modal only to somebody using a mouse.\n * - **Escape closes.** Every dialog in every application does this.\n * - **The page underneath does not scroll.** Otherwise dismissing the dialog\n * returns you somewhere else.\n * - **The backdrop closes it, the panel does not.** A click that starts inside\n * the panel and ends on the backdrop is not a click on the backdrop, which is\n * why this listens for a press on the backdrop itself rather than any click\n * that reaches it.\n */\nconst {\n title,\n closeLabel,\n tone = 'default',\n dismissible = true,\n} = defineProps<{\n /** Names the dialog for assistive tech. Required; an unnamed dialog is a box. */\n title: string\n /** Accessible name for the close button. */\n closeLabel: string\n /**\n * `alert` marks it as a decision that interrupts — `role=\"alertdialog\"`,\n * which a screen reader announces immediately rather than on arrival.\n */\n tone?: 'default' | 'alert' | undefined\n /**\n * Whether Escape and the backdrop close it.\n *\n * Off for a dialog that must be answered — but never off as a way of forcing\n * a reader to a decision they have not been given the information for.\n */\n dismissible?: boolean | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The body. */\n default: () => unknown\n /** Buttons, at the foot. */\n actions?: () => unknown\n}>()\n\nconst panel = ref<HTMLElement | null>(null)\n/** Who had focus before this opened, so it can be given back. */\nlet restoreTo: HTMLElement | null = null\n/** Gives the page behind back; set while open. */\nlet releaseInert: (() => void) | null = null\n\nconst FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nfunction focusable(): HTMLElement[] {\n if (!panel.value) return []\n\n return Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'Escape' && dismissible) {\n event.stopPropagation()\n open.value = false\n\n return\n }\n\n if (event.key !== 'Tab') return\n\n const items = focusable()\n if (items.length === 0) return event.preventDefault()\n\n const first = items[0]!\n const last = items[items.length - 1]!\n const active = document.activeElement\n\n // The wrap has to be done by hand: the browser's own Tab order is the whole\n // document, and the dialog is only part of it.\n if (event.shiftKey && (active === first || !panel.value?.contains(active))) {\n event.preventDefault()\n last.focus()\n } else if (!event.shiftKey && active === last) {\n event.preventDefault()\n first.focus()\n }\n}\n\nwatch(open, async (isOpen) => {\n if (isOpen) {\n restoreTo = document.activeElement as HTMLElement | null\n document.body.style.overflow = 'hidden'\n await nextTick()\n // The Tab wrap above keeps focus cycling; `inert` is what also stops a\n // screen reader's virtual cursor, which walks past keydown handlers.\n if (panel.value) releaseInert = inertOutside(panel.value)\n // The panel itself when it holds nothing focusable, so focus is at least\n // inside the dialog rather than behind it.\n ;(focusable()[0] ?? panel.value)?.focus()\n } else {\n document.body.style.overflow = ''\n // Before focus goes back: an inert element cannot take it.\n releaseInert?.()\n releaseInert = null\n restoreTo?.focus()\n restoreTo = null\n }\n})\n\n// A dialog unmounted while open would otherwise leave the page unscrollable,\n// with nothing on screen to explain why.\nonBeforeUnmount(() => {\n if (open.value) document.body.style.overflow = ''\n releaseInert?.()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <Transition name=\"rk-modal\">\n <div\n v-if=\"open\"\n class=\"rk-modal-scrim\"\n @keydown=\"onKeydown\"\n @mousedown.self=\"dismissible && (open = false)\"\n >\n <div\n ref=\"panel\"\n class=\"rk-modal-panel surface-overlay\"\n :role=\"tone === 'alert' ? 'alertdialog' : 'dialog'\"\n aria-modal=\"true\"\n :aria-label=\"title\"\n tabindex=\"-1\"\n >\n <div class=\"rk-modal-head\">\n <h2 class=\"rk-modal-title\">{{ title }}</h2>\n\n <button\n v-if=\"dismissible\"\n type=\"button\"\n class=\"rk-modal-close\"\n :aria-label=\"closeLabel\"\n @click=\"open = false\"\n >\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M18 6 6 18M6 6l12 12\" stroke-linecap=\"round\" />\n </svg>\n </button>\n </div>\n\n <div class=\"rk-modal-body\"><slot /></div>\n\n <div v-if=\"$slots.actions\" class=\"rk-modal-actions\"><slot name=\"actions\" /></div>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n.rk-modal-scrim {\n position: fixed;\n inset: 0;\n z-index: 50;\n display: grid;\n place-items: center;\n padding: 1.5rem;\n background: color-mix(in srgb, var(--color-ink) 70%, transparent);\n backdrop-filter: blur(4px);\n}\n\n.rk-modal-panel {\n width: 100%;\n max-width: 28rem;\n max-height: 85vh;\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 1.5rem;\n}\n\n.rk-modal-panel:focus {\n outline: none;\n}\n\n.rk-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n}\n\n.rk-modal-title {\n font-size: 1.0625rem;\n font-weight: 600;\n color: var(--color-ink);\n}\n\n.rk-modal-close {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 2rem;\n height: 2rem;\n border-radius: 9999px;\n color: var(--color-ink-soft);\n transition: background-color var(--duration-fast);\n}\n\n.rk-modal-close:hover {\n background: var(--color-muted);\n}\n\n.rk-modal-close:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-modal-close svg {\n width: 1rem;\n height: 1rem;\n}\n\n.rk-modal-body {\n margin-top: 0.75rem;\n color: var(--color-ink-soft);\n font-size: 0.875rem;\n line-height: 1.625;\n}\n\n.rk-modal-actions {\n margin-top: 1.5rem;\n display: flex;\n justify-content: flex-end;\n gap: 0.5rem;\n}\n\n.rk-modal-enter-active,\n.rk-modal-leave-active {\n transition: opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-active .rk-modal-panel,\n.rk-modal-leave-active .rk-modal-panel {\n transition:\n transform var(--duration-base) var(--ease-sheet),\n opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-from,\n.rk-modal-leave-to {\n opacity: 0;\n}\n\n/* From nowhere, not from an edge: a scale is what separates this from a sheet. */\n.rk-modal-enter-from .rk-modal-panel,\n.rk-modal-leave-to .rk-modal-panel {\n transform: scale(0.96);\n opacity: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-modal-enter-active .rk-modal-panel,\n .rk-modal-leave-active .rk-modal-panel {\n transition: opacity var(--duration-base) ease;\n transform: none;\n }\n\n .rk-modal-enter-from .rk-modal-panel,\n .rk-modal-leave-to .rk-modal-panel {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * Moving between pages of a list.\n *\n * A `nav` of buttons rather than links, because this component does not know\n * whether the app pages by route, by query or in memory. It emits a number and\n * the app decides what that means.\n *\n * ## The ellipsis\n *\n * Every page number is unusable past about a dozen, and cutting to\n * \"1 … 7 8 9 … 40\" is what every list does. The window is a fixed size around\n * the current page and the first and last are always present, so the control\n * never changes width as you move through it — a row of numbers that reflows\n * under the pointer is a row you have to re-aim at.\n *\n * The gap is a `<span>`, not a disabled button: it is not a control, and\n * announcing it as one gives a screen reader something to try to press.\n */\nconst {\n page,\n pages,\n siblings = 1,\n label = '',\n previousLabel,\n nextLabel,\n} = defineProps<{\n /** Current page, 1-based. */\n page: number\n /** How many there are. */\n pages: number\n /** How many numbers to show either side of the current one. */\n siblings?: number | undefined\n /** Accessible name for the landmark. */\n label?: string | undefined\n /**\n * Names for the arrows, already translated.\n *\n * Required, because `‹` is a glyph and not a word: a screen reader reading\n * the arrow announces nothing a person can act on.\n */\n previousLabel: string\n nextLabel: string\n}>()\n\nconst emit = defineEmits<{ change: [page: number] }>()\n\nconst GAP = 'gap' as const\n\nconst slots = computed<(number | typeof GAP)[]>(() => {\n if (pages <= 1) return [1]\n\n const first = 1\n const last = pages\n\n /* The widest the windowed form ever gets: first, last, the current page, its\n siblings either side, and the two gaps. Below that the window saves\n nothing, and \"1 2 … 5\" is a worse control than \"1 2 3 4 5\" — so short lists\n are shown whole rather than cut on principle. */\n if (pages <= siblings * 2 + 5) {\n return Array.from({ length: pages }, (_, i) => i + 1)\n }\n const from = Math.max(first + 1, page - siblings)\n const to = Math.min(last - 1, page + siblings)\n\n const out: (number | typeof GAP)[] = [first]\n // A gap standing in for a single page is longer than the page it replaces.\n if (from > first + 1) out.push(GAP)\n for (let n = from; n <= to; n++) out.push(n)\n if (to < last - 1) out.push(GAP)\n if (last > first) out.push(last)\n\n return out\n})\n\nconst go = (next: number) => {\n if (next >= 1 && next <= pages && next !== page) emit('change', next)\n}\n</script>\n\n<template>\n <nav class=\"rk-pager\" :aria-label=\"label || undefined\">\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page <= 1\"\n :aria-label=\"previousLabel\"\n @click=\"go(page - 1)\"\n >\n ‹\n </button>\n\n <template v-for=\"(slot, index) in slots\" :key=\"`${slot}-${index}`\">\n <span v-if=\"slot === 'gap'\" class=\"rk-pager-gap\" aria-hidden=\"true\">…</span>\n <button\n v-else\n type=\"button\"\n class=\"rk-pager-page\"\n :class=\"{ 'is-active': slot === page }\"\n :aria-current=\"slot === page ? 'page' : undefined\"\n @click=\"go(slot)\"\n >\n {{ slot }}\n </button>\n </template>\n\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page >= pages\"\n :aria-label=\"nextLabel\"\n @click=\"go(page + 1)\"\n >\n ›\n </button>\n </nav>\n</template>\n\n<style scoped>\n.rk-pager {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-pager-step,\n.rk-pager-page {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.5rem;\n border-radius: var(--radius-cell);\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition:\n background-color var(--duration-fast),\n color var(--duration-fast);\n}\n\n.rk-pager-step:hover:not(:disabled),\n.rk-pager-page:hover {\n background: var(--color-muted);\n color: var(--color-ink);\n}\n\n.rk-pager-step:focus-visible,\n.rk-pager-page:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-pager-step:disabled {\n opacity: 0.4;\n pointer-events: none;\n}\n\n.rk-pager-page.is-active {\n background: var(--color-primary);\n color: var(--color-on-primary);\n font-weight: 500;\n}\n\n.rk-pager-gap {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n color: var(--color-ink-soft);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * Moving between pages of a list.\n *\n * A `nav` of buttons rather than links, because this component does not know\n * whether the app pages by route, by query or in memory. It emits a number and\n * the app decides what that means.\n *\n * ## The ellipsis\n *\n * Every page number is unusable past about a dozen, and cutting to\n * \"1 … 7 8 9 … 40\" is what every list does. The window is a fixed size around\n * the current page and the first and last are always present, so the control\n * never changes width as you move through it — a row of numbers that reflows\n * under the pointer is a row you have to re-aim at.\n *\n * The gap is a `<span>`, not a disabled button: it is not a control, and\n * announcing it as one gives a screen reader something to try to press.\n */\nconst {\n page,\n pages,\n siblings = 1,\n label = '',\n previousLabel,\n nextLabel,\n} = defineProps<{\n /** Current page, 1-based. */\n page: number\n /** How many there are. */\n pages: number\n /** How many numbers to show either side of the current one. */\n siblings?: number | undefined\n /** Accessible name for the landmark. */\n label?: string | undefined\n /**\n * Names for the arrows, already translated.\n *\n * Required, because `‹` is a glyph and not a word: a screen reader reading\n * the arrow announces nothing a person can act on.\n */\n previousLabel: string\n nextLabel: string\n}>()\n\nconst emit = defineEmits<{ change: [page: number] }>()\n\nconst GAP = 'gap' as const\n\nconst slots = computed<(number | typeof GAP)[]>(() => {\n if (pages <= 1) return [1]\n\n const first = 1\n const last = pages\n\n /* The widest the windowed form ever gets: first, last, the current page, its\n siblings either side, and the two gaps. Below that the window saves\n nothing, and \"1 2 … 5\" is a worse control than \"1 2 3 4 5\" — so short lists\n are shown whole rather than cut on principle. */\n if (pages <= siblings * 2 + 5) {\n return Array.from({ length: pages }, (_, i) => i + 1)\n }\n const from = Math.max(first + 1, page - siblings)\n const to = Math.min(last - 1, page + siblings)\n\n const out: (number | typeof GAP)[] = [first]\n // A gap standing in for a single page is longer than the page it replaces.\n if (from > first + 1) out.push(GAP)\n for (let n = from; n <= to; n++) out.push(n)\n if (to < last - 1) out.push(GAP)\n if (last > first) out.push(last)\n\n return out\n})\n\nconst go = (next: number) => {\n if (next >= 1 && next <= pages && next !== page) emit('change', next)\n}\n</script>\n\n<template>\n <nav class=\"rk-pager\" :aria-label=\"label || undefined\">\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page <= 1\"\n :aria-label=\"previousLabel\"\n @click=\"go(page - 1)\"\n >\n ‹\n </button>\n\n <template v-for=\"(slot, index) in slots\" :key=\"`${slot}-${index}`\">\n <span v-if=\"slot === 'gap'\" class=\"rk-pager-gap\" aria-hidden=\"true\">…</span>\n <button\n v-else\n type=\"button\"\n class=\"rk-pager-page\"\n :class=\"{ 'is-active': slot === page }\"\n :aria-current=\"slot === page ? 'page' : undefined\"\n @click=\"go(slot)\"\n >\n {{ slot }}\n </button>\n </template>\n\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page >= pages\"\n :aria-label=\"nextLabel\"\n @click=\"go(page + 1)\"\n >\n ›\n </button>\n </nav>\n</template>\n\n<style scoped>\n.rk-pager {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-pager-step,\n.rk-pager-page {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.5rem;\n border-radius: var(--radius-cell);\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition:\n background-color var(--duration-fast),\n color var(--duration-fast);\n}\n\n.rk-pager-step:hover:not(:disabled),\n.rk-pager-page:hover {\n background: var(--color-muted);\n color: var(--color-ink);\n}\n\n.rk-pager-step:focus-visible,\n.rk-pager-page:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-pager-step:disabled {\n opacity: 0.4;\n pointer-events: none;\n}\n\n.rk-pager-page.is-active {\n background: var(--color-primary);\n color: var(--color-on-primary);\n font-weight: 500;\n}\n\n.rk-pager-gap {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n color: var(--color-ink-soft);\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { ref, useId } from 'vue'\n\nexport interface TabPanel<K extends string> {\n key: K\n /** Already translated. */\n label: string\n}\n\n/**\n * Sections of one page, one visible at a time.\n *\n * Not `TabBar` and not `NavLinks`: those navigate, and the browser's back\n * button undoes them. These switch a panel inside a page that does not change,\n * so there is no history entry and no route.\n *\n * ## The keyboard, which is the whole reason this is a component\n *\n * The pattern is prescribed and it is not what a row of buttons does by\n * default. Tab enters the tablist once and leaves it once — it does not walk\n * through every tab — and the arrow keys move between them. That is roving\n * tabindex: exactly one tab is reachable by Tab at a time, and moving the\n * selection moves it. Home and End jump to the ends, and the selection wraps,\n * because a list with no edges is faster than one you can fall off.\n *\n * Hand-written tabs almost always get this wrong in the same way: every tab is\n * tabbable, so a keyboard user presses Tab six times to reach the content.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly TabPanel<K>[]\n /** Accessible name for the tablist. */\n label?: string | undefined\n}>()\n\ndefineSlots<{\n /** The panel for the selected tab. */\n default: (props: { item: TabPanel<K>; active: K }) => unknown\n}>()\n\nconst active = defineModel<K>({ required: true })\n\nconst uid = useId()\nconst tabs = ref<HTMLElement[]>([])\n\nconst tabId = (key: K) => `${uid}-tab-${key}`\nconst panelId = (key: K) => `${uid}-panel-${key}`\n\nfunction select(key: K, focus = false) {\n active.value = key\n if (!focus) return\n\n // Focus follows selection, which is what the arrow keys are for. The element\n // is looked up after the update so the new tab is the one that is reachable.\n const index = items.findIndex((item) => item.key === key)\n requestAnimationFrame(() => tabs.value[index]?.focus())\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const index = items.findIndex((item) => item.key === active.value)\n if (index < 0) return\n\n const last = items.length - 1\n let next: number | undefined\n\n if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1\n else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1\n else if (event.key === 'Home') next = 0\n else if (event.key === 'End') next = last\n else return\n\n event.preventDefault()\n const item = items[next]\n if (item) select(item.key, true)\n}\n</script>\n\n<template>\n <div>\n <div class=\"rk-tabs\" role=\"tablist\" :aria-label=\"label || undefined\" @keydown=\"onKeydown\">\n <button\n v-for=\"item in items\"\n :key=\"item.key\"\n ref=\"tabs\"\n type=\"button\"\n role=\"tab\"\n class=\"rk-tab\"\n :class=\"{ 'is-active': item.key === active }\"\n :id=\"tabId(item.key)\"\n :aria-selected=\"item.key === active\"\n :aria-controls=\"panelId(item.key)\"\n :tabindex=\"item.key === active ? 0 : -1\"\n @click=\"select(item.key)\"\n >\n {{ item.label }}\n </button>\n </div>\n\n <div\n v-for=\"item in items\"\n v-show=\"item.key === active\"\n :key=\"item.key\"\n role=\"tabpanel\"\n class=\"rk-tabpanel\"\n :id=\"panelId(item.key)\"\n :aria-labelledby=\"tabId(item.key)\"\n tabindex=\"0\"\n >\n <!-- Every panel is rendered and hidden rather than swapped, so the page\n keeps its height and a crawler sees all of it. -->\n <slot :item=\"item\" :active=\"active\" />\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-tabs {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-tab {\n position: relative;\n padding: 0.625rem 0.875rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-tab:hover {\n color: var(--color-ink);\n}\n\n.rk-tab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: -2px;\n border-radius: var(--radius-cell);\n}\n\n.rk-tab.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-tab.is-active::after {\n content: '';\n position: absolute;\n inset-inline: 0.5rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n}\n\n.rk-tabpanel {\n padding-top: 1rem;\n}\n\n.rk-tabpanel:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { ref, useId } from 'vue'\n\nexport interface TabPanel<K extends string> {\n key: K\n /** Already translated. */\n label: string\n}\n\n/**\n * Sections of one page, one visible at a time.\n *\n * Not `TabBar` and not `NavLinks`: those navigate, and the browser's back\n * button undoes them. These switch a panel inside a page that does not change,\n * so there is no history entry and no route.\n *\n * ## The keyboard, which is the whole reason this is a component\n *\n * The pattern is prescribed and it is not what a row of buttons does by\n * default. Tab enters the tablist once and leaves it once — it does not walk\n * through every tab — and the arrow keys move between them. That is roving\n * tabindex: exactly one tab is reachable by Tab at a time, and moving the\n * selection moves it. Home and End jump to the ends, and the selection wraps,\n * because a list with no edges is faster than one you can fall off.\n *\n * Hand-written tabs almost always get this wrong in the same way: every tab is\n * tabbable, so a keyboard user presses Tab six times to reach the content.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly TabPanel<K>[]\n /** Accessible name for the tablist. */\n label?: string | undefined\n}>()\n\ndefineSlots<{\n /** The panel for the selected tab. */\n default: (props: { item: TabPanel<K>; active: K }) => unknown\n}>()\n\nconst active = defineModel<K>({ required: true })\n\nconst uid = useId()\nconst tabs = ref<HTMLElement[]>([])\n\nconst tabId = (key: K) => `${uid}-tab-${key}`\nconst panelId = (key: K) => `${uid}-panel-${key}`\n\nfunction select(key: K, focus = false) {\n active.value = key\n if (!focus) return\n\n // Focus follows selection, which is what the arrow keys are for. The element\n // is looked up after the update so the new tab is the one that is reachable.\n const index = items.findIndex((item) => item.key === key)\n requestAnimationFrame(() => tabs.value[index]?.focus())\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const index = items.findIndex((item) => item.key === active.value)\n if (index < 0) return\n\n const last = items.length - 1\n let next: number | undefined\n\n if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1\n else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1\n else if (event.key === 'Home') next = 0\n else if (event.key === 'End') next = last\n else return\n\n event.preventDefault()\n const item = items[next]\n if (item) select(item.key, true)\n}\n</script>\n\n<template>\n <div>\n <div class=\"rk-tabs\" role=\"tablist\" :aria-label=\"label || undefined\" @keydown=\"onKeydown\">\n <button\n v-for=\"item in items\"\n :key=\"item.key\"\n ref=\"tabs\"\n type=\"button\"\n role=\"tab\"\n class=\"rk-tab\"\n :class=\"{ 'is-active': item.key === active }\"\n :id=\"tabId(item.key)\"\n :aria-selected=\"item.key === active\"\n :aria-controls=\"panelId(item.key)\"\n :tabindex=\"item.key === active ? 0 : -1\"\n @click=\"select(item.key)\"\n >\n {{ item.label }}\n </button>\n </div>\n\n <div\n v-for=\"item in items\"\n v-show=\"item.key === active\"\n :key=\"item.key\"\n role=\"tabpanel\"\n class=\"rk-tabpanel\"\n :id=\"panelId(item.key)\"\n :aria-labelledby=\"tabId(item.key)\"\n tabindex=\"0\"\n >\n <!-- Every panel is rendered and hidden rather than swapped, so the page\n keeps its height and a crawler sees all of it. -->\n <slot :item=\"item\" :active=\"active\" />\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-tabs {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-tab {\n position: relative;\n padding: 0.625rem 0.875rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-tab:hover {\n color: var(--color-ink);\n}\n\n.rk-tab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: -2px;\n border-radius: var(--radius-cell);\n}\n\n.rk-tab.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-tab.is-active::after {\n content: '';\n position: absolute;\n inset-inline: 0.5rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n}\n\n.rk-tabpanel {\n padding-top: 1rem;\n}\n\n.rk-tabpanel:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * A short label that appears beside a control.\n *\n * ## CSS, not JavaScript, and that is a decision\n *\n * The bubble is shown by `:hover` and `:focus-within` in the stylesheet. No\n * listener, no state, no positioning library. That costs collision detection —\n * a tooltip near the right edge is clipped rather than flipped — and buys the\n * only thing that matters more: it works in a prerendered page, before the\n * bundle has arrived, and with JavaScript switched off entirely.\n *\n * `:focus-within` rather than `:hover` alone is the part people leave out. A\n * tooltip that only answers a mouse is a tooltip that does not exist for anyone\n * using a keyboard, and the content is usually the only explanation of what the\n * control does.\n *\n * The bubble is wired with `aria-describedby`, so a screen reader reads it as a\n * description of the control rather than as loose text nearby. That is why the\n * trigger goes in a slot: the component has to own the id on both ends.\n *\n * ## When not to use it\n *\n * For anything the reader must have. A tooltip is unreachable on a touch screen\n * without a hover state, so text that changes a decision belongs on the page.\n */\nconst { label, placement = 'top' } = defineProps<{\n /** The description. Already translated. */\n label: string\n placement?: 'top' | 'bottom' | undefined\n}>()\n\ndefineSlots<{\n /** The control being described. */\n default: (props: { describedBy: string }) => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <span class=\"rk-tip\">\n <slot :described-by=\"id\" />\n\n <!-- `role=\"tooltip\"` and the id, so the control can point at it. Not\n `aria-hidden`: it is the description, not decoration. -->\n <span :id=\"id\" role=\"tooltip\" class=\"rk-tip-bubble\" :class=\"`is-${placement}`\">\n {{ label }}\n </span>\n </span>\n</template>\n\n<style scoped>\n.rk-tip {\n position: relative;\n display: inline-flex;\n}\n\n.rk-tip-bubble {\n position: absolute;\n left: 50%;\n z-index: 60;\n width: max-content;\n max-width: 16rem;\n transform: translateX(-50%);\n border-radius: var(--radius-cell);\n background: var(--color-ink);\n padding: 0.375rem 0.5rem;\n font-size: 0.75rem;\n line-height: 1.4;\n color: var(--color-canvas);\n opacity: 0;\n /* Out of the hit area while hidden, or it would swallow the pointer on its\n way to the control it describes. */\n pointer-events: none;\n transition: opacity var(--duration-fast) ease;\n}\n\n.rk-tip-bubble.is-top {\n bottom: calc(100% + 0.375rem);\n}\n\n.rk-tip-bubble.is-bottom {\n top: calc(100% + 0.375rem);\n}\n\n.rk-tip:hover .rk-tip-bubble,\n.rk-tip:focus-within .rk-tip-bubble {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-tip-bubble {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * A short label that appears beside a control.\n *\n * ## CSS, not JavaScript, and that is a decision\n *\n * The bubble is shown by `:hover` and `:focus-within` in the stylesheet. No\n * listener, no state, no positioning library. That costs collision detection —\n * a tooltip near the right edge is clipped rather than flipped — and buys the\n * only thing that matters more: it works in a prerendered page, before the\n * bundle has arrived, and with JavaScript switched off entirely.\n *\n * `:focus-within` rather than `:hover` alone is the part people leave out. A\n * tooltip that only answers a mouse is a tooltip that does not exist for anyone\n * using a keyboard, and the content is usually the only explanation of what the\n * control does.\n *\n * The bubble is wired with `aria-describedby`, so a screen reader reads it as a\n * description of the control rather than as loose text nearby. That is why the\n * trigger goes in a slot: the component has to own the id on both ends.\n *\n * ## When not to use it\n *\n * For anything the reader must have. A tooltip is unreachable on a touch screen\n * without a hover state, so text that changes a decision belongs on the page.\n */\nconst { label, placement = 'top' } = defineProps<{\n /** The description. Already translated. */\n label: string\n placement?: 'top' | 'bottom' | undefined\n}>()\n\ndefineSlots<{\n /** The control being described. */\n default: (props: { describedBy: string }) => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <span class=\"rk-tip\">\n <slot :described-by=\"id\" />\n\n <!-- `role=\"tooltip\"` and the id, so the control can point at it. Not\n `aria-hidden`: it is the description, not decoration. -->\n <span :id=\"id\" role=\"tooltip\" class=\"rk-tip-bubble\" :class=\"`is-${placement}`\">\n {{ label }}\n </span>\n </span>\n</template>\n\n<style scoped>\n.rk-tip {\n position: relative;\n display: inline-flex;\n}\n\n.rk-tip-bubble {\n position: absolute;\n left: 50%;\n z-index: 60;\n width: max-content;\n max-width: 16rem;\n transform: translateX(-50%);\n border-radius: var(--radius-cell);\n background: var(--color-ink);\n padding: 0.375rem 0.5rem;\n font-size: 0.75rem;\n line-height: 1.4;\n color: var(--color-canvas);\n opacity: 0;\n /* Out of the hit area while hidden, or it would swallow the pointer on its\n way to the control it describes. */\n pointer-events: none;\n transition: opacity var(--duration-fast) ease;\n}\n\n.rk-tip-bubble.is-top {\n bottom: calc(100% + 0.375rem);\n}\n\n.rk-tip-bubble.is-bottom {\n top: calc(100% + 0.375rem);\n}\n\n.rk-tip:hover .rk-tip-bubble,\n.rk-tip:focus-within .rk-tip-bubble {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-tip-bubble {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { RouterLink } from 'vue-router'\n\nexport interface NavLinkItem<K extends string> {\n /** Identity, compared against `active`. */\n key: K\n /** Router destination. */\n to: string\n /** Already translated. */\n label: string\n}\n\n/**\n * The primary navigation of a wide site.\n *\n * The same contract as `TabBar` minus the icon, so moving an app between a\n * bottom bar and a top one is a change of component and not of data. That is\n * the whole reason the shapes match.\n *\n * A rule under the current section rather than a filled pill: at this size a\n * pill reads as a button, and these are not buttons — they are where you are.\n * The rule is a scaled element rather than a border so it can travel, and it is\n * `aria-hidden` because `aria-current` already says the same thing to anyone not\n * looking at it.\n */\nconst {\n items,\n active,\n label = '',\n} = defineProps<{\n items: readonly NavLinkItem<K>[]\n /** Which item is current. Usually derived from the route. */\n active?: K | undefined\n /** Accessible name for the navigation landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <div class=\"rk-nav-inner\">\n <RouterLink\n v-for=\"item in items\"\n :key=\"item.key\"\n :to=\"item.to\"\n class=\"rk-nav-link\"\n :class=\"{ 'is-active': item.key === active }\"\n :aria-current=\"item.key === active ? 'page' : undefined\"\n >\n {{ item.label }}\n <span class=\"rk-nav-rule\" aria-hidden=\"true\" />\n </RouterLink>\n </div>\n </nav>\n</template>\n\n<style scoped>\n/* The root carries no layout of its own, and that is the fix for a real bug\n rather than a style preference.\n\n A rule on the root loses to nothing: Vue's scoped CSS compiles `.rk-nav` to\n `.rk-nav[data-v-hash]`, which outranks a utility class like Tailwind's\n `hidden` outright — so `class=\"hidden sm:flex\"` did nothing and the nav\n appeared on a phone, on top of the call to action. `:where()` does not save\n it either; the scoping attribute puts the specificity back.\n\n So the flex row lives on an element the caller does not own, and the root is\n left for them to style. `TabBar` is built the same way for the same reason. */\n.rk-nav-inner {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-nav-link {\n position: relative;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.75rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-nav-link:hover {\n color: var(--color-ink);\n}\n\n.rk-nav-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-nav-link.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-nav-rule {\n position: absolute;\n inset-inline: 0.75rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n transform: scaleX(0);\n transform-origin: left;\n transition: transform var(--duration-slow);\n}\n\n.rk-nav-link.is-active .rk-nav-rule {\n transform: scaleX(1);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-nav-rule {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { RouterLink } from 'vue-router'\n\nexport interface NavLinkItem<K extends string> {\n /** Identity, compared against `active`. */\n key: K\n /** Router destination. */\n to: string\n /** Already translated. */\n label: string\n}\n\n/**\n * The primary navigation of a wide site.\n *\n * The same contract as `TabBar` minus the icon, so moving an app between a\n * bottom bar and a top one is a change of component and not of data. That is\n * the whole reason the shapes match.\n *\n * A rule under the current section rather than a filled pill: at this size a\n * pill reads as a button, and these are not buttons — they are where you are.\n * The rule is a scaled element rather than a border so it can travel, and it is\n * `aria-hidden` because `aria-current` already says the same thing to anyone not\n * looking at it.\n */\nconst {\n items,\n active,\n label = '',\n} = defineProps<{\n items: readonly NavLinkItem<K>[]\n /** Which item is current. Usually derived from the route. */\n active?: K | undefined\n /** Accessible name for the navigation landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <div class=\"rk-nav-inner\">\n <RouterLink\n v-for=\"item in items\"\n :key=\"item.key\"\n :to=\"item.to\"\n class=\"rk-nav-link\"\n :class=\"{ 'is-active': item.key === active }\"\n :aria-current=\"item.key === active ? 'page' : undefined\"\n >\n {{ item.label }}\n <span class=\"rk-nav-rule\" aria-hidden=\"true\" />\n </RouterLink>\n </div>\n </nav>\n</template>\n\n<style scoped>\n/* The root carries no layout of its own, and that is the fix for a real bug\n rather than a style preference.\n\n A rule on the root loses to nothing: Vue's scoped CSS compiles `.rk-nav` to\n `.rk-nav[data-v-hash]`, which outranks a utility class like Tailwind's\n `hidden` outright — so `class=\"hidden sm:flex\"` did nothing and the nav\n appeared on a phone, on top of the call to action. `:where()` does not save\n it either; the scoping attribute puts the specificity back.\n\n So the flex row lives on an element the caller does not own, and the root is\n left for them to style. `TabBar` is built the same way for the same reason. */\n.rk-nav-inner {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-nav-link {\n position: relative;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.75rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-nav-link:hover {\n color: var(--color-ink);\n}\n\n.rk-nav-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-nav-link.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-nav-rule {\n position: absolute;\n inset-inline: 0.75rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n transform: scaleX(0);\n transform-origin: left;\n transition: transform var(--duration-slow);\n}\n\n.rk-nav-link.is-active .rk-nav-rule {\n transform: scaleX(1);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-nav-rule {\n transition: none;\n }\n}\n</style>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"web.js","names":["$slots"],"sources":["../src/web/BaseAccordion.vue","../src/web/BaseAccordion.vue","../src/web/BaseBreadcrumb.vue","../src/web/BaseBreadcrumb.vue","../src/web/BaseDisclosure.vue","../src/web/BaseDisclosure.vue","../src/web/BaseModal.vue","../src/web/BaseModal.vue","../src/web/BasePagination.vue","../src/web/BasePagination.vue","../src/web/BaseTabs.vue","../src/web/BaseTabs.vue","../src/web/BaseTooltip.vue","../src/web/BaseTooltip.vue","../src/web/NavLinks.vue","../src/web/NavLinks.vue"],"sourcesContent":["<script setup lang=\"ts\" generic=\"K extends string\">\nimport { shallowRef, useId } from 'vue'\n\nexport interface AccordionItem<K extends string> {\n key: K\n /** The question. Already translated. */\n title: string\n}\n\n/**\n * A list that opens one section at a time.\n *\n * ## Why not `<details>`\n *\n * `<details>` is the honest element for this and it was the first choice. A\n * browser removes its content from layout entirely when closed, so there is\n * nothing to animate between — the panel can only appear. Faking it by leaving\n * `open` set and hiding with CSS tells a screen reader the section is expanded\n * while a sighted reader sees it shut, which is worse than no animation.\n *\n * So: a button carrying `aria-expanded`, the answer always in the markup — the\n * prerendered HTML carries every answer, which is what a crawler and a reader\n * without JavaScript get — and a grid row that travels from `0fr` to `1fr`,\n * which is the one way to animate to a height nobody has measured.\n *\n * The content is always present, so anything expensive in it is paid for on\n * first paint. That is the trade: a section a crawler can read costs a section\n * that is never lazy.\n */\nconst {\n items,\n multiple = false,\n headingLevel = 3,\n} = defineProps<{\n items: readonly AccordionItem<K>[]\n /** Allow several open at once. Off by default, which is what makes it an accordion. */\n multiple?: boolean | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level is a prop because it depends on what\n * is above it on the page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\ndefineSlots<{\n /** The answer, per item. */\n default: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n /** The question, when the item's `title` is not enough. */\n title?: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n}>()\n\nconst uid = useId()\n/* `shallowRef` because the list is replaced wholesale rather than mutated, and\n because a deep ref unwraps a generic key type into something TypeScript can\n no longer match against `K`. */\nconst open = shallowRef<K[]>([])\n\nconst isOpen = (key: K) => open.value.includes(key)\n\nfunction toggle(key: K) {\n if (isOpen(key)) {\n open.value = open.value.filter((k) => k !== key)\n } else {\n open.value = multiple ? [...open.value, key] : [key]\n }\n}\n</script>\n\n<template>\n <ul class=\"rk-accordion\">\n <li v-for=\"item in items\" :key=\"item.key\" class=\"rk-accordion-item\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-accordion-control\"\n :aria-expanded=\"isOpen(item.key)\"\n :aria-controls=\"`${uid}-${item.key}`\"\n @click=\"toggle(item.key)\"\n >\n <span class=\"rk-accordion-title\">\n <slot name=\"title\" :item=\"item\" :open=\"isOpen(item.key)\">{{ item.title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control\n keeps its place while it changes meaning. -->\n <span class=\"rk-accordion-mark\" aria-hidden=\"true\">\n <span class=\"rk-accordion-bar\" />\n <span\n class=\"rk-accordion-bar rk-accordion-bar-turn\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n />\n </span>\n </button>\n </component>\n\n <div\n :id=\"`${uid}-${item.key}`\"\n class=\"rk-accordion-panel\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n >\n <div class=\"rk-accordion-clip\">\n <div class=\"rk-accordion-body\" :class=\"{ 'is-open': isOpen(item.key) }\">\n <slot :item=\"item\" :open=\"isOpen(item.key)\" />\n </div>\n </div>\n </div>\n </li>\n </ul>\n</template>\n\n<style scoped>\n.rk-accordion {\n border-top: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-item {\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-accordion-control:hover {\n color: var(--color-primary);\n}\n\n.rk-accordion-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-accordion-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-accordion-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-accordion-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-accordion-bar-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-accordion-bar-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which is what `height: auto` cannot do. */\n.rk-accordion-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-accordion-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-accordion-clip {\n overflow: hidden;\n}\n\n.rk-accordion-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-accordion-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-accordion-panel,\n .rk-accordion-bar-turn,\n .rk-accordion-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { shallowRef, useId } from 'vue'\n\nexport interface AccordionItem<K extends string> {\n key: K\n /** The question. Already translated. */\n title: string\n}\n\n/**\n * A list that opens one section at a time.\n *\n * ## Why not `<details>`\n *\n * `<details>` is the honest element for this and it was the first choice. A\n * browser removes its content from layout entirely when closed, so there is\n * nothing to animate between — the panel can only appear. Faking it by leaving\n * `open` set and hiding with CSS tells a screen reader the section is expanded\n * while a sighted reader sees it shut, which is worse than no animation.\n *\n * So: a button carrying `aria-expanded`, the answer always in the markup — the\n * prerendered HTML carries every answer, which is what a crawler and a reader\n * without JavaScript get — and a grid row that travels from `0fr` to `1fr`,\n * which is the one way to animate to a height nobody has measured.\n *\n * The content is always present, so anything expensive in it is paid for on\n * first paint. That is the trade: a section a crawler can read costs a section\n * that is never lazy.\n */\nconst {\n items,\n multiple = false,\n headingLevel = 3,\n} = defineProps<{\n items: readonly AccordionItem<K>[]\n /** Allow several open at once. Off by default, which is what makes it an accordion. */\n multiple?: boolean | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level is a prop because it depends on what\n * is above it on the page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\ndefineSlots<{\n /** The answer, per item. */\n default: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n /** The question, when the item's `title` is not enough. */\n title?: (props: { item: AccordionItem<K>; open: boolean }) => unknown\n}>()\n\nconst uid = useId()\n/* `shallowRef` because the list is replaced wholesale rather than mutated, and\n because a deep ref unwraps a generic key type into something TypeScript can\n no longer match against `K`. */\nconst open = shallowRef<K[]>([])\n\nconst isOpen = (key: K) => open.value.includes(key)\n\nfunction toggle(key: K) {\n if (isOpen(key)) {\n open.value = open.value.filter((k) => k !== key)\n } else {\n open.value = multiple ? [...open.value, key] : [key]\n }\n}\n</script>\n\n<template>\n <ul class=\"rk-accordion\">\n <li v-for=\"item in items\" :key=\"item.key\" class=\"rk-accordion-item\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-accordion-control\"\n :aria-expanded=\"isOpen(item.key)\"\n :aria-controls=\"`${uid}-${item.key}`\"\n @click=\"toggle(item.key)\"\n >\n <span class=\"rk-accordion-title\">\n <slot name=\"title\" :item=\"item\" :open=\"isOpen(item.key)\">{{ item.title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control\n keeps its place while it changes meaning. -->\n <span class=\"rk-accordion-mark\" aria-hidden=\"true\">\n <span class=\"rk-accordion-bar\" />\n <span\n class=\"rk-accordion-bar rk-accordion-bar-turn\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n />\n </span>\n </button>\n </component>\n\n <div\n :id=\"`${uid}-${item.key}`\"\n class=\"rk-accordion-panel\"\n :class=\"{ 'is-open': isOpen(item.key) }\"\n >\n <div class=\"rk-accordion-clip\">\n <div class=\"rk-accordion-body\" :class=\"{ 'is-open': isOpen(item.key) }\">\n <slot :item=\"item\" :open=\"isOpen(item.key)\" />\n </div>\n </div>\n </div>\n </li>\n </ul>\n</template>\n\n<style scoped>\n.rk-accordion {\n border-top: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-item {\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-accordion-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-accordion-control:hover {\n color: var(--color-primary);\n}\n\n.rk-accordion-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-accordion-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-accordion-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-accordion-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-accordion-bar-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-accordion-bar-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which is what `height: auto` cannot do. */\n.rk-accordion-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-accordion-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-accordion-clip {\n overflow: hidden;\n}\n\n.rk-accordion-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-accordion-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-accordion-panel,\n .rk-accordion-bar-turn,\n .rk-accordion-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { RouterLink } from 'vue-router'\n\nexport interface Crumb {\n /** Already translated. */\n label: string\n /** Omitted for the current page, which is not a link to itself. */\n to?: string | undefined\n}\n\n/**\n * Where this page sits, and the way back up.\n *\n * An ordered list inside a `nav`, because the order is the meaning. The last\n * crumb is the current page and is deliberately not a link: a link to the page\n * you are on is a control that does nothing, and `aria-current=\"page\"` is what\n * says so to everyone else.\n *\n * The separators are `aria-hidden`. A screen reader announces a list and its\n * position in it; reading \"slash\" between every item is noise on top of\n * information the reader already has.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly Crumb[]\n /** Accessible name for the landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <ol class=\"rk-crumbs\">\n <li v-for=\"(item, index) in items\" :key=\"item.label\" class=\"rk-crumb\">\n <RouterLink v-if=\"item.to\" :to=\"item.to\" class=\"rk-crumb-link\">\n {{ item.label }}\n </RouterLink>\n <span v-else class=\"rk-crumb-current\" aria-current=\"page\">{{ item.label }}</span>\n\n <span v-if=\"index < items.length - 1\" class=\"rk-crumb-sep\" aria-hidden=\"true\">/</span>\n </li>\n </ol>\n </nav>\n</template>\n\n<style scoped>\n.rk-crumbs {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.8125rem;\n}\n\n.rk-crumb {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.rk-crumb-link {\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-crumb-link:hover {\n color: var(--color-ink);\n}\n\n.rk-crumb-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\n.rk-crumb-current {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-crumb-sep {\n color: var(--color-hair);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { RouterLink } from 'vue-router'\n\nexport interface Crumb {\n /** Already translated. */\n label: string\n /** Omitted for the current page, which is not a link to itself. */\n to?: string | undefined\n}\n\n/**\n * Where this page sits, and the way back up.\n *\n * An ordered list inside a `nav`, because the order is the meaning. The last\n * crumb is the current page and is deliberately not a link: a link to the page\n * you are on is a control that does nothing, and `aria-current=\"page\"` is what\n * says so to everyone else.\n *\n * The separators are `aria-hidden`. A screen reader announces a list and its\n * position in it; reading \"slash\" between every item is noise on top of\n * information the reader already has.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly Crumb[]\n /** Accessible name for the landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <ol class=\"rk-crumbs\">\n <li v-for=\"(item, index) in items\" :key=\"item.label\" class=\"rk-crumb\">\n <RouterLink v-if=\"item.to\" :to=\"item.to\" class=\"rk-crumb-link\">\n {{ item.label }}\n </RouterLink>\n <span v-else class=\"rk-crumb-current\" aria-current=\"page\">{{ item.label }}</span>\n\n <span v-if=\"index < items.length - 1\" class=\"rk-crumb-sep\" aria-hidden=\"true\">/</span>\n </li>\n </ol>\n </nav>\n</template>\n\n<style scoped>\n.rk-crumbs {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.8125rem;\n}\n\n.rk-crumb {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.rk-crumb-link {\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-crumb-link:hover {\n color: var(--color-ink);\n}\n\n.rk-crumb-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\n.rk-crumb-current {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-crumb-sep {\n color: var(--color-hair);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * One section that opens and closes.\n *\n * `BaseAccordion` is a list of these with the rule that only one stays open.\n * This is the same row on its own, for the common case where the list belongs\n * to the app — because it is staggered as it scrolls in, or interleaved with\n * something else, or rendered from a source the accordion cannot know about.\n *\n * That case is not hypothetical: the first list this kit met decorated every\n * row with a reveal directive, which has to sit on the element the accordion\n * would have owned. Shipping only the list would have meant choosing between\n * the component and the design.\n *\n * ## Why not `<details>`\n *\n * A browser removes `<details>` content from layout entirely when closed, so\n * there is nothing to animate between — the panel can only appear. Faking it by\n * leaving `open` set and hiding with CSS tells a screen reader the section is\n * expanded while a sighted reader sees it shut, which is worse than no\n * animation.\n *\n * So: a button carrying `aria-expanded`, the content always in the markup —\n * which is what a crawler and a reader without JavaScript get — and a grid row\n * travelling from `0fr` to `1fr`, the one way to animate to a height nobody has\n * measured.\n */\nconst { title = '', headingLevel = 3 } = defineProps<{\n /**\n * The question. Already translated.\n *\n * Optional only because the `title` slot is the other way to give one — pass\n * exactly one of them. A disclosure with neither has a control nobody can\n * read, which is why this is documented rather than defaulted to something.\n */\n title?: string | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level depends on what is above it on the\n * page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The answer. */\n default: () => unknown\n /**\n * The question, when the prop is not enough.\n *\n * A slot as well as a prop because the size of a heading is a decision about\n * the page it is on, not about disclosure: the first list to use this wanted\n * the question a step larger on a wide screen, and a component that hard-codes\n * one size makes that an override against its own stylesheet.\n */\n title?: () => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <div class=\"rk-disclosure\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-disclosure-control\"\n :aria-expanded=\"open\"\n :aria-controls=\"id\"\n @click=\"open = !open\"\n >\n <span class=\"rk-disclosure-title\">\n <slot name=\"title\">{{ title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control keeps\n its place while it changes meaning. -->\n <span class=\"rk-disclosure-mark\" aria-hidden=\"true\">\n <span class=\"rk-disclosure-bar\" />\n <span class=\"rk-disclosure-bar rk-disclosure-turn\" :class=\"{ 'is-open': open }\" />\n </span>\n </button>\n </component>\n\n <div :id=\"id\" class=\"rk-disclosure-panel\" :class=\"{ 'is-open': open }\">\n <div class=\"rk-disclosure-clip\">\n <div class=\"rk-disclosure-body\" :class=\"{ 'is-open': open }\"><slot /></div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-disclosure-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-disclosure-control:hover {\n color: var(--color-primary);\n}\n\n.rk-disclosure-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-disclosure-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-disclosure-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-disclosure-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-disclosure-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which `height: auto` cannot do. */\n.rk-disclosure-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-disclosure-clip {\n overflow: hidden;\n}\n\n.rk-disclosure-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-disclosure-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-disclosure-panel,\n .rk-disclosure-turn,\n .rk-disclosure-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * One section that opens and closes.\n *\n * `BaseAccordion` is a list of these with the rule that only one stays open.\n * This is the same row on its own, for the common case where the list belongs\n * to the app — because it is staggered as it scrolls in, or interleaved with\n * something else, or rendered from a source the accordion cannot know about.\n *\n * That case is not hypothetical: the first list this kit met decorated every\n * row with a reveal directive, which has to sit on the element the accordion\n * would have owned. Shipping only the list would have meant choosing between\n * the component and the design.\n *\n * ## Why not `<details>`\n *\n * A browser removes `<details>` content from layout entirely when closed, so\n * there is nothing to animate between — the panel can only appear. Faking it by\n * leaving `open` set and hiding with CSS tells a screen reader the section is\n * expanded while a sighted reader sees it shut, which is worse than no\n * animation.\n *\n * So: a button carrying `aria-expanded`, the content always in the markup —\n * which is what a crawler and a reader without JavaScript get — and a grid row\n * travelling from `0fr` to `1fr`, the one way to animate to a height nobody has\n * measured.\n */\nconst { title = '', headingLevel = 3 } = defineProps<{\n /**\n * The question. Already translated.\n *\n * Optional only because the `title` slot is the other way to give one — pass\n * exactly one of them. A disclosure with neither has a control nobody can\n * read, which is why this is documented rather than defaulted to something.\n */\n title?: string | undefined\n /**\n * Which heading the control sits inside.\n *\n * A heading, always — it is how a screen reader user moves between sections\n * without opening any of them. The level depends on what is above it on the\n * page, and getting it wrong breaks the outline.\n */\n headingLevel?: 2 | 3 | 4 | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The answer. */\n default: () => unknown\n /**\n * The question, when the prop is not enough.\n *\n * A slot as well as a prop because the size of a heading is a decision about\n * the page it is on, not about disclosure: the first list to use this wanted\n * the question a step larger on a wide screen, and a component that hard-codes\n * one size makes that an override against its own stylesheet.\n */\n title?: () => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <div class=\"rk-disclosure\">\n <component :is=\"`h${headingLevel}`\">\n <button\n type=\"button\"\n class=\"rk-disclosure-control\"\n :aria-expanded=\"open\"\n :aria-controls=\"id\"\n @click=\"open = !open\"\n >\n <span class=\"rk-disclosure-title\">\n <slot name=\"title\">{{ title }}</slot>\n </span>\n\n <!-- A plus that becomes a minus: one stroke turns, so the control keeps\n its place while it changes meaning. -->\n <span class=\"rk-disclosure-mark\" aria-hidden=\"true\">\n <span class=\"rk-disclosure-bar\" />\n <span class=\"rk-disclosure-bar rk-disclosure-turn\" :class=\"{ 'is-open': open }\" />\n </span>\n </button>\n </component>\n\n <div :id=\"id\" class=\"rk-disclosure-panel\" :class=\"{ 'is-open': open }\">\n <div class=\"rk-disclosure-clip\">\n <div class=\"rk-disclosure-body\" :class=\"{ 'is-open': open }\"><slot /></div>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-disclosure-control {\n display: flex;\n width: 100%;\n cursor: pointer;\n align-items: center;\n justify-content: space-between;\n gap: 1.5rem;\n padding: 1.25rem 0;\n text-align: left;\n transition: color var(--duration-fast);\n}\n\n.rk-disclosure-control:hover {\n color: var(--color-primary);\n}\n\n.rk-disclosure-control:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-disclosure-title {\n color: var(--color-ink);\n font-size: 1rem;\n font-weight: 500;\n}\n\n.rk-disclosure-mark {\n position: relative;\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 1.5rem;\n height: 1.5rem;\n}\n\n.rk-disclosure-bar {\n position: absolute;\n height: 1px;\n width: 0.875rem;\n background: var(--color-ink-soft);\n}\n\n.rk-disclosure-turn {\n transform: rotate(90deg);\n transition: transform var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-turn.is-open {\n transform: rotate(0deg);\n}\n\n/* 0fr to 1fr is the whole trick: a grid row can be animated to a height that\n was never measured, which `height: auto` cannot do. */\n.rk-disclosure-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--duration-slower) ease-out;\n}\n\n.rk-disclosure-panel.is-open {\n grid-template-rows: 1fr;\n}\n\n.rk-disclosure-clip {\n overflow: hidden;\n}\n\n.rk-disclosure-body {\n max-width: 68ch;\n padding-bottom: 1.5rem;\n color: var(--color-ink-soft);\n line-height: 1.625;\n opacity: 0;\n transition: opacity var(--duration-slow);\n}\n\n.rk-disclosure-body.is-open {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-disclosure-panel,\n .rk-disclosure-turn,\n .rk-disclosure-body {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { nextTick, onBeforeUnmount, ref, watch } from 'vue'\n\nimport { inertOutside } from '../utils/inert'\n\n/**\n * A dialog that arrives from nowhere.\n *\n * Not `BaseSheet` with different padding. A sheet slides up from the bottom\n * edge, is dismissed by dragging it back down, and belongs to a thumb; a modal\n * appears in the middle of what you were reading and is dismissed by leaving\n * it. They are two answers to two questions and merging them would give a\n * component that is wrong on a phone and wrong on a desktop.\n *\n * ## The parts that are easy to get wrong\n *\n * This is why it exists rather than being written per dialog, which is what the\n * one wide consumer was doing in two places. None of the following is visible\n * on screen when it is missing:\n *\n * - **Focus moves in and comes back.** Opening without moving focus leaves a\n * keyboard user behind the dialog, operating a page they cannot see. Closing\n * without restoring it drops them at the top of the document.\n * - **Tab does not leave.** A dialog you can Tab out of is a dialog that is\n * modal only to somebody using a mouse.\n * - **Escape closes.** Every dialog in every application does this.\n * - **The page underneath does not scroll.** Otherwise dismissing the dialog\n * returns you somewhere else.\n * - **The backdrop closes it, the panel does not.** A click that starts inside\n * the panel and ends on the backdrop is not a click on the backdrop, which is\n * why this listens for a press on the backdrop itself rather than any click\n * that reaches it.\n */\nconst {\n title,\n closeLabel,\n tone = 'default',\n dismissible = true,\n} = defineProps<{\n /** Names the dialog for assistive tech. Required; an unnamed dialog is a box. */\n title: string\n /** Accessible name for the close button. */\n closeLabel: string\n /**\n * `alert` marks it as a decision that interrupts — `role=\"alertdialog\"`,\n * which a screen reader announces immediately rather than on arrival.\n */\n tone?: 'default' | 'alert' | undefined\n /**\n * Whether Escape and the backdrop close it.\n *\n * Off for a dialog that must be answered — but never off as a way of forcing\n * a reader to a decision they have not been given the information for.\n */\n dismissible?: boolean | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The body. */\n default: () => unknown\n /** Buttons, at the foot. */\n actions?: () => unknown\n}>()\n\nconst panel = ref<HTMLElement | null>(null)\n/** Who had focus before this opened, so it can be given back. */\nlet restoreTo: HTMLElement | null = null\n/** Gives the page behind back; set while open. */\nlet releaseInert: (() => void) | null = null\n\nconst FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nfunction focusable(): HTMLElement[] {\n if (!panel.value) return []\n\n return Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'Escape' && dismissible) {\n event.stopPropagation()\n open.value = false\n\n return\n }\n\n if (event.key !== 'Tab') return\n\n const items = focusable()\n if (items.length === 0) return event.preventDefault()\n\n const first = items[0]!\n const last = items[items.length - 1]!\n const active = document.activeElement\n\n // The wrap has to be done by hand: the browser's own Tab order is the whole\n // document, and the dialog is only part of it.\n if (event.shiftKey && (active === first || !panel.value?.contains(active))) {\n event.preventDefault()\n last.focus()\n } else if (!event.shiftKey && active === last) {\n event.preventDefault()\n first.focus()\n }\n}\n\nwatch(open, async (isOpen) => {\n if (isOpen) {\n restoreTo = document.activeElement as HTMLElement | null\n document.body.style.overflow = 'hidden'\n await nextTick()\n // The Tab wrap above keeps focus cycling; `inert` is what also stops a\n // screen reader's virtual cursor, which walks past keydown handlers.\n if (panel.value) releaseInert = inertOutside(panel.value)\n // The panel itself when it holds nothing focusable, so focus is at least\n // inside the dialog rather than behind it.\n ;(focusable()[0] ?? panel.value)?.focus()\n } else {\n document.body.style.overflow = ''\n // Before focus goes back: an inert element cannot take it.\n releaseInert?.()\n releaseInert = null\n restoreTo?.focus()\n restoreTo = null\n }\n})\n\n// A dialog unmounted while open would otherwise leave the page unscrollable,\n// with nothing on screen to explain why.\nonBeforeUnmount(() => {\n if (open.value) document.body.style.overflow = ''\n releaseInert?.()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <Transition name=\"rk-modal\">\n <div\n v-if=\"open\"\n class=\"rk-modal-scrim\"\n @keydown=\"onKeydown\"\n @mousedown.self=\"dismissible && (open = false)\"\n >\n <div\n ref=\"panel\"\n class=\"rk-modal-panel surface-overlay\"\n :role=\"tone === 'alert' ? 'alertdialog' : 'dialog'\"\n aria-modal=\"true\"\n :aria-label=\"title\"\n tabindex=\"-1\"\n >\n <div class=\"rk-modal-head\">\n <h2 class=\"rk-modal-title\">{{ title }}</h2>\n\n <button\n v-if=\"dismissible\"\n type=\"button\"\n class=\"rk-modal-close\"\n :aria-label=\"closeLabel\"\n @click=\"open = false\"\n >\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M18 6 6 18M6 6l12 12\" stroke-linecap=\"round\" />\n </svg>\n </button>\n </div>\n\n <div class=\"rk-modal-body\"><slot /></div>\n\n <div v-if=\"$slots.actions\" class=\"rk-modal-actions\"><slot name=\"actions\" /></div>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n.rk-modal-scrim {\n position: fixed;\n inset: 0;\n z-index: 50;\n display: grid;\n place-items: center;\n padding: 1.5rem;\n background: color-mix(in srgb, var(--color-ink) 70%, transparent);\n backdrop-filter: blur(4px);\n}\n\n.rk-modal-panel {\n width: 100%;\n max-width: 28rem;\n max-height: 85vh;\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 1.5rem;\n}\n\n.rk-modal-panel:focus {\n outline: none;\n}\n\n.rk-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n}\n\n.rk-modal-title {\n font-size: 1.0625rem;\n font-weight: 600;\n color: var(--color-ink);\n}\n\n.rk-modal-close {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 2rem;\n height: 2rem;\n border-radius: 9999px;\n color: var(--color-ink-soft);\n transition: background-color var(--duration-fast);\n}\n\n.rk-modal-close:hover {\n background: var(--color-muted);\n}\n\n.rk-modal-close:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-modal-close svg {\n width: 1rem;\n height: 1rem;\n}\n\n.rk-modal-body {\n margin-top: 0.75rem;\n color: var(--color-ink-soft);\n font-size: 0.875rem;\n line-height: 1.625;\n}\n\n.rk-modal-actions {\n margin-top: 1.5rem;\n display: flex;\n justify-content: flex-end;\n gap: 0.5rem;\n}\n\n.rk-modal-enter-active,\n.rk-modal-leave-active {\n transition: opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-active .rk-modal-panel,\n.rk-modal-leave-active .rk-modal-panel {\n transition:\n transform var(--duration-base) var(--ease-sheet),\n opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-from,\n.rk-modal-leave-to {\n opacity: 0;\n}\n\n/* From nowhere, not from an edge: a scale is what separates this from a sheet. */\n.rk-modal-enter-from .rk-modal-panel,\n.rk-modal-leave-to .rk-modal-panel {\n transform: scale(0.96);\n opacity: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-modal-enter-active .rk-modal-panel,\n .rk-modal-leave-active .rk-modal-panel {\n transition: opacity var(--duration-base) ease;\n transform: none;\n }\n\n .rk-modal-enter-from .rk-modal-panel,\n .rk-modal-leave-to .rk-modal-panel {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { nextTick, onBeforeUnmount, ref, watch } from 'vue'\n\nimport { inertOutside } from '../utils/inert'\n\n/**\n * A dialog that arrives from nowhere.\n *\n * Not `BaseSheet` with different padding. A sheet slides up from the bottom\n * edge, is dismissed by dragging it back down, and belongs to a thumb; a modal\n * appears in the middle of what you were reading and is dismissed by leaving\n * it. They are two answers to two questions and merging them would give a\n * component that is wrong on a phone and wrong on a desktop.\n *\n * ## The parts that are easy to get wrong\n *\n * This is why it exists rather than being written per dialog, which is what the\n * one wide consumer was doing in two places. None of the following is visible\n * on screen when it is missing:\n *\n * - **Focus moves in and comes back.** Opening without moving focus leaves a\n * keyboard user behind the dialog, operating a page they cannot see. Closing\n * without restoring it drops them at the top of the document.\n * - **Tab does not leave.** A dialog you can Tab out of is a dialog that is\n * modal only to somebody using a mouse.\n * - **Escape closes.** Every dialog in every application does this.\n * - **The page underneath does not scroll.** Otherwise dismissing the dialog\n * returns you somewhere else.\n * - **The backdrop closes it, the panel does not.** A click that starts inside\n * the panel and ends on the backdrop is not a click on the backdrop, which is\n * why this listens for a press on the backdrop itself rather than any click\n * that reaches it.\n */\nconst {\n title,\n closeLabel,\n tone = 'default',\n dismissible = true,\n} = defineProps<{\n /** Names the dialog for assistive tech. Required; an unnamed dialog is a box. */\n title: string\n /** Accessible name for the close button. */\n closeLabel: string\n /**\n * `alert` marks it as a decision that interrupts — `role=\"alertdialog\"`,\n * which a screen reader announces immediately rather than on arrival.\n */\n tone?: 'default' | 'alert' | undefined\n /**\n * Whether Escape and the backdrop close it.\n *\n * Off for a dialog that must be answered — but never off as a way of forcing\n * a reader to a decision they have not been given the information for.\n */\n dismissible?: boolean | undefined\n}>()\n\nconst open = defineModel<boolean>({ default: false })\n\ndefineSlots<{\n /** The body. */\n default: () => unknown\n /** Buttons, at the foot. */\n actions?: () => unknown\n}>()\n\nconst panel = ref<HTMLElement | null>(null)\n/** Who had focus before this opened, so it can be given back. */\nlet restoreTo: HTMLElement | null = null\n/** Gives the page behind back; set while open. */\nlet releaseInert: (() => void) | null = null\n\nconst FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nfunction focusable(): HTMLElement[] {\n if (!panel.value) return []\n\n return Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'Escape' && dismissible) {\n event.stopPropagation()\n open.value = false\n\n return\n }\n\n if (event.key !== 'Tab') return\n\n const items = focusable()\n if (items.length === 0) return event.preventDefault()\n\n const first = items[0]!\n const last = items[items.length - 1]!\n const active = document.activeElement\n\n // The wrap has to be done by hand: the browser's own Tab order is the whole\n // document, and the dialog is only part of it.\n if (event.shiftKey && (active === first || !panel.value?.contains(active))) {\n event.preventDefault()\n last.focus()\n } else if (!event.shiftKey && active === last) {\n event.preventDefault()\n first.focus()\n }\n}\n\nwatch(open, async (isOpen) => {\n if (isOpen) {\n restoreTo = document.activeElement as HTMLElement | null\n document.body.style.overflow = 'hidden'\n await nextTick()\n // The Tab wrap above keeps focus cycling; `inert` is what also stops a\n // screen reader's virtual cursor, which walks past keydown handlers.\n if (panel.value) releaseInert = inertOutside(panel.value)\n // The panel itself when it holds nothing focusable, so focus is at least\n // inside the dialog rather than behind it.\n ;(focusable()[0] ?? panel.value)?.focus()\n } else {\n document.body.style.overflow = ''\n // Before focus goes back: an inert element cannot take it.\n releaseInert?.()\n releaseInert = null\n restoreTo?.focus()\n restoreTo = null\n }\n})\n\n// A dialog unmounted while open would otherwise leave the page unscrollable,\n// with nothing on screen to explain why.\nonBeforeUnmount(() => {\n if (open.value) document.body.style.overflow = ''\n releaseInert?.()\n})\n</script>\n\n<template>\n <Teleport to=\"body\">\n <Transition name=\"rk-modal\">\n <div\n v-if=\"open\"\n class=\"rk-modal-scrim\"\n @keydown=\"onKeydown\"\n @mousedown.self=\"dismissible && (open = false)\"\n >\n <div\n ref=\"panel\"\n class=\"rk-modal-panel surface-overlay\"\n :role=\"tone === 'alert' ? 'alertdialog' : 'dialog'\"\n aria-modal=\"true\"\n :aria-label=\"title\"\n tabindex=\"-1\"\n >\n <div class=\"rk-modal-head\">\n <h2 class=\"rk-modal-title\">{{ title }}</h2>\n\n <button\n v-if=\"dismissible\"\n type=\"button\"\n class=\"rk-modal-close\"\n :aria-label=\"closeLabel\"\n @click=\"open = false\"\n >\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M18 6 6 18M6 6l12 12\" stroke-linecap=\"round\" />\n </svg>\n </button>\n </div>\n\n <div class=\"rk-modal-body\"><slot /></div>\n\n <div v-if=\"$slots.actions\" class=\"rk-modal-actions\"><slot name=\"actions\" /></div>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n.rk-modal-scrim {\n position: fixed;\n inset: 0;\n z-index: 50;\n display: grid;\n place-items: center;\n padding: 1.5rem;\n background: color-mix(in srgb, var(--color-ink) 70%, transparent);\n backdrop-filter: blur(4px);\n}\n\n.rk-modal-panel {\n width: 100%;\n max-width: 28rem;\n max-height: 85vh;\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 1.5rem;\n}\n\n.rk-modal-panel:focus {\n outline: none;\n}\n\n.rk-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n}\n\n.rk-modal-title {\n font-size: 1.0625rem;\n font-weight: 600;\n color: var(--color-ink);\n}\n\n.rk-modal-close {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n width: 2rem;\n height: 2rem;\n border-radius: 9999px;\n color: var(--color-ink-soft);\n transition: background-color var(--duration-fast);\n}\n\n.rk-modal-close:hover {\n background: var(--color-muted);\n}\n\n.rk-modal-close:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-modal-close svg {\n width: 1rem;\n height: 1rem;\n}\n\n.rk-modal-body {\n margin-top: 0.75rem;\n color: var(--color-ink-soft);\n font-size: 0.875rem;\n line-height: 1.625;\n}\n\n.rk-modal-actions {\n margin-top: 1.5rem;\n display: flex;\n justify-content: flex-end;\n gap: 0.5rem;\n}\n\n.rk-modal-enter-active,\n.rk-modal-leave-active {\n transition: opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-active .rk-modal-panel,\n.rk-modal-leave-active .rk-modal-panel {\n transition:\n transform var(--duration-base) var(--ease-sheet),\n opacity var(--duration-base) ease;\n}\n\n.rk-modal-enter-from,\n.rk-modal-leave-to {\n opacity: 0;\n}\n\n/* From nowhere, not from an edge: a scale is what separates this from a sheet. */\n.rk-modal-enter-from .rk-modal-panel,\n.rk-modal-leave-to .rk-modal-panel {\n transform: scale(0.96);\n opacity: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-modal-enter-active .rk-modal-panel,\n .rk-modal-leave-active .rk-modal-panel {\n transition: opacity var(--duration-base) ease;\n transform: none;\n }\n\n .rk-modal-enter-from .rk-modal-panel,\n .rk-modal-leave-to .rk-modal-panel {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * Moving between pages of a list.\n *\n * A `nav` of buttons rather than links, because this component does not know\n * whether the app pages by route, by query or in memory. It emits a number and\n * the app decides what that means.\n *\n * ## The ellipsis\n *\n * Every page number is unusable past about a dozen, and cutting to\n * \"1 … 7 8 9 … 40\" is what every list does. The window is a fixed size around\n * the current page and the first and last are always present, so the control\n * never changes width as you move through it — a row of numbers that reflows\n * under the pointer is a row you have to re-aim at.\n *\n * The gap is a `<span>`, not a disabled button: it is not a control, and\n * announcing it as one gives a screen reader something to try to press.\n */\nconst {\n page,\n pages,\n siblings = 1,\n label = '',\n previousLabel,\n nextLabel,\n} = defineProps<{\n /** Current page, 1-based. */\n page: number\n /** How many there are. */\n pages: number\n /** How many numbers to show either side of the current one. */\n siblings?: number | undefined\n /** Accessible name for the landmark. */\n label?: string | undefined\n /**\n * Names for the arrows, already translated.\n *\n * Required, because `‹` is a glyph and not a word: a screen reader reading\n * the arrow announces nothing a person can act on.\n */\n previousLabel: string\n nextLabel: string\n}>()\n\nconst emit = defineEmits<{ change: [page: number] }>()\n\nconst GAP = 'gap' as const\n\nconst slots = computed<(number | typeof GAP)[]>(() => {\n if (pages <= 1) return [1]\n\n const first = 1\n const last = pages\n\n /* The widest the windowed form ever gets: first, last, the current page, its\n siblings either side, and the two gaps. Below that the window saves\n nothing, and \"1 2 … 5\" is a worse control than \"1 2 3 4 5\" — so short lists\n are shown whole rather than cut on principle. */\n if (pages <= siblings * 2 + 5) {\n return Array.from({ length: pages }, (_, i) => i + 1)\n }\n const from = Math.max(first + 1, page - siblings)\n const to = Math.min(last - 1, page + siblings)\n\n const out: (number | typeof GAP)[] = [first]\n // A gap standing in for a single page is longer than the page it replaces.\n if (from > first + 1) out.push(GAP)\n for (let n = from; n <= to; n++) out.push(n)\n if (to < last - 1) out.push(GAP)\n if (last > first) out.push(last)\n\n return out\n})\n\nconst go = (next: number) => {\n if (next >= 1 && next <= pages && next !== page) emit('change', next)\n}\n</script>\n\n<template>\n <nav class=\"rk-pager\" :aria-label=\"label || undefined\">\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page <= 1\"\n :aria-label=\"previousLabel\"\n @click=\"go(page - 1)\"\n >\n ‹\n </button>\n\n <template v-for=\"(slot, index) in slots\" :key=\"`${slot}-${index}`\">\n <span v-if=\"slot === 'gap'\" class=\"rk-pager-gap\" aria-hidden=\"true\">…</span>\n <button\n v-else\n type=\"button\"\n class=\"rk-pager-page\"\n :class=\"{ 'is-active': slot === page }\"\n :aria-current=\"slot === page ? 'page' : undefined\"\n @click=\"go(slot)\"\n >\n {{ slot }}\n </button>\n </template>\n\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page >= pages\"\n :aria-label=\"nextLabel\"\n @click=\"go(page + 1)\"\n >\n ›\n </button>\n </nav>\n</template>\n\n<style scoped>\n.rk-pager {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-pager-step,\n.rk-pager-page {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.5rem;\n border-radius: var(--radius-cell);\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition:\n background-color var(--duration-fast),\n color var(--duration-fast);\n}\n\n.rk-pager-step:hover:not(:disabled),\n.rk-pager-page:hover {\n background: var(--color-muted);\n color: var(--color-ink);\n}\n\n.rk-pager-step:focus-visible,\n.rk-pager-page:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-pager-step:disabled {\n opacity: 0.4;\n pointer-events: none;\n}\n\n.rk-pager-page.is-active {\n background: var(--color-primary);\n color: var(--color-on-primary);\n font-weight: 500;\n}\n\n.rk-pager-gap {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n color: var(--color-ink-soft);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * Moving between pages of a list.\n *\n * A `nav` of buttons rather than links, because this component does not know\n * whether the app pages by route, by query or in memory. It emits a number and\n * the app decides what that means.\n *\n * ## The ellipsis\n *\n * Every page number is unusable past about a dozen, and cutting to\n * \"1 … 7 8 9 … 40\" is what every list does. The window is a fixed size around\n * the current page and the first and last are always present, so the control\n * never changes width as you move through it — a row of numbers that reflows\n * under the pointer is a row you have to re-aim at.\n *\n * The gap is a `<span>`, not a disabled button: it is not a control, and\n * announcing it as one gives a screen reader something to try to press.\n */\nconst {\n page,\n pages,\n siblings = 1,\n label = '',\n previousLabel,\n nextLabel,\n} = defineProps<{\n /** Current page, 1-based. */\n page: number\n /** How many there are. */\n pages: number\n /** How many numbers to show either side of the current one. */\n siblings?: number | undefined\n /** Accessible name for the landmark. */\n label?: string | undefined\n /**\n * Names for the arrows, already translated.\n *\n * Required, because `‹` is a glyph and not a word: a screen reader reading\n * the arrow announces nothing a person can act on.\n */\n previousLabel: string\n nextLabel: string\n}>()\n\nconst emit = defineEmits<{ change: [page: number] }>()\n\nconst GAP = 'gap' as const\n\nconst slots = computed<(number | typeof GAP)[]>(() => {\n if (pages <= 1) return [1]\n\n const first = 1\n const last = pages\n\n /* The widest the windowed form ever gets: first, last, the current page, its\n siblings either side, and the two gaps. Below that the window saves\n nothing, and \"1 2 … 5\" is a worse control than \"1 2 3 4 5\" — so short lists\n are shown whole rather than cut on principle. */\n if (pages <= siblings * 2 + 5) {\n return Array.from({ length: pages }, (_, i) => i + 1)\n }\n const from = Math.max(first + 1, page - siblings)\n const to = Math.min(last - 1, page + siblings)\n\n const out: (number | typeof GAP)[] = [first]\n // A gap standing in for a single page is longer than the page it replaces.\n if (from > first + 1) out.push(GAP)\n for (let n = from; n <= to; n++) out.push(n)\n if (to < last - 1) out.push(GAP)\n if (last > first) out.push(last)\n\n return out\n})\n\nconst go = (next: number) => {\n if (next >= 1 && next <= pages && next !== page) emit('change', next)\n}\n</script>\n\n<template>\n <nav class=\"rk-pager\" :aria-label=\"label || undefined\">\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page <= 1\"\n :aria-label=\"previousLabel\"\n @click=\"go(page - 1)\"\n >\n ‹\n </button>\n\n <template v-for=\"(slot, index) in slots\" :key=\"`${slot}-${index}`\">\n <span v-if=\"slot === 'gap'\" class=\"rk-pager-gap\" aria-hidden=\"true\">…</span>\n <button\n v-else\n type=\"button\"\n class=\"rk-pager-page\"\n :class=\"{ 'is-active': slot === page }\"\n :aria-current=\"slot === page ? 'page' : undefined\"\n @click=\"go(slot)\"\n >\n {{ slot }}\n </button>\n </template>\n\n <button\n type=\"button\"\n class=\"rk-pager-step\"\n :disabled=\"page >= pages\"\n :aria-label=\"nextLabel\"\n @click=\"go(page + 1)\"\n >\n ›\n </button>\n </nav>\n</template>\n\n<style scoped>\n.rk-pager {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-pager-step,\n.rk-pager-page {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.5rem;\n border-radius: var(--radius-cell);\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition:\n background-color var(--duration-fast),\n color var(--duration-fast);\n}\n\n.rk-pager-step:hover:not(:disabled),\n.rk-pager-page:hover {\n background: var(--color-muted);\n color: var(--color-ink);\n}\n\n.rk-pager-step:focus-visible,\n.rk-pager-page:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-pager-step:disabled {\n opacity: 0.4;\n pointer-events: none;\n}\n\n.rk-pager-page.is-active {\n background: var(--color-primary);\n color: var(--color-on-primary);\n font-weight: 500;\n}\n\n.rk-pager-gap {\n display: grid;\n place-items: center;\n min-width: 2rem;\n height: 2rem;\n color: var(--color-ink-soft);\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { ref, useId } from 'vue'\n\nexport interface TabPanel<K extends string> {\n key: K\n /** Already translated. */\n label: string\n}\n\n/**\n * Sections of one page, one visible at a time.\n *\n * Not `TabBar` and not `NavLinks`: those navigate, and the browser's back\n * button undoes them. These switch a panel inside a page that does not change,\n * so there is no history entry and no route.\n *\n * ## The keyboard, which is the whole reason this is a component\n *\n * The pattern is prescribed and it is not what a row of buttons does by\n * default. Tab enters the tablist once and leaves it once — it does not walk\n * through every tab — and the arrow keys move between them. That is roving\n * tabindex: exactly one tab is reachable by Tab at a time, and moving the\n * selection moves it. Home and End jump to the ends, and the selection wraps,\n * because a list with no edges is faster than one you can fall off.\n *\n * Hand-written tabs almost always get this wrong in the same way: every tab is\n * tabbable, so a keyboard user presses Tab six times to reach the content.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly TabPanel<K>[]\n /** Accessible name for the tablist. */\n label?: string | undefined\n}>()\n\ndefineSlots<{\n /** The panel for the selected tab. */\n default: (props: { item: TabPanel<K>; active: K }) => unknown\n}>()\n\nconst active = defineModel<K>({ required: true })\n\nconst uid = useId()\nconst tabs = ref<HTMLElement[]>([])\n\nconst tabId = (key: K) => `${uid}-tab-${key}`\nconst panelId = (key: K) => `${uid}-panel-${key}`\n\nfunction select(key: K, focus = false) {\n active.value = key\n if (!focus) return\n\n // Focus follows selection, which is what the arrow keys are for. The element\n // is looked up after the update so the new tab is the one that is reachable.\n const index = items.findIndex((item) => item.key === key)\n requestAnimationFrame(() => tabs.value[index]?.focus())\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const index = items.findIndex((item) => item.key === active.value)\n if (index < 0) return\n\n const last = items.length - 1\n let next: number | undefined\n\n if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1\n else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1\n else if (event.key === 'Home') next = 0\n else if (event.key === 'End') next = last\n else return\n\n event.preventDefault()\n const item = items[next]\n if (item) select(item.key, true)\n}\n</script>\n\n<template>\n <div>\n <div class=\"rk-tabs\" role=\"tablist\" :aria-label=\"label || undefined\" @keydown=\"onKeydown\">\n <button\n v-for=\"item in items\"\n :id=\"tabId(item.key)\"\n :key=\"item.key\"\n ref=\"tabs\"\n type=\"button\"\n role=\"tab\"\n class=\"rk-tab\"\n :class=\"{ 'is-active': item.key === active }\"\n :aria-selected=\"item.key === active\"\n :aria-controls=\"panelId(item.key)\"\n :tabindex=\"item.key === active ? 0 : -1\"\n @click=\"select(item.key)\"\n >\n {{ item.label }}\n </button>\n </div>\n\n <div\n v-for=\"item in items\"\n v-show=\"item.key === active\"\n :id=\"panelId(item.key)\"\n :key=\"item.key\"\n role=\"tabpanel\"\n class=\"rk-tabpanel\"\n :aria-labelledby=\"tabId(item.key)\"\n tabindex=\"0\"\n >\n <!-- Every panel is rendered and hidden rather than swapped, so the page\n keeps its height and a crawler sees all of it. -->\n <slot :item=\"item\" :active=\"active\" />\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-tabs {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-tab {\n position: relative;\n padding: 0.625rem 0.875rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-tab:hover {\n color: var(--color-ink);\n}\n\n.rk-tab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: -2px;\n border-radius: var(--radius-cell);\n}\n\n.rk-tab.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-tab.is-active::after {\n content: '';\n position: absolute;\n inset-inline: 0.5rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n}\n\n.rk-tabpanel {\n padding-top: 1rem;\n}\n\n.rk-tabpanel:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { ref, useId } from 'vue'\n\nexport interface TabPanel<K extends string> {\n key: K\n /** Already translated. */\n label: string\n}\n\n/**\n * Sections of one page, one visible at a time.\n *\n * Not `TabBar` and not `NavLinks`: those navigate, and the browser's back\n * button undoes them. These switch a panel inside a page that does not change,\n * so there is no history entry and no route.\n *\n * ## The keyboard, which is the whole reason this is a component\n *\n * The pattern is prescribed and it is not what a row of buttons does by\n * default. Tab enters the tablist once and leaves it once — it does not walk\n * through every tab — and the arrow keys move between them. That is roving\n * tabindex: exactly one tab is reachable by Tab at a time, and moving the\n * selection moves it. Home and End jump to the ends, and the selection wraps,\n * because a list with no edges is faster than one you can fall off.\n *\n * Hand-written tabs almost always get this wrong in the same way: every tab is\n * tabbable, so a keyboard user presses Tab six times to reach the content.\n */\nconst { items, label = '' } = defineProps<{\n items: readonly TabPanel<K>[]\n /** Accessible name for the tablist. */\n label?: string | undefined\n}>()\n\ndefineSlots<{\n /** The panel for the selected tab. */\n default: (props: { item: TabPanel<K>; active: K }) => unknown\n}>()\n\nconst active = defineModel<K>({ required: true })\n\nconst uid = useId()\nconst tabs = ref<HTMLElement[]>([])\n\nconst tabId = (key: K) => `${uid}-tab-${key}`\nconst panelId = (key: K) => `${uid}-panel-${key}`\n\nfunction select(key: K, focus = false) {\n active.value = key\n if (!focus) return\n\n // Focus follows selection, which is what the arrow keys are for. The element\n // is looked up after the update so the new tab is the one that is reachable.\n const index = items.findIndex((item) => item.key === key)\n requestAnimationFrame(() => tabs.value[index]?.focus())\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const index = items.findIndex((item) => item.key === active.value)\n if (index < 0) return\n\n const last = items.length - 1\n let next: number | undefined\n\n if (event.key === 'ArrowRight') next = index === last ? 0 : index + 1\n else if (event.key === 'ArrowLeft') next = index === 0 ? last : index - 1\n else if (event.key === 'Home') next = 0\n else if (event.key === 'End') next = last\n else return\n\n event.preventDefault()\n const item = items[next]\n if (item) select(item.key, true)\n}\n</script>\n\n<template>\n <div>\n <div class=\"rk-tabs\" role=\"tablist\" :aria-label=\"label || undefined\" @keydown=\"onKeydown\">\n <button\n v-for=\"item in items\"\n :id=\"tabId(item.key)\"\n :key=\"item.key\"\n ref=\"tabs\"\n type=\"button\"\n role=\"tab\"\n class=\"rk-tab\"\n :class=\"{ 'is-active': item.key === active }\"\n :aria-selected=\"item.key === active\"\n :aria-controls=\"panelId(item.key)\"\n :tabindex=\"item.key === active ? 0 : -1\"\n @click=\"select(item.key)\"\n >\n {{ item.label }}\n </button>\n </div>\n\n <div\n v-for=\"item in items\"\n v-show=\"item.key === active\"\n :id=\"panelId(item.key)\"\n :key=\"item.key\"\n role=\"tabpanel\"\n class=\"rk-tabpanel\"\n :aria-labelledby=\"tabId(item.key)\"\n tabindex=\"0\"\n >\n <!-- Every panel is rendered and hidden rather than swapped, so the page\n keeps its height and a crawler sees all of it. -->\n <slot :item=\"item\" :active=\"active\" />\n </div>\n </div>\n</template>\n\n<style scoped>\n.rk-tabs {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n border-bottom: 1px solid color-mix(in srgb, var(--color-hair) 70%, transparent);\n}\n\n.rk-tab {\n position: relative;\n padding: 0.625rem 0.875rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-tab:hover {\n color: var(--color-ink);\n}\n\n.rk-tab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: -2px;\n border-radius: var(--radius-cell);\n}\n\n.rk-tab.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-tab.is-active::after {\n content: '';\n position: absolute;\n inset-inline: 0.5rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n}\n\n.rk-tabpanel {\n padding-top: 1rem;\n}\n\n.rk-tabpanel:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * A short label that appears beside a control.\n *\n * ## CSS, not JavaScript, and that is a decision\n *\n * The bubble is shown by `:hover` and `:focus-within` in the stylesheet. No\n * listener, no state, no positioning library. That costs collision detection —\n * a tooltip near the right edge is clipped rather than flipped — and buys the\n * only thing that matters more: it works in a prerendered page, before the\n * bundle has arrived, and with JavaScript switched off entirely.\n *\n * `:focus-within` rather than `:hover` alone is the part people leave out. A\n * tooltip that only answers a mouse is a tooltip that does not exist for anyone\n * using a keyboard, and the content is usually the only explanation of what the\n * control does.\n *\n * The bubble is wired with `aria-describedby`, so a screen reader reads it as a\n * description of the control rather than as loose text nearby. That is why the\n * trigger goes in a slot: the component has to own the id on both ends.\n *\n * ## When not to use it\n *\n * For anything the reader must have. A tooltip is unreachable on a touch screen\n * without a hover state, so text that changes a decision belongs on the page.\n */\nconst { label, placement = 'top' } = defineProps<{\n /** The description. Already translated. */\n label: string\n placement?: 'top' | 'bottom' | undefined\n}>()\n\ndefineSlots<{\n /** The control being described. */\n default: (props: { describedBy: string }) => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <span class=\"rk-tip\">\n <slot :described-by=\"id\" />\n\n <!-- `role=\"tooltip\"` and the id, so the control can point at it. Not\n `aria-hidden`: it is the description, not decoration. -->\n <span :id=\"id\" role=\"tooltip\" class=\"rk-tip-bubble\" :class=\"`is-${placement}`\">\n {{ label }}\n </span>\n </span>\n</template>\n\n<style scoped>\n.rk-tip {\n position: relative;\n display: inline-flex;\n}\n\n.rk-tip-bubble {\n position: absolute;\n left: 50%;\n z-index: 60;\n width: max-content;\n max-width: 16rem;\n transform: translateX(-50%);\n border-radius: var(--radius-cell);\n background: var(--color-ink);\n padding: 0.375rem 0.5rem;\n font-size: 0.75rem;\n line-height: 1.4;\n color: var(--color-canvas);\n opacity: 0;\n /* Out of the hit area while hidden, or it would swallow the pointer on its\n way to the control it describes. */\n pointer-events: none;\n transition: opacity var(--duration-fast) ease;\n}\n\n.rk-tip-bubble.is-top {\n bottom: calc(100% + 0.375rem);\n}\n\n.rk-tip-bubble.is-bottom {\n top: calc(100% + 0.375rem);\n}\n\n.rk-tip:hover .rk-tip-bubble,\n.rk-tip:focus-within .rk-tip-bubble {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-tip-bubble {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useId } from 'vue'\n\n/**\n * A short label that appears beside a control.\n *\n * ## CSS, not JavaScript, and that is a decision\n *\n * The bubble is shown by `:hover` and `:focus-within` in the stylesheet. No\n * listener, no state, no positioning library. That costs collision detection —\n * a tooltip near the right edge is clipped rather than flipped — and buys the\n * only thing that matters more: it works in a prerendered page, before the\n * bundle has arrived, and with JavaScript switched off entirely.\n *\n * `:focus-within` rather than `:hover` alone is the part people leave out. A\n * tooltip that only answers a mouse is a tooltip that does not exist for anyone\n * using a keyboard, and the content is usually the only explanation of what the\n * control does.\n *\n * The bubble is wired with `aria-describedby`, so a screen reader reads it as a\n * description of the control rather than as loose text nearby. That is why the\n * trigger goes in a slot: the component has to own the id on both ends.\n *\n * ## When not to use it\n *\n * For anything the reader must have. A tooltip is unreachable on a touch screen\n * without a hover state, so text that changes a decision belongs on the page.\n */\nconst { label, placement = 'top' } = defineProps<{\n /** The description. Already translated. */\n label: string\n placement?: 'top' | 'bottom' | undefined\n}>()\n\ndefineSlots<{\n /** The control being described. */\n default: (props: { describedBy: string }) => unknown\n}>()\n\nconst id = useId()\n</script>\n\n<template>\n <span class=\"rk-tip\">\n <slot :described-by=\"id\" />\n\n <!-- `role=\"tooltip\"` and the id, so the control can point at it. Not\n `aria-hidden`: it is the description, not decoration. -->\n <span :id=\"id\" role=\"tooltip\" class=\"rk-tip-bubble\" :class=\"`is-${placement}`\">\n {{ label }}\n </span>\n </span>\n</template>\n\n<style scoped>\n.rk-tip {\n position: relative;\n display: inline-flex;\n}\n\n.rk-tip-bubble {\n position: absolute;\n left: 50%;\n z-index: 60;\n width: max-content;\n max-width: 16rem;\n transform: translateX(-50%);\n border-radius: var(--radius-cell);\n background: var(--color-ink);\n padding: 0.375rem 0.5rem;\n font-size: 0.75rem;\n line-height: 1.4;\n color: var(--color-canvas);\n opacity: 0;\n /* Out of the hit area while hidden, or it would swallow the pointer on its\n way to the control it describes. */\n pointer-events: none;\n transition: opacity var(--duration-fast) ease;\n}\n\n.rk-tip-bubble.is-top {\n bottom: calc(100% + 0.375rem);\n}\n\n.rk-tip-bubble.is-bottom {\n top: calc(100% + 0.375rem);\n}\n\n.rk-tip:hover .rk-tip-bubble,\n.rk-tip:focus-within .rk-tip-bubble {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-tip-bubble {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { RouterLink } from 'vue-router'\n\nexport interface NavLinkItem<K extends string> {\n /** Identity, compared against `active`. */\n key: K\n /** Router destination. */\n to: string\n /** Already translated. */\n label: string\n}\n\n/**\n * The primary navigation of a wide site.\n *\n * The same contract as `TabBar` minus the icon, so moving an app between a\n * bottom bar and a top one is a change of component and not of data. That is\n * the whole reason the shapes match.\n *\n * A rule under the current section rather than a filled pill: at this size a\n * pill reads as a button, and these are not buttons — they are where you are.\n * The rule is a scaled element rather than a border so it can travel, and it is\n * `aria-hidden` because `aria-current` already says the same thing to anyone not\n * looking at it.\n */\nconst {\n items,\n active,\n label = '',\n} = defineProps<{\n items: readonly NavLinkItem<K>[]\n /** Which item is current. Usually derived from the route. */\n active?: K | undefined\n /** Accessible name for the navigation landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <div class=\"rk-nav-inner\">\n <RouterLink\n v-for=\"item in items\"\n :key=\"item.key\"\n :to=\"item.to\"\n class=\"rk-nav-link\"\n :class=\"{ 'is-active': item.key === active }\"\n :aria-current=\"item.key === active ? 'page' : undefined\"\n >\n {{ item.label }}\n <span class=\"rk-nav-rule\" aria-hidden=\"true\" />\n </RouterLink>\n </div>\n </nav>\n</template>\n\n<style scoped>\n/* The root carries no layout of its own, and that is the fix for a real bug\n rather than a style preference.\n\n A rule on the root loses to nothing: Vue's scoped CSS compiles `.rk-nav` to\n `.rk-nav[data-v-hash]`, which outranks a utility class like Tailwind's\n `hidden` outright — so `class=\"hidden sm:flex\"` did nothing and the nav\n appeared on a phone, on top of the call to action. `:where()` does not save\n it either; the scoping attribute puts the specificity back.\n\n So the flex row lives on an element the caller does not own, and the root is\n left for them to style. `TabBar` is built the same way for the same reason. */\n.rk-nav-inner {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-nav-link {\n position: relative;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.75rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-nav-link:hover {\n color: var(--color-ink);\n}\n\n.rk-nav-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-nav-link.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-nav-rule {\n position: absolute;\n inset-inline: 0.75rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n transform: scaleX(0);\n transform-origin: left;\n transition: transform var(--duration-slow);\n}\n\n.rk-nav-link.is-active .rk-nav-rule {\n transform: scaleX(1);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-nav-rule {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\" generic=\"K extends string\">\nimport { RouterLink } from 'vue-router'\n\nexport interface NavLinkItem<K extends string> {\n /** Identity, compared against `active`. */\n key: K\n /** Router destination. */\n to: string\n /** Already translated. */\n label: string\n}\n\n/**\n * The primary navigation of a wide site.\n *\n * The same contract as `TabBar` minus the icon, so moving an app between a\n * bottom bar and a top one is a change of component and not of data. That is\n * the whole reason the shapes match.\n *\n * A rule under the current section rather than a filled pill: at this size a\n * pill reads as a button, and these are not buttons — they are where you are.\n * The rule is a scaled element rather than a border so it can travel, and it is\n * `aria-hidden` because `aria-current` already says the same thing to anyone not\n * looking at it.\n */\nconst {\n items,\n active,\n label = '',\n} = defineProps<{\n items: readonly NavLinkItem<K>[]\n /** Which item is current. Usually derived from the route. */\n active?: K | undefined\n /** Accessible name for the navigation landmark. */\n label?: string | undefined\n}>()\n</script>\n\n<template>\n <nav :aria-label=\"label || undefined\">\n <div class=\"rk-nav-inner\">\n <RouterLink\n v-for=\"item in items\"\n :key=\"item.key\"\n :to=\"item.to\"\n class=\"rk-nav-link\"\n :class=\"{ 'is-active': item.key === active }\"\n :aria-current=\"item.key === active ? 'page' : undefined\"\n >\n {{ item.label }}\n <span class=\"rk-nav-rule\" aria-hidden=\"true\" />\n </RouterLink>\n </div>\n </nav>\n</template>\n\n<style scoped>\n/* The root carries no layout of its own, and that is the fix for a real bug\n rather than a style preference.\n\n A rule on the root loses to nothing: Vue's scoped CSS compiles `.rk-nav` to\n `.rk-nav[data-v-hash]`, which outranks a utility class like Tailwind's\n `hidden` outright — so `class=\"hidden sm:flex\"` did nothing and the nav\n appeared on a phone, on top of the call to action. `:where()` does not save\n it either; the scoping attribute puts the specificity back.\n\n So the flex row lives on an element the caller does not own, and the root is\n left for them to style. `TabBar` is built the same way for the same reason. */\n.rk-nav-inner {\n display: flex;\n align-items: center;\n gap: 0.25rem;\n}\n\n.rk-nav-link {\n position: relative;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.75rem;\n font-size: 0.875rem;\n color: var(--color-ink-soft);\n transition: color var(--duration-fast);\n}\n\n.rk-nav-link:hover {\n color: var(--color-ink);\n}\n\n.rk-nav-link:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-nav-link.is-active {\n color: var(--color-ink);\n font-weight: 500;\n}\n\n.rk-nav-rule {\n position: absolute;\n inset-inline: 0.75rem;\n bottom: -1px;\n height: 2px;\n border-radius: 9999px;\n background: var(--color-primary);\n transform: scaleX(0);\n transform-origin: left;\n transition: transform var(--duration-slow);\n}\n\n.rk-nav-link.is-active .rk-nav-rule {\n transform: scaleX(1);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-nav-rule {\n transition: none;\n }\n}\n</style>\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rei-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "One kit, every look. 53 accessible Vue 3 + Tailwind 4 components, four materials (quiet, glass, brutal, soft) and ten WCAG-checked palettes, each switched with one attribute.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vue",
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"types": "./dist/index.d.ts",
|
|
48
48
|
"import": "./dist/index.js"
|
|
49
49
|
},
|
|
50
|
+
"./mobile.css": "./dist/mobile.css",
|
|
51
|
+
"./web.css": "./dist/web.css",
|
|
50
52
|
"./tokens.css": "./dist/tokens.css",
|
|
51
53
|
"./shell/mobile.css": "./dist/shell/mobile.css",
|
|
52
54
|
"./shell/web.css": "./dist/shell/web.css",
|