rei-kit 2.24.0 → 2.25.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
@@ -157,7 +157,7 @@ app, and the case *least* favourable to this kit:
157
157
 
158
158
  | Kit | JS | CSS | Total |
159
159
  | -------------------- | ---------: | ----------: | ----------: |
160
- | **rei-kit 2.24.0** | **3.3 KB** | **23.1 KB** | **26.4 KB** |
160
+ | **rei-kit 2.25.0** | **3.3 KB** | **23.1 KB** | **26.4 KB** |
161
161
  | element-plus 2.14.6 | 27.3 KB | 6.0 KB | 33.3 KB |
162
162
  | naive-ui 2.45.3 | 51.2 KB | — | 51.2 KB |
163
163
  | primevue 5.0.1 | 53.6 KB | — | 53.6 KB |
@@ -169,7 +169,7 @@ a data table, a tooltip and a card. A screen rather than a demo:
169
169
 
170
170
  | Kit | JS | CSS | Total |
171
171
  | -------------------- | ---------: | ----------: | -----------: |
172
- | **rei-kit 2.24.0** | **8.3 KB** | **23.1 KB** | **31.3 KB** |
172
+ | **rei-kit 2.25.0** | **8.3 KB** | **23.1 KB** | **31.3 KB** |
173
173
  | element-plus 2.14.6 | 90.1 KB | 14.0 KB | 104.1 KB |
174
174
  | naive-ui 2.45.3 | 132.4 KB | — | 132.4 KB |
175
175
  | primevue 5.0.1 | 135.8 KB | — | 135.8 KB |
@@ -226,7 +226,7 @@ Each claim here is enforced by something that fails, not by a promise.
226
226
 
227
227
  ## Status
228
228
 
229
- **v2.24.0 — three consumers.**
229
+ **v2.25.0 — three consumers.**
230
230
 
231
231
  | | |
232
232
  | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -542,8 +542,8 @@ var BaseListbox_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__
542
542
  }), 128)), __props.options.length === 0 ? (openBlock(), createElementBlock("p", _hoisted_6)) : createCommentVNode("", true)], 44, _hoisted_1);
543
543
  };
544
544
  }
545
- }), [["__scopeId", "data-v-7f8cd788"]]);
545
+ }), [["__scopeId", "data-v-3cd5a3c6"]]);
546
546
  //#endregion
547
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 };
548
548
 
549
- //# sourceMappingURL=BaseListbox-BQoSwYdJ.js.map
549
+ //# sourceMappingURL=BaseListbox-DvZ7IhNn.js.map
@@ -1 +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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"}
1
+ {"version":3,"file":"BaseListbox-DvZ7IhNn.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/* The chosen row is its tinted ground, its weight and its tick — not a\n primary-coloured label on a ten per cent tint of primary, which is the\n same fault `BaseBadge` had: a faint wash of a colour is still nearly that\n colour, and the pair measured 4.16:1. */\n.rk-listbox-option.is-chosen {\n color: var(--color-ink);\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/* The chosen row is its tinted ground, its weight and its tick — not a\n primary-coloured label on a ten per cent tint of primary, which is the\n same fault `BaseBadge` had: a faint wash of a colour is still nearly that\n colour, and the pair measured 4.16:1. */\n.rk-listbox-option.is-chosen {\n color: var(--color-ink);\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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"}
@@ -12,11 +12,17 @@
12
12
  */
13
13
  export { default as AuthForm } from './AuthForm.vue';
14
14
  export type { AuthFormLabels, AuthFormValues } from './auth-form';
15
+ /** The frame every sign-in screen sits in. */
15
16
  export { default as AuthShell } from './AuthShell.vue';
17
+ /** The one action the app is built around, reachable from every screen. */
16
18
  export { default as FabButton } from './FabButton.vue';
19
+ /** Choosing the interface language, from a settings row. */
17
20
  export { default as LocaleSheet } from './LocaleSheet.vue';
