rei-kit 2.22.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # rei-kit
2
2
 
3
- **One kit. Every look.** 102 accessible components for Vue 3 and Tailwind 4.
3
+ **One kit. Every look.** 105 accessible components for Vue 3 and Tailwind 4.
4
4
  Change the **material** with one attribute and the **palette** with another —
5
5
  every component follows, and none of them knows your brand.
6
6
 
@@ -41,6 +41,73 @@ Three apps run on it and no two resemble each other. Every component is typed,
41
41
  tested, themed by role rather than by colour, and carries the reasoning for its
42
42
  own awkward decisions in the source.
43
43
 
44
+ ## What is different here
45
+
46
+ Four things, and each one is a number or a file you can open rather than an
47
+ adjective.
48
+
49
+ ### 1. Three axes, not one theme
50
+
51
+ Most kits give you a theme: change it and everything changes together.
52
+ This one separates **what a surface is made of** from **what colour it is**
53
+ from **light or dark** — 4 materials × 10 palettes × 2 modes, each switched
54
+ by its own attribute, and a product may hand any of the three to its own
55
+ users. There is no hex value inside a component, and a test fails if one
56
+ appears. That is what lets a phone journal, a phone ledger and a wide
57
+ course site share one kit and look nothing alike.
58
+
59
+ ### 2. The smallest of six, measured, with the trade stated
60
+
61
+ At ten components — a real screen — **31.3 KB against 104 to 195** for the
62
+ five kits measured beside it. The gap is not the total, it is the slope:
63
+ seven components past the first three cost this kit 5 KB and cost them 60 to
64
+ 125, because the stylesheet here is flat and theirs is inside the JavaScript.
65
+ The tables are in [Small, and measured](#small-and-measured), the script is
66
+ in `bench/`, and it says what it does not measure.
67
+
68
+ ### 3. Every claim is a test that fails
69
+
70
+ This is the part that is hard to see from outside and is most of the work.
71
+ The kit does not assert that it is accessible or stable; it has checks that
72
+ go red, and several of them exist nowhere else:
73
+
74
+ | The check | What it catches that nothing else does |
75
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | **`direction-styles.spec.ts`** | One case per file, reading each stylesheet for a property that picks a side — `padding-left` where `padding-inline-start` was meant — and for a one-sided gradient with no right-to-left counterpart. It found 61 on its first run. |
77
+ | **`direction-keys.spec.ts`** | One case per control, pressing **ArrowLeft in a right-to-left document** and asserting it went *forward*. Ten components were walking backwards through themselves. It type-checks, it renders, it passes axe, and only an Arabic reader sees it. |
78
+ | **`examples-axe.spec.ts`** | axe on all 105 usage examples, **and again with each one opened** — a menu, a dialog, a combobox list is exactly where the ARIA lives and where a closed-state audit says nothing. |
79
+ | **`focus.spec.ts`** | Reads every component for a focus ring. A missing one compiles, renders and passes every other check. |
80
+ | **`public-api.spec.ts`** | Names every runtime export of all six entries. A kit compiles fine without an export nothing inside it calls. |
81
+ | **`type-surface.ts`** | Re-exports every published type and is type-checked twice, because a test cannot see types — they are gone by the time one runs. |
82
+ | **`showcase-catalogue.spec.ts`** | Fails if a component has no usage sample, no description, no behaviour test, or no row in `AGENTS.md`. The documentation cannot fall behind the package. |
83
+ | **`appearance.spec.ts`** | One case per component for a hex value, and one for a `text-white` on a filled role — neither is a colour a palette can follow, and white on the kit's own warning measures 2.3:1. Plus a frozen `shadow-card` where the runtime token was meant, and a `cubic-bezier` pasted in where a material's easing should reach. |
84
+ | **`consumer.yml`** | Packs the real tarball, installs it into all three apps and runs each one's whole gate. The only check that imports the package the way an app does. |
85
+ | **`pnpm size`** | Six budgets. The build fails when one grows past its line, and raising it has to say why in the commit. |
86
+
87
+ **1301 tests across 57 files**, and every guard added is verified by being
88
+ broken first — the commit says what failed when it was removed. That habit
89
+ is what found the two cases in `anchored-panel.spec.ts` that were passing
90
+ with the feature deleted, and the comment in `BasePopover` that had
91
+ described the wrong behaviour since the day it was written.
92
+
93
+ ### 4. The kit has no language of its own
94
+
95
+ Every visible string is a required prop. A component with an English
96
+ default would ship English into an app that has none, and it would do it
97
+ silently. That is also why `PasswordInput` asks for one label rather than
98
+ four, and why `ActivityGrid` takes `levelFor` instead of deciding whether
99
+ four of something is a lot.
100
+
101
+ ### What is deliberately not here
102
+
103
+ No `create-rei-app`, no Nuxt module, no base library underneath — the
104
+ primitives are ours, so there is no second project's roadmap between a bug
105
+ and its fix. No icons of its own, no copy, and no chart library — `BarChart`
106
+ and `DonutChart` draw a series and a composition, and an app that needs axes,
107
+ time scales or stacking reaches for something built for that. And no claim that has
108
+ not been measured: contrast and focus order still need a real browser, and
109
+ until that exists this file says so rather than implying otherwise.
110
+
44
111
  ## Why it exists
45
112
 
46
113
  **The kit distributes decisions, not a look.** Three apps run on it and no two
@@ -81,26 +148,61 @@ the app, and not a matter of taste.
81
148
 
82
149
  ## Small, and measured
83
150
 
84
- A button, a text input and a modal: the three parts every product has.
85
- Bundled with each kit and set up the way its own docs recommend first, with
86
- styles included and Vue left out. Minified, gzip -9:
151
+ Two cases, because one is how a size comparison lies in either direction.
152
+ Each kit is bundled the way its own docs set it up first, with styles
153
+ included and Vue left out. Minified, gzip -9:
154
+
155
+ **Three components** — a button, a text input and a modal. The smallest real
156
+ app, and the case *least* favourable to this kit:
87
157
 
88
158
  | Kit | JS | CSS | Total |
89
159
  | -------------------- | ---------: | ----------: | ----------: |
90
- | **rei-kit 2.4.0** | **3.1 KB** | **15.2 KB** | **18.4 KB** |
160
+ | **rei-kit 2.24.0** | **3.3 KB** | **23.1 KB** | **26.4 KB** |
91
161
  | element-plus 2.14.6 | 27.3 KB | 6.0 KB | 33.3 KB |
92
162
  | naive-ui 2.45.3 | 51.2 KB | — | 51.2 KB |
93
163
  | primevue 5.0.1 | 53.6 KB | — | 53.6 KB |
94
164
  | ant-design-vue 4.2.6 | 70.3 KB | — | 70.3 KB |
95
165
  | vuetify 4.2.1 | 44.0 KB | 34.3 KB | 78.4 KB |
96
166
 
97
- rei-kit's CSS column is the whole `mobile.css` preset: the stylesheet for
98
- **every** component, all four materials and all ten palettes. The JavaScript
99
- is only what the three need, because each component is its own
100
- tree-shakeable module and there are no runtime dependencies. Naive UI, PrimeVue and Ant Design put their styles in the
101
- JavaScript. Run it yourself: `cd bench && npm install && npm run bench`.
102
- Nuxt UI is left out because it builds through its own Nuxt or Vite module and
103
- cannot be bundled the same way.
167
+ **Ten components** — the same three plus a select, a checkbox, a switch, tabs,
168
+ a data table, a tooltip and a card. A screen rather than a demo:
169
+
170
+ | Kit | JS | CSS | Total |
171
+ | -------------------- | ---------: | ----------: | -----------: |
172
+ | **rei-kit 2.24.0** | **8.3 KB** | **23.1 KB** | **31.3 KB** |
173
+ | element-plus 2.14.6 | 90.1 KB | 14.0 KB | 104.1 KB |
174
+ | naive-ui 2.45.3 | 132.4 KB | — | 132.4 KB |
175
+ | primevue 5.0.1 | 135.8 KB | — | 135.8 KB |
176
+ | vuetify 4.2.1 | 96.7 KB | 44.1 KB | 140.7 KB |
177
+ | ant-design-vue 4.2.6 | 195.4 KB | — | 195.4 KB |
178
+
179
+ **The slope is the point, not the total.** Seven more components cost this kit
180
+ **4.9 KB**, because the stylesheet does not move and only the JavaScript grows.
181
+ The kits that put their styles in the JavaScript have no flat part at all, so
182
+ the same seven cost them **60 to 125 KB**. A quarter smaller at three
183
+ components; **3.3× smaller at ten**.
184
+
185
+ That is the trade, stated rather than left to be inferred: rei-kit's CSS
186
+ column is the entire `mobile.css` preset — all 105 components, four materials,
187
+ ten palettes — and it is the same 23.1 KB whether an app imports three
188
+ components or every one of them. An app using very little of the kit carries
189
+ stylesheet it does not need; an app using a screenful stops paying anything
190
+ much for the next one. Which is why the three-component table above is the one
191
+ to be sceptical of, and it is still the one this kit wins.
192
+
193
+ The JavaScript is only what those components need: each is its own
194
+ tree-shakeable module and there are no runtime dependencies. Naive UI,
195
+ PrimeVue and Ant Design put their styles in the JavaScript, which is why their
196
+ CSS column is empty and their JS column is not.
197
+
198
+ **What this does not say:** nothing has been measured past ten components, and
199
+ these are ten components *imported* rather than an app using them. The
200
+ ten-component row uses this kit's `DataTable` rather than its plainer
201
+ `BaseTable`, because the other five bring a data grid and the comparable part
202
+ is the one with sorting and selection — the heavier of the two. Run it
203
+ yourself: `cd bench && npm install && npm run bench`; it writes the numbers
204
+ this README and the showcase both read. Nuxt UI is left out because it builds
205
+ through its own Nuxt or Vite module and cannot be bundled the same way.
104
206
 
105
207
  The kit's own sizes are a budget, not a boast: `pnpm size` bundles a
106
208
  one-button app, the three-part app and an app using everything, and `check`
@@ -112,7 +214,7 @@ Each claim here is enforced by something that fails, not by a promise.
112
214
 
113
215
  | Standard | How it is held |
114
216
  | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
- | **Open for extension, closed for edits** | A new look is a token, a material or a palette — never an edit to a component. Materials and palettes restyle all 102 without touching one, and a test fails if a component holds a colour. |
217
+ | **Open for extension, closed for edits** | A new look is a token, a material or a palette — never an edit to a component. Materials and palettes restyle all 105 without touching one, and a test fails if a component holds a colour. |
116
218
  | **Single responsibility** | One part, one job. Label-and-error wiring lives in `FormField`, not in five inputs; taking the page behind a layer out of reach lives in one helper the modal, the sheet and the guide share. |
117
219
  | **Depend on roles, not values** | Components read `primary`, `surface`, `--shadow-card` — never a hex or a pixel shadow. An app's brand wins in both modes, tested. |
118
220
  | **WAI-ARIA Authoring Practices** | Menus, comboboxes, tabs, sliders, dialogs and accordions follow their APG pattern: arrows move, Tab leaves, Escape closes, focus returns. Behaviour tests drive each with the keyboard. |
@@ -124,14 +226,14 @@ Each claim here is enforced by something that fails, not by a promise.
124
226
 
125
227
  ## Status
126
228
 
127
- **v2.19.0 — three consumers.**
229
+ **v2.24.0 — three consumers.**
128
230
 
129
231
  | | |
130
232
  | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
- | Components | 102 (`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.) |
132
- | Composables | 16 (`useToast`, `useTheme`, `useMaterial`, `usePalette`, `useToday`, `useMediaQuery`, `useInstall`, `watchInstallability`, `createTabTransition`, `useThemeSync`, `useVisualViewport`, etc.) |
133
- | Utilities | 33 (`applyTheme`, `applyMaterial`, `applyPalette`, `MATERIALS`, `PALETTES`, `formatDate`, `fieldErrors`, `toAuthMessageKey`, `createAuthGuard`, `createQueryDefaults`, `createWriteReport`, `toRedirectPath`, `Supabase error mapper`, i18n runtime, etc.) |
134
- | 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 |
233
+ | Components | 105 (`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.) |
234
+ | Composables | 14 (`useToast`, `useTheme`, `useMaterial`, `usePalette`, `useToday`, `useMediaQuery`, `useOnline`, `useInstall`, `useSnooze`, `useThemeSync`, `useVisualViewport`, `useDragScroll`, `useDebouncedCallback`, `useAnnounce`) |
235
+ | Utilities | 48 functions and 5 constants (`applyTheme`, `applyMaterial`, `applyPalette`, `MATERIALS`, `PALETTES`, `formatDate`, `fieldErrors`, `toAuthMessageKey`, `createAuthGuard`, `createQueryDefaults`, `createWriteReport`, `toRedirectPath`, `Supabase error mapper`, i18n runtime, etc.) |
236
+ | Entry Points | `rei-kit`, `rei-kit/app`, `rei-kit/web`, `rei-kit/pwa`, `rei-kit/motion`, `rei-kit/supabase`, `rei-kit/mobile.css`, `rei-kit/web.css`, and each stylesheet on its own |
135
237
 
136
238
  | Consumers | [Hibi](https://github.com/ramazandogna/hibi) · [Kakei](https://github.com/ramazandogna/kakei) · [Kakehashi](https://github.com/ramazandogna/kakehashi-nihongo) |
137
239
 
@@ -542,10 +644,16 @@ Pushing a `v*` tag runs the full check and publishes to npm. Nothing publishes
542
644
  from a branch, so `main` can move without shipping.
543
645
 
544
646
  ```sh
647
+ cd bench && npm run bench && cd .. # the site's comparison table reads this
545
648
  pnpm version minor
546
649
  git push --follow-tags
547
650
  ```
548
651
 
652
+ The benchmark step is not optional and not a courtesy: `showcase-catalogue.spec.ts`
653
+ fails when `bench/results.json` names a version other than the one being
654
+ published, because the evidence section on the site reads that file. A
655
+ comparison table nobody re-ran is a claim about a build nobody ships.
656
+
549
657
  Then write the release into `PATCHNOTES.md` — what a consumer gains, and what
550
658
  they have to do to take it.
551
659
 
@@ -1,6 +1,6 @@
1
- import { a as useBoundValue } from "./focus-target-D0WsXwKh.js";
1
+ import { c as useBoundValue } from "./focus-target-DRgd8UnQ.js";
2
2
  import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
3
- import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, ref, renderList, renderSlot, toDisplayString, unref, useId, useTemplateRef, watch, watchEffect } from "vue";
3
+ import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, ref, renderList, renderSlot, toDisplayString, unref, useId, useTemplateRef, watch, watchEffect } from "vue";
4
4
  import { Check } from "lucide-vue-next";
