rei-kit 2.22.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # rei-kit
2
2
 
3
- **One kit. Every look.** 102 accessible components for Vue 3 and Tailwind 4.
3
+ **One kit. Every look.** 103 accessible components for Vue 3 and Tailwind 4.
4
4
  Change the **material** with one attribute and the **palette** with another —
5
5
  every component follows, and none of them knows your brand.
6
6
 
@@ -112,7 +112,7 @@ Each claim here is enforced by something that fails, not by a promise.
112
112
 
113
113
  | Standard | How it is held |
114
114
  | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
- | **Open for extension, closed for edits** | A new look is a token, a material or a palette — never an edit to a component. Materials and palettes restyle all 102 without touching one, and a test fails if a component holds a colour. |
115
+ | **Open for extension, closed for edits** | A new look is a token, a material or a palette — never an edit to a component. Materials and palettes restyle all 103 without touching one, and a test fails if a component holds a colour. |
116
116
  | **Single responsibility** | One part, one job. Label-and-error wiring lives in `FormField`, not in five inputs; taking the page behind a layer out of reach lives in one helper the modal, the sheet and the guide share. |
117
117
  | **Depend on roles, not values** | Components read `primary`, `surface`, `--shadow-card` — never a hex or a pixel shadow. An app's brand wins in both modes, tested. |
118
118
  | **WAI-ARIA Authoring Practices** | Menus, comboboxes, tabs, sliders, dialogs and accordions follow their APG pattern: arrows move, Tab leaves, Escape closes, focus returns. Behaviour tests drive each with the keyboard. |
@@ -128,7 +128,7 @@ Each claim here is enforced by something that fails, not by a promise.
128
128
 
129
129
  | | |
130
130
  | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
- | Components | 102 (`AuthForm`, `BaseTable`, `BaseCombobox`, `BaseSlider`, `TabShell`, `BaseModal`, `BaseTabs`, `BaseTooltip`, `BasePagination`, `BaseBreadcrumb`, `BaseDisclosure`, `BaseAccordion`, `NavLinks`, `OfflineBanner`, `FabButton`, `BaseButton`, `BaseCard`, `BaseInput`, `BaseSelect`, `BaseTextarea`, `BaseCheckbox`, `BaseSwitch`, `BaseRadioGroup`, `BaseMenu`, `BaseAvatar`, `BaseSpinner`, `BaseAlert`, `BaseBadge`, `BaseSheet`, `ProgressBar`, `PriceCard`, `ToastHost`, `TabBar`, `GoogleButton`, `LocaleLinks`, `LocaleSheet`, `AuthShell`, `TourShell`, `InstallPrompt`, `UpdatePrompt`, `InstallSettings`, `SkeletonList`, `PageContainer`, `ErrorBoundary`, etc.) |
131
+ | Components | 103 (`AuthForm`, `BaseTable`, `BaseCombobox`, `BaseSlider`, `TabShell`, `BaseModal`, `BaseTabs`, `BaseTooltip`, `BasePagination`, `BaseBreadcrumb`, `BaseDisclosure`, `BaseAccordion`, `NavLinks`, `OfflineBanner`, `FabButton`, `BaseButton`, `BaseCard`, `BaseInput`, `BaseSelect`, `BaseTextarea`, `BaseCheckbox`, `BaseSwitch`, `BaseRadioGroup`, `BaseMenu`, `BaseAvatar`, `BaseSpinner`, `BaseAlert`, `BaseBadge`, `BaseSheet`, `ProgressBar`, `PriceCard`, `ToastHost`, `TabBar`, `GoogleButton`, `LocaleLinks`, `LocaleSheet`, `AuthShell`, `TourShell`, `InstallPrompt`, `UpdatePrompt`, `InstallSettings`, `SkeletonList`, `PageContainer`, `ErrorBoundary`, etc.) |
132
132
  | Composables | 16 (`useToast`, `useTheme`, `useMaterial`, `usePalette`, `useToday`, `useMediaQuery`, `useInstall`, `watchInstallability`, `createTabTransition`, `useThemeSync`, `useVisualViewport`, etc.) |
133
133
  | Utilities | 33 (`applyTheme`, `applyMaterial`, `applyPalette`, `MATERIALS`, `PALETTES`, `formatDate`, `fieldErrors`, `toAuthMessageKey`, `createAuthGuard`, `createQueryDefaults`, `createWriteReport`, `toRedirectPath`, `Supabase error mapper`, i18n runtime, etc.) |