21
+ /** A floating note that the connection has gone. */
18
22
  export { default as OfflineBanner } from './OfflineBanner.vue';
23
+ /** The phone frame the whole app sits inside. */
19
24
  export { default as TabShell } from './TabShell.vue';
25
+ /** The frame an onboarding guide runs inside. */
20
26
  export { default as TourShell } from './TourShell.vue';
21
27
  export { createAuthGuard, createRouteAnnouncer, createTitleGuard } from './guards';
22
28
  export type { AuthGuardOptions } from './guards';
package/dist/index.d.ts CHANGED
@@ -46,48 +46,83 @@ export { useVisualViewport } from './composables/use-visual-viewport';
46
46
  export { useToast } from './composables/use-toast';
47
47
  export type { Toast, ToastAction, ToastOptions, ToastTone } from './composables/use-toast';
48
48
  export type { VisualViewportRect } from './composables/use-visual-viewport';
49
+ /** The person, in the corner where the account lives. */
49
50
  export { default as BaseAvatar } from './components/BaseAvatar.vue';
51
+ /** A message the reader has to take in before carrying on. */
50
52
  export { default as BaseAlert } from './components/BaseAlert.vue';
53
+ /** A small standing label: a level, a state, a count. */
51
54
  export { default as BaseBadge } from './components/BaseBadge.vue';
55
+ /** The kit's button, and — when asked — its link. */
52
56
  export { default as BaseButton } from './components/BaseButton.vue';
57
+ /** A text field with its label, hint and error already wired to it. */
53
58
  export { default as BaseInput } from './components/BaseInput.vue';
59
+ /** A list of actions behind one control. */
54
60
  export { default as BaseMenu } from './components/BaseMenu.vue';
61
+ /** Pins the sheet to the area the keyboard has left visible. */
55
62
  export { default as BaseSheet } from './components/BaseSheet.vue';
63
+ /** A surface with a border, and optionally a head and a foot. */
56
64
  export { default as BaseCard } from './components/BaseCard.vue';
65
+ /** A field you type into to narrow a list, then choose from it. */
57
66
  export { default as BaseCombobox } from './components/BaseCombobox.vue';
58
67
  export type { ComboboxOption } from './components/BaseCombobox.vue';
68
+ /** A single checkbox, with its label beside it. */
59
69
  export { default as BaseCheckbox } from './components/BaseCheckbox.vue';
70
+ /** A set of radios, and the reason there is no `BaseRadio`. */
60
71
  export { default as BaseRadioGroup } from './components/BaseRadioGroup.vue';
72
+ /** A native `<select>`, wearing the kit's field. */
61
73
  export { default as BaseSelect } from './components/BaseSelect.vue';
74
+ /** A value picked from a range, where roughly right is the point. */
62
75
  export { default as BaseSlider } from './components/BaseSlider.vue';
63
76
  /**
64
77
  * A slider and a number field on one value: drag to find it, type to land
65
78
  * on it. See `SliderField.vue` for why both carry the same name.
66
79
  */
67
80
  export { default as SliderField } from './components/SliderField.vue';
81
+ /** Work in progress, with no idea how much is left. */
68
82
  export { default as BaseSpinner } from './components/BaseSpinner.vue';
83
+ /** A setting that takes effect the moment it is touched. */
69
84
  export { default as BaseSwitch } from './components/BaseSwitch.vue';
85
+ /** Rows of data, with the parts a hand-written `<table>` leaves out. */
70
86
  export { default as BaseTable } from './components/BaseTable.vue';
71
87
  export type { Column } from './components/BaseTable.vue';
88
+ /** A multi-line field. */
72
89
  export { default as BaseTextarea } from './components/BaseTextarea.vue';
90
+ /** A list with nothing in it yet, said kindly. */
73
91
  export { default as EmptyState } from './components/EmptyState.vue';
92
+ /** A label, a hint, an error, and the wiring between them. */
74
93
  export { default as FormField } from './components/FormField.vue';