5
5
  //#region src/composables/use-drag-scroll.ts
6
6
  /** Movement before a press counts as a drag rather than a tap. */
@@ -115,6 +115,77 @@ function useMediaQuery(query) {
115
115
  return matches;
116
116
  }
117
117
  //#endregion
118
+ //#region src/composables/use-anchored-panel.ts
119
+ /**
120
+ * Keeping a panel on screen.
121
+ *
122
+ * Two corrections, in this order, because they are not equal. **Flip**: a
123
+ * panel that does not fit below its anchor opens above it when there is
124
+ * more room there. **Shift**: whatever is left of it past the side of the
125
+ * window is slid back in.
126
+ *
127
+ * A panel taller than the window fits neither way, and then the rule is
128
+ * still "more room wins" — which shows more of it, and leaves the rest
129
+ * scrolled to rather than gone. `BasePopover` carried a comment saying it
130
+ * kept the side it asked for in that case; it never did, and nothing
131
+ * checked, because the arithmetic had never been given a layout to work
132
+ * on. The comment is gone and the behaviour is the one under test.
133
+ *
134
+ * This was written inside `BasePopover`, which is how the kit came to ship
135
+ * a popover that flips and a menu that does not. `BaseMenu`'s panel was
136
+ * pinned under its trigger with `top: 100%`, so a menu button near the
137
+ * bottom of a window — a row action, the `…` on a card, anything on a
138
+ * phone — opened a list that ran off the bottom of the screen. Nothing said
139
+ * so: it rendered, it passed axe, and the items were all there.
140
+ *
141
+ * Re-measured while open, because the page can scroll or the window change
142
+ * size underneath, and a panel that stays where it opened ends up attached
143
+ * to nothing. The scroll listener captures, so a scroll in any ancestor
144
+ * counts, and it is passive-by-omission: it only reads.
145
+ */
146
+ function useAnchoredPanel({ root, panel, open, side = "bottom", margin = 8 }) {
147
+ /** Where it actually opened, after measuring. */
148
+ const placed = ref(side);
149
+ /** Pixels slid sideways to stay on screen. */
150
+ const shift = ref(0);
151
+ function place() {
152
+ if (!panel.value || !root.value || typeof window === "undefined") return;
153
+ const rect = panel.value.getBoundingClientRect();
154
+ const anchor = root.value.getBoundingClientRect();
155
+ const below = window.innerHeight - anchor.bottom;
156
+ const above = anchor.top;
157
+ if (side === "bottom" && rect.height > below && above > below) placed.value = "top";
158
+ if (side === "top" && rect.height > above && below > above) placed.value = "bottom";
159
+ if (rect.right > window.innerWidth - margin) shift.value = window.innerWidth - margin - rect.right;
160
+ if (rect.left + shift.value < margin) shift.value = margin - rect.left;
161
+ }
162
+ function listen(on) {
163
+ if (typeof window === "undefined") return;
164
+ const method = on ? "addEventListener" : "removeEventListener";
165
+ window[method]("resize", place);
166
+ window[method]("scroll", place, true);
167
+ }
168
+ /** Measured where it asked to be, rather than where it ended up last time. */
169
+ function reset() {
170
+ placed.value = side;
171
+ shift.value = 0;
172
+ }
173
+ watch(open, async (isOpen) => {
174
+ listen(isOpen);
175
+ if (!isOpen) return;
176
+ reset();
177
+ await nextTick();
178
+ place();
179
+ }, { immediate: true });
180
+ onBeforeUnmount(() => listen(false));
181
+ return {
182
+ placed,
183
+ shift,
184
+ place,
185
+ reset
186
+ };
187
+ }
188
+ //#endregion
118
189
  //#region src/composables/use-menu-keys.ts
119
190
  function useMenuKeys({ panel, onClose }) {
120
191
  const items = () => panel.value ? Array.from(panel.value.querySelectorAll("[role=\"menuitem\"]")) : [];
@@ -198,6 +269,35 @@ var BaseSkeleton_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @_
198
269
  }
199
270
  }), [["__scopeId", "data-v-37327de6"]]);
200
271
  //#endregion
272
+ //#region src/utils/focusable.ts
273
+ /**
274
+ * What the keyboard can reach, as one selector.
275
+ *
276
+ * Three parts of the kit ask this question and each had written its own
277
+ * answer, which is how three answers came to differ: the dialog trap and
278
+ * the popover agreed, and `ScrollArea` left out `:not([disabled])`, so a
279
+ * box holding nothing but disabled buttons counted as reachable and was
280
+ * denied the focus stop that would have been its only way in.
281
+ *
282
+ * `summary` belongs here for the same reason it belonged in `ScrollArea`:
283
+ * a `<details>` heading takes focus, and a dialog whose trap skipped one
284
+ * would let Tab out of itself.
285
+ *
286
+ * Two things it deliberately does not do. It does not check whether an
287
+ * element is visible or inside an `inert` subtree — a selector cannot, and
288
+ * every caller here is already scoped to something on screen. And it is
289
+ * not what a roving-tabindex container should use to find its own
290
+ * controls: `[tabindex]:not([tabindex="-1"])` matches exactly the one
291
+ * control such a container has already put in the tab order, so it would
292
+ * find one item and then stop. `BaseToolbar` says so where it keeps its
293
+ * own.
294
+ */
295
+ var FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex=\"-1\"])";
296
+ /** The reachable elements inside `root`, in document order. */
297
+ function focusableWithin(root) {
298
+ return Array.from(root?.querySelectorAll("a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex=\"-1\"])") ?? []);
299
+ }
300
+ //#endregion
201
301
  //#region src/components/ScrollArea.vue?vue&type=script&setup=true&lang.ts
202
302
  var _hoisted_1$1 = { class: "rk-scroll" };
203
303
  var _hoisted_2$1 = [
@@ -225,38 +325,6 @@ var _hoisted_6$1 = {
225
325
  class: "rk-scroll-fade is-end-y",
226
326
  "aria-hidden": "true"
227
327
  };
228
- /**
229
- * A box that scrolls, with the two things a hand-written one leaves out.
230
- *
231
- * ## The edge fade is the affordance `no-scrollbar` takes away
232
- *
233
- * Every app here hides the scrollbar on a horizontal row — a strip of
234
- * months, a row of filter chips — because a native bar under a 40px row is
235
- * louder than the row. What goes with it is the only sign on screen that
236
- * there is anything further along. So the fades are driven by the actual
237
- * scroll position: one appears at an edge exactly when there is content
238
- * past it, and both are gone when everything fits.
239
- *
240
- * ## A focus stop, but only when there is no other way in
241
- *
242
- * A box that scrolls and cannot take focus cannot be scrolled by a
243
- * keyboard, so the far end is simply unreachable. That is the fault
244
- * `CodeBlock` was written for, and it is not about code: it is true of any
245
- * scrolling box whose contents are not focusable.
246
- *
247
- * But it is *only* true of those. A row of buttons already moves under the
248
- * keyboard — Tab scrolls the next one into view — and giving that row a
249
- * stop of its own costs a press for nothing, on every row. So this checks:
250
- * it becomes a named region when it overflows **and** holds nothing
251
- * focusable, and stays out of the tab order otherwise.
252
- *
253
- * The check runs when the box or its contents resize, which covers a list
254
- * that arrives from a server. A swap that changes neither size — the same
255
- * number of rows, buttons turned into plain text — will not be noticed;
256
- * that is a trade against watching every mutation of a scroll container,
257
- * and the shape it misses is a rare one.
258
- */
259
- var FOCUSABLE = "a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex=\"-1\"])";
260
328
  //#endregion
261
329
  //#region src/components/ScrollArea.vue
262
330
  var ScrollArea_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
@@ -278,7 +346,6 @@ var ScrollArea_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__P
278
346
  setup(__props, { expose: __expose, emit: __emit }) {
279
347
  const emit = __emit;
280
348
  const viewport = useTemplateRef("viewport");
281
- /** Anything that takes focus on its own, so the box does not need to. */
282
349
  const horizontal = computed(() => __props.axis === "x" || __props.axis === "both");
283
350
  const vertical = computed(() => __props.axis === "y" || __props.axis === "both");
284
351
  const start = ref({
@@ -348,7 +415,7 @@ viewport });
348
415
  ], 64)) : createCommentVNode("", true)]);
349
416
  };
350
417
  }
351
- }), [["__scopeId", "data-v-4c21cdc9"]]);
418
+ }), [["__scopeId", "data-v-383954d8"]]);
352
419
  //#endregion
353
420
  //#region src/components/BaseListbox.vue?vue&type=script&setup=true&lang.ts
354
421
  var _hoisted_1 = [
@@ -477,6 +544,6 @@ var BaseListbox_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__
477
544
  }
478
545
  }), [["__scopeId", "data-v-7f8cd788"]]);
479
546
  //#endregion
480
- export { useMediaQuery as a, useMenuKeys as i, ScrollArea_default as n, useDragScroll as o, BaseSkeleton_default as r, BaseListbox_default as t };
547
+ export { BaseSkeleton_default as a, useMediaQuery as c, focusableWithin as i, useDragScroll as l, ScrollArea_default as n, useMenuKeys as o, FOCUSABLE as r, useAnchoredPanel as s, BaseListbox_default as t };
481
548
 
482
- //# sourceMappingURL=BaseListbox-DMR-Zn1u.js.map
549
+ //# sourceMappingURL=BaseListbox-BQoSwYdJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BaseListbox-BQoSwYdJ.js","names":[],"sources":["../src/composables/use-drag-scroll.ts","../src/composables/use-media-query.ts","../src/composables/use-anchored-panel.ts","../src/composables/use-menu-keys.ts","../src/components/BaseSkeleton.vue","../src/components/BaseSkeleton.vue","../src/utils/focusable.ts","../src/components/ScrollArea.vue","../src/components/ScrollArea.vue","../src/components/BaseListbox.vue","../src/components/BaseListbox.vue"],"sourcesContent":["import { onScopeDispose, watch } from 'vue'\nimport type { Ref } from 'vue'\n\n/** Movement before a press counts as a drag rather than a tap. */\nconst DRAG_THRESHOLD_PX = 6\n\n/**\n * Drag-to-scroll for a horizontally scrolling element.\n *\n * The app puts `touch-action: pan-y` on the page content so the tab-swipe\n * gesture keeps its pointer events — the browser never claims a horizontal\n * drag, which also means it never pans this element natively. Rather than give\n * that up, horizontal scrolling is driven here.\n *\n * @param target - The scroll container.\n * @returns `didDrag`, so a click handler can ignore the press that ended a drag.\n *\n * @example\n * ```ts\n * const scroller = ref<HTMLElement | null>(null)\n * const { didDrag } = useDragScroll(scroller)\n *\n * function onClick() {\n * if (didDrag()) return\n * // …treat as a tap\n * }\n * ```\n */\nexport function useDragScroll(target: Ref<HTMLElement | null>) {\n let pointerId: number | null = null\n let startX = 0\n let startScroll = 0\n let dragged = false\n\n function onPointerDown(event: PointerEvent) {\n const element = target.value\n if (!element || event.pointerType === 'mouse') return\n\n pointerId = event.pointerId\n startX = event.clientX\n startScroll = element.scrollLeft\n dragged = false\n }\n\n function onPointerMove(event: PointerEvent) {\n const element = target.value\n if (!element || event.pointerId !== pointerId) return\n\n const dx = event.clientX - startX\n if (!dragged && Math.abs(dx) < DRAG_THRESHOLD_PX) return\n\n // Capture only once the gesture is clearly horizontal, so a vertical scroll\n // that happens to start here still belongs to the page.\n if (!dragged) {\n dragged = true\n element.setPointerCapture(event.pointerId)\n }\n\n element.scrollLeft = startScroll - dx\n }\n\n function onPointerUp(event: PointerEvent) {\n const element = target.value\n if (element?.hasPointerCapture(event.pointerId)) {\n element.releasePointerCapture(event.pointerId)\n }\n\n pointerId = null\n }\n\n function bind(element: HTMLElement) {\n element.addEventListener('pointerdown', onPointerDown)\n element.addEventListener('pointermove', onPointerMove)\n element.addEventListener('pointerup', onPointerUp)\n element.addEventListener('pointercancel', onPointerUp)\n }\n\n function unbind(element: HTMLElement) {\n element.removeEventListener('pointerdown', onPointerDown)\n element.removeEventListener('pointermove', onPointerMove)\n element.removeEventListener('pointerup', onPointerUp)\n element.removeEventListener('pointercancel', onPointerUp)\n }\n\n watch(\n target,\n (element, previous) => {\n if (previous) unbind(previous)\n if (element) bind(element)\n },\n { immediate: true },\n )\n\n onScopeDispose(() => {\n if (target.value) unbind(target.value)\n })\n\n return { didDrag: () => dragged }\n}\n","import { onBeforeUnmount, onMounted, ref } from 'vue'\n\n/**\n * Whether a media query matches, kept up to date.\n *\n * Starts false and resolves on mount, which is deliberate: this is the one\n * place a component is tempted to branch on viewport during render, and doing\n * that under prerendering produces HTML built for a screen the server does not\n * have. Hydration then swaps it and the page jumps. False first, correct a\n * frame later, no jump — and a layout that reads badly at `false` is a layout\n * with a mobile-first bug worth knowing about.\n *\n * Guarded for the server for the same reason the rest of the kit is: this\n * package has to be importable in Node, and `matchMedia` does not exist there.\n *\n * @example\n * ```ts\n * const wide = useMediaQuery('(min-width: 64rem)')\n * ```\n */\nexport function useMediaQuery(query: string) {\n const matches = ref(false)\n\n let list: MediaQueryList | undefined\n\n function update(event: MediaQueryList | MediaQueryListEvent) {\n matches.value = event.matches\n }\n\n onMounted(() => {\n if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return\n\n list = window.matchMedia(query)\n update(list)\n list.addEventListener('change', update)\n })\n\n onBeforeUnmount(() => {\n list?.removeEventListener('change', update)\n })\n\n return matches\n}\n","import { nextTick, onBeforeUnmount, ref, watch } from 'vue'\nimport type { Ref } from 'vue'\n\n/** Above the anchor or below it. */\nexport type PanelSide = 'top' | 'bottom'\n\nexport interface AnchoredPanelOptions {\n /** The element the panel is positioned against. */\n root: Ref<HTMLElement | null>\n /** The panel itself, measured once it is on screen. */\n panel: Ref<HTMLElement | null>\n /** Whether it is showing. */\n open: Ref<boolean>\n /** Which way it opens before anything is measured. */\n side?: PanelSide\n /** How close to the window edge it may come, in pixels. */\n margin?: number\n}\n\n/**\n * Keeping a panel on screen.\n *\n * Two corrections, in this order, because they are not equal. **Flip**: a\n * panel that does not fit below its anchor opens above it when there is\n * more room there. **Shift**: whatever is left of it past the side of the\n * window is slid back in.\n *\n * A panel taller than the window fits neither way, and then the rule is\n * still \"more room wins\" — which shows more of it, and leaves the rest\n * scrolled to rather than gone. `BasePopover` carried a comment saying it\n * kept the side it asked for in that case; it never did, and nothing\n * checked, because the arithmetic had never been given a layout to work\n * on. The comment is gone and the behaviour is the one under test.\n *\n * This was written inside `BasePopover`, which is how the kit came to ship\n * a popover that flips and a menu that does not. `BaseMenu`'s panel was\n * pinned under its trigger with `top: 100%`, so a menu button near the\n * bottom of a window — a row action, the `…` on a card, anything on a\n * phone — opened a list that ran off the bottom of the screen. Nothing said\n * so: it rendered, it passed axe, and the items were all there.\n *\n * Re-measured while open, because the page can scroll or the window change\n * size underneath, and a panel that stays where it opened ends up attached\n * to nothing. The scroll listener captures, so a scroll in any ancestor\n * counts, and it is passive-by-omission: it only reads.\n */\nexport function useAnchoredPanel({\n root,\n panel,\n open,\n side = 'bottom',\n margin = 8,\n}: AnchoredPanelOptions) {\n /** Where it actually opened, after measuring. */\n const placed = ref<PanelSide>(side)\n /** Pixels slid sideways to stay on screen. */\n const shift = ref(0)\n\n function place() {\n if (!panel.value || !root.value || typeof window === 'undefined') return\n\n const rect = panel.value.getBoundingClientRect()\n const anchor = root.value.getBoundingClientRect()\n const below = window.innerHeight - anchor.bottom\n const above = anchor.top\n\n if (side === 'bottom' && rect.height > below && above > below) placed.value = 'top'\n if (side === 'top' && rect.height > above && below > above) placed.value = 'bottom'\n\n if (rect.right > window.innerWidth - margin) {\n shift.value = window.innerWidth - margin - rect.right\n }\n if (rect.left + shift.value < margin) shift.value = margin - rect.left\n }\n\n function listen(on: boolean) {\n if (typeof window === 'undefined') return\n\n const method = on ? 'addEventListener' : 'removeEventListener'\n window[method]('resize', place)\n window[method]('scroll', place, true)\n }\n\n /** Measured where it asked to be, rather than where it ended up last time. */\n function reset() {\n placed.value = side\n shift.value = 0\n }\n\n /*\n * The opening is handled here rather than left to each caller, because\n * three of them open from more than one place: `BaseCombobox` opens on a\n * keystroke, on an arrow and on a press, and a panel measured at two of\n * those three is a panel that is usually right.\n *\n * Reset before the tick, measure after it: the panel has no size until\n * it has rendered, and it must be measured where it asked to be rather\n * than where it ended up the last time it was open.\n */\n watch(\n open,\n async (isOpen) => {\n listen(isOpen)\n if (!isOpen) return\n\n reset()\n await nextTick()\n place()\n },\n { immediate: true },\n )\n\n onBeforeUnmount(() => listen(false))\n\n return { placed, shift, place, reset }\n}\n","import type { Ref } from 'vue'\n\n/**\n * The keyboard contract `role=\"menu\"` promises.\n *\n * Declaring `role=\"menu\"` is a promise to a screen reader: the arrows move\n * through the items, Home and End go to the ends, Escape closes, and Tab\n * *leaves* rather than cycling inside. That last one is the single place a\n * menu differs from a dialog, and getting it backwards traps somebody in a\n * list of links.\n *\n * It lives here because a menu anchored to a button and a menu anchored to\n * wherever the pointer was are the same keyboard and two different\n * positions. Only the position is worth writing twice.\n */\nexport interface MenuKeysOptions {\n /** The element holding the `[role=\"menuitem\"]` children. */\n panel: Ref<HTMLElement | null>\n /** Close it — Escape, and Tab on the way out. */\n onClose: () => void\n}\n\nexport function useMenuKeys({ panel, onClose }: MenuKeysOptions) {\n const items = (): HTMLElement[] =>\n panel.value ? Array.from(panel.value.querySelectorAll<HTMLElement>('[role=\"menuitem\"]')) : []\n\n function focusAt(index: number) {\n const list = items()\n if (list.length === 0) return\n\n // Wrapping, because a list with no edges is faster than one you fall off.\n list[(index + list.length) % list.length]?.focus()\n }\n\n const currentIndex = () => items().indexOf(document.activeElement as HTMLElement)\n\n /** Returns whether the key was one of the menu's own. */\n function onKeydown(event: KeyboardEvent): boolean {\n switch (event.key) {\n case 'Escape':\n event.preventDefault()\n onClose()\n return true\n case 'ArrowDown':\n event.preventDefault()\n focusAt(currentIndex() + 1)\n return true\n case 'ArrowUp':\n event.preventDefault()\n focusAt(currentIndex() - 1)\n return true\n case 'Home':\n event.preventDefault()\n focusAt(0)\n return true\n case 'End':\n event.preventDefault()\n focusAt(items().length - 1)\n return true\n case 'Tab':\n onClose()\n return true\n default:\n return false\n }\n }\n\n return { items, focusAt, currentIndex, onKeydown }\n}\n","<script setup lang=\"ts\">\n/**\n * One grey box standing in for content that has not arrived.\n *\n * `SkeletonList` is rows of these for a list; this is the primitive, for the\n * shapes a list does not cover — an avatar, a heading, a chart.\n *\n * A height is a CSS length, never a class: `h-4` inside a component the app\n * does not control would render at zero the day that utility is not in the\n * app's stylesheet, which is exactly how every skeleton in one app came out\n * invisible.\n *\n * It is hidden from assistive tech. The thing that is loading says so —\n * through `aria-busy`, or a status message — and a screen reader reading\n * \"blank, blank, blank\" helps nobody.\n */\nconst {\n width = '100%',\n height = '1rem',\n shape = 'block',\n} = defineProps<{\n /** Any CSS length: `12rem`, `60%`, `8ch`. */\n width?: string | undefined\n height?: string | undefined\n /** `circle` makes a round one and squares the width to the height. */\n shape?: 'block' | 'text' | 'circle' | undefined\n}>()\n</script>\n\n<template>\n <span\n class=\"rk-skeleton\"\n :class=\"`is-${shape}`\"\n :style=\"{ width: shape === 'circle' ? height : width, height }\"\n aria-hidden=\"true\"\n />\n</template>\n\n<style scoped>\n.rk-skeleton {\n display: block;\n flex-shrink: 0;\n background: var(--color-muted);\n animation: rk-skeleton-pulse 1.6s ease-in-out infinite;\n}\n\n.rk-skeleton.is-block {\n border-radius: var(--radius-cell);\n}\n\n/* A line of text, rounded like one and a little short of its box. */\n.rk-skeleton.is-text {\n border-radius: 9999px;\n}\n\n.rk-skeleton.is-circle {\n border-radius: 9999px;\n}\n\n@keyframes rk-skeleton-pulse {\n 50% {\n opacity: 0.55;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-skeleton {\n animation: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\n/**\n * One grey box standing in for content that has not arrived.\n *\n * `SkeletonList` is rows of these for a list; this is the primitive, for the\n * shapes a list does not cover — an avatar, a heading, a chart.\n *\n * A height is a CSS length, never a class: `h-4` inside a component the app\n * does not control would render at zero the day that utility is not in the\n * app's stylesheet, which is exactly how every skeleton in one app came out\n * invisible.\n *\n * It is hidden from assistive tech. The thing that is loading says so —\n * through `aria-busy`, or a status message — and a screen reader reading\n * \"blank, blank, blank\" helps nobody.\n */\nconst {\n width = '100%',\n height = '1rem',\n shape = 'block',\n} = defineProps<{\n /** Any CSS length: `12rem`, `60%`, `8ch`. */\n width?: string | undefined\n height?: string | undefined\n /** `circle` makes a round one and squares the width to the height. */\n shape?: 'block' | 'text' | 'circle' | undefined\n}>()\n</script>\n\n<template>\n <span\n class=\"rk-skeleton\"\n :class=\"`is-${shape}`\"\n :style=\"{ width: shape === 'circle' ? height : width, height }\"\n aria-hidden=\"true\"\n />\n</template>\n\n<style scoped>\n.rk-skeleton {\n display: block;\n flex-shrink: 0;\n background: var(--color-muted);\n animation: rk-skeleton-pulse 1.6s ease-in-out infinite;\n}\n\n.rk-skeleton.is-block {\n border-radius: var(--radius-cell);\n}\n\n/* A line of text, rounded like one and a little short of its box. */\n.rk-skeleton.is-text {\n border-radius: 9999px;\n}\n\n.rk-skeleton.is-circle {\n border-radius: 9999px;\n}\n\n@keyframes rk-skeleton-pulse {\n 50% {\n opacity: 0.55;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-skeleton {\n animation: none;\n }\n}\n</style>\n","/**\n * What the keyboard can reach, as one selector.\n *\n * Three parts of the kit ask this question and each had written its own\n * answer, which is how three answers came to differ: the dialog trap and\n * the popover agreed, and `ScrollArea` left out `:not([disabled])`, so a\n * box holding nothing but disabled buttons counted as reachable and was\n * denied the focus stop that would have been its only way in.\n *\n * `summary` belongs here for the same reason it belonged in `ScrollArea`:\n * a `<details>` heading takes focus, and a dialog whose trap skipped one\n * would let Tab out of itself.\n *\n * Two things it deliberately does not do. It does not check whether an\n * element is visible or inside an `inert` subtree — a selector cannot, and\n * every caller here is already scoped to something on screen. And it is\n * not what a roving-tabindex container should use to find its own\n * controls: `[tabindex]:not([tabindex=\"-1\"])` matches exactly the one\n * control such a container has already put in the tab order, so it would\n * find one item and then stop. `BaseToolbar` says so where it keeps its\n * own.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex=\"-1\"])'\n\n/** The reachable elements inside `root`, in document order. */\nexport function focusableWithin(root: Element | null | undefined): HTMLElement[] {\n return Array.from(root?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])\n}\n","<script setup lang=\"ts\">\nimport { computed, onScopeDispose, ref, useTemplateRef, watchEffect } from 'vue'\n\nimport { useDragScroll } from '../composables/use-drag-scroll'\nimport { FOCUSABLE } from '../utils/focusable'\n\n/**\n * A box that scrolls, with the two things a hand-written one leaves out.\n *\n * ## The edge fade is the affordance `no-scrollbar` takes away\n *\n * Every app here hides the scrollbar on a horizontal row — a strip of\n * months, a row of filter chips — because a native bar under a 40px row is\n * louder than the row. What goes with it is the only sign on screen that\n * there is anything further along. So the fades are driven by the actual\n * scroll position: one appears at an edge exactly when there is content\n * past it, and both are gone when everything fits.\n *\n * ## A focus stop, but only when there is no other way in\n *\n * A box that scrolls and cannot take focus cannot be scrolled by a\n * keyboard, so the far end is simply unreachable. That is the fault\n * `CodeBlock` was written for, and it is not about code: it is true of any\n * scrolling box whose contents are not focusable.\n *\n * But it is *only* true of those. A row of buttons already moves under the\n * keyboard — Tab scrolls the next one into view — and giving that row a\n * stop of its own costs a press for nothing, on every row. So this checks:\n * it becomes a named region when it overflows **and** holds nothing\n * focusable, and stays out of the tab order otherwise.\n *\n * The check runs when the box or its contents resize, which covers a list\n * that arrives from a server. A swap that changes neither size — the same\n * number of rows, buttons turned into plain text — will not be noticed;\n * that is a trade against watching every mutation of a scroll container,\n * and the shape it misses is a rare one.\n */\nconst {\n label,\n axis = 'y',\n scrollbar = 'auto',\n fade = true,\n drag = false,\n} = defineProps<{\n /**\n * Names the box, e.g. \"Categories\".\n *\n * Required because this becomes a region a reader can land in, and an\n * unnamed region is announced as nothing at all. It is only used when\n * that happens, so it costs nothing in the common case.\n */\n label: string\n /** Which way it scrolls. */\n axis?: 'x' | 'y' | 'both' | undefined\n /** `hidden` takes the native bar away; the fades stay either way. */\n scrollbar?: 'auto' | 'hidden' | undefined\n /** The gradient at an edge with more content past it. */\n fade?: boolean | undefined\n /** Drag to scroll sideways, for a page that has claimed horizontal gestures. */\n drag?: boolean | undefined\n}>()\n\ndefineSlots<{ default: () => unknown }>()\n\n/* The scroll event does not bubble, so a parent cannot listen for it on the\n wrapper. `VirtualList` needs it, and needs the element it came from, so\n both are handed out rather than left to a template ref into our markup. */\nconst emit = defineEmits<{ scroll: [event: Event] }>()\n\nconst viewport = useTemplateRef<HTMLElement>('viewport')\n\nconst horizontal = computed(() => axis === 'x' || axis === 'both')\nconst vertical = computed(() => axis === 'y' || axis === 'both')\n\nconst start = ref({ x: false, y: false })\nconst end = ref({ x: false, y: false })\nconst overflows = ref(false)\nconst hasFocusable = ref(true)\n\nfunction measure() {\n const element = viewport.value\n if (!element) return\n\n const overX = element.scrollWidth - element.clientWidth\n const overY = element.scrollHeight - element.clientHeight\n\n /* A fractional pixel of overflow is a rounding artefact, not content:\n without this every box that fits exactly draws a fade at one end. */\n const slack = 1\n\n /* `scrollLeft` counts down from zero in a right-to-left box, so the\n distance travelled is its magnitude either way. That is what makes\n \"start\" and \"end\" below mean the same thing in both directions. */\n const alongX = Math.abs(element.scrollLeft)\n\n start.value = { x: alongX > slack, y: element.scrollTop > slack }\n end.value = { x: overX - alongX > slack, y: overY - element.scrollTop > slack }\n\n overflows.value = (horizontal.value && overX > slack) || (vertical.value && overY > slack)\n hasFocusable.value = element.querySelector(FOCUSABLE) !== null\n}\n\nfunction onScroll(event: Event) {\n measure()\n emit('scroll', event)\n}\n\n/** The box itself is the way in only when nothing inside it is. */\nconst isStop = computed(() => overflows.value && !hasFocusable.value)\n\nlet observer: ResizeObserver | null = null\n\nwatchEffect(() => {\n observer?.disconnect()\n observer = null\n\n const element = viewport.value\n if (!element || typeof ResizeObserver === 'undefined') return\n\n observer = new ResizeObserver(measure)\n observer.observe(element)\n // The contents too: a list that grows changes what there is to scroll\n // without the box itself changing size at all.\n for (const child of element.children) observer.observe(child)\n\n measure()\n})\n\nonScopeDispose(() => observer?.disconnect())\n\nuseDragScroll(computed(() => (drag ? viewport.value : null)))\n\ndefineExpose({\n /** The element that actually scrolls, for a parent that has to measure it. */\n viewport,\n})\n</script>\n\n<template>\n <div class=\"rk-scroll\">\n <div\n ref=\"viewport\"\n class=\"rk-scroll-viewport\"\n :class=\"[`is-${axis}`, { 'no-scrollbar': scrollbar === 'hidden' }]\"\n :tabindex=\"isStop ? 0 : undefined\"\n :role=\"isStop ? 'region' : undefined\"\n :aria-label=\"isStop ? label : undefined\"\n @scroll=\"onScroll\"\n >\n <slot />\n </div>\n\n <!-- Decoration, and never in the way of a press: the box under them is\n what scrolls. -->\n <template v-if=\"fade\">\n <span v-if=\"horizontal && start.x\" class=\"rk-scroll-fade is-start-x\" aria-hidden=\"true\" />\n <span v-if=\"horizontal && end.x\" class=\"rk-scroll-fade is-end-x\" aria-hidden=\"true\" />\n <span v-if=\"vertical && start.y\" class=\"rk-scroll-fade is-start-y\" aria-hidden=\"true\" />\n <span v-if=\"vertical && end.y\" class=\"rk-scroll-fade is-end-y\" aria-hidden=\"true\" />\n </template>\n </div>\n</template>\n\n<style scoped>\n/*\n * A flex column, so a height written on this box reaches the box that\n * scrolls.\n *\n * The caller's class lands here, on the wrapper, because that is the\n * element the component renders outermost — so `class=\"max-h-56\"` is the\n * obvious and only thing to write. It used to do nothing. The viewport\n * carried `max-height: 100%`, and a percentage height resolves against the\n * parent's *height*, which is `auto` here; a parent with only a\n * `max-height` gives it nothing to resolve against, so the rule was inert\n * and the viewport grew to the full height of its contents.\n *\n * Visually that spills. For `VirtualList` it is worse than spilling: the\n * window is sized from `clientHeight`, so an unconstrained viewport reports\n * the height of the whole list, every row is \"near the viewport\", and five\n * thousand of them render at once. That is the component's whole purpose,\n * inverted, by the one line every caller writes.\n *\n * `flex: 1 1 auto` with `min-height: 0` is what lets the item shrink below\n * its content, which is the part that is easy to leave out: a flex item's\n * default `min-height: auto` refuses to go below its content and the box\n * spills exactly as before.\n */\n.rk-scroll {\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n}\n\n.rk-scroll-viewport {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n max-width: 100%;\n}\n\n.rk-scroll-viewport.is-x {\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.rk-scroll-viewport.is-y {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n.rk-scroll-viewport.is-both {\n overflow: auto;\n}\n\n.rk-scroll-viewport:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n/* The fade paints over whatever the box sits on, so the colour is a hook:\n a scroll area on a card sets it to the card's ground. */\n.rk-scroll-fade {\n position: absolute;\n z-index: 1;\n pointer-events: none;\n --rk-scroll-fade: var(--scroll-fade-color, var(--color-canvas));\n}\n\n.rk-scroll-fade.is-start-x,\n.rk-scroll-fade.is-end-x {\n inset-block: 0;\n width: 1.5rem;\n}\n\n.rk-scroll-fade.is-start-y,\n.rk-scroll-fade.is-end-y {\n inset-inline: 0;\n height: 1.5rem;\n}\n\n.rk-scroll-fade.is-start-x {\n inset-inline-start: 0;\n background: linear-gradient(to right, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-end-x {\n inset-inline-end: 0;\n background: linear-gradient(to left, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-start-y {\n inset-block-start: 0;\n background: linear-gradient(to bottom, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-end-y {\n inset-block-end: 0;\n background: linear-gradient(to top, var(--rk-scroll-fade), transparent);\n}\n\n/* The insets are logical, so they land on the correct side on their own.\n A gradient has no logical direction, so these two flip by hand. */\n[dir='rtl'] .rk-scroll-fade.is-start-x {\n background: linear-gradient(to left, var(--rk-scroll-fade), transparent);\n}\n\n[dir='rtl'] .rk-scroll-fade.is-end-x {\n background: linear-gradient(to right, var(--rk-scroll-fade), transparent);\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, onScopeDispose, ref, useTemplateRef, watchEffect } from 'vue'\n\nimport { useDragScroll } from '../composables/use-drag-scroll'\nimport { FOCUSABLE } from '../utils/focusable'\n\n/**\n * A box that scrolls, with the two things a hand-written one leaves out.\n *\n * ## The edge fade is the affordance `no-scrollbar` takes away\n *\n * Every app here hides the scrollbar on a horizontal row — a strip of\n * months, a row of filter chips — because a native bar under a 40px row is\n * louder than the row. What goes with it is the only sign on screen that\n * there is anything further along. So the fades are driven by the actual\n * scroll position: one appears at an edge exactly when there is content\n * past it, and both are gone when everything fits.\n *\n * ## A focus stop, but only when there is no other way in\n *\n * A box that scrolls and cannot take focus cannot be scrolled by a\n * keyboard, so the far end is simply unreachable. That is the fault\n * `CodeBlock` was written for, and it is not about code: it is true of any\n * scrolling box whose contents are not focusable.\n *\n * But it is *only* true of those. A row of buttons already moves under the\n * keyboard — Tab scrolls the next one into view — and giving that row a\n * stop of its own costs a press for nothing, on every row. So this checks:\n * it becomes a named region when it overflows **and** holds nothing\n * focusable, and stays out of the tab order otherwise.\n *\n * The check runs when the box or its contents resize, which covers a list\n * that arrives from a server. A swap that changes neither size — the same\n * number of rows, buttons turned into plain text — will not be noticed;\n * that is a trade against watching every mutation of a scroll container,\n * and the shape it misses is a rare one.\n */\nconst {\n label,\n axis = 'y',\n scrollbar = 'auto',\n fade = true,\n drag = false,\n} = defineProps<{\n /**\n * Names the box, e.g. \"Categories\".\n *\n * Required because this becomes a region a reader can land in, and an\n * unnamed region is announced as nothing at all. It is only used when\n * that happens, so it costs nothing in the common case.\n */\n label: string\n /** Which way it scrolls. */\n axis?: 'x' | 'y' | 'both' | undefined\n /** `hidden` takes the native bar away; the fades stay either way. */\n scrollbar?: 'auto' | 'hidden' | undefined\n /** The gradient at an edge with more content past it. */\n fade?: boolean | undefined\n /** Drag to scroll sideways, for a page that has claimed horizontal gestures. */\n drag?: boolean | undefined\n}>()\n\ndefineSlots<{ default: () => unknown }>()\n\n/* The scroll event does not bubble, so a parent cannot listen for it on the\n wrapper. `VirtualList` needs it, and needs the element it came from, so\n both are handed out rather than left to a template ref into our markup. */\nconst emit = defineEmits<{ scroll: [event: Event] }>()\n\nconst viewport = useTemplateRef<HTMLElement>('viewport')\n\nconst horizontal = computed(() => axis === 'x' || axis === 'both')\nconst vertical = computed(() => axis === 'y' || axis === 'both')\n\nconst start = ref({ x: false, y: false })\nconst end = ref({ x: false, y: false })\nconst overflows = ref(false)\nconst hasFocusable = ref(true)\n\nfunction measure() {\n const element = viewport.value\n if (!element) return\n\n const overX = element.scrollWidth - element.clientWidth\n const overY = element.scrollHeight - element.clientHeight\n\n /* A fractional pixel of overflow is a rounding artefact, not content:\n without this every box that fits exactly draws a fade at one end. */\n const slack = 1\n\n /* `scrollLeft` counts down from zero in a right-to-left box, so the\n distance travelled is its magnitude either way. That is what makes\n \"start\" and \"end\" below mean the same thing in both directions. */\n const alongX = Math.abs(element.scrollLeft)\n\n start.value = { x: alongX > slack, y: element.scrollTop > slack }\n end.value = { x: overX - alongX > slack, y: overY - element.scrollTop > slack }\n\n overflows.value = (horizontal.value && overX > slack) || (vertical.value && overY > slack)\n hasFocusable.value = element.querySelector(FOCUSABLE) !== null\n}\n\nfunction onScroll(event: Event) {\n measure()\n emit('scroll', event)\n}\n\n/** The box itself is the way in only when nothing inside it is. */\nconst isStop = computed(() => overflows.value && !hasFocusable.value)\n\nlet observer: ResizeObserver | null = null\n\nwatchEffect(() => {\n observer?.disconnect()\n observer = null\n\n const element = viewport.value\n if (!element || typeof ResizeObserver === 'undefined') return\n\n observer = new ResizeObserver(measure)\n observer.observe(element)\n // The contents too: a list that grows changes what there is to scroll\n // without the box itself changing size at all.\n for (const child of element.children) observer.observe(child)\n\n measure()\n})\n\nonScopeDispose(() => observer?.disconnect())\n\nuseDragScroll(computed(() => (drag ? viewport.value : null)))\n\ndefineExpose({\n /** The element that actually scrolls, for a parent that has to measure it. */\n viewport,\n})\n</script>\n\n<template>\n <div class=\"rk-scroll\">\n <div\n ref=\"viewport\"\n class=\"rk-scroll-viewport\"\n :class=\"[`is-${axis}`, { 'no-scrollbar': scrollbar === 'hidden' }]\"\n :tabindex=\"isStop ? 0 : undefined\"\n :role=\"isStop ? 'region' : undefined\"\n :aria-label=\"isStop ? label : undefined\"\n @scroll=\"onScroll\"\n >\n <slot />\n </div>\n\n <!-- Decoration, and never in the way of a press: the box under them is\n what scrolls. -->\n <template v-if=\"fade\">\n <span v-if=\"horizontal && start.x\" class=\"rk-scroll-fade is-start-x\" aria-hidden=\"true\" />\n <span v-if=\"horizontal && end.x\" class=\"rk-scroll-fade is-end-x\" aria-hidden=\"true\" />\n <span v-if=\"vertical && start.y\" class=\"rk-scroll-fade is-start-y\" aria-hidden=\"true\" />\n <span v-if=\"vertical && end.y\" class=\"rk-scroll-fade is-end-y\" aria-hidden=\"true\" />\n </template>\n </div>\n</template>\n\n<style scoped>\n/*\n * A flex column, so a height written on this box reaches the box that\n * scrolls.\n *\n * The caller's class lands here, on the wrapper, because that is the\n * element the component renders outermost — so `class=\"max-h-56\"` is the\n * obvious and only thing to write. It used to do nothing. The viewport\n * carried `max-height: 100%`, and a percentage height resolves against the\n * parent's *height*, which is `auto` here; a parent with only a\n * `max-height` gives it nothing to resolve against, so the rule was inert\n * and the viewport grew to the full height of its contents.\n *\n * Visually that spills. For `VirtualList` it is worse than spilling: the\n * window is sized from `clientHeight`, so an unconstrained viewport reports\n * the height of the whole list, every row is \"near the viewport\", and five\n * thousand of them render at once. That is the component's whole purpose,\n * inverted, by the one line every caller writes.\n *\n * `flex: 1 1 auto` with `min-height: 0` is what lets the item shrink below\n * its content, which is the part that is easy to leave out: a flex item's\n * default `min-height: auto` refuses to go below its content and the box\n * spills exactly as before.\n */\n.rk-scroll {\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n}\n\n.rk-scroll-viewport {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n max-width: 100%;\n}\n\n.rk-scroll-viewport.is-x {\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.rk-scroll-viewport.is-y {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n.rk-scroll-viewport.is-both {\n overflow: auto;\n}\n\n.rk-scroll-viewport:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n/* The fade paints over whatever the box sits on, so the colour is a hook:\n a scroll area on a card sets it to the card's ground. */\n.rk-scroll-fade {\n position: absolute;\n z-index: 1;\n pointer-events: none;\n --rk-scroll-fade: var(--scroll-fade-color, var(--color-canvas));\n}\n\n.rk-scroll-fade.is-start-x,\n.rk-scroll-fade.is-end-x {\n inset-block: 0;\n width: 1.5rem;\n}\n\n.rk-scroll-fade.is-start-y,\n.rk-scroll-fade.is-end-y {\n inset-inline: 0;\n height: 1.5rem;\n}\n\n.rk-scroll-fade.is-start-x {\n inset-inline-start: 0;\n background: linear-gradient(to right, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-end-x {\n inset-inline-end: 0;\n background: linear-gradient(to left, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-start-y {\n inset-block-start: 0;\n background: linear-gradient(to bottom, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-end-y {\n inset-block-end: 0;\n background: linear-gradient(to top, var(--rk-scroll-fade), transparent);\n}\n\n/* The insets are logical, so they land on the correct side on their own.\n A gradient has no logical direction, so these two flip by hand. */\n[dir='rtl'] .rk-scroll-fade.is-start-x {\n background: linear-gradient(to left, var(--rk-scroll-fade), transparent);\n}\n\n[dir='rtl'] .rk-scroll-fade.is-end-x {\n background: linear-gradient(to right, var(--rk-scroll-fade), transparent);\n}\n</style>\n","<script setup lang=\"ts\" generic=\"V extends string, M extends 'single' | 'multiple' = 'single'\">\nimport { Check } from 'lucide-vue-next'\nimport { computed, ref, useId } from 'vue'\n\nimport { useBoundValue } from '../composables/use-bound-value'\n\nexport interface ListboxOption<V extends string> {\n value: V\n /** Already translated. */\n label: string\n /** A line under the label. */\n description?: string | undefined\n disabled?: boolean | undefined\n}\n\n/**\n * A list you choose from, open on the page.\n *\n * Between `BaseRadioGroup` and `BaseSelect`: a radio group is a handful of\n * options that are all visible and all equal; a select opens the platform's\n * own picker; this is a scrollable list of many, choosing one or several,\n * that stays on screen — a list of accounts, of tags, of files to act on.\n *\n * One Tab stop. The list itself takes focus and `aria-activedescendant`\n * says which option is current, so the arrows move without thirty stops in\n * the tab order. Typing a letter jumps to the next option that starts with\n * it, which is how every native list has always worked and the first thing\n * people try.\n *\n * In `single` mode the selection follows the arrows, because that is what a\n * listbox does; in `multiple` mode Space and Enter toggle the current one.\n */\nconst {\n modelValue = undefined,\n options,\n label,\n mode = 'single' as M,\n height = '14rem',\n} = defineProps<{\n /** The chosen option, or options, with `v-model`. */\n modelValue?: Value | undefined\n options: readonly ListboxOption<V>[]\n /** The list's accessible name. */\n label: string\n /** One option, or any number of them. */\n mode?: M | undefined\n /** How tall before it scrolls. Any CSS length. */\n height?: string | undefined\n}>()\n\n/* Multiple always hands back an array — toggling one produces a list, never\n nothing — so only the single value can be undefined. */\ntype Value = M extends 'multiple' ? V[] : V | undefined\n\n/* Declared by hand rather than with defineModel, which cannot both accept\n `undefined` and promise never to emit it. See `use-bound-value.ts`. */\nconst emit = defineEmits<{ 'update:modelValue': [value: Value] }>()\nconst model = useBoundValue<Value>(\n () => modelValue as Value | undefined,\n (value) => emit('update:modelValue', value),\n)\n\nconst id = useId()\nconst active = ref(0)\n\nlet typed = ''\nlet typedAt = 0\n\nconst chosen = computed(() => {\n const value = model.value\n if (Array.isArray(value)) return new Set<V>(value)\n\n return new Set<V>(value === undefined ? [] : [value as V])\n})\n\nfunction select(option: ListboxOption<V>) {\n if (option.disabled) return\n\n if (mode === 'multiple') {\n const next = new Set(chosen.value)\n if (next.has(option.value)) next.delete(option.value)\n else next.add(option.value)\n\n // In the order of the options, so the value reads the same however the\n // reader got there.\n model.value = options\n .map((one) => one.value)\n .filter((value) => next.has(value)) as typeof model.value\n return\n }\n\n model.value = option.value as typeof model.value\n}\n\nfunction moveTo(index: number) {\n if (options.length === 0) return\n\n active.value = Math.max(0, Math.min(options.length - 1, index))\n const option = options[active.value]\n\n // Single: the selection follows the focus, which is what a listbox does.\n if (mode === 'single' && option && !option.disabled) select(option)\n\n document.getElementById(`${id}-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })\n}\n\n/** The next option after the current one that starts with what was typed. */\nfunction jumpTo(letter: string) {\n const now = Date.now()\n typed = now - typedAt > 700 ? letter : typed + letter\n typedAt = now\n\n const from = typed.length === 1 ? active.value + 1 : active.value\n const order = [...options.slice(from), ...options.slice(0, from)]\n const found = order.find(\n (option) => !option.disabled && option.label.toLowerCase().startsWith(typed.toLowerCase()),\n )\n\n if (found) moveTo(options.indexOf(found))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const moves: Record<string, number> = {\n ArrowDown: active.value + 1,\n ArrowUp: active.value - 1,\n Home: 0,\n End: options.length - 1,\n }\n\n if (event.key in moves) {\n event.preventDefault()\n moveTo(moves[event.key]!)\n return\n }\n\n if (event.key === ' ' || event.key === 'Enter') {\n const option = options[active.value]\n if (!option) return\n event.preventDefault()\n select(option)\n return\n }\n\n // A single printable character: the typeahead every native list has.\n if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {\n jumpTo(event.key)\n }\n}\n</script>\n\n<template>\n <div\n class=\"rk-listbox control\"\n :style=\"{ maxHeight: height }\"\n role=\"listbox\"\n tabindex=\"0\"\n :aria-label=\"label\"\n :aria-multiselectable=\"mode === 'multiple' ? true : undefined\"\n :aria-activedescendant=\"options.length ? `${id}-${active}` : undefined\"\n @keydown=\"onKeydown\"\n >\n <!-- `aria-disabled` is omitted rather than `false`: an option that can be\n chosen says nothing about being disabled. -->\n <div\n v-for=\"(option, index) in options\"\n :id=\"`${id}-${index}`\"\n :key=\"option.value\"\n class=\"rk-listbox-option\"\n :class=\"{\n 'is-active': index === active,\n 'is-chosen': chosen.has(option.value),\n 'is-disabled': option.disabled,\n }\"\n role=\"option\"\n :aria-selected=\"chosen.has(option.value)\"\n :aria-disabled=\"option.disabled || undefined\"\n @click=\"((active = index), select(option))\"\n >\n <span class=\"min-w-0 flex-1\">\n <span class=\"rk-listbox-label\">{{ option.label }}</span>\n <span v-if=\"option.description\" class=\"rk-listbox-description\">{{\n option.description\n }}</span>\n </span>\n\n <Check v-if=\"chosen.has(option.value)\" class=\"size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <p v-if=\"options.length === 0\" class=\"rk-listbox-empty\" />\n </div>\n</template>\n\n<style scoped>\n.rk-listbox {\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 0.25rem;\n}\n\n.rk-listbox:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 1px;\n}\n\n.rk-listbox-option {\n display: flex;\n cursor: pointer;\n align-items: center;\n gap: 0.625rem;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.625rem;\n font-size: 0.875rem;\n color: var(--color-ink);\n}\n\n/* The current row shows only while the list has focus. A list that is not\n being used has no \"current\" row, and a highlight left behind on one reads\n as a selection — which is exactly what it is not. */\n.rk-listbox:focus-within .rk-listbox-option.is-active {\n background: var(--color-muted);\n}\n\n.rk-listbox-option.is-chosen {\n color: var(--color-primary);\n font-weight: 500;\n}\n\n/* Chosen is the tick and the tint, and it holds whether the list is in use\n or not: the current option and the chosen one are different things. */\n.rk-listbox-option.is-chosen {\n background: color-mix(in oklab, var(--color-primary) 10%, transparent);\n}\n\n.rk-listbox:focus-within .rk-listbox-option.is-chosen.is-active {\n background: color-mix(in oklab, var(--color-primary) 20%, transparent);\n}\n\n.rk-listbox-option.is-disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n\n.rk-listbox-label {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.rk-listbox-description {\n display: block;\n font-size: 0.75rem;\n color: var(--color-ink-soft);\n}\n\n.rk-listbox-empty {\n padding: 1rem;\n}\n</style>\n","<script setup lang=\"ts\" generic=\"V extends string, M extends 'single' | 'multiple' = 'single'\">\nimport { Check } from 'lucide-vue-next'\nimport { computed, ref, useId } from 'vue'\n\nimport { useBoundValue } from '../composables/use-bound-value'\n\nexport interface ListboxOption<V extends string> {\n value: V\n /** Already translated. */\n label: string\n /** A line under the label. */\n description?: string | undefined\n disabled?: boolean | undefined\n}\n\n/**\n * A list you choose from, open on the page.\n *\n * Between `BaseRadioGroup` and `BaseSelect`: a radio group is a handful of\n * options that are all visible and all equal; a select opens the platform's\n * own picker; this is a scrollable list of many, choosing one or several,\n * that stays on screen — a list of accounts, of tags, of files to act on.\n *\n * One Tab stop. The list itself takes focus and `aria-activedescendant`\n * says which option is current, so the arrows move without thirty stops in\n * the tab order. Typing a letter jumps to the next option that starts with\n * it, which is how every native list has always worked and the first thing\n * people try.\n *\n * In `single` mode the selection follows the arrows, because that is what a\n * listbox does; in `multiple` mode Space and Enter toggle the current one.\n */\nconst {\n modelValue = undefined,\n options,\n label,\n mode = 'single' as M,\n height = '14rem',\n} = defineProps<{\n /** The chosen option, or options, with `v-model`. */\n modelValue?: Value | undefined\n options: readonly ListboxOption<V>[]\n /** The list's accessible name. */\n label: string\n /** One option, or any number of them. */\n mode?: M | undefined\n /** How tall before it scrolls. Any CSS length. */\n height?: string | undefined\n}>()\n\n/* Multiple always hands back an array — toggling one produces a list, never\n nothing — so only the single value can be undefined. */\ntype Value = M extends 'multiple' ? V[] : V | undefined\n\n/* Declared by hand rather than with defineModel, which cannot both accept\n `undefined` and promise never to emit it. See `use-bound-value.ts`. */\nconst emit = defineEmits<{ 'update:modelValue': [value: Value] }>()\nconst model = useBoundValue<Value>(\n () => modelValue as Value | undefined,\n (value) => emit('update:modelValue', value),\n)\n\nconst id = useId()\nconst active = ref(0)\n\nlet typed = ''\nlet typedAt = 0\n\nconst chosen = computed(() => {\n const value = model.value\n if (Array.isArray(value)) return new Set<V>(value)\n\n return new Set<V>(value === undefined ? [] : [value as V])\n})\n\nfunction select(option: ListboxOption<V>) {\n if (option.disabled) return\n\n if (mode === 'multiple') {\n const next = new Set(chosen.value)\n if (next.has(option.value)) next.delete(option.value)\n else next.add(option.value)\n\n // In the order of the options, so the value reads the same however the\n // reader got there.\n model.value = options\n .map((one) => one.value)\n .filter((value) => next.has(value)) as typeof model.value\n return\n }\n\n model.value = option.value as typeof model.value\n}\n\nfunction moveTo(index: number) {\n if (options.length === 0) return\n\n active.value = Math.max(0, Math.min(options.length - 1, index))\n const option = options[active.value]\n\n // Single: the selection follows the focus, which is what a listbox does.\n if (mode === 'single' && option && !option.disabled) select(option)\n\n document.getElementById(`${id}-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })\n}\n\n/** The next option after the current one that starts with what was typed. */\nfunction jumpTo(letter: string) {\n const now = Date.now()\n typed = now - typedAt > 700 ? letter : typed + letter\n typedAt = now\n\n const from = typed.length === 1 ? active.value + 1 : active.value\n const order = [...options.slice(from), ...options.slice(0, from)]\n const found = order.find(\n (option) => !option.disabled && option.label.toLowerCase().startsWith(typed.toLowerCase()),\n )\n\n if (found) moveTo(options.indexOf(found))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const moves: Record<string, number> = {\n ArrowDown: active.value + 1,\n ArrowUp: active.value - 1,\n Home: 0,\n End: options.length - 1,\n }\n\n if (event.key in moves) {\n event.preventDefault()\n moveTo(moves[event.key]!)\n return\n }\n\n if (event.key === ' ' || event.key === 'Enter') {\n const option = options[active.value]\n if (!option) return\n event.preventDefault()\n select(option)\n return\n }\n\n // A single printable character: the typeahead every native list has.\n if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {\n jumpTo(event.key)\n }\n}\n</script>\n\n<template>\n <div\n class=\"rk-listbox control\"\n :style=\"{ maxHeight: height }\"\n role=\"listbox\"\n tabindex=\"0\"\n :aria-label=\"label\"\n :aria-multiselectable=\"mode === 'multiple' ? true : undefined\"\n :aria-activedescendant=\"options.length ? `${id}-${active}` : undefined\"\n @keydown=\"onKeydown\"\n >\n <!-- `aria-disabled` is omitted rather than `false`: an option that can be\n chosen says nothing about being disabled. -->\n <div\n v-for=\"(option, index) in options\"\n :id=\"`${id}-${index}`\"\n :key=\"option.value\"\n class=\"rk-listbox-option\"\n :class=\"{\n 'is-active': index === active,\n 'is-chosen': chosen.has(option.value),\n 'is-disabled': option.disabled,\n }\"\n role=\"option\"\n :aria-selected=\"chosen.has(option.value)\"\n :aria-disabled=\"option.disabled || undefined\"\n @click=\"((active = index), select(option))\"\n >\n <span class=\"min-w-0 flex-1\">\n <span class=\"rk-listbox-label\">{{ option.label }}</span>\n <span v-if=\"option.description\" class=\"rk-listbox-description\">{{\n option.description\n }}</span>\n </span>\n\n <Check v-if=\"chosen.has(option.value)\" class=\"size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <p v-if=\"options.length === 0\" class=\"rk-listbox-empty\" />\n </div>\n</template>\n\n<style scoped>\n.rk-listbox {\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 0.25rem;\n}\n\n.rk-listbox:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 1px;\n}\n\n.rk-listbox-option {\n display: flex;\n cursor: pointer;\n align-items: center;\n gap: 0.625rem;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.625rem;\n font-size: 0.875rem;\n color: var(--color-ink);\n}\n\n/* The current row shows only while the list has focus. A list that is not\n being used has no \"current\" row, and a highlight left behind on one reads\n as a selection — which is exactly what it is not. */\n.rk-listbox:focus-within .rk-listbox-option.is-active {\n background: var(--color-muted);\n}\n\n.rk-listbox-option.is-chosen {\n color: var(--color-primary);\n font-weight: 500;\n}\n\n/* Chosen is the tick and the tint, and it holds whether the list is in use\n or not: the current option and the chosen one are different things. */\n.rk-listbox-option.is-chosen {\n background: color-mix(in oklab, var(--color-primary) 10%, transparent);\n}\n\n.rk-listbox:focus-within .rk-listbox-option.is-chosen.is-active {\n background: color-mix(in oklab, var(--color-primary) 20%, transparent);\n}\n\n.rk-listbox-option.is-disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n\n.rk-listbox-label {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.rk-listbox-description {\n display: block;\n font-size: 0.75rem;\n color: var(--color-ink-soft);\n}\n\n.rk-listbox-empty {\n padding: 1rem;\n}\n</style>\n"],"mappings":"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"}
package/dist/app.js CHANGED
@@ -1,5 +1,5 @@
1
- import { i as inertOutside, n as BaseCheckbox_default, r as BaseSheet_default, t as focusTarget } from "./focus-target-D0WsXwKh.js";
2
- import { c as useOnline, g as toRedirectPath, h as safeRedirect, i as BaseInput_default, m as tapFeedback, o as BaseAlert_default, p as useTheme, r as GoogleButton_default, s as useToast, t as announce, u as isThemePreference } from "./use-announce-B1LsfsZx.js";
1
+ import { a as BaseCheckbox_default, o as BaseSheet_default, r as horizontalStep, s as inertOutside, t as focusTarget } from "./focus-target-DRgd8UnQ.js";
2
+ import { c as useOnline, g as toRedirectPath, h as safeRedirect, i as BaseInput_default, m as tapFeedback, o as BaseAlert_default, p as useTheme, r as GoogleButton_default, s as useToast, t as announce, u as isThemePreference } from "./use-announce-qM0ZD9Ey.js";
3
3
  import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
4
4
  import { t as BaseButton_default } from "./BaseButton-RN2an975.js";
5
5
  import { t as SettingsRow_default } from "./SettingsRow-Ct1p2C9Q.js";
@@ -432,8 +432,9 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
432
432
  }, { immediate: true });
433
433
  onUnmounted(() => releaseInert?.());
434
434
  function onKeydown(event) {
435
- if (event.key === "ArrowRight") emit("next");
436
- if (event.key === "ArrowLeft") emit("back");
435
+ const along = horizontalStep(event.key, event.currentTarget);
436
+ if (along === 1) emit("next");
437
+ if (along === -1) emit("back");
437
438
  if (event.key === "Escape") emit("dismiss");
438
439
  }
439
440
  return (_ctx, _cache) => {
@@ -510,7 +511,7 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
510
511
  })], 8, ["to"]);
511
512
  };
512
513
  }
513
- }), [["__scopeId", "data-v-88982fee"]]);
514
+ }), [["__scopeId", "data-v-8c293eec"]]);
514
515
  //#endregion
515
516
  //#region src/app/guards.ts
516
517
  /**