134
134
  | Entry Points | `rei-kit`, `rei-kit/app`, `rei-kit/web`, `rei-kit/pwa`, `rei-kit/supabase`, `rei-kit/mobile.css`, `rei-kit/web.css`, and each stylesheet on its own |
@@ -1,4 +1,4 @@
1
- import { a as useBoundValue } from "./focus-target-D0WsXwKh.js";
1
+ import { c as useBoundValue } from "./focus-target-DRgd8UnQ.js";
2
2
  import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
3
3
  import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, ref, renderList, renderSlot, toDisplayString, unref, useId, useTemplateRef, watch, watchEffect } from "vue";
4
4
  import { Check } from "lucide-vue-next";
@@ -479,4 +479,4 @@ var BaseListbox_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__
479
479
  //#endregion
480
480
  export { useMediaQuery as a, useMenuKeys as i, ScrollArea_default as n, useDragScroll as o, BaseSkeleton_default as r, BaseListbox_default as t };
481
481
 
482
- //# sourceMappingURL=BaseListbox-DMR-Zn1u.js.map
482
+ //# sourceMappingURL=BaseListbox-BrIZxUmx.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"BaseListbox-DMR-Zn1u.js","names":[],"sources":["../src/composables/use-drag-scroll.ts","../src/composables/use-media-query.ts","../src/composables/use-menu-keys.ts","../src/components/BaseSkeleton.vue","../src/components/BaseSkeleton.vue","../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 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","<script setup lang=\"ts\">\nimport { computed, onScopeDispose, ref, useTemplateRef, watchEffect } from 'vue'\n\nimport { useDragScroll } from '../composables/use-drag-scroll'\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\n/** Anything that takes focus on its own, so the box does not need to. */\nconst FOCUSABLE =\n 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex=\"-1\"])'\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.rk-scroll {\n position: relative;\n min-width: 0;\n min-height: 0;\n}\n\n.rk-scroll-viewport {\n max-width: 100%;\n max-height: 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'\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\n/** Anything that takes focus on its own, so the box does not need to. */\nconst FOCUSABLE =\n 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex=\"-1\"])'\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.rk-scroll {\n position: relative;\n min-width: 0;\n min-height: 0;\n}\n\n.rk-scroll-viewport {\n max-width: 100%;\n max-height: 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-BrIZxUmx.js","names":[],"sources":["../src/composables/use-drag-scroll.ts","../src/composables/use-media-query.ts","../src/composables/use-menu-keys.ts","../src/components/BaseSkeleton.vue","../src/components/BaseSkeleton.vue","../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 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","<script setup lang=\"ts\">\nimport { computed, onScopeDispose, ref, useTemplateRef, watchEffect } from 'vue'\n\nimport { useDragScroll } from '../composables/use-drag-scroll'\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\n/** Anything that takes focus on its own, so the box does not need to. */\nconst FOCUSABLE =\n 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex=\"-1\"])'\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.rk-scroll {\n position: relative;\n min-width: 0;\n min-height: 0;\n}\n\n.rk-scroll-viewport {\n max-width: 100%;\n max-height: 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'\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\n/** Anything that takes focus on its own, so the box does not need to. */\nconst FOCUSABLE =\n 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex=\"-1\"])'\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.rk-scroll {\n position: relative;\n min-width: 0;\n min-height: 0;\n}\n\n.rk-scroll-viewport {\n max-width: 100%;\n max-height: 100%;\n}\n\n.rk-scroll-viewport.is-x {\n overflow-x: auto;\n overflow-y: hidden;\n}\n\n.rk-scroll-viewport.is-y {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n.rk-scroll-viewport.is-both {\n overflow: auto;\n}\n\n.rk-scroll-viewport:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n/* The fade paints over whatever the box sits on, so the colour is a hook:\n a scroll area on a card sets it to the card's ground. */\n.rk-scroll-fade {\n position: absolute;\n z-index: 1;\n pointer-events: none;\n --rk-scroll-fade: var(--scroll-fade-color, var(--color-canvas));\n}\n\n.rk-scroll-fade.is-start-x,\n.rk-scroll-fade.is-end-x {\n inset-block: 0;\n width: 1.5rem;\n}\n\n.rk-scroll-fade.is-start-y,\n.rk-scroll-fade.is-end-y {\n inset-inline: 0;\n height: 1.5rem;\n}\n\n.rk-scroll-fade.is-start-x {\n inset-inline-start: 0;\n background: linear-gradient(to right, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-end-x {\n inset-inline-end: 0;\n background: linear-gradient(to left, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-start-y {\n inset-block-start: 0;\n background: linear-gradient(to bottom, var(--rk-scroll-fade), transparent);\n}\n\n.rk-scroll-fade.is-end-y {\n inset-block-end: 0;\n background: linear-gradient(to top, var(--rk-scroll-fade), transparent);\n}\n\n/* The insets are logical, so they land on the correct side on their own.\n A gradient has no logical direction, so these two flip by hand. */\n[dir='rtl'] .rk-scroll-fade.is-start-x {\n background: linear-gradient(to left, var(--rk-scroll-fade), transparent);\n}\n\n[dir='rtl'] .rk-scroll-fade.is-end-x {\n background: linear-gradient(to right, var(--rk-scroll-fade), transparent);\n}\n</style>\n","<script setup lang=\"ts\" generic=\"V extends string, M extends 'single' | 'multiple' = 'single'\">\nimport { Check } from 'lucide-vue-next'\nimport { computed, ref, useId } from 'vue'\n\nimport { useBoundValue } from '../composables/use-bound-value'\n\nexport interface ListboxOption<V extends string> {\n value: V\n /** Already translated. */\n label: string\n /** A line under the label. */\n description?: string | undefined\n disabled?: boolean | undefined\n}\n\n/**\n * A list you choose from, open on the page.\n *\n * Between `BaseRadioGroup` and `BaseSelect`: a radio group is a handful of\n * options that are all visible and all equal; a select opens the platform's\n * own picker; this is a scrollable list of many, choosing one or several,\n * that stays on screen — a list of accounts, of tags, of files to act on.\n *\n * One Tab stop. The list itself takes focus and `aria-activedescendant`\n * says which option is current, so the arrows move without thirty stops in\n * the tab order. Typing a letter jumps to the next option that starts with\n * it, which is how every native list has always worked and the first thing\n * people try.\n *\n * In `single` mode the selection follows the arrows, because that is what a\n * listbox does; in `multiple` mode Space and Enter toggle the current one.\n */\nconst {\n modelValue = undefined,\n options,\n label,\n mode = 'single' as M,\n height = '14rem',\n} = defineProps<{\n /** The chosen option, or options, with `v-model`. */\n modelValue?: Value | undefined\n options: readonly ListboxOption<V>[]\n /** The list's accessible name. */\n label: string\n /** One option, or any number of them. */\n mode?: M | undefined\n /** How tall before it scrolls. Any CSS length. */\n height?: string | undefined\n}>()\n\n/* Multiple always hands back an array — toggling one produces a list, never\n nothing — so only the single value can be undefined. */\ntype Value = M extends 'multiple' ? V[] : V | undefined\n\n/* Declared by hand rather than with defineModel, which cannot both accept\n `undefined` and promise never to emit it. See `use-bound-value.ts`. */\nconst emit = defineEmits<{ 'update:modelValue': [value: Value] }>()\nconst model = useBoundValue<Value>(\n () => modelValue as Value | undefined,\n (value) => emit('update:modelValue', value),\n)\n\nconst id = useId()\nconst active = ref(0)\n\nlet typed = ''\nlet typedAt = 0\n\nconst chosen = computed(() => {\n const value = model.value\n if (Array.isArray(value)) return new Set<V>(value)\n\n return new Set<V>(value === undefined ? [] : [value as V])\n})\n\nfunction select(option: ListboxOption<V>) {\n if (option.disabled) return\n\n if (mode === 'multiple') {\n const next = new Set(chosen.value)\n if (next.has(option.value)) next.delete(option.value)\n else next.add(option.value)\n\n // In the order of the options, so the value reads the same however the\n // reader got there.\n model.value = options\n .map((one) => one.value)\n .filter((value) => next.has(value)) as typeof model.value\n return\n }\n\n model.value = option.value as typeof model.value\n}\n\nfunction moveTo(index: number) {\n if (options.length === 0) return\n\n active.value = Math.max(0, Math.min(options.length - 1, index))\n const option = options[active.value]\n\n // Single: the selection follows the focus, which is what a listbox does.\n if (mode === 'single' && option && !option.disabled) select(option)\n\n document.getElementById(`${id}-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })\n}\n\n/** The next option after the current one that starts with what was typed. */\nfunction jumpTo(letter: string) {\n const now = Date.now()\n typed = now - typedAt > 700 ? letter : typed + letter\n typedAt = now\n\n const from = typed.length === 1 ? active.value + 1 : active.value\n const order = [...options.slice(from), ...options.slice(0, from)]\n const found = order.find(\n (option) => !option.disabled && option.label.toLowerCase().startsWith(typed.toLowerCase()),\n )\n\n if (found) moveTo(options.indexOf(found))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const moves: Record<string, number> = {\n ArrowDown: active.value + 1,\n ArrowUp: active.value - 1,\n Home: 0,\n End: options.length - 1,\n }\n\n if (event.key in moves) {\n event.preventDefault()\n moveTo(moves[event.key]!)\n return\n }\n\n if (event.key === ' ' || event.key === 'Enter') {\n const option = options[active.value]\n if (!option) return\n event.preventDefault()\n select(option)\n return\n }\n\n // A single printable character: the typeahead every native list has.\n if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {\n jumpTo(event.key)\n }\n}\n</script>\n\n<template>\n <div\n class=\"rk-listbox control\"\n :style=\"{ maxHeight: height }\"\n role=\"listbox\"\n tabindex=\"0\"\n :aria-label=\"label\"\n :aria-multiselectable=\"mode === 'multiple' ? true : undefined\"\n :aria-activedescendant=\"options.length ? `${id}-${active}` : undefined\"\n @keydown=\"onKeydown\"\n >\n <!-- `aria-disabled` is omitted rather than `false`: an option that can be\n chosen says nothing about being disabled. -->\n <div\n v-for=\"(option, index) in options\"\n :id=\"`${id}-${index}`\"\n :key=\"option.value\"\n class=\"rk-listbox-option\"\n :class=\"{\n 'is-active': index === active,\n 'is-chosen': chosen.has(option.value),\n 'is-disabled': option.disabled,\n }\"\n role=\"option\"\n :aria-selected=\"chosen.has(option.value)\"\n :aria-disabled=\"option.disabled || undefined\"\n @click=\"((active = index), select(option))\"\n >\n <span class=\"min-w-0 flex-1\">\n <span class=\"rk-listbox-label\">{{ option.label }}</span>\n <span v-if=\"option.description\" class=\"rk-listbox-description\">{{\n option.description\n }}</span>\n </span>\n\n <Check v-if=\"chosen.has(option.value)\" class=\"size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <p v-if=\"options.length === 0\" class=\"rk-listbox-empty\" />\n </div>\n</template>\n\n<style scoped>\n.rk-listbox {\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 0.25rem;\n}\n\n.rk-listbox:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 1px;\n}\n\n.rk-listbox-option {\n display: flex;\n cursor: pointer;\n align-items: center;\n gap: 0.625rem;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.625rem;\n font-size: 0.875rem;\n color: var(--color-ink);\n}\n\n/* The current row shows only while the list has focus. A list that is not\n being used has no \"current\" row, and a highlight left behind on one reads\n as a selection — which is exactly what it is not. */\n.rk-listbox:focus-within .rk-listbox-option.is-active {\n background: var(--color-muted);\n}\n\n.rk-listbox-option.is-chosen {\n color: var(--color-primary);\n font-weight: 500;\n}\n\n/* Chosen is the tick and the tint, and it holds whether the list is in use\n or not: the current option and the chosen one are different things. */\n.rk-listbox-option.is-chosen {\n background: color-mix(in oklab, var(--color-primary) 10%, transparent);\n}\n\n.rk-listbox:focus-within .rk-listbox-option.is-chosen.is-active {\n background: color-mix(in oklab, var(--color-primary) 20%, transparent);\n}\n\n.rk-listbox-option.is-disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n\n.rk-listbox-label {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.rk-listbox-description {\n display: block;\n font-size: 0.75rem;\n color: var(--color-ink-soft);\n}\n\n.rk-listbox-empty {\n padding: 1rem;\n}\n</style>\n","<script setup lang=\"ts\" generic=\"V extends string, M extends 'single' | 'multiple' = 'single'\">\nimport { Check } from 'lucide-vue-next'\nimport { computed, ref, useId } from 'vue'\n\nimport { useBoundValue } from '../composables/use-bound-value'\n\nexport interface ListboxOption<V extends string> {\n value: V\n /** Already translated. */\n label: string\n /** A line under the label. */\n description?: string | undefined\n disabled?: boolean | undefined\n}\n\n/**\n * A list you choose from, open on the page.\n *\n * Between `BaseRadioGroup` and `BaseSelect`: a radio group is a handful of\n * options that are all visible and all equal; a select opens the platform's\n * own picker; this is a scrollable list of many, choosing one or several,\n * that stays on screen — a list of accounts, of tags, of files to act on.\n *\n * One Tab stop. The list itself takes focus and `aria-activedescendant`\n * says which option is current, so the arrows move without thirty stops in\n * the tab order. Typing a letter jumps to the next option that starts with\n * it, which is how every native list has always worked and the first thing\n * people try.\n *\n * In `single` mode the selection follows the arrows, because that is what a\n * listbox does; in `multiple` mode Space and Enter toggle the current one.\n */\nconst {\n modelValue = undefined,\n options,\n label,\n mode = 'single' as M,\n height = '14rem',\n} = defineProps<{\n /** The chosen option, or options, with `v-model`. */\n modelValue?: Value | undefined\n options: readonly ListboxOption<V>[]\n /** The list's accessible name. */\n label: string\n /** One option, or any number of them. */\n mode?: M | undefined\n /** How tall before it scrolls. Any CSS length. */\n height?: string | undefined\n}>()\n\n/* Multiple always hands back an array — toggling one produces a list, never\n nothing — so only the single value can be undefined. */\ntype Value = M extends 'multiple' ? V[] : V | undefined\n\n/* Declared by hand rather than with defineModel, which cannot both accept\n `undefined` and promise never to emit it. See `use-bound-value.ts`. */\nconst emit = defineEmits<{ 'update:modelValue': [value: Value] }>()\nconst model = useBoundValue<Value>(\n () => modelValue as Value | undefined,\n (value) => emit('update:modelValue', value),\n)\n\nconst id = useId()\nconst active = ref(0)\n\nlet typed = ''\nlet typedAt = 0\n\nconst chosen = computed(() => {\n const value = model.value\n if (Array.isArray(value)) return new Set<V>(value)\n\n return new Set<V>(value === undefined ? [] : [value as V])\n})\n\nfunction select(option: ListboxOption<V>) {\n if (option.disabled) return\n\n if (mode === 'multiple') {\n const next = new Set(chosen.value)\n if (next.has(option.value)) next.delete(option.value)\n else next.add(option.value)\n\n // In the order of the options, so the value reads the same however the\n // reader got there.\n model.value = options\n .map((one) => one.value)\n .filter((value) => next.has(value)) as typeof model.value\n return\n }\n\n model.value = option.value as typeof model.value\n}\n\nfunction moveTo(index: number) {\n if (options.length === 0) return\n\n active.value = Math.max(0, Math.min(options.length - 1, index))\n const option = options[active.value]\n\n // Single: the selection follows the focus, which is what a listbox does.\n if (mode === 'single' && option && !option.disabled) select(option)\n\n document.getElementById(`${id}-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })\n}\n\n/** The next option after the current one that starts with what was typed. */\nfunction jumpTo(letter: string) {\n const now = Date.now()\n typed = now - typedAt > 700 ? letter : typed + letter\n typedAt = now\n\n const from = typed.length === 1 ? active.value + 1 : active.value\n const order = [...options.slice(from), ...options.slice(0, from)]\n const found = order.find(\n (option) => !option.disabled && option.label.toLowerCase().startsWith(typed.toLowerCase()),\n )\n\n if (found) moveTo(options.indexOf(found))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const moves: Record<string, number> = {\n ArrowDown: active.value + 1,\n ArrowUp: active.value - 1,\n Home: 0,\n End: options.length - 1,\n }\n\n if (event.key in moves) {\n event.preventDefault()\n moveTo(moves[event.key]!)\n return\n }\n\n if (event.key === ' ' || event.key === 'Enter') {\n const option = options[active.value]\n if (!option) return\n event.preventDefault()\n select(option)\n return\n }\n\n // A single printable character: the typeahead every native list has.\n if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {\n jumpTo(event.key)\n }\n}\n</script>\n\n<template>\n <div\n class=\"rk-listbox control\"\n :style=\"{ maxHeight: height }\"\n role=\"listbox\"\n tabindex=\"0\"\n :aria-label=\"label\"\n :aria-multiselectable=\"mode === 'multiple' ? true : undefined\"\n :aria-activedescendant=\"options.length ? `${id}-${active}` : undefined\"\n @keydown=\"onKeydown\"\n >\n <!-- `aria-disabled` is omitted rather than `false`: an option that can be\n chosen says nothing about being disabled. -->\n <div\n v-for=\"(option, index) in options\"\n :id=\"`${id}-${index}`\"\n :key=\"option.value\"\n class=\"rk-listbox-option\"\n :class=\"{\n 'is-active': index === active,\n 'is-chosen': chosen.has(option.value),\n 'is-disabled': option.disabled,\n }\"\n role=\"option\"\n :aria-selected=\"chosen.has(option.value)\"\n :aria-disabled=\"option.disabled || undefined\"\n @click=\"((active = index), select(option))\"\n >\n <span class=\"min-w-0 flex-1\">\n <span class=\"rk-listbox-label\">{{ option.label }}</span>\n <span v-if=\"option.description\" class=\"rk-listbox-description\">{{\n option.description\n }}</span>\n </span>\n\n <Check v-if=\"chosen.has(option.value)\" class=\"size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <p v-if=\"options.length === 0\" class=\"rk-listbox-empty\" />\n </div>\n</template>\n\n<style scoped>\n.rk-listbox {\n overflow-y: auto;\n border-radius: var(--radius-card);\n padding: 0.25rem;\n}\n\n.rk-listbox:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 1px;\n}\n\n.rk-listbox-option {\n display: flex;\n cursor: pointer;\n align-items: center;\n gap: 0.625rem;\n border-radius: var(--radius-cell);\n padding: 0.5rem 0.625rem;\n font-size: 0.875rem;\n color: var(--color-ink);\n}\n\n/* The current row shows only while the list has focus. A list that is not\n being used has no \"current\" row, and a highlight left behind on one reads\n as a selection — which is exactly what it is not. */\n.rk-listbox:focus-within .rk-listbox-option.is-active {\n background: var(--color-muted);\n}\n\n.rk-listbox-option.is-chosen {\n color: var(--color-primary);\n font-weight: 500;\n}\n\n/* Chosen is the tick and the tint, and it holds whether the list is in use\n or not: the current option and the chosen one are different things. */\n.rk-listbox-option.is-chosen {\n background: color-mix(in oklab, var(--color-primary) 10%, transparent);\n}\n\n.rk-listbox:focus-within .rk-listbox-option.is-chosen.is-active {\n background: color-mix(in oklab, var(--color-primary) 20%, transparent);\n}\n\n.rk-listbox-option.is-disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n\n.rk-listbox-label {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.rk-listbox-description {\n display: block;\n font-size: 0.75rem;\n color: var(--color-ink-soft);\n}\n\n.rk-listbox-empty {\n padding: 1rem;\n}\n</style>\n"],"mappings":"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"}
package/dist/app.js CHANGED
@@ -1,5 +1,5 @@
1
- import { i as inertOutside, n as BaseCheckbox_default, r as BaseSheet_default, t as focusTarget } from "./focus-target-D0WsXwKh.js";
2
- import { c as useOnline, g as toRedirectPath, h as safeRedirect, i as BaseInput_default, m as tapFeedback, o as BaseAlert_default, p as useTheme, r as GoogleButton_default, s as useToast, t as announce, u as isThemePreference } from "./use-announce-B1LsfsZx.js";
1
+ import { a as BaseCheckbox_default, o as BaseSheet_default, r as horizontalStep, s as inertOutside, t as focusTarget } from "./focus-target-DRgd8UnQ.js";
2
+ import { c as useOnline, g as toRedirectPath, h as safeRedirect, i as BaseInput_default, m as tapFeedback, o as BaseAlert_default, p as useTheme, r as GoogleButton_default, s as useToast, t as announce, u as isThemePreference } from "./use-announce-qM0ZD9Ey.js";
3
3
  import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
4
4
  import { t as BaseButton_default } from "./BaseButton-RN2an975.js";
5
5
  import { t as SettingsRow_default } from "./SettingsRow-Ct1p2C9Q.js";
@@ -432,8 +432,9 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
432
432
  }, { immediate: true });
433
433
  onUnmounted(() => releaseInert?.());
434
434
  function onKeydown(event) {
435
- if (event.key === "ArrowRight") emit("next");
436
- if (event.key === "ArrowLeft") emit("back");
435
+ const along = horizontalStep(event.key, event.currentTarget);
436
+ if (along === 1) emit("next");
437
+ if (along === -1) emit("back");
437
438
  if (event.key === "Escape") emit("dismiss");
438
439
  }
439
440
  return (_ctx, _cache) => {
@@ -510,7 +511,7 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
510
511
  })], 8, ["to"]);
511
512
  };
512
513
  }
513
- }), [["__scopeId", "data-v-88982fee"]]);
514
+ }), [["__scopeId", "data-v-8c293eec"]]);
514
515
  //#endregion
515
516
  //#region src/app/guards.ts
516
517
  /**