94
+ /** Keeps one broken screen from taking the whole app down. */
75
95
  export { default as ErrorBoundary } from './components/ErrorBoundary.vue';
96
+ /** One measure, centred, with the page's gutters. */
76
97
  export { default as PageContainer } from './components/PageContainer.vue';
98
+ /** The bar at the top of a screen: a title with room either side of it. */
77
99
  export { default as PageHeader } from './components/PageHeader.vue';
100
+ /** How far through something somebody is. */
78
101
  export { default as ProgressBar } from './components/ProgressBar.vue';
102
+ /** One plan in a pricing table. */
79
103
  export { default as PriceCard } from './components/PriceCard.vue';
104
+ /** A pill heading for a group of things. */
80
105
  export { default as SectionHeading } from './components/SectionHeading.vue';
106
+ /** A row of mutually exclusive choices. */
81
107
  export { default as SegmentedControl } from './components/SegmentedControl.vue';
108
+ /** A titled run of settings rows. */
82
109
  export { default as SettingsGroup } from './components/SettingsGroup.vue';
110
+ /** One line in a settings card. */
83
111
  export { default as SettingsRow } from './components/SettingsRow.vue';
112
+ /** A length starts with a digit, a dot, or opens a CSS function. */
84
113
  export { default as SkeletonList } from './components/SkeletonList.vue';
114
+ /** One number, with what it means and which way it is going. */
85
115
  export { default as StatCard } from './components/StatCard.vue';
116
+ /** Where the toasts land. One of these, at the app root. */
86
117
  export { default as ToastHost } from './components/ToastHost.vue';
118
+ /** A small coloured dot, optionally labelled. */
87
119
  export { default as ToneDot } from './components/ToneDot.vue';
88
120
  export type { Tone } from './components/SectionHeading.vue';
121
+ /** A flat language switcher for screens with no Settings behind them. */
89
122
  export { default as LocaleLinks } from './components/LocaleLinks.vue';
123
+ /** Sign in with Google, in Google's own clothes. */
90
124
  export { default as GoogleButton } from './components/GoogleButton.vue';
125
+ /** The floating bottom bar. */
91
126
  export { default as TabBar } from './components/TabBar.vue';
92
127
  export type { TabItem } from './components/TabBar.vue';
93
128
  /**
@@ -102,6 +137,7 @@ export { default as ActivityGrid } from './components/ActivityGrid.vue';
102
137
  * it, rather than a picture with a sentence describing it.
103
138
  */
104
139
  export { default as BarChart } from './components/BarChart.vue';
140
+ /** Parts of a whole. */
105
141
  export { default as DonutChart } from './components/DonutChart.vue';
106
142
  /**
107
143
  * `ScrollArea`, a scrolling box with the two things a hand-written one
@@ -143,16 +179,32 @@ export { textDirection } from './utils/direction';
143
179
  export { elementDirection, horizontalStep } from './utils/direction';
144
180
  export type { TextDirection } from './utils/direction';
145
181
  export type { AnnounceOptions } from './composables/use-announce';
182
+ /**
183
+ * The people on a thing, overlapped: who is in a conversation, who shares
184
+ * a list.
185
+ */
146
186
  export { default as AvatarStack } from './components/AvatarStack.vue';
187
+ /** A month of days, for choosing one or a stretch of them. */
147
188
  export { default as BaseCalendar } from './components/BaseCalendar.vue';
189
+ /**
190
+ * A short label with, when it is one of a set someone assembled, a way to
191
+ * take it off: a filter, a recipient, a tag.
192
+ */
148
193
  export { default as BaseChip } from './components/BaseChip.vue';
194
+ /** A keyboard key, or a chord of them: `⌘ K`, `Ctrl Shift P`. */
149
195
  export { default as BaseKbd } from './components/BaseKbd.vue';
196
+ /** A list you choose from, open on the page. */
150
197
  export { default as BaseListbox } from './components/BaseListbox.vue';
151
198
  export type { ListboxOption } from './components/BaseListbox.vue';
