rei-kit 2.24.0 → 3.0.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 +5 -7
- package/dist/{BaseButton-RN2an975.js → BaseButton-BD2HKB1L.js} +2 -2
- package/dist/BaseButton-BD2HKB1L.js.map +1 -0
- package/dist/{BaseListbox-BQoSwYdJ.js → BaseListbox-D-NQ6nDd.js} +3 -3
- package/dist/{BaseListbox-BQoSwYdJ.js.map → BaseListbox-D-NQ6nDd.js.map} +1 -1
- package/dist/{SettingsRow-Ct1p2C9Q.js → SettingsRow-C6Spi7Np.js} +2 -2
- package/dist/SettingsRow-C6Spi7Np.js.map +1 -0
- package/dist/app/index.d.ts +6 -0
- package/dist/app.js +5 -5
- package/dist/app.js.map +1 -1
- package/dist/composables/use-field-attrs.d.ts +17 -0
- package/dist/{focus-target-DRgd8UnQ.js → focus-target-BtcoJdmz.js} +4 -4
- package/dist/focus-target-BtcoJdmz.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +31 -21
- package/dist/index.js.map +1 -1
- package/dist/motion/index.d.ts +11 -0
- package/dist/palettes/palettes.generated.json.d.ts +30 -30
- package/dist/palettes.css +28 -28
- package/dist/pwa/index.d.ts +6 -0
- package/dist/pwa.js +4 -4
- package/dist/pwa.js.map +1 -1
- package/dist/styles.css +84 -68
- package/dist/tokens.css +16 -3
- package/dist/{use-announce-qM0ZD9Ey.js → use-announce-B_JGTXHZ.js} +33 -5
- package/dist/use-announce-B_JGTXHZ.js.map +1 -0
- package/dist/web/index.d.ts +19 -0
- package/dist/web.js +3 -3
- package/package.json +5 -2
- package/dist/BaseButton-RN2an975.js.map +0 -1
- package/dist/SettingsRow-Ct1p2C9Q.js.map +0 -1
- package/dist/focus-target-DRgd8UnQ.js.map +0 -1
- package/dist/use-announce-qM0ZD9Ey.js.map +0 -1
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ go red, and several of them exist nowhere else:
|
|
|
79
79
|
| **`focus.spec.ts`** | Reads every component for a focus ring. A missing one compiles, renders and passes every other check. |
|
|
80
80
|
| **`public-api.spec.ts`** | Names every runtime export of all six entries. A kit compiles fine without an export nothing inside it calls. |
|
|
81
81
|
| **`type-surface.ts`** | Re-exports every published type and is type-checked twice, because a test cannot see types — they are gone by the time one runs. |
|
|
82
|
-
| **`showcase-catalogue.spec.ts`** | Fails if a component has no usage sample, no description, no behaviour test
|
|
82
|
+
| **`showcase-catalogue.spec.ts`** | Fails if a component has no usage sample, no description, or no behaviour test. The documentation cannot fall behind the package. |
|
|
83
83
|
| **`appearance.spec.ts`** | One case per component for a hex value, and one for a `text-white` on a filled role — neither is a colour a palette can follow, and white on the kit's own warning measures 2.3:1. Plus a frozen `shadow-card` where the runtime token was meant, and a `cubic-bezier` pasted in where a material's easing should reach. |
|
|
84
84
|
| **`consumer.yml`** | Packs the real tarball, installs it into all three apps and runs each one's whole gate. The only check that imports the package the way an app does. |
|
|
85
85
|
| **`pnpm size`** | Six budgets. The build fails when one grows past its line, and raising it has to say why in the commit. |
|
|
@@ -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
|
|
160
|
+
| **rei-kit 3.0.0** | **3.4 KB** | **23.1 KB** | **26.5 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
|
|
172
|
+
| **rei-kit 3.0.0** | **8.4 KB** | **23.1 KB** | **31.4 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 |
|
|
@@ -177,7 +177,7 @@ a data table, a tooltip and a card. A screen rather than a demo:
|
|
|
177
177
|
| ant-design-vue 4.2.6 | 195.4 KB | — | 195.4 KB |
|
|
178
178
|
|
|
179
179
|
**The slope is the point, not the total.** Seven more components cost this kit
|
|
180
|
-
**
|
|
180
|
+
**5.0 KB**, because the stylesheet does not move and only the JavaScript grows.
|
|
181
181
|
The kits that put their styles in the JavaScript have no flat part at all, so
|
|
182
182
|
the same seven cost them **60 to 125 KB**. A quarter smaller at three
|
|
183
183
|
components; **3.3× smaller at ten**.
|
|
@@ -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
|
-
**
|
|
229
|
+
**v3.0.0 — three consumers.**
|
|
230
230
|
|
|
231
231
|
| | |
|
|
232
232
|
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -662,9 +662,7 @@ they have to do to take it.
|
|
|
662
662
|
| File | What it answers |
|
|
663
663
|
| -------------------------------------- | ------------------------------------------------------------------------ |
|
|
664
664
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to set it up, the one command CI runs, and what a new component needs |
|
|
665
|
-
| [ROADMAP.md](ROADMAP.md) | What is next, what is being weighed up, and what this will never do |
|
|
666
665
|
| [SECURITY.md](SECURITY.md) | How to report a vulnerability, and what the package does and does not do |
|
|
667
|
-
| [AGENTS.md](AGENTS.md) | The design document: which part to reach for, and the mistakes that compile |
|
|
668
666
|
| [CHANGELOG.md](CHANGELOG.md) | Why each change was made |
|
|
669
667
|
| [PATCHNOTES.md](PATCHNOTES.md) | What you gain per release, and what you have to do to take it |
|
|
670
668
|
|
|
@@ -105,7 +105,7 @@ var BaseButton_default = /* @__PURE__ */ defineComponent({
|
|
|
105
105
|
});
|
|
106
106
|
const radius = computed(() => {
|
|
107
107
|
if (__props.variant === "unstyled") return "";
|
|
108
|
-
if (__props.variant === "link") return "rounded-
|
|
108
|
+
if (__props.variant === "link") return "rounded-cell";
|
|
109
109
|
return __props.pill ? "rounded-full" : "rounded-card";
|
|
110
110
|
});
|
|
111
111
|
/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */
|
|
@@ -146,4 +146,4 @@ var BaseButton_default = /* @__PURE__ */ defineComponent({
|
|
|
146
146
|
//#endregion
|
|
147
147
|
export { BaseButton_default as t };
|
|
148
148
|
|
|
149
|
-
//# sourceMappingURL=BaseButton-
|
|
149
|
+
//# sourceMappingURL=BaseButton-BD2HKB1L.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BaseButton-BD2HKB1L.js","names":[],"sources":["../src/components/BaseButton.vue","../src/components/BaseButton.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { HTMLAttributes } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<\n {\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n /* The root element's own attributes and listeners — `@click`, `aria-label`,\n `role` — typed for editors and for vue-tsc's strictTemplates, where an\n undeclared one is an error. `@vue-ignore` keeps them out of the runtime\n props, so they still fall through to the element as before. */\n } & /* @vue-ignore */ HTMLAttributes\n>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-on-primary shadow-(--shadow-control) hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'control text-ink hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-on-negative shadow-(--shadow-control) hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-on-positive shadow-(--shadow-control) hover:bg-positive/90',\n warning: 'bg-warning text-on-warning shadow-(--shadow-control) hover:bg-warning/90',\n accent: 'bg-accent text-on-accent shadow-(--shadow-control) hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-on-primary hover:bg-primary/90',\n quiet: 'bg-primary text-on-primary hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-on-primary hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel =\n 'transition-[transform,translate,scale,color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-standard select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n /* The press belongs to the material. A shrink by default; a material that\n draws hard offset shadows moves the button *into* its shadow instead, and\n drops the shadow, so pressing reads as pressing on every one of them. */\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-(--press-scale) active:translate-x-(--press-offset) active:translate-y-(--press-offset) active:shadow-(--shadow-control-pressed)`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-cell'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { HTMLAttributes } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<\n {\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n /* The root element's own attributes and listeners — `@click`, `aria-label`,\n `role` — typed for editors and for vue-tsc's strictTemplates, where an\n undeclared one is an error. `@vue-ignore` keeps them out of the runtime\n props, so they still fall through to the element as before. */\n } & /* @vue-ignore */ HTMLAttributes\n>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-on-primary shadow-(--shadow-control) hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'control text-ink hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-on-negative shadow-(--shadow-control) hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-on-positive shadow-(--shadow-control) hover:bg-positive/90',\n warning: 'bg-warning text-on-warning shadow-(--shadow-control) hover:bg-warning/90',\n accent: 'bg-accent text-on-accent shadow-(--shadow-control) hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-on-primary hover:bg-primary/90',\n quiet: 'bg-primary text-on-primary hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-on-primary hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel =\n 'transition-[transform,translate,scale,color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-standard select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n /* The press belongs to the material. A shrink by default; a material that\n draws hard offset shadows moves the button *into* its shadow instead, and\n drops the shadow, so pressing reads as pressing on every one of them. */\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-(--press-scale) active:translate-x-(--press-offset) active:translate-y-(--press-offset) active:shadow-(--shadow-control-pressed)`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-cell'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwGA,MAAM,gBAAgB;GACpB,SAAS;GAUT,WAAW;GACX,OAAO;GAeP,aAAa;GAab,KAAK;GAgBL,OAAO;GACP,QAAQ;GAUR,UAAU;GACV,SAAS;GACT,QAAQ;GAIR,MAAM;GAeN,UAAU;EACZ;EAKA,MAAM,aAAa;GACjB,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAI;EACN;EAEA,MAAM,kBAAkB;GACtB,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAI;EACN;EAKA,MAAM,iBAAiB;GACrB,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAI;EACN;EAIA,MAAM,kBAAkB;GACtB,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAI;EACN;EAEA,MAAM,SAAS,eAAe;GAE5B,IAAI,QAAA,YAAY,YAAY,OAAO;GACnC,IAAI,QAAA,YAAY,QAAQ,OAAO,gBAAgB,QAAA;GAC/C,IAAI,QAAA,YAAY,OAAO,OAAO,eAAe,QAAA;GAC7C,OAAO,QAAA,OAAO,gBAAgB,QAAA,QAAQ,WAAW,QAAA;EACnD,CAAC;EAID,MAAM,gBAAiD;GACrD,OAAO;GACP,OAAO;GACP,WAAW;GAIX,KAAK;EACP;EAEA,MAAM,UAAU,eAAe;GAC7B,IAAI,QAAA,YAAY,MAAM,OAAO,cAAc,QAAA,YAAY,cAAc,QAAA;GAIrE,IAAI,QAAA,YAAY,eAAe,OAAO,GAAG,cAAc,YAAY;GAEnE,OAAO,cAAc,QAAA;EACvB,CAAC;EAWD,MAAM,QAAQ,eAAe;GAC3B,IAAI,QAAA,YAAY,YAAY,OAAO;GAEnC,MAAM,OACJ;GAIF,IAAI,QAAA,YAAY,OAAO,OAAO,8CAA8C;GAK5E,OAAO,6DAA6D,KAAK;EAC3E,CAAC;EAED,MAAM,SAAS,eAAe;GAC5B,IAAI,QAAA,YAAY,YAAY,OAAO;GACnC,IAAI,QAAA,YAAY,QAAQ,OAAO;GAC/B,OAAO,QAAA,OAAO,iBAAiB;EACjC,CAAC;;EAGD,MAAM,WAAW,eAAe,QAAA,YAAY,QAAA,OAAO;EAEnD,MAAM,YAAY,eAAe;GAC/B,IAAI,QAAA,OAAO,eAAe,OAAO,EAAE,IAAC,QAAA,GAAE;GAItC,IAAI,QAAA,OAAO,KAAK,OAAO,SAAS,QAAQ,CAAC,IAAI,EAAE,MAAG,QAAA,KAAE;GACpD,OAAO,CAAC;EACV,CAAC;;GAIC,OAAA,UAAA,GAAA,YAiBY,wBAhBL,QAAA,EAAE,GADT,WAEU,UAeE,OAfO;IAChB,MAAM,QAAA,OAAE,WAAgB,QAAA,OAAO,KAAA;IAC/B,UAAU,QAAA,OAAE,WAAgB,SAAA,QAAW,KAAA;IACvC,iBAAe,QAAA,OAAE,YAAiB,SAAA,QAAQ,SAAY,KAAA;IACtD,aAAW,QAAA;IACX,gBAAc,QAAA,YAAY,KAAA,IAAY,KAAA,IAAY,OAAO,QAAA,OAAO;IACjE,OAAK,CAAC,oMAAkM;KAC/L,MAAA;KAAO,QAAA;KAAS,OAAA;KAAQ,OAAA;KAAQ,QAAA,QAAK,WAAA;IAAA,CAAA;;IAE9C,SAAA,cAIE,CAHM,QAAA,WADR,UAAA,GAAA,mBAIE,QAJF,UAIE,KAAA,mBAAA,IAAA,IAAA,GACF,WAAQ,KAAA,QAAA,SAAA,CAAA,CAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as useBoundValue } from "./focus-target-
|
|
1
|
+
import { c as useBoundValue } from "./focus-target-BtcoJdmz.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, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, ref, renderList, renderSlot, toDisplayString, unref, useId, useTemplateRef, watch, watchEffect } from "vue";
|
|
4
4
|
import { Check } from "lucide-vue-next";
|
|
@@ -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-
|
|
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-
|
|
549
|
+
//# sourceMappingURL=BaseListbox-D-NQ6nDd.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-D-NQ6nDd.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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"}
|
|
@@ -4,7 +4,7 @@ import { ChevronRight } from "lucide-vue-next";
|
|
|
4
4
|
var _hoisted_1 = { class: "flex items-center gap-3" };
|
|
5
5
|
var _hoisted_2 = {
|
|
6
6
|
key: 0,
|
|
7
|
-
class: "bg-muted text-ink-soft flex size-9 shrink-0 items-center justify-center
|
|
7
|
+
class: "bg-muted text-ink-soft rounded-control flex size-9 shrink-0 items-center justify-center",
|
|
8
8
|
"aria-hidden": "true"
|
|
9
9
|
};
|
|
10
10
|
var _hoisted_3 = { class: "min-w-0 flex-1" };
|
|
@@ -62,4 +62,4 @@ var SettingsRow_default = /* @__PURE__ */ defineComponent({
|
|
|
62
62
|
//#endregion
|
|
63
63
|
export { SettingsRow_default as t };
|
|
64
64
|
|
|
65
|
-
//# sourceMappingURL=SettingsRow-
|
|
65
|
+
//# sourceMappingURL=SettingsRow-C6Spi7Np.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SettingsRow-C6Spi7Np.js","names":[],"sources":["../src/components/SettingsRow.vue","../src/components/SettingsRow.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { ChevronRight } from 'lucide-vue-next'\nimport type { Component } from 'vue'\n\n/**\n * One line in a settings card.\n *\n * `as` decides the element: a row that navigates has to be a button, and a row\n * that merely holds a control must not be, or the control becomes unreachable.\n */\nconst {\n label,\n description = '',\n icon = undefined,\n interactive = false,\n stacked = false,\n} = defineProps<{\n label: string\n description?: string | undefined\n icon?: Component | undefined\n /** Renders the row as a button with a chevron. */\n interactive?: boolean | undefined\n /** Puts the control on its own line below the label, for wide controls. */\n stacked?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <component\n :is=\"interactive ? 'button' : 'div'\"\n :type=\"interactive ? 'button' : undefined\"\n class=\"flex w-full items-center gap-3 px-4 py-3 text-left\"\n :class=\"[\n interactive ? 'hover:bg-muted/60 transition-colors active:scale-[0.99]' : '',\n stacked ? 'flex-col items-stretch gap-3' : '',\n ]\"\n @click=\"interactive && emit('click')\"\n >\n <div class=\"flex items-center gap-3\">\n <span\n v-if=\"icon\"\n class=\"bg-muted text-ink-soft rounded-control flex size-9 shrink-0 items-center justify-center\"\n aria-hidden=\"true\"\n >\n <component :is=\"icon\" class=\"size-[18px]\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"text-ink text-sm font-medium\">{{ label }}</p>\n <p v-if=\"description\" class=\"text-ink-soft mt-0.5 text-xs leading-snug\">\n {{ description }}\n </p>\n </div>\n\n <div v-if=\"!stacked\" class=\"shrink-0\"><slot /></div>\n\n <ChevronRight v-if=\"interactive\" class=\"text-ink-soft size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <div v-if=\"stacked\"><slot /></div>\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { ChevronRight } from 'lucide-vue-next'\nimport type { Component } from 'vue'\n\n/**\n * One line in a settings card.\n *\n * `as` decides the element: a row that navigates has to be a button, and a row\n * that merely holds a control must not be, or the control becomes unreachable.\n */\nconst {\n label,\n description = '',\n icon = undefined,\n interactive = false,\n stacked = false,\n} = defineProps<{\n label: string\n description?: string | undefined\n icon?: Component | undefined\n /** Renders the row as a button with a chevron. */\n interactive?: boolean | undefined\n /** Puts the control on its own line below the label, for wide controls. */\n stacked?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <component\n :is=\"interactive ? 'button' : 'div'\"\n :type=\"interactive ? 'button' : undefined\"\n class=\"flex w-full items-center gap-3 px-4 py-3 text-left\"\n :class=\"[\n interactive ? 'hover:bg-muted/60 transition-colors active:scale-[0.99]' : '',\n stacked ? 'flex-col items-stretch gap-3' : '',\n ]\"\n @click=\"interactive && emit('click')\"\n >\n <div class=\"flex items-center gap-3\">\n <span\n v-if=\"icon\"\n class=\"bg-muted text-ink-soft rounded-control flex size-9 shrink-0 items-center justify-center\"\n aria-hidden=\"true\"\n >\n <component :is=\"icon\" class=\"size-[18px]\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"text-ink text-sm font-medium\">{{ label }}</p>\n <p v-if=\"description\" class=\"text-ink-soft mt-0.5 text-xs leading-snug\">\n {{ description }}\n </p>\n </div>\n\n <div v-if=\"!stacked\" class=\"shrink-0\"><slot /></div>\n\n <ChevronRight v-if=\"interactive\" class=\"text-ink-soft size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <div v-if=\"stacked\"><slot /></div>\n </component>\n</template>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0BA,MAAM,OAAO;;GAIX,OAAA,UAAA,GAAA,YAgCY,wBA/BL,QAAA,cAAW,WAAA,KAAA,GAAA;IACf,MAAM,QAAA,cAAW,WAAc,KAAA;IAChC,OAAK,eAAA,CAAC,sDAAoD,CAC1C,QAAA,cAAW,4DAAA,IAAyE,QAAA,UAAO,iCAAA,EAAA,CAAA,CAAA;IAI1G,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,QAAA,eAAe,KAAI,OAAA;;IAE3B,SAAA,cAmBM,CAnBN,mBAmBM,OAnBN,YAmBM;KAjBI,QAAA,QADR,UAAA,GAAA,mBAMO,QANP,YAMO,EADL,UAAA,GAAA,YAA4C,wBAA5B,QAAA,IAAI,GAAA,EAAE,OAAM,cAAa,CAAA,EAAA,CAAA,KAAA,mBAAA,IAAA,IAAA;KAG3C,mBAKM,OALN,YAKM,CAJJ,mBAAuD,KAAvD,YAAuD,gBAAZ,QAAA,KAAK,GAAA,CAAA,GACvC,QAAA,eAAT,UAAA,GAAA,mBAEI,KAFJ,YAEI,gBADC,QAAA,WAAW,GAAA,CAAA,KAAA,mBAAA,IAAA,IAAA,CAAA,CAAA;KAIN,CAAA,QAAA,WAAZ,UAAA,GAAA,mBAAoD,OAApD,YAAoD,CAAd,WAAQ,KAAA,QAAA,SAAA,CAAA,CAAA,KAAA,mBAAA,IAAA,IAAA;KAE1B,QAAA,eAApB,UAAA,GAAA,YAA4F,MAAA,YAAA,GAAA;;MAA3D,OAAM;MAAgC,eAAY;;IAG1E,CAAA,GAAA,QAAA,WAAX,UAAA,GAAA,mBAAkC,OAAA,YAAA,CAAd,WAAQ,KAAA,QAAA,SAAA,CAAA,CAAA,KAAA,mBAAA,IAAA,IAAA,CAAA,CAAA"}
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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/app.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { a as BaseCheckbox_default, o as BaseSheet_default, r as horizontalStep, s as inertOutside, t as focusTarget } from "./focus-target-
|
|
2
|
-
import { c as
|
|
1
|
+
import { a as BaseCheckbox_default, o as BaseSheet_default, r as horizontalStep, s as inertOutside, t as focusTarget } from "./focus-target-BtcoJdmz.js";
|
|
2
|
+
import { _ as toRedirectPath, c as useToast, d as isThemePreference, g as safeRedirect, h as tapFeedback, i as BaseInput_default, l as useOnline, m as useTheme, r as GoogleButton_default, s as BaseAlert_default, t as announce } from "./use-announce-B_JGTXHZ.js";
|
|
3
3
|
import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
|
|
4
|
-
import { t as BaseButton_default } from "./BaseButton-
|
|
5
|
-
import { t as SettingsRow_default } from "./SettingsRow-
|
|
4
|
+
import { t as BaseButton_default } from "./BaseButton-BD2HKB1L.js";
|
|
5
|
+
import { t as SettingsRow_default } from "./SettingsRow-C6Spi7Np.js";
|
|
6
6
|
import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, onUnmounted, openBlock, reactive, readonly, ref, renderList, renderSlot, toDisplayString, unref, useModel, watch, withCtx, withModifiers } from "vue";
|
|
7
7
|
import { Check, Languages } from "lucide-vue-next";
|
|
8
8
|
//#region src/app/AuthForm.vue?vue&type=script&setup=true&lang.ts
|
|
@@ -275,7 +275,7 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
|
|
|
275
275
|
default: withCtx(() => [createElementVNode("ul", _hoisted_2$2, [(openBlock(true), createElementBlock(Fragment, null, renderList(rows.value, (row) => {
|
|
276
276
|
return openBlock(), createElementBlock("li", { key: row.value }, [createVNode(BaseButton_default, {
|
|
277
277
|
variant: "row",
|
|
278
|
-
class: "rounded-
|
|
278
|
+
class: "rounded-control",
|
|
279
279
|
pressed: model.value === row.value,
|
|
280
280
|
onClick: ($event) => select(row.value)
|
|
281
281
|
}, {
|