199
+ /** A link in a sentence — the one thing a button is not. */
152
200
  export { default as BaseLink } from './components/BaseLink.vue';
201
+ /** A score out of five, given or shown. */
153
202
  export { default as BaseRating } from './components/BaseRating.vue';
203
+ /** A line between things — with, when it helps, a word on it. */
154
204
  export { default as BaseSeparator } from './components/BaseSeparator.vue';
205
+ /** One grey box standing in for content that has not arrived. */
155
206
  export { default as BaseSkeleton } from './components/BaseSkeleton.vue';
207
+ /** A colour, as a hex value. */
156
208
  export { default as ColorPicker } from './components/ColorPicker.vue';
157
209
  export type { ColorSwatch } from './components/ColorPicker.vue';
158
210
  /**
@@ -160,25 +212,49 @@ export type { ColorSwatch } from './components/ColorPicker.vue';
160
212
  * focusable when it scrolls, or the end of a long line is unreachable.
161
213
  */
162
214
  export { default as CodeBlock } from './components/CodeBlock.vue';
215
+ /** Copies a piece of text, and says that it did. */
163
216
  export { default as CopyButton } from './components/CopyButton.vue';
217
+ /**
218
+ * Pairs of "what it is" and "what it says": the summary at the top of a
219
+ * detail page, the facts under an invoice.
220
+ */
164
221
  export { default as DescriptionList } from './components/DescriptionList.vue';
165
222
  export type { DescriptionItem } from './components/DescriptionList.vue';
223
+ /** Files, dropped on or chosen from a real file input. */
166
224
  export { default as FileDrop } from './components/FileDrop.vue';
225
+ /** A field that holds several short values: recipients, labels, skills. */
167
226
  export { default as TagsInput } from './components/TagsInput.vue';
227
+ /** A time of day, chosen from two columns. */
168
228
  export { default as TimePicker } from './components/TimePicker.vue';
229
+ /** What happened, in order, on a rail. */
169
230
  export { default as BaseTimeline } from './components/BaseTimeline.vue';
170
231
  export type { TimelineEvent } from './components/BaseTimeline.vue';
171
232
  export type { DateRange } from './components/BaseCalendar.vue';
233
+ /** A date, or a stretch of dates, chosen from a calendar in a popover. */
172
234
  export { default as BaseDatePicker } from './components/BaseDatePicker.vue';
173
235
  export type { DatePreset } from './components/BaseDatePicker.vue';
236
+ /** "Are you sure?", asked beside the button that asked it. */
174
237
  export { default as BasePopconfirm } from './components/BasePopconfirm.vue';
238
+ /**
239
+ * A panel of anything — a form, a picker, a few settings — anchored to the
240
+ * control that opened it.
241
+ */
175
242
  export { default as BasePopover } from './components/BasePopover.vue';
176
243
  export type { PopoverTriggerProps } from './components/BasePopover.vue';
244
+ /** A row of buttons that stay pressed — one at a time, or several at once. */
177
245
  export { default as ToggleGroup } from './components/ToggleGroup.vue';
246
+ /** A number, typed or stepped — a quantity, a price, a count of minutes. */
178
247
  export { default as NumberInput } from './components/NumberInput.vue';
248
+ /** A short code typed one character per box — a sign-in code, a PIN. */
179
249
  export { default as PinInput } from './components/PinInput.vue';
250
+ /** A password field you can look at. */
180
251
  export { default as PasswordInput } from './components/PasswordInput.vue';
252
+ /** Progress as a ring — for a small space, a card's corner, a goal. */
181
253
  export { default as CircularProgress } from './components/CircularProgress.vue';
254
+ /**
255
+ * Where someone is in a process of several steps — a sign-up, a checkout,
256
+ * a form split into pages.
257
+ */
182
258
  export { default as BaseStepper } from './components/BaseStepper.vue';
183
259
  export type { StepperStep } from './components/BaseStepper.vue';
184
260
  export { createI18nRuntime } from './i18n/runtime';