rei-kit 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -18
- package/dist/{BaseListbox-DMR-Zn1u.js → BaseListbox-BQoSwYdJ.js} +105 -38
- package/dist/BaseListbox-BQoSwYdJ.js.map +1 -0
- package/dist/app.js +6 -5
- package/dist/app.js.map +1 -1
- package/dist/components/BarChart.vue.d.ts +41 -0
- package/dist/components/BaseMenu.vue.d.ts +5 -0
- package/dist/components/DonutChart.vue.d.ts +33 -0
- package/dist/components/PasswordInput.vue.d.ts +63 -0
- package/dist/composables/use-anchored-panel.d.ts +48 -0
- package/dist/composables/use-dialog-shell.d.ts +17 -0
- package/dist/{format-BEvBK1SJ.js → environment-Cz5KiC1t.js} +45 -2
- package/dist/environment-Cz5KiC1t.js.map +1 -0
- package/dist/{focus-target-D0WsXwKh.js → focus-target-DRgd8UnQ.js} +88 -2
- package/dist/focus-target-DRgd8UnQ.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +650 -391
- package/dist/index.js.map +1 -1
- package/dist/motion.js +1 -44
- package/dist/motion.js.map +1 -1
- package/dist/styles.css +317 -189
- package/dist/{use-announce-B1LsfsZx.js → use-announce-qM0ZD9Ey.js} +2 -2
- package/dist/{use-announce-B1LsfsZx.js.map → use-announce-qM0ZD9Ey.js.map} +1 -1
- package/dist/utils/direction.d.ts +39 -0
- package/dist/utils/focusable.d.ts +25 -0
- package/dist/web/BaseHoverCard.vue.d.ts +1 -0
- package/dist/web.js +46 -48
- package/dist/web.js.map +1 -1
- package/package.json +2 -2
- package/dist/BaseListbox-DMR-Zn1u.js.map +0 -1
- package/dist/focus-target-D0WsXwKh.js.map +0 -1
- package/dist/format-BEvBK1SJ.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as useBoundValue } from "./focus-target-DRgd8UnQ.js";
|
|
2
2
|
import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createStaticVNode, createTextVNode, defineComponent, isRef, mergeProps, nextTick, normalizeClass, onMounted, onUnmounted, openBlock, readonly, ref, renderSlot, toDisplayString, unref, useId, vModelDynamic, watch, withCtx, withDirectives } from "vue";
|
|
3
3
|
//#region src/utils/redirect.ts
|
|
4
4
|
/**
|
|
@@ -565,4 +565,4 @@ function useAnnounce() {
|
|
|
565
565
|
//#endregion
|
|
566
566
|
export { FormField_default as a, useOnline as c, readStoredTheme as d, setThemeStorageKey as f, toRedirectPath as g, safeRedirect as h, BaseInput_default as i, applyTheme as l, tapFeedback as m, useAnnounce as n, BaseAlert_default as o, useTheme as p, GoogleButton_default as r, useToast as s, announce as t, isThemePreference as u };
|
|
567
567
|
|
|
568
|
-
//# sourceMappingURL=use-announce-
|
|
568
|
+
//# sourceMappingURL=use-announce-qM0ZD9Ey.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-announce-B1LsfsZx.js","names":["$slots","$attrs"],"sources":["../src/utils/redirect.ts","../src/utils/haptics.ts","../src/composables/use-theme.ts","../src/composables/use-online.ts","../src/composables/use-toast.ts","../src/components/BaseAlert.vue","../src/components/BaseAlert.vue","../src/components/FormField.vue","../src/components/FormField.vue","../src/components/BaseInput.vue","../src/components/BaseInput.vue","../src/components/GoogleButton.vue","../src/components/GoogleButton.vue","../src/composables/use-announce.ts"],"sourcesContent":["/**\n * What a router hands back for one query key.\n *\n * Inlined rather than imported from vue-router: the shape is `string | null`\n * either way, and a helper this small should not drag a router into the\n * package's dependencies.\n */\nexport type QueryValue = string | null\n\n/**\n * Resolves a `?redirect=` query value into a safe in-app path.\n *\n * Only same-origin paths are accepted. Anything else falls back to `/`,\n * so a crafted link cannot bounce a user from the real login page to a\n * phishing clone.\n *\n * Pure: takes the query value instead of reading the router, so it also\n * works inside navigation guards and can be unit tested.\n *\n * @param target - Raw `route.query.redirect` value. May be a string, an\n * array (repeated query key), `null`, or `undefined`.\n * @returns A path starting with a single `/`. Defaults to `/`.\n *\n * @example\n * ```ts\n * // in a view\n * await router.push(safeRedirect(route.query.redirect))\n *\n * // in a guard\n * return safeRedirect(to.query.redirect)\n * ```\n *\n * @example\n * ```ts\n * safeRedirect('/week') // '/week'\n * safeRedirect('https://evil.com') // '/'\n * safeRedirect('//evil.com') // '/' (protocol-relative URL)\n * safeRedirect(['/a', '/b']) // '/'\n * safeRedirect(undefined) // '/'\n * ```\n */\nexport function safeRedirect(target: QueryValue | QueryValue[] | undefined): string {\n if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {\n return target\n }\n\n return '/'\n}\n\n/**\n * Where to send somebody back to after signing in, without the fragment.\n *\n * Supabase's implicit OAuth flow hands the browser back with the access and\n * refresh tokens in the URL fragment. A fragment is client-side only — it is\n * never sent to a server. Copied into a query parameter it stops being one:\n * `/login?redirect=/%23access_token=…` is sent on the very next request and\n * lands in the host's access logs, in `Referer` headers and in browser history.\n *\n * So the redirect keeps the path and the query and drops everything from the\n * `#`. There is nothing after it worth returning to anyway.\n *\n * One of the two phone apps had this and the other was passing `fullPath`\n * straight through, which is the leak above with nothing in the way of it. That\n * is the shape of bug a shared kit exists to end: it was fixed once, in the app\n * whose author happened to think of it.\n *\n * @example\n * ```ts\n * toRedirectPath('/ledger?direction=out') // '/ledger?direction=out'\n * toRedirectPath('/#access_token=abc') // '/'\n * ```\n */\nexport function toRedirectPath(fullPath: string): string {\n const [path] = fullPath.split('#')\n\n return path === undefined || path === '' ? '/' : path\n}\n","/**\n * A short vibration for a confirmed tap.\n *\n * Optional chaining is not decoration: iOS Safari has no `vibrate` at all, and\n * calling it unguarded would throw on every marked day.\n *\n * @param duration - Milliseconds. Keep it under ~15ms; longer reads as an alert.\n */\nexport function tapFeedback(duration = 10): void {\n navigator.vibrate?.(duration)\n}\n","import { ref, watch } from 'vue'\nimport type { Ref } from 'vue'\n\n/** What the user asked for; `system` follows the OS. */\nexport type ThemePreference = 'system' | 'light' | 'dark'\n\n/**\n * Namespaced by the app, not by this package.\n *\n * Two rei-kit apps served from the same origin would otherwise share one theme\n * setting — and during development on localhost, they will be.\n */\nlet storageKey = 'rei-theme'\n\nexport function isThemePreference(value: unknown): value is ThemePreference {\n return value === 'system' || value === 'light' || value === 'dark'\n}\n\n/** Reads the stored preference, falling back to `system`. */\nexport function readStoredTheme(): ThemePreference {\n try {\n const stored = localStorage.getItem(storageKey)\n\n return isThemePreference(stored) ? stored : 'system'\n } catch {\n return 'system'\n }\n}\n\nfunction storeTheme(preference: ThemePreference): void {\n try {\n localStorage.setItem(storageKey, preference)\n } catch {\n // Private mode or blocked storage: the choice just will not persist.\n }\n}\n\n/**\n * Does the environment prefer a dark scheme?\n *\n * `matchMedia` is checked for on its own rather than inferred from `document`.\n * Having one does not imply having the other: jsdom supplies a document and no\n * `matchMedia`, so a consumer's component test that so much as mounts something\n * calling `useTheme` threw — and some embedded webviews are the same. Where\n * there is nothing to ask, the answer is no rather than an exception.\n */\nfunction prefersDarkScheme(): boolean {\n return typeof window !== 'undefined' && typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)').matches\n : false\n}\n\n/**\n * Adds or removes `.dark` on `<html>`, resolving `system` against the OS.\n *\n * A no-op without a document. There is no OS preference to read on a server and\n * no `<html>` to write to, so a prerender leaves the class off and the app\n * decides the theme before hydration — see the note in the README.\n */\nexport function applyTheme(preference: ThemePreference): void {\n if (typeof document === 'undefined') return\n\n const isDark = preference === 'dark' || (preference === 'system' && prefersDarkScheme())\n\n document.documentElement.classList.toggle('dark', isDark)\n}\n\n/**\n * The shared preference, created on first use rather than at import.\n *\n * Lazy on purpose: reading storage at import time would lock in the default key\n * before an app had a chance to set its own, leaving the controller reading one\n * key and writing another.\n */\nlet preference: Ref<ThemePreference> | null = null\n\nfunction controller(): Ref<ThemePreference> {\n if (preference) return preference\n\n preference = ref<ThemePreference>(readStoredTheme())\n\n watch(\n preference,\n (next) => {\n storeTheme(next)\n applyTheme(next)\n },\n { immediate: true },\n )\n\n // While on `system`, follow the OS if the user flips it at night. Only where\n // there is something to listen to; see `prefersDarkScheme`.\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {\n if (preference?.value === 'system') applyTheme('system')\n })\n }\n\n return preference\n}\n\n/**\n * Sets where the preference is stored.\n *\n * Safe in either order: called before the first `useTheme()` it simply changes\n * the key, and called after it re-reads under the new one, so the controller\n * never reads from one key while writing to another.\n *\n * @example\n * ```ts\n * setThemeStorageKey('hibi-theme') // once, at startup\n * ```\n */\nexport function setThemeStorageKey(key: string): void {\n storageKey = key\n if (preference) preference.value = readStoredTheme()\n}\n\n/** @returns The shared preference ref; assigning to it stores and applies it. */\nexport function useTheme(): Ref<ThemePreference> {\n return controller()\n}\n","import { onMounted, onUnmounted, readonly, ref } from 'vue'\n\n/**\n * Tracks whether the browser thinks it has a network connection.\n *\n * Note the limit: `navigator.onLine` only reports whether a network interface\n * is up, not whether requests actually succeed. Treat it as a hint for the UI,\n * never as a reason to skip error handling.\n *\n * Listeners are removed on unmount, so the composable is safe to call per view.\n *\n * @returns A readonly ref that flips with the browser's online/offline events.\n *\n * @example\n * ```ts\n * const isOnline = useOnline()\n * // <p v-if=\"!isOnline\">You're offline.</p>\n * ```\n */\nexport function useOnline() {\n const isOnline = ref(true)\n\n function update() {\n isOnline.value = navigator.onLine\n }\n\n onMounted(() => {\n update()\n window.addEventListener('online', update)\n window.addEventListener('offline', update)\n })\n\n onUnmounted(() => {\n window.removeEventListener('online', update)\n window.removeEventListener('offline', update)\n })\n\n return readonly(isOnline)\n}\n","import { readonly, ref } from 'vue'\n\n/**\n * Saying that something happened.\n *\n * The reason this exists is a measurement rather than a preference: the word\n * \"toast\" appeared **zero times** across all three consuming apps. Not because\n * they had decided against it — because there was no mechanism, so every save,\n * every delete and every export finished in silence and the only way to know\n * it had worked was that nothing had visibly broken.\n *\n * ── What is deliberately not here ──\n *\n * **No text.** The kit never knows a sentence. Callers pass the message; a\n * component that called a translator would force one on the app.\n *\n * **Not for form errors.** A field that was rejected says so beside itself,\n * where the reader's eye already is and where it stays until fixed. A toast\n * that disappears after four seconds is the wrong place for something the\n * reader has to act on. Use `BaseAlert` and `FormField` for those; use this\n * for what has already happened.\n *\n * **A singleton, on purpose.** Two hosts would mean two stacks racing for the\n * same corner. The store lives at module scope and `ToastHost` renders it.\n */\nexport type ToastTone = 'info' | 'success' | 'warning' | 'danger'\n\n/**\n * One button on a toast — nearly always \"Undo\".\n *\n * The toast is dismissed when it is pressed, so the handler only has to do\n * the undoing.\n */\nexport interface ToastAction {\n /** The button's text. Already translated. */\n readonly label: string\n readonly onClick: () => void\n}\n\nexport interface Toast {\n readonly id: number\n readonly message: string\n readonly tone: ToastTone\n /** Milliseconds on screen. `0` stays until dismissed. */\n readonly duration: number\n readonly action?: ToastAction | undefined\n}\n\nexport interface ToastOptions {\n /** Milliseconds on screen; `0` stays until dismissed. */\n duration?: number | undefined\n /**\n * A button on the toast. An undo is the kindest confirmation there is: act\n * first, and let the reader take it back, instead of asking \"are you sure\"\n * before every delete.\n */\n action?: ToastAction | undefined\n}\n\n/**\n * Four seconds: long enough to read a short sentence twice, short enough that\n * a second action does not queue behind it.\n */\nconst DEFAULT_DURATION = 4000\n\n/**\n * A failure is read more slowly than a confirmation, and more often twice.\n */\nconst DANGER_DURATION = 7000\n\n/**\n * A toast with a button stays longer: the reader has to read it, decide and\n * reach the button. Four seconds is enough to read \"Deleted\", not to undo it.\n */\nconst ACTION_DURATION = 8000\n\n/**\n * Three at once. A fourth pushes the oldest out rather than growing the stack\n * off the top of the screen — an action that produces ten toasts is a loop,\n * and a loop should not be able to cover the app it is running in.\n */\nconst MAX_VISIBLE = 3\n\nconst items = ref<Toast[]>([])\n\nlet nextId = 0\n\ninterface Countdown {\n handle: ReturnType<typeof setTimeout>\n remaining: number\n startedAt: number\n}\n\nconst countdowns = new Map<number, Countdown>()\n\nfunction clearCountdown(id: number): void {\n const countdown = countdowns.get(id)\n if (countdown === undefined) return\n\n clearTimeout(countdown.handle)\n countdowns.delete(id)\n}\n\n/** Removes a toast, whether it timed out or was dismissed. */\nfunction dismiss(id: number): void {\n clearCountdown(id)\n items.value = items.value.filter((item) => item.id !== id)\n}\n\n/** Removes everything on screen. For a route change, or a sign-out. */\nfunction dismissAll(): void {\n for (const id of countdowns.keys()) clearCountdown(id)\n items.value = []\n}\n\nfunction arm(id: number, remaining: number): void {\n // A timer is a browser thing. On a server there is nothing to time and\n // nothing to see, and arming one would keep the process alive past the last\n // page — which is how a prerender build hangs instead of finishing.\n if (typeof window === 'undefined' || remaining <= 0) return\n\n countdowns.set(id, {\n handle: setTimeout(() => dismiss(id), remaining),\n remaining,\n startedAt: Date.now(),\n })\n}\n\n/**\n * Stops the clock on a toast the reader is pointing at.\n *\n * Somebody who has moved the pointer onto it is reading it, and taking it away\n * mid-sentence is the one thing a notification must not do.\n */\nfunction pause(id: number): void {\n const countdown = countdowns.get(id)\n if (countdown === undefined) return\n\n clearTimeout(countdown.handle)\n countdowns.set(id, {\n ...countdown,\n remaining: Math.max(0, countdown.remaining - (Date.now() - countdown.startedAt)),\n })\n}\n\n/** Starts it again, from where it stopped rather than from the beginning. */\nfunction resume(id: number): void {\n const countdown = countdowns.get(id)\n if (countdown === undefined) return\n\n arm(id, countdown.remaining)\n}\n\nfunction push(tone: ToastTone, message: string, options: ToastOptions = {}): number {\n const id = ++nextId\n const duration =\n options.duration ??\n (options.action ? ACTION_DURATION : tone === 'danger' ? DANGER_DURATION : DEFAULT_DURATION)\n\n const next = [...items.value, { id, message, tone, duration, action: options.action }]\n\n while (next.length > MAX_VISIBLE) {\n const oldest = next.shift()\n if (oldest !== undefined) clearCountdown(oldest.id)\n }\n\n items.value = next\n arm(id, duration)\n\n return id\n}\n\n/**\n * The stack, and the four ways to add to it.\n *\n * @example\n * ```ts\n * const toast = useToast()\n *\n * toast.success(t('habit.saved'))\n * toast.danger(t('common.failed'), { duration: 0 }) // stays until dismissed\n *\n * const id = toast.info(t('export.preparing'), { duration: 0 })\n * toast.dismiss(id)\n *\n * toast.success(t('entry.deleted'), {\n * action: { label: t('common.undo'), onClick: () => restore(entry) },\n * })\n * ```\n */\nexport function useToast() {\n return {\n /** Every toast on screen, oldest first. `ToastHost` renders this. */\n toasts: readonly(items),\n info: (message: string, options?: ToastOptions) => push('info', message, options),\n success: (message: string, options?: ToastOptions) => push('success', message, options),\n warning: (message: string, options?: ToastOptions) => push('warning', message, options),\n danger: (message: string, options?: ToastOptions) => push('danger', message, options),\n dismiss,\n dismissAll,\n pause,\n resume,\n }\n}\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * A message the reader has to take in before carrying on.\n *\n * Roles rather than colours, like everything else here: `info` is neutral,\n * `success` confirms, `warning` is a condition to know about, `danger` is\n * something that went wrong or is about to. A component that took a hex would\n * be a component that ignores the theme, and the theme is the whole reason the\n * kit exists.\n *\n * `assertive` decides how a screen reader treats it: a failed save interrupts,\n * a note about a form field waits its turn. Getting this wrong is invisible on\n * screen and rude in a screen reader, which is why it is a prop and not a\n * guess.\n */\nconst { tone = 'info', assertive = false } = defineProps<{\n tone?: 'info' | 'success' | 'warning' | 'danger' | undefined\n /** Announce immediately, interrupting. For failures the reader must act on. */\n assertive?: boolean | undefined\n}>()\n\nconst TONES = {\n info: 'border-hair bg-muted/40 text-ink',\n success: 'border-positive/35 bg-positive/8 text-ink',\n warning: 'border-warning/40 bg-warning/8 text-ink',\n danger: 'border-negative/35 bg-negative/8 text-ink',\n} as const\n\nconst MARKS = {\n info: 'bg-ink-soft/15 text-ink-soft',\n success: 'bg-positive/15 text-positive',\n warning: 'bg-warning/15 text-warning',\n danger: 'bg-negative/15 text-negative',\n} as const\n\nconst skin = computed(() => TONES[tone])\nconst mark = computed(() => MARKS[tone])\n</script>\n\n<template>\n <div\n class=\"rounded-card flex items-start gap-3 border px-4 py-3.5 text-sm leading-relaxed\"\n :class=\"skin\"\n :role=\"assertive ? 'alert' : 'status'\"\n :aria-live=\"assertive ? 'assertive' : 'polite'\"\n >\n <span\n v-if=\"$slots.mark\"\n class=\"mt-px grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold\"\n :class=\"mark\"\n aria-hidden=\"true\"\n >\n <slot name=\"mark\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p v-if=\"$slots.title\" class=\"text-ink font-semibold\">\n <slot name=\"title\" />\n </p>\n <div :class=\"$slots.title ? 'mt-1' : ''\"><slot /></div>\n </div>\n\n <slot name=\"action\" />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * A message the reader has to take in before carrying on.\n *\n * Roles rather than colours, like everything else here: `info` is neutral,\n * `success` confirms, `warning` is a condition to know about, `danger` is\n * something that went wrong or is about to. A component that took a hex would\n * be a component that ignores the theme, and the theme is the whole reason the\n * kit exists.\n *\n * `assertive` decides how a screen reader treats it: a failed save interrupts,\n * a note about a form field waits its turn. Getting this wrong is invisible on\n * screen and rude in a screen reader, which is why it is a prop and not a\n * guess.\n */\nconst { tone = 'info', assertive = false } = defineProps<{\n tone?: 'info' | 'success' | 'warning' | 'danger' | undefined\n /** Announce immediately, interrupting. For failures the reader must act on. */\n assertive?: boolean | undefined\n}>()\n\nconst TONES = {\n info: 'border-hair bg-muted/40 text-ink',\n success: 'border-positive/35 bg-positive/8 text-ink',\n warning: 'border-warning/40 bg-warning/8 text-ink',\n danger: 'border-negative/35 bg-negative/8 text-ink',\n} as const\n\nconst MARKS = {\n info: 'bg-ink-soft/15 text-ink-soft',\n success: 'bg-positive/15 text-positive',\n warning: 'bg-warning/15 text-warning',\n danger: 'bg-negative/15 text-negative',\n} as const\n\nconst skin = computed(() => TONES[tone])\nconst mark = computed(() => MARKS[tone])\n</script>\n\n<template>\n <div\n class=\"rounded-card flex items-start gap-3 border px-4 py-3.5 text-sm leading-relaxed\"\n :class=\"skin\"\n :role=\"assertive ? 'alert' : 'status'\"\n :aria-live=\"assertive ? 'assertive' : 'polite'\"\n >\n <span\n v-if=\"$slots.mark\"\n class=\"mt-px grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold\"\n :class=\"mark\"\n aria-hidden=\"true\"\n >\n <slot name=\"mark\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p v-if=\"$slots.title\" class=\"text-ink font-semibold\">\n <slot name=\"title\" />\n </p>\n <div :class=\"$slots.title ? 'mt-1' : ''\"><slot /></div>\n </div>\n\n <slot name=\"action\" />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, useId } from 'vue'\n\n/**\n * A label, a hint, an error, and the wiring between them.\n *\n * This was inside `BaseInput`, which is why the kit had one form control\n * instead of five. The hard part of a field is not the `<input>` — it is\n * generating an id, pointing the label at it, deciding whether the description\n * is the hint or the error, and telling assistive tech which one to read. That\n * is identical for a select, a textarea and an input, and every app that\n * needed one of the other two wrote the whole thing again.\n *\n * The control comes in through the slot and is handed what it needs to be\n * described. It is a slot rather than a prop so the field never has to know\n * what it is wrapping.\n *\n * @example\n * ```vue\n * <FormField :label=\"t('profile.name')\" :error=\"errors.name\">\n * <template #default=\"{ id, describedBy, invalid }\">\n * <input :id=\"id\" :aria-describedby=\"describedBy\" :aria-invalid=\"invalid\" />\n * </template>\n * </FormField>\n * ```\n */\nconst {\n label,\n error = '',\n hint = '',\n labelHidden = false,\n fieldId = undefined,\n size = 'md',\n} = defineProps<{\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * `sm` for a control that sits inside something else — a toolbar, a filter\n * row, a settings line — rather than in a form of its own.\n *\n * It exists because every hand-written select in all three apps was the\n * small one, and the kit only had the large one. A part is not reusable if\n * reaching for it costs a size somebody chose on purpose.\n */\n size?: 'sm' | 'md' | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the control with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * The control's `id`, when the app needs to address it from outside —\n * an `ErrorSummary` linking to the field it is about, a label elsewhere\n * on the page. Generated when it is not given, which is almost always.\n */\n fieldId?: string | undefined\n}>()\n\nconst generated = useId()\nconst id = computed(() => fieldId ?? generated)\nconst errorId = computed(() => `${id.value}-error`)\nconst hintId = computed(() => `${id.value}-hint`)\n\n/* One description at a time, and the error wins. Announcing the hint as well\n buries the reason the field was rejected under advice the reader has already\n had. */\nconst describedBy = computed(() => {\n if (error) return errorId.value\n if (hint) return hintId.value\n return undefined\n})\n</script>\n\n<template>\n <div class=\"flex flex-col\" :class=\"size === 'sm' ? 'gap-1' : 'gap-1.5'\">\n <label\n :for=\"id\"\n class=\"font-medium\"\n :class=\"[\n labelHidden ? 'sr-only' : '',\n size === 'sm' ? 'text-ink-soft text-xs' : 'text-ink text-sm',\n ]\"\n >\n {{ label }}\n </label>\n\n <slot :id=\"id\" :described-by=\"describedBy\" :invalid=\"Boolean(error)\" :size=\"size\" />\n\n <p v-if=\"error\" :id=\"errorId\" class=\"text-negative text-xs\">{{ error }}</p>\n <p v-else-if=\"hint\" :id=\"hintId\" class=\"text-ink-soft text-xs\">{{ hint }}</p>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, useId } from 'vue'\n\n/**\n * A label, a hint, an error, and the wiring between them.\n *\n * This was inside `BaseInput`, which is why the kit had one form control\n * instead of five. The hard part of a field is not the `<input>` — it is\n * generating an id, pointing the label at it, deciding whether the description\n * is the hint or the error, and telling assistive tech which one to read. That\n * is identical for a select, a textarea and an input, and every app that\n * needed one of the other two wrote the whole thing again.\n *\n * The control comes in through the slot and is handed what it needs to be\n * described. It is a slot rather than a prop so the field never has to know\n * what it is wrapping.\n *\n * @example\n * ```vue\n * <FormField :label=\"t('profile.name')\" :error=\"errors.name\">\n * <template #default=\"{ id, describedBy, invalid }\">\n * <input :id=\"id\" :aria-describedby=\"describedBy\" :aria-invalid=\"invalid\" />\n * </template>\n * </FormField>\n * ```\n */\nconst {\n label,\n error = '',\n hint = '',\n labelHidden = false,\n fieldId = undefined,\n size = 'md',\n} = defineProps<{\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * `sm` for a control that sits inside something else — a toolbar, a filter\n * row, a settings line — rather than in a form of its own.\n *\n * It exists because every hand-written select in all three apps was the\n * small one, and the kit only had the large one. A part is not reusable if\n * reaching for it costs a size somebody chose on purpose.\n */\n size?: 'sm' | 'md' | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the control with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * The control's `id`, when the app needs to address it from outside —\n * an `ErrorSummary` linking to the field it is about, a label elsewhere\n * on the page. Generated when it is not given, which is almost always.\n */\n fieldId?: string | undefined\n}>()\n\nconst generated = useId()\nconst id = computed(() => fieldId ?? generated)\nconst errorId = computed(() => `${id.value}-error`)\nconst hintId = computed(() => `${id.value}-hint`)\n\n/* One description at a time, and the error wins. Announcing the hint as well\n buries the reason the field was rejected under advice the reader has already\n had. */\nconst describedBy = computed(() => {\n if (error) return errorId.value\n if (hint) return hintId.value\n return undefined\n})\n</script>\n\n<template>\n <div class=\"flex flex-col\" :class=\"size === 'sm' ? 'gap-1' : 'gap-1.5'\">\n <label\n :for=\"id\"\n class=\"font-medium\"\n :class=\"[\n labelHidden ? 'sr-only' : '',\n size === 'sm' ? 'text-ink-soft text-xs' : 'text-ink text-sm',\n ]\"\n >\n {{ label }}\n </label>\n\n <slot :id=\"id\" :described-by=\"describedBy\" :invalid=\"Boolean(error)\" :size=\"size\" />\n\n <p v-if=\"error\" :id=\"errorId\" class=\"text-negative text-xs\">{{ error }}</p>\n <p v-else-if=\"hint\" :id=\"hintId\" class=\"text-ink-soft text-xs\">{{ hint }}</p>\n </div>\n</template>\n","<script setup lang=\"ts\" generic=\"T extends string | number | undefined = string\">\nimport type { InputHTMLAttributes } from 'vue'\n\nimport FormField from './FormField.vue'\nimport { useBoundValue } from '../composables/use-bound-value'\n\ndefineOptions({ inheritAttrs: false })\n\n/**\n * A text field with its label, hint and error already wired to it.\n *\n * Every attribute of a native input — `placeholder`, `autocomplete`,\n * `inputmode` — goes to the input itself, not to the wrapper around it.\n */\nconst {\n modelValue = undefined,\n label,\n error = '',\n hint = '',\n type = 'text',\n labelHidden = false,\n size = 'md',\n variant = 'default',\n} = defineProps<\n {\n /** The value, with `v-model`. Its type is whatever you bind. */\n modelValue?: T | undefined\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the input with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * Every type a text field can be, because the ones missing were the ones\n * apps needed: `date` and `search` were hand-written three times each and\n * `url` twice, in files that already imported this component.\n */\n type?:\n | 'text'\n | 'email'\n | 'password'\n | 'number'\n | 'search'\n | 'tel'\n | 'url'\n | 'date'\n | 'time'\n | 'datetime-local'\n | undefined\n /** `sm` for a field inside a row rather than in a form of its own. */\n size?: 'sm' | 'md' | undefined\n /**\n * `unstyled` keeps the wiring and drops the surface.\n *\n * The label, the generated id, `aria-describedby` and the error are what a\n * field is; the border and the height are what it looks like. A search box\n * inside a bordered row, a url field in an editor popover — those places\n * were hand-writing the whole thing to avoid the appearance, and losing the\n * wiring with it.\n *\n * The same reasoning as `BaseButton`'s `unstyled`, and the same test: a\n * primitive is finished when the app can take its behaviour without its\n * paint.\n */\n variant?: 'default' | 'unstyled' | undefined\n /* The input's own attributes — `placeholder`, `autocomplete`, `inputmode` —\n typed for strictTemplates and passed to the input through `$attrs`. Not\n `size` and `type`, which are this component's own. */\n } & /* @vue-ignore */ Omit<InputHTMLAttributes, 'size' | 'type'>\n>()\n\n/* The control keeps 16px at every size, and that is not a rounding of the\n scale — it is the rule. iOS zooms the viewport when a text field it is\n focusing has a font-size under 16px, and the page never zooms back. Both\n phone consumers had written `input { font-size: 16px }` into their base\n layer to stop exactly this, and a `text-sm` utility from here would have\n overridden it in every app at once.\n\n So `size` reaches the label and the spacing, through FormField, and leaves\n the typing target alone. `BaseSelect` is free to shrink: a select opens a\n native picker rather than a caret, and does not trigger the zoom. */\nconst CONTROL_CLASS = 'h-11 text-base'\n\n/**\n * A number field's value is a number.\n *\n * Typed to `string` alone, `type=\"number\"` forced the caller to keep a string\n * ref and convert on both sides of it — and a component you have to wrap in\n * order to use is one you write yourself instead, which is exactly what the\n * first numeric field tried to reach for it did.\n *\n * Generic rather than `string | number`, so the value is whatever the app\n * bound: a `ref('')` gets strings back and a `ref(0)` numbers, and a\n * `string | undefined` from a form library is accepted as it is.\n */\nconst emit = defineEmits<{ 'update:modelValue': [value: T] }>()\n// The destructured prop is typed by T's constraint, not by T itself — a\n// limit of destructuring in a generic component — so it is narrowed here.\nconst model = useBoundValue<T>(\n () => modelValue as T | undefined,\n (value) => emit('update:modelValue', value),\n)\n</script>\n\n<template>\n <FormField :label=\"label\" :error=\"error\" :hint=\"hint\" :label-hidden=\"labelHidden\" :size=\"size\">\n <template #default=\"{ id, describedBy, invalid }\">\n <input\n :id=\"id\"\n v-model=\"model\"\n :type=\"type\"\n :aria-invalid=\"invalid\"\n :aria-describedby=\"describedBy\"\n v-bind=\"$attrs\"\n :class=\"[\n variant === 'unstyled'\n ? ''\n : 'control text-ink rounded-card focus-visible:outline-primary px-3 focus-visible:outline-2 focus-visible:outline-offset-1',\n variant === 'unstyled' ? 'text-base' : CONTROL_CLASS,\n variant !== 'unstyled' && invalid ? 'border-negative' : '',\n ]\"\n />\n </template>\n </FormField>\n</template>\n","<script setup lang=\"ts\" generic=\"T extends string | number | undefined = string\">\nimport type { InputHTMLAttributes } from 'vue'\n\nimport FormField from './FormField.vue'\nimport { useBoundValue } from '../composables/use-bound-value'\n\ndefineOptions({ inheritAttrs: false })\n\n/**\n * A text field with its label, hint and error already wired to it.\n *\n * Every attribute of a native input — `placeholder`, `autocomplete`,\n * `inputmode` — goes to the input itself, not to the wrapper around it.\n */\nconst {\n modelValue = undefined,\n label,\n error = '',\n hint = '',\n type = 'text',\n labelHidden = false,\n size = 'md',\n variant = 'default',\n} = defineProps<\n {\n /** The value, with `v-model`. Its type is whatever you bind. */\n modelValue?: T | undefined\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the input with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * Every type a text field can be, because the ones missing were the ones\n * apps needed: `date` and `search` were hand-written three times each and\n * `url` twice, in files that already imported this component.\n */\n type?:\n | 'text'\n | 'email'\n | 'password'\n | 'number'\n | 'search'\n | 'tel'\n | 'url'\n | 'date'\n | 'time'\n | 'datetime-local'\n | undefined\n /** `sm` for a field inside a row rather than in a form of its own. */\n size?: 'sm' | 'md' | undefined\n /**\n * `unstyled` keeps the wiring and drops the surface.\n *\n * The label, the generated id, `aria-describedby` and the error are what a\n * field is; the border and the height are what it looks like. A search box\n * inside a bordered row, a url field in an editor popover — those places\n * were hand-writing the whole thing to avoid the appearance, and losing the\n * wiring with it.\n *\n * The same reasoning as `BaseButton`'s `unstyled`, and the same test: a\n * primitive is finished when the app can take its behaviour without its\n * paint.\n */\n variant?: 'default' | 'unstyled' | undefined\n /* The input's own attributes — `placeholder`, `autocomplete`, `inputmode` —\n typed for strictTemplates and passed to the input through `$attrs`. Not\n `size` and `type`, which are this component's own. */\n } & /* @vue-ignore */ Omit<InputHTMLAttributes, 'size' | 'type'>\n>()\n\n/* The control keeps 16px at every size, and that is not a rounding of the\n scale — it is the rule. iOS zooms the viewport when a text field it is\n focusing has a font-size under 16px, and the page never zooms back. Both\n phone consumers had written `input { font-size: 16px }` into their base\n layer to stop exactly this, and a `text-sm` utility from here would have\n overridden it in every app at once.\n\n So `size` reaches the label and the spacing, through FormField, and leaves\n the typing target alone. `BaseSelect` is free to shrink: a select opens a\n native picker rather than a caret, and does not trigger the zoom. */\nconst CONTROL_CLASS = 'h-11 text-base'\n\n/**\n * A number field's value is a number.\n *\n * Typed to `string` alone, `type=\"number\"` forced the caller to keep a string\n * ref and convert on both sides of it — and a component you have to wrap in\n * order to use is one you write yourself instead, which is exactly what the\n * first numeric field tried to reach for it did.\n *\n * Generic rather than `string | number`, so the value is whatever the app\n * bound: a `ref('')` gets strings back and a `ref(0)` numbers, and a\n * `string | undefined` from a form library is accepted as it is.\n */\nconst emit = defineEmits<{ 'update:modelValue': [value: T] }>()\n// The destructured prop is typed by T's constraint, not by T itself — a\n// limit of destructuring in a generic component — so it is narrowed here.\nconst model = useBoundValue<T>(\n () => modelValue as T | undefined,\n (value) => emit('update:modelValue', value),\n)\n</script>\n\n<template>\n <FormField :label=\"label\" :error=\"error\" :hint=\"hint\" :label-hidden=\"labelHidden\" :size=\"size\">\n <template #default=\"{ id, describedBy, invalid }\">\n <input\n :id=\"id\"\n v-model=\"model\"\n :type=\"type\"\n :aria-invalid=\"invalid\"\n :aria-describedby=\"describedBy\"\n v-bind=\"$attrs\"\n :class=\"[\n variant === 'unstyled'\n ? ''\n : 'control text-ink rounded-card focus-visible:outline-primary px-3 focus-visible:outline-2 focus-visible:outline-offset-1',\n variant === 'unstyled' ? 'text-base' : CONTROL_CLASS,\n variant !== 'unstyled' && invalid ? 'border-negative' : '',\n ]\"\n />\n </template>\n </FormField>\n</template>\n","<script setup lang=\"ts\">\n/**\n * Sign in with Google, in Google's own clothes.\n *\n * The mark is inlined rather than themed, and that is not an oversight: Google's\n * brand guidelines require their colours, and a reader scanning for the button\n * they already know is scanning for those exact four. It is the one component\n * here that carries a colour value, and the reason is written on the path.\n */\nconst { label, disabled = false } = defineProps<{\n label: string\n /**\n * Greyed out and unclickable while a sign-in is already in flight.\n *\n * A real prop rather than a fallthrough attribute: the button needs to look\n * disabled as well as be it, and a bare `disabled` landing on the element by\n * itself gets the second half only.\n */\n disabled?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <button\n type=\"button\"\n :disabled=\"disabled\"\n class=\"control text-ink rounded-card hover:bg-muted focus-ring flex h-11 w-full items-center justify-center gap-2 text-sm font-medium transition-colors duration-(--duration-fast) active:scale-(--press-scale) disabled:pointer-events-none disabled:opacity-50\"\n @click=\"emit('click')\"\n >\n <!-- Google asks for its own mark, so it is inlined rather than themed. -->\n <svg class=\"size-4\" viewBox=\"0 0 48 48\" aria-hidden=\"true\">\n <path\n fill=\"#EA4335\"\n d=\"M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.7 30.2.5 24 .5 14.6.5 6.5 5.9 2.6 13.7l7.8 6.1C12.3 13.7 17.7 9.5 24 9.5z\"\n />\n <path\n fill=\"#4285F4\"\n d=\"M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.7c-.6 3-2.3 5.6-4.9 7.3l7.6 5.9c4.4-4.1 7.1-10.2 7.1-17.7z\"\n />\n <path\n fill=\"#FBBC05\"\n d=\"M10.4 28.2a14.6 14.6 0 0 1 0-8.4l-7.8-6.1a24 24 0 0 0 0 20.6l7.8-6.1z\"\n />\n <path\n fill=\"#34A853\"\n d=\"M24 47.5c6.2 0 11.5-2 15.4-5.6l-7.6-5.9c-2.1 1.4-4.8 2.3-7.8 2.3-6.3 0-11.7-4.2-13.6-10l-7.8 6.1C6.5 42.1 14.6 47.5 24 47.5z\"\n />\n </svg>\n {{ label }}\n </button>\n</template>\n","<script setup lang=\"ts\">\n/**\n * Sign in with Google, in Google's own clothes.\n *\n * The mark is inlined rather than themed, and that is not an oversight: Google's\n * brand guidelines require their colours, and a reader scanning for the button\n * they already know is scanning for those exact four. It is the one component\n * here that carries a colour value, and the reason is written on the path.\n */\nconst { label, disabled = false } = defineProps<{\n label: string\n /**\n * Greyed out and unclickable while a sign-in is already in flight.\n *\n * A real prop rather than a fallthrough attribute: the button needs to look\n * disabled as well as be it, and a bare `disabled` landing on the element by\n * itself gets the second half only.\n */\n disabled?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <button\n type=\"button\"\n :disabled=\"disabled\"\n class=\"control text-ink rounded-card hover:bg-muted focus-ring flex h-11 w-full items-center justify-center gap-2 text-sm font-medium transition-colors duration-(--duration-fast) active:scale-(--press-scale) disabled:pointer-events-none disabled:opacity-50\"\n @click=\"emit('click')\"\n >\n <!-- Google asks for its own mark, so it is inlined rather than themed. -->\n <svg class=\"size-4\" viewBox=\"0 0 48 48\" aria-hidden=\"true\">\n <path\n fill=\"#EA4335\"\n d=\"M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.7 30.2.5 24 .5 14.6.5 6.5 5.9 2.6 13.7l7.8 6.1C12.3 13.7 17.7 9.5 24 9.5z\"\n />\n <path\n fill=\"#4285F4\"\n d=\"M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.7c-.6 3-2.3 5.6-4.9 7.3l7.6 5.9c4.4-4.1 7.1-10.2 7.1-17.7z\"\n />\n <path\n fill=\"#FBBC05\"\n d=\"M10.4 28.2a14.6 14.6 0 0 1 0-8.4l-7.8-6.1a24 24 0 0 0 0 20.6l7.8-6.1z\"\n />\n <path\n fill=\"#34A853\"\n d=\"M24 47.5c6.2 0 11.5-2 15.4-5.6l-7.6-5.9c-2.1 1.4-4.8 2.3-7.8 2.3-6.3 0-11.7-4.2-13.6-10l-7.8 6.1C6.5 42.1 14.6 47.5 24 47.5z\"\n />\n </svg>\n {{ label }}\n </button>\n</template>\n","import { nextTick, readonly, ref } from 'vue'\n\n/**\n * Saying something to a screen reader when nothing on screen has changed\n * enough to say it.\n *\n * The kit already has a live region in a dozen places — `ToastHost`,\n * `BaseSpinner`, `OfflineBanner`, `CopyButton` — but every one of them\n * belongs to a component that happens to be on screen. What has no home is\n * everything else: a filter that narrows a list to three results, a route\n * that changed under a client-side navigation, a draft that saved itself.\n * On screen all of those are obvious. To a reader they are silence.\n *\n * ── Three things this gets right that a hand-written region does not ──\n *\n * **The region exists before the text does.** A live region inserted into\n * the page together with its first message is usually not announced at\n * all: the reader has nothing to notice a change *to*. So `AnnounceHost`\n * renders both regions empty, from mount, and only the text inside them\n * ever changes. This is the single most common reason a hand-rolled\n * announcement does nothing, and it fails silently on every platform.\n *\n * **The same sentence twice is still said twice.** \"3 results\" after\n * \"3 results\" is not a mutation, so a reader says nothing — and the reader\n * has no idea the filter did anything. The text is cleared and set again\n * on the next tick, which is a change either way.\n *\n * **Polite by default.** `assertive` interrupts whatever is being read,\n * mid-word. That is right for \"connection lost\" and rude for \"saved\", and\n * a region that is always assertive trains the reader to resent it.\n *\n * ── What is deliberately not here ──\n *\n * **No text of its own.** Like everything else in the kit, the sentence is\n * the app's.\n *\n * **Not for anything on screen.** If there is a visible message, it should\n * be the live region — `BaseAlert`, `FormField`, a toast. This is only for\n * what a sighted reader learns from the screen itself.\n *\n * **A singleton, on purpose.** Two hosts would be two regions, and a\n * message would be read twice.\n *\n * @example\n * ```ts\n * const { announce } = useAnnounce()\n * watch(results, (rows) => announce(t('search.found', { count: rows.length })))\n * ```\n */\nexport interface AnnounceOptions {\n /**\n * Interrupt whatever is being read. For something the reader has to know\n * now — a connection lost, a session about to expire — and nothing else.\n */\n assertive?: boolean | undefined\n}\n\nconst polite = ref('')\nconst assertive = ref('')\n\n/** Long enough to be read, short enough that a reader landing in the region\n * later does not find a sentence about something that has since changed. */\nconst CLEAR_AFTER_MS = 7000\n\nlet timer: ReturnType<typeof setTimeout> | undefined\n\n/**\n * Says something once. Render one `AnnounceHost` in the app for it to reach a\n * reader at all.\n */\nexport function announce(message: string, { assertive: urgent = false }: AnnounceOptions = {}) {\n const target = urgent ? assertive : polite\n\n if (timer !== undefined) clearTimeout(timer)\n\n /* Cleared first, then set on the next tick. Setting the same string again\n is not a change, and a reader announces changes — so without this, the\n second \"3 results\" is silence. */\n polite.value = ''\n assertive.value = ''\n\n void nextTick(() => {\n target.value = message\n\n timer = setTimeout(() => {\n target.value = ''\n timer = undefined\n }, CLEAR_AFTER_MS)\n })\n}\n\nexport function useAnnounce() {\n return {\n announce,\n /** What the regions are saying. `AnnounceHost` renders these; apps read them in tests. */\n polite: readonly(polite),\n assertive: readonly(assertive),\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-announce-qM0ZD9Ey.js","names":["$slots","$attrs"],"sources":["../src/utils/redirect.ts","../src/utils/haptics.ts","../src/composables/use-theme.ts","../src/composables/use-online.ts","../src/composables/use-toast.ts","../src/components/BaseAlert.vue","../src/components/BaseAlert.vue","../src/components/FormField.vue","../src/components/FormField.vue","../src/components/BaseInput.vue","../src/components/BaseInput.vue","../src/components/GoogleButton.vue","../src/components/GoogleButton.vue","../src/composables/use-announce.ts"],"sourcesContent":["/**\n * What a router hands back for one query key.\n *\n * Inlined rather than imported from vue-router: the shape is `string | null`\n * either way, and a helper this small should not drag a router into the\n * package's dependencies.\n */\nexport type QueryValue = string | null\n\n/**\n * Resolves a `?redirect=` query value into a safe in-app path.\n *\n * Only same-origin paths are accepted. Anything else falls back to `/`,\n * so a crafted link cannot bounce a user from the real login page to a\n * phishing clone.\n *\n * Pure: takes the query value instead of reading the router, so it also\n * works inside navigation guards and can be unit tested.\n *\n * @param target - Raw `route.query.redirect` value. May be a string, an\n * array (repeated query key), `null`, or `undefined`.\n * @returns A path starting with a single `/`. Defaults to `/`.\n *\n * @example\n * ```ts\n * // in a view\n * await router.push(safeRedirect(route.query.redirect))\n *\n * // in a guard\n * return safeRedirect(to.query.redirect)\n * ```\n *\n * @example\n * ```ts\n * safeRedirect('/week') // '/week'\n * safeRedirect('https://evil.com') // '/'\n * safeRedirect('//evil.com') // '/' (protocol-relative URL)\n * safeRedirect(['/a', '/b']) // '/'\n * safeRedirect(undefined) // '/'\n * ```\n */\nexport function safeRedirect(target: QueryValue | QueryValue[] | undefined): string {\n if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {\n return target\n }\n\n return '/'\n}\n\n/**\n * Where to send somebody back to after signing in, without the fragment.\n *\n * Supabase's implicit OAuth flow hands the browser back with the access and\n * refresh tokens in the URL fragment. A fragment is client-side only — it is\n * never sent to a server. Copied into a query parameter it stops being one:\n * `/login?redirect=/%23access_token=…` is sent on the very next request and\n * lands in the host's access logs, in `Referer` headers and in browser history.\n *\n * So the redirect keeps the path and the query and drops everything from the\n * `#`. There is nothing after it worth returning to anyway.\n *\n * One of the two phone apps had this and the other was passing `fullPath`\n * straight through, which is the leak above with nothing in the way of it. That\n * is the shape of bug a shared kit exists to end: it was fixed once, in the app\n * whose author happened to think of it.\n *\n * @example\n * ```ts\n * toRedirectPath('/ledger?direction=out') // '/ledger?direction=out'\n * toRedirectPath('/#access_token=abc') // '/'\n * ```\n */\nexport function toRedirectPath(fullPath: string): string {\n const [path] = fullPath.split('#')\n\n return path === undefined || path === '' ? '/' : path\n}\n","/**\n * A short vibration for a confirmed tap.\n *\n * Optional chaining is not decoration: iOS Safari has no `vibrate` at all, and\n * calling it unguarded would throw on every marked day.\n *\n * @param duration - Milliseconds. Keep it under ~15ms; longer reads as an alert.\n */\nexport function tapFeedback(duration = 10): void {\n navigator.vibrate?.(duration)\n}\n","import { ref, watch } from 'vue'\nimport type { Ref } from 'vue'\n\n/** What the user asked for; `system` follows the OS. */\nexport type ThemePreference = 'system' | 'light' | 'dark'\n\n/**\n * Namespaced by the app, not by this package.\n *\n * Two rei-kit apps served from the same origin would otherwise share one theme\n * setting — and during development on localhost, they will be.\n */\nlet storageKey = 'rei-theme'\n\nexport function isThemePreference(value: unknown): value is ThemePreference {\n return value === 'system' || value === 'light' || value === 'dark'\n}\n\n/** Reads the stored preference, falling back to `system`. */\nexport function readStoredTheme(): ThemePreference {\n try {\n const stored = localStorage.getItem(storageKey)\n\n return isThemePreference(stored) ? stored : 'system'\n } catch {\n return 'system'\n }\n}\n\nfunction storeTheme(preference: ThemePreference): void {\n try {\n localStorage.setItem(storageKey, preference)\n } catch {\n // Private mode or blocked storage: the choice just will not persist.\n }\n}\n\n/**\n * Does the environment prefer a dark scheme?\n *\n * `matchMedia` is checked for on its own rather than inferred from `document`.\n * Having one does not imply having the other: jsdom supplies a document and no\n * `matchMedia`, so a consumer's component test that so much as mounts something\n * calling `useTheme` threw — and some embedded webviews are the same. Where\n * there is nothing to ask, the answer is no rather than an exception.\n */\nfunction prefersDarkScheme(): boolean {\n return typeof window !== 'undefined' && typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)').matches\n : false\n}\n\n/**\n * Adds or removes `.dark` on `<html>`, resolving `system` against the OS.\n *\n * A no-op without a document. There is no OS preference to read on a server and\n * no `<html>` to write to, so a prerender leaves the class off and the app\n * decides the theme before hydration — see the note in the README.\n */\nexport function applyTheme(preference: ThemePreference): void {\n if (typeof document === 'undefined') return\n\n const isDark = preference === 'dark' || (preference === 'system' && prefersDarkScheme())\n\n document.documentElement.classList.toggle('dark', isDark)\n}\n\n/**\n * The shared preference, created on first use rather than at import.\n *\n * Lazy on purpose: reading storage at import time would lock in the default key\n * before an app had a chance to set its own, leaving the controller reading one\n * key and writing another.\n */\nlet preference: Ref<ThemePreference> | null = null\n\nfunction controller(): Ref<ThemePreference> {\n if (preference) return preference\n\n preference = ref<ThemePreference>(readStoredTheme())\n\n watch(\n preference,\n (next) => {\n storeTheme(next)\n applyTheme(next)\n },\n { immediate: true },\n )\n\n // While on `system`, follow the OS if the user flips it at night. Only where\n // there is something to listen to; see `prefersDarkScheme`.\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {\n if (preference?.value === 'system') applyTheme('system')\n })\n }\n\n return preference\n}\n\n/**\n * Sets where the preference is stored.\n *\n * Safe in either order: called before the first `useTheme()` it simply changes\n * the key, and called after it re-reads under the new one, so the controller\n * never reads from one key while writing to another.\n *\n * @example\n * ```ts\n * setThemeStorageKey('hibi-theme') // once, at startup\n * ```\n */\nexport function setThemeStorageKey(key: string): void {\n storageKey = key\n if (preference) preference.value = readStoredTheme()\n}\n\n/** @returns The shared preference ref; assigning to it stores and applies it. */\nexport function useTheme(): Ref<ThemePreference> {\n return controller()\n}\n","import { onMounted, onUnmounted, readonly, ref } from 'vue'\n\n/**\n * Tracks whether the browser thinks it has a network connection.\n *\n * Note the limit: `navigator.onLine` only reports whether a network interface\n * is up, not whether requests actually succeed. Treat it as a hint for the UI,\n * never as a reason to skip error handling.\n *\n * Listeners are removed on unmount, so the composable is safe to call per view.\n *\n * @returns A readonly ref that flips with the browser's online/offline events.\n *\n * @example\n * ```ts\n * const isOnline = useOnline()\n * // <p v-if=\"!isOnline\">You're offline.</p>\n * ```\n */\nexport function useOnline() {\n const isOnline = ref(true)\n\n function update() {\n isOnline.value = navigator.onLine\n }\n\n onMounted(() => {\n update()\n window.addEventListener('online', update)\n window.addEventListener('offline', update)\n })\n\n onUnmounted(() => {\n window.removeEventListener('online', update)\n window.removeEventListener('offline', update)\n })\n\n return readonly(isOnline)\n}\n","import { readonly, ref } from 'vue'\n\n/**\n * Saying that something happened.\n *\n * The reason this exists is a measurement rather than a preference: the word\n * \"toast\" appeared **zero times** across all three consuming apps. Not because\n * they had decided against it — because there was no mechanism, so every save,\n * every delete and every export finished in silence and the only way to know\n * it had worked was that nothing had visibly broken.\n *\n * ── What is deliberately not here ──\n *\n * **No text.** The kit never knows a sentence. Callers pass the message; a\n * component that called a translator would force one on the app.\n *\n * **Not for form errors.** A field that was rejected says so beside itself,\n * where the reader's eye already is and where it stays until fixed. A toast\n * that disappears after four seconds is the wrong place for something the\n * reader has to act on. Use `BaseAlert` and `FormField` for those; use this\n * for what has already happened.\n *\n * **A singleton, on purpose.** Two hosts would mean two stacks racing for the\n * same corner. The store lives at module scope and `ToastHost` renders it.\n */\nexport type ToastTone = 'info' | 'success' | 'warning' | 'danger'\n\n/**\n * One button on a toast — nearly always \"Undo\".\n *\n * The toast is dismissed when it is pressed, so the handler only has to do\n * the undoing.\n */\nexport interface ToastAction {\n /** The button's text. Already translated. */\n readonly label: string\n readonly onClick: () => void\n}\n\nexport interface Toast {\n readonly id: number\n readonly message: string\n readonly tone: ToastTone\n /** Milliseconds on screen. `0` stays until dismissed. */\n readonly duration: number\n readonly action?: ToastAction | undefined\n}\n\nexport interface ToastOptions {\n /** Milliseconds on screen; `0` stays until dismissed. */\n duration?: number | undefined\n /**\n * A button on the toast. An undo is the kindest confirmation there is: act\n * first, and let the reader take it back, instead of asking \"are you sure\"\n * before every delete.\n */\n action?: ToastAction | undefined\n}\n\n/**\n * Four seconds: long enough to read a short sentence twice, short enough that\n * a second action does not queue behind it.\n */\nconst DEFAULT_DURATION = 4000\n\n/**\n * A failure is read more slowly than a confirmation, and more often twice.\n */\nconst DANGER_DURATION = 7000\n\n/**\n * A toast with a button stays longer: the reader has to read it, decide and\n * reach the button. Four seconds is enough to read \"Deleted\", not to undo it.\n */\nconst ACTION_DURATION = 8000\n\n/**\n * Three at once. A fourth pushes the oldest out rather than growing the stack\n * off the top of the screen — an action that produces ten toasts is a loop,\n * and a loop should not be able to cover the app it is running in.\n */\nconst MAX_VISIBLE = 3\n\nconst items = ref<Toast[]>([])\n\nlet nextId = 0\n\ninterface Countdown {\n handle: ReturnType<typeof setTimeout>\n remaining: number\n startedAt: number\n}\n\nconst countdowns = new Map<number, Countdown>()\n\nfunction clearCountdown(id: number): void {\n const countdown = countdowns.get(id)\n if (countdown === undefined) return\n\n clearTimeout(countdown.handle)\n countdowns.delete(id)\n}\n\n/** Removes a toast, whether it timed out or was dismissed. */\nfunction dismiss(id: number): void {\n clearCountdown(id)\n items.value = items.value.filter((item) => item.id !== id)\n}\n\n/** Removes everything on screen. For a route change, or a sign-out. */\nfunction dismissAll(): void {\n for (const id of countdowns.keys()) clearCountdown(id)\n items.value = []\n}\n\nfunction arm(id: number, remaining: number): void {\n // A timer is a browser thing. On a server there is nothing to time and\n // nothing to see, and arming one would keep the process alive past the last\n // page — which is how a prerender build hangs instead of finishing.\n if (typeof window === 'undefined' || remaining <= 0) return\n\n countdowns.set(id, {\n handle: setTimeout(() => dismiss(id), remaining),\n remaining,\n startedAt: Date.now(),\n })\n}\n\n/**\n * Stops the clock on a toast the reader is pointing at.\n *\n * Somebody who has moved the pointer onto it is reading it, and taking it away\n * mid-sentence is the one thing a notification must not do.\n */\nfunction pause(id: number): void {\n const countdown = countdowns.get(id)\n if (countdown === undefined) return\n\n clearTimeout(countdown.handle)\n countdowns.set(id, {\n ...countdown,\n remaining: Math.max(0, countdown.remaining - (Date.now() - countdown.startedAt)),\n })\n}\n\n/** Starts it again, from where it stopped rather than from the beginning. */\nfunction resume(id: number): void {\n const countdown = countdowns.get(id)\n if (countdown === undefined) return\n\n arm(id, countdown.remaining)\n}\n\nfunction push(tone: ToastTone, message: string, options: ToastOptions = {}): number {\n const id = ++nextId\n const duration =\n options.duration ??\n (options.action ? ACTION_DURATION : tone === 'danger' ? DANGER_DURATION : DEFAULT_DURATION)\n\n const next = [...items.value, { id, message, tone, duration, action: options.action }]\n\n while (next.length > MAX_VISIBLE) {\n const oldest = next.shift()\n if (oldest !== undefined) clearCountdown(oldest.id)\n }\n\n items.value = next\n arm(id, duration)\n\n return id\n}\n\n/**\n * The stack, and the four ways to add to it.\n *\n * @example\n * ```ts\n * const toast = useToast()\n *\n * toast.success(t('habit.saved'))\n * toast.danger(t('common.failed'), { duration: 0 }) // stays until dismissed\n *\n * const id = toast.info(t('export.preparing'), { duration: 0 })\n * toast.dismiss(id)\n *\n * toast.success(t('entry.deleted'), {\n * action: { label: t('common.undo'), onClick: () => restore(entry) },\n * })\n * ```\n */\nexport function useToast() {\n return {\n /** Every toast on screen, oldest first. `ToastHost` renders this. */\n toasts: readonly(items),\n info: (message: string, options?: ToastOptions) => push('info', message, options),\n success: (message: string, options?: ToastOptions) => push('success', message, options),\n warning: (message: string, options?: ToastOptions) => push('warning', message, options),\n danger: (message: string, options?: ToastOptions) => push('danger', message, options),\n dismiss,\n dismissAll,\n pause,\n resume,\n }\n}\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * A message the reader has to take in before carrying on.\n *\n * Roles rather than colours, like everything else here: `info` is neutral,\n * `success` confirms, `warning` is a condition to know about, `danger` is\n * something that went wrong or is about to. A component that took a hex would\n * be a component that ignores the theme, and the theme is the whole reason the\n * kit exists.\n *\n * `assertive` decides how a screen reader treats it: a failed save interrupts,\n * a note about a form field waits its turn. Getting this wrong is invisible on\n * screen and rude in a screen reader, which is why it is a prop and not a\n * guess.\n */\nconst { tone = 'info', assertive = false } = defineProps<{\n tone?: 'info' | 'success' | 'warning' | 'danger' | undefined\n /** Announce immediately, interrupting. For failures the reader must act on. */\n assertive?: boolean | undefined\n}>()\n\nconst TONES = {\n info: 'border-hair bg-muted/40 text-ink',\n success: 'border-positive/35 bg-positive/8 text-ink',\n warning: 'border-warning/40 bg-warning/8 text-ink',\n danger: 'border-negative/35 bg-negative/8 text-ink',\n} as const\n\nconst MARKS = {\n info: 'bg-ink-soft/15 text-ink-soft',\n success: 'bg-positive/15 text-positive',\n warning: 'bg-warning/15 text-warning',\n danger: 'bg-negative/15 text-negative',\n} as const\n\nconst skin = computed(() => TONES[tone])\nconst mark = computed(() => MARKS[tone])\n</script>\n\n<template>\n <div\n class=\"rounded-card flex items-start gap-3 border px-4 py-3.5 text-sm leading-relaxed\"\n :class=\"skin\"\n :role=\"assertive ? 'alert' : 'status'\"\n :aria-live=\"assertive ? 'assertive' : 'polite'\"\n >\n <span\n v-if=\"$slots.mark\"\n class=\"mt-px grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold\"\n :class=\"mark\"\n aria-hidden=\"true\"\n >\n <slot name=\"mark\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p v-if=\"$slots.title\" class=\"text-ink font-semibold\">\n <slot name=\"title\" />\n </p>\n <div :class=\"$slots.title ? 'mt-1' : ''\"><slot /></div>\n </div>\n\n <slot name=\"action\" />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * A message the reader has to take in before carrying on.\n *\n * Roles rather than colours, like everything else here: `info` is neutral,\n * `success` confirms, `warning` is a condition to know about, `danger` is\n * something that went wrong or is about to. A component that took a hex would\n * be a component that ignores the theme, and the theme is the whole reason the\n * kit exists.\n *\n * `assertive` decides how a screen reader treats it: a failed save interrupts,\n * a note about a form field waits its turn. Getting this wrong is invisible on\n * screen and rude in a screen reader, which is why it is a prop and not a\n * guess.\n */\nconst { tone = 'info', assertive = false } = defineProps<{\n tone?: 'info' | 'success' | 'warning' | 'danger' | undefined\n /** Announce immediately, interrupting. For failures the reader must act on. */\n assertive?: boolean | undefined\n}>()\n\nconst TONES = {\n info: 'border-hair bg-muted/40 text-ink',\n success: 'border-positive/35 bg-positive/8 text-ink',\n warning: 'border-warning/40 bg-warning/8 text-ink',\n danger: 'border-negative/35 bg-negative/8 text-ink',\n} as const\n\nconst MARKS = {\n info: 'bg-ink-soft/15 text-ink-soft',\n success: 'bg-positive/15 text-positive',\n warning: 'bg-warning/15 text-warning',\n danger: 'bg-negative/15 text-negative',\n} as const\n\nconst skin = computed(() => TONES[tone])\nconst mark = computed(() => MARKS[tone])\n</script>\n\n<template>\n <div\n class=\"rounded-card flex items-start gap-3 border px-4 py-3.5 text-sm leading-relaxed\"\n :class=\"skin\"\n :role=\"assertive ? 'alert' : 'status'\"\n :aria-live=\"assertive ? 'assertive' : 'polite'\"\n >\n <span\n v-if=\"$slots.mark\"\n class=\"mt-px grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold\"\n :class=\"mark\"\n aria-hidden=\"true\"\n >\n <slot name=\"mark\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p v-if=\"$slots.title\" class=\"text-ink font-semibold\">\n <slot name=\"title\" />\n </p>\n <div :class=\"$slots.title ? 'mt-1' : ''\"><slot /></div>\n </div>\n\n <slot name=\"action\" />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, useId } from 'vue'\n\n/**\n * A label, a hint, an error, and the wiring between them.\n *\n * This was inside `BaseInput`, which is why the kit had one form control\n * instead of five. The hard part of a field is not the `<input>` — it is\n * generating an id, pointing the label at it, deciding whether the description\n * is the hint or the error, and telling assistive tech which one to read. That\n * is identical for a select, a textarea and an input, and every app that\n * needed one of the other two wrote the whole thing again.\n *\n * The control comes in through the slot and is handed what it needs to be\n * described. It is a slot rather than a prop so the field never has to know\n * what it is wrapping.\n *\n * @example\n * ```vue\n * <FormField :label=\"t('profile.name')\" :error=\"errors.name\">\n * <template #default=\"{ id, describedBy, invalid }\">\n * <input :id=\"id\" :aria-describedby=\"describedBy\" :aria-invalid=\"invalid\" />\n * </template>\n * </FormField>\n * ```\n */\nconst {\n label,\n error = '',\n hint = '',\n labelHidden = false,\n fieldId = undefined,\n size = 'md',\n} = defineProps<{\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * `sm` for a control that sits inside something else — a toolbar, a filter\n * row, a settings line — rather than in a form of its own.\n *\n * It exists because every hand-written select in all three apps was the\n * small one, and the kit only had the large one. A part is not reusable if\n * reaching for it costs a size somebody chose on purpose.\n */\n size?: 'sm' | 'md' | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the control with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * The control's `id`, when the app needs to address it from outside —\n * an `ErrorSummary` linking to the field it is about, a label elsewhere\n * on the page. Generated when it is not given, which is almost always.\n */\n fieldId?: string | undefined\n}>()\n\nconst generated = useId()\nconst id = computed(() => fieldId ?? generated)\nconst errorId = computed(() => `${id.value}-error`)\nconst hintId = computed(() => `${id.value}-hint`)\n\n/* One description at a time, and the error wins. Announcing the hint as well\n buries the reason the field was rejected under advice the reader has already\n had. */\nconst describedBy = computed(() => {\n if (error) return errorId.value\n if (hint) return hintId.value\n return undefined\n})\n</script>\n\n<template>\n <div class=\"flex flex-col\" :class=\"size === 'sm' ? 'gap-1' : 'gap-1.5'\">\n <label\n :for=\"id\"\n class=\"font-medium\"\n :class=\"[\n labelHidden ? 'sr-only' : '',\n size === 'sm' ? 'text-ink-soft text-xs' : 'text-ink text-sm',\n ]\"\n >\n {{ label }}\n </label>\n\n <slot :id=\"id\" :described-by=\"describedBy\" :invalid=\"Boolean(error)\" :size=\"size\" />\n\n <p v-if=\"error\" :id=\"errorId\" class=\"text-negative text-xs\">{{ error }}</p>\n <p v-else-if=\"hint\" :id=\"hintId\" class=\"text-ink-soft text-xs\">{{ hint }}</p>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, useId } from 'vue'\n\n/**\n * A label, a hint, an error, and the wiring between them.\n *\n * This was inside `BaseInput`, which is why the kit had one form control\n * instead of five. The hard part of a field is not the `<input>` — it is\n * generating an id, pointing the label at it, deciding whether the description\n * is the hint or the error, and telling assistive tech which one to read. That\n * is identical for a select, a textarea and an input, and every app that\n * needed one of the other two wrote the whole thing again.\n *\n * The control comes in through the slot and is handed what it needs to be\n * described. It is a slot rather than a prop so the field never has to know\n * what it is wrapping.\n *\n * @example\n * ```vue\n * <FormField :label=\"t('profile.name')\" :error=\"errors.name\">\n * <template #default=\"{ id, describedBy, invalid }\">\n * <input :id=\"id\" :aria-describedby=\"describedBy\" :aria-invalid=\"invalid\" />\n * </template>\n * </FormField>\n * ```\n */\nconst {\n label,\n error = '',\n hint = '',\n labelHidden = false,\n fieldId = undefined,\n size = 'md',\n} = defineProps<{\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * `sm` for a control that sits inside something else — a toolbar, a filter\n * row, a settings line — rather than in a form of its own.\n *\n * It exists because every hand-written select in all three apps was the\n * small one, and the kit only had the large one. A part is not reusable if\n * reaching for it costs a size somebody chose on purpose.\n */\n size?: 'sm' | 'md' | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the control with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * The control's `id`, when the app needs to address it from outside —\n * an `ErrorSummary` linking to the field it is about, a label elsewhere\n * on the page. Generated when it is not given, which is almost always.\n */\n fieldId?: string | undefined\n}>()\n\nconst generated = useId()\nconst id = computed(() => fieldId ?? generated)\nconst errorId = computed(() => `${id.value}-error`)\nconst hintId = computed(() => `${id.value}-hint`)\n\n/* One description at a time, and the error wins. Announcing the hint as well\n buries the reason the field was rejected under advice the reader has already\n had. */\nconst describedBy = computed(() => {\n if (error) return errorId.value\n if (hint) return hintId.value\n return undefined\n})\n</script>\n\n<template>\n <div class=\"flex flex-col\" :class=\"size === 'sm' ? 'gap-1' : 'gap-1.5'\">\n <label\n :for=\"id\"\n class=\"font-medium\"\n :class=\"[\n labelHidden ? 'sr-only' : '',\n size === 'sm' ? 'text-ink-soft text-xs' : 'text-ink text-sm',\n ]\"\n >\n {{ label }}\n </label>\n\n <slot :id=\"id\" :described-by=\"describedBy\" :invalid=\"Boolean(error)\" :size=\"size\" />\n\n <p v-if=\"error\" :id=\"errorId\" class=\"text-negative text-xs\">{{ error }}</p>\n <p v-else-if=\"hint\" :id=\"hintId\" class=\"text-ink-soft text-xs\">{{ hint }}</p>\n </div>\n</template>\n","<script setup lang=\"ts\" generic=\"T extends string | number | undefined = string\">\nimport type { InputHTMLAttributes } from 'vue'\n\nimport FormField from './FormField.vue'\nimport { useBoundValue } from '../composables/use-bound-value'\n\ndefineOptions({ inheritAttrs: false })\n\n/**\n * A text field with its label, hint and error already wired to it.\n *\n * Every attribute of a native input — `placeholder`, `autocomplete`,\n * `inputmode` — goes to the input itself, not to the wrapper around it.\n */\nconst {\n modelValue = undefined,\n label,\n error = '',\n hint = '',\n type = 'text',\n labelHidden = false,\n size = 'md',\n variant = 'default',\n} = defineProps<\n {\n /** The value, with `v-model`. Its type is whatever you bind. */\n modelValue?: T | undefined\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the input with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * Every type a text field can be, because the ones missing were the ones\n * apps needed: `date` and `search` were hand-written three times each and\n * `url` twice, in files that already imported this component.\n */\n type?:\n | 'text'\n | 'email'\n | 'password'\n | 'number'\n | 'search'\n | 'tel'\n | 'url'\n | 'date'\n | 'time'\n | 'datetime-local'\n | undefined\n /** `sm` for a field inside a row rather than in a form of its own. */\n size?: 'sm' | 'md' | undefined\n /**\n * `unstyled` keeps the wiring and drops the surface.\n *\n * The label, the generated id, `aria-describedby` and the error are what a\n * field is; the border and the height are what it looks like. A search box\n * inside a bordered row, a url field in an editor popover — those places\n * were hand-writing the whole thing to avoid the appearance, and losing the\n * wiring with it.\n *\n * The same reasoning as `BaseButton`'s `unstyled`, and the same test: a\n * primitive is finished when the app can take its behaviour without its\n * paint.\n */\n variant?: 'default' | 'unstyled' | undefined\n /* The input's own attributes — `placeholder`, `autocomplete`, `inputmode` —\n typed for strictTemplates and passed to the input through `$attrs`. Not\n `size` and `type`, which are this component's own. */\n } & /* @vue-ignore */ Omit<InputHTMLAttributes, 'size' | 'type'>\n>()\n\n/* The control keeps 16px at every size, and that is not a rounding of the\n scale — it is the rule. iOS zooms the viewport when a text field it is\n focusing has a font-size under 16px, and the page never zooms back. Both\n phone consumers had written `input { font-size: 16px }` into their base\n layer to stop exactly this, and a `text-sm` utility from here would have\n overridden it in every app at once.\n\n So `size` reaches the label and the spacing, through FormField, and leaves\n the typing target alone. `BaseSelect` is free to shrink: a select opens a\n native picker rather than a caret, and does not trigger the zoom. */\nconst CONTROL_CLASS = 'h-11 text-base'\n\n/**\n * A number field's value is a number.\n *\n * Typed to `string` alone, `type=\"number\"` forced the caller to keep a string\n * ref and convert on both sides of it — and a component you have to wrap in\n * order to use is one you write yourself instead, which is exactly what the\n * first numeric field tried to reach for it did.\n *\n * Generic rather than `string | number`, so the value is whatever the app\n * bound: a `ref('')` gets strings back and a `ref(0)` numbers, and a\n * `string | undefined` from a form library is accepted as it is.\n */\nconst emit = defineEmits<{ 'update:modelValue': [value: T] }>()\n// The destructured prop is typed by T's constraint, not by T itself — a\n// limit of destructuring in a generic component — so it is narrowed here.\nconst model = useBoundValue<T>(\n () => modelValue as T | undefined,\n (value) => emit('update:modelValue', value),\n)\n</script>\n\n<template>\n <FormField :label=\"label\" :error=\"error\" :hint=\"hint\" :label-hidden=\"labelHidden\" :size=\"size\">\n <template #default=\"{ id, describedBy, invalid }\">\n <input\n :id=\"id\"\n v-model=\"model\"\n :type=\"type\"\n :aria-invalid=\"invalid\"\n :aria-describedby=\"describedBy\"\n v-bind=\"$attrs\"\n :class=\"[\n variant === 'unstyled'\n ? ''\n : 'control text-ink rounded-card focus-visible:outline-primary px-3 focus-visible:outline-2 focus-visible:outline-offset-1',\n variant === 'unstyled' ? 'text-base' : CONTROL_CLASS,\n variant !== 'unstyled' && invalid ? 'border-negative' : '',\n ]\"\n />\n </template>\n </FormField>\n</template>\n","<script setup lang=\"ts\" generic=\"T extends string | number | undefined = string\">\nimport type { InputHTMLAttributes } from 'vue'\n\nimport FormField from './FormField.vue'\nimport { useBoundValue } from '../composables/use-bound-value'\n\ndefineOptions({ inheritAttrs: false })\n\n/**\n * A text field with its label, hint and error already wired to it.\n *\n * Every attribute of a native input — `placeholder`, `autocomplete`,\n * `inputmode` — goes to the input itself, not to the wrapper around it.\n */\nconst {\n modelValue = undefined,\n label,\n error = '',\n hint = '',\n type = 'text',\n labelHidden = false,\n size = 'md',\n variant = 'default',\n} = defineProps<\n {\n /** The value, with `v-model`. Its type is whatever you bind. */\n modelValue?: T | undefined\n label: string\n error?: string | undefined\n hint?: string | undefined\n /**\n * Hides the label visually but keeps it for assistive tech. For fields whose\n * surrounding row already names them — dropping the label entirely would\n * leave the input with no accessible name at all.\n */\n labelHidden?: boolean | undefined\n /**\n * Every type a text field can be, because the ones missing were the ones\n * apps needed: `date` and `search` were hand-written three times each and\n * `url` twice, in files that already imported this component.\n */\n type?:\n | 'text'\n | 'email'\n | 'password'\n | 'number'\n | 'search'\n | 'tel'\n | 'url'\n | 'date'\n | 'time'\n | 'datetime-local'\n | undefined\n /** `sm` for a field inside a row rather than in a form of its own. */\n size?: 'sm' | 'md' | undefined\n /**\n * `unstyled` keeps the wiring and drops the surface.\n *\n * The label, the generated id, `aria-describedby` and the error are what a\n * field is; the border and the height are what it looks like. A search box\n * inside a bordered row, a url field in an editor popover — those places\n * were hand-writing the whole thing to avoid the appearance, and losing the\n * wiring with it.\n *\n * The same reasoning as `BaseButton`'s `unstyled`, and the same test: a\n * primitive is finished when the app can take its behaviour without its\n * paint.\n */\n variant?: 'default' | 'unstyled' | undefined\n /* The input's own attributes — `placeholder`, `autocomplete`, `inputmode` —\n typed for strictTemplates and passed to the input through `$attrs`. Not\n `size` and `type`, which are this component's own. */\n } & /* @vue-ignore */ Omit<InputHTMLAttributes, 'size' | 'type'>\n>()\n\n/* The control keeps 16px at every size, and that is not a rounding of the\n scale — it is the rule. iOS zooms the viewport when a text field it is\n focusing has a font-size under 16px, and the page never zooms back. Both\n phone consumers had written `input { font-size: 16px }` into their base\n layer to stop exactly this, and a `text-sm` utility from here would have\n overridden it in every app at once.\n\n So `size` reaches the label and the spacing, through FormField, and leaves\n the typing target alone. `BaseSelect` is free to shrink: a select opens a\n native picker rather than a caret, and does not trigger the zoom. */\nconst CONTROL_CLASS = 'h-11 text-base'\n\n/**\n * A number field's value is a number.\n *\n * Typed to `string` alone, `type=\"number\"` forced the caller to keep a string\n * ref and convert on both sides of it — and a component you have to wrap in\n * order to use is one you write yourself instead, which is exactly what the\n * first numeric field tried to reach for it did.\n *\n * Generic rather than `string | number`, so the value is whatever the app\n * bound: a `ref('')` gets strings back and a `ref(0)` numbers, and a\n * `string | undefined` from a form library is accepted as it is.\n */\nconst emit = defineEmits<{ 'update:modelValue': [value: T] }>()\n// The destructured prop is typed by T's constraint, not by T itself — a\n// limit of destructuring in a generic component — so it is narrowed here.\nconst model = useBoundValue<T>(\n () => modelValue as T | undefined,\n (value) => emit('update:modelValue', value),\n)\n</script>\n\n<template>\n <FormField :label=\"label\" :error=\"error\" :hint=\"hint\" :label-hidden=\"labelHidden\" :size=\"size\">\n <template #default=\"{ id, describedBy, invalid }\">\n <input\n :id=\"id\"\n v-model=\"model\"\n :type=\"type\"\n :aria-invalid=\"invalid\"\n :aria-describedby=\"describedBy\"\n v-bind=\"$attrs\"\n :class=\"[\n variant === 'unstyled'\n ? ''\n : 'control text-ink rounded-card focus-visible:outline-primary px-3 focus-visible:outline-2 focus-visible:outline-offset-1',\n variant === 'unstyled' ? 'text-base' : CONTROL_CLASS,\n variant !== 'unstyled' && invalid ? 'border-negative' : '',\n ]\"\n />\n </template>\n </FormField>\n</template>\n","<script setup lang=\"ts\">\n/**\n * Sign in with Google, in Google's own clothes.\n *\n * The mark is inlined rather than themed, and that is not an oversight: Google's\n * brand guidelines require their colours, and a reader scanning for the button\n * they already know is scanning for those exact four. It is the one component\n * here that carries a colour value, and the reason is written on the path.\n */\nconst { label, disabled = false } = defineProps<{\n label: string\n /**\n * Greyed out and unclickable while a sign-in is already in flight.\n *\n * A real prop rather than a fallthrough attribute: the button needs to look\n * disabled as well as be it, and a bare `disabled` landing on the element by\n * itself gets the second half only.\n */\n disabled?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <button\n type=\"button\"\n :disabled=\"disabled\"\n class=\"control text-ink rounded-card hover:bg-muted focus-ring flex h-11 w-full items-center justify-center gap-2 text-sm font-medium transition-colors duration-(--duration-fast) active:scale-(--press-scale) disabled:pointer-events-none disabled:opacity-50\"\n @click=\"emit('click')\"\n >\n <!-- Google asks for its own mark, so it is inlined rather than themed. -->\n <svg class=\"size-4\" viewBox=\"0 0 48 48\" aria-hidden=\"true\">\n <path\n fill=\"#EA4335\"\n d=\"M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.7 30.2.5 24 .5 14.6.5 6.5 5.9 2.6 13.7l7.8 6.1C12.3 13.7 17.7 9.5 24 9.5z\"\n />\n <path\n fill=\"#4285F4\"\n d=\"M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.7c-.6 3-2.3 5.6-4.9 7.3l7.6 5.9c4.4-4.1 7.1-10.2 7.1-17.7z\"\n />\n <path\n fill=\"#FBBC05\"\n d=\"M10.4 28.2a14.6 14.6 0 0 1 0-8.4l-7.8-6.1a24 24 0 0 0 0 20.6l7.8-6.1z\"\n />\n <path\n fill=\"#34A853\"\n d=\"M24 47.5c6.2 0 11.5-2 15.4-5.6l-7.6-5.9c-2.1 1.4-4.8 2.3-7.8 2.3-6.3 0-11.7-4.2-13.6-10l-7.8 6.1C6.5 42.1 14.6 47.5 24 47.5z\"\n />\n </svg>\n {{ label }}\n </button>\n</template>\n","<script setup lang=\"ts\">\n/**\n * Sign in with Google, in Google's own clothes.\n *\n * The mark is inlined rather than themed, and that is not an oversight: Google's\n * brand guidelines require their colours, and a reader scanning for the button\n * they already know is scanning for those exact four. It is the one component\n * here that carries a colour value, and the reason is written on the path.\n */\nconst { label, disabled = false } = defineProps<{\n label: string\n /**\n * Greyed out and unclickable while a sign-in is already in flight.\n *\n * A real prop rather than a fallthrough attribute: the button needs to look\n * disabled as well as be it, and a bare `disabled` landing on the element by\n * itself gets the second half only.\n */\n disabled?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <button\n type=\"button\"\n :disabled=\"disabled\"\n class=\"control text-ink rounded-card hover:bg-muted focus-ring flex h-11 w-full items-center justify-center gap-2 text-sm font-medium transition-colors duration-(--duration-fast) active:scale-(--press-scale) disabled:pointer-events-none disabled:opacity-50\"\n @click=\"emit('click')\"\n >\n <!-- Google asks for its own mark, so it is inlined rather than themed. -->\n <svg class=\"size-4\" viewBox=\"0 0 48 48\" aria-hidden=\"true\">\n <path\n fill=\"#EA4335\"\n d=\"M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.7 30.2.5 24 .5 14.6.5 6.5 5.9 2.6 13.7l7.8 6.1C12.3 13.7 17.7 9.5 24 9.5z\"\n />\n <path\n fill=\"#4285F4\"\n d=\"M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.7c-.6 3-2.3 5.6-4.9 7.3l7.6 5.9c4.4-4.1 7.1-10.2 7.1-17.7z\"\n />\n <path\n fill=\"#FBBC05\"\n d=\"M10.4 28.2a14.6 14.6 0 0 1 0-8.4l-7.8-6.1a24 24 0 0 0 0 20.6l7.8-6.1z\"\n />\n <path\n fill=\"#34A853\"\n d=\"M24 47.5c6.2 0 11.5-2 15.4-5.6l-7.6-5.9c-2.1 1.4-4.8 2.3-7.8 2.3-6.3 0-11.7-4.2-13.6-10l-7.8 6.1C6.5 42.1 14.6 47.5 24 47.5z\"\n />\n </svg>\n {{ label }}\n </button>\n</template>\n","import { nextTick, readonly, ref } from 'vue'\n\n/**\n * Saying something to a screen reader when nothing on screen has changed\n * enough to say it.\n *\n * The kit already has a live region in a dozen places — `ToastHost`,\n * `BaseSpinner`, `OfflineBanner`, `CopyButton` — but every one of them\n * belongs to a component that happens to be on screen. What has no home is\n * everything else: a filter that narrows a list to three results, a route\n * that changed under a client-side navigation, a draft that saved itself.\n * On screen all of those are obvious. To a reader they are silence.\n *\n * ── Three things this gets right that a hand-written region does not ──\n *\n * **The region exists before the text does.** A live region inserted into\n * the page together with its first message is usually not announced at\n * all: the reader has nothing to notice a change *to*. So `AnnounceHost`\n * renders both regions empty, from mount, and only the text inside them\n * ever changes. This is the single most common reason a hand-rolled\n * announcement does nothing, and it fails silently on every platform.\n *\n * **The same sentence twice is still said twice.** \"3 results\" after\n * \"3 results\" is not a mutation, so a reader says nothing — and the reader\n * has no idea the filter did anything. The text is cleared and set again\n * on the next tick, which is a change either way.\n *\n * **Polite by default.** `assertive` interrupts whatever is being read,\n * mid-word. That is right for \"connection lost\" and rude for \"saved\", and\n * a region that is always assertive trains the reader to resent it.\n *\n * ── What is deliberately not here ──\n *\n * **No text of its own.** Like everything else in the kit, the sentence is\n * the app's.\n *\n * **Not for anything on screen.** If there is a visible message, it should\n * be the live region — `BaseAlert`, `FormField`, a toast. This is only for\n * what a sighted reader learns from the screen itself.\n *\n * **A singleton, on purpose.** Two hosts would be two regions, and a\n * message would be read twice.\n *\n * @example\n * ```ts\n * const { announce } = useAnnounce()\n * watch(results, (rows) => announce(t('search.found', { count: rows.length })))\n * ```\n */\nexport interface AnnounceOptions {\n /**\n * Interrupt whatever is being read. For something the reader has to know\n * now — a connection lost, a session about to expire — and nothing else.\n */\n assertive?: boolean | undefined\n}\n\nconst polite = ref('')\nconst assertive = ref('')\n\n/** Long enough to be read, short enough that a reader landing in the region\n * later does not find a sentence about something that has since changed. */\nconst CLEAR_AFTER_MS = 7000\n\nlet timer: ReturnType<typeof setTimeout> | undefined\n\n/**\n * Says something once. Render one `AnnounceHost` in the app for it to reach a\n * reader at all.\n */\nexport function announce(message: string, { assertive: urgent = false }: AnnounceOptions = {}) {\n const target = urgent ? assertive : polite\n\n if (timer !== undefined) clearTimeout(timer)\n\n /* Cleared first, then set on the next tick. Setting the same string again\n is not a change, and a reader announces changes — so without this, the\n second \"3 results\" is silence. */\n polite.value = ''\n assertive.value = ''\n\n void nextTick(() => {\n target.value = message\n\n timer = setTimeout(() => {\n target.value = ''\n timer = undefined\n }, CLEAR_AFTER_MS)\n })\n}\n\nexport function useAnnounce() {\n return {\n announce,\n /** What the regions are saying. `AnnounceHost` renders these; apps read them in tests. */\n polite: readonly(polite),\n assertive: readonly(assertive),\n }\n}\n"],"mappings":"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"}
|
|
@@ -18,3 +18,42 @@ export type TextDirection = 'ltr' | 'rtl';
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
export declare function textDirection(locale: string): TextDirection;
|
|
21
|
+
/**
|
|
22
|
+
* Which way the text around an element runs.
|
|
23
|
+
*
|
|
24
|
+
* The companion to `textDirection`, and the other end of it: that one asks
|
|
25
|
+
* a locale, this one asks the page. An element inside a quoted passage may
|
|
26
|
+
* run the other way from the document it is in, so the nearest `dir`
|
|
27
|
+
* attribute wins and the document is the fallback.
|
|
28
|
+
*
|
|
29
|
+
* The attribute rather than `getComputedStyle().direction`: the computed
|
|
30
|
+
* value is the real answer, but it is inherited through the cascade, and
|
|
31
|
+
* jsdom does not inherit it — so the tests that exist to prove the
|
|
32
|
+
* right-to-left behaviour would all pass with the behaviour removed. What
|
|
33
|
+
* actually sets the direction in this kit is the attribute, in one place,
|
|
34
|
+
* on `<html>`, written by `createI18nRuntime`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function elementDirection(from: Element | null | undefined): TextDirection;
|
|
37
|
+
/**
|
|
38
|
+
* `ArrowLeft` and `ArrowRight` as a direction of travel: `-1` back through
|
|
39
|
+
* a list, `1` on through it, `0` for any other key.
|
|
40
|
+
*
|
|
41
|
+
* They are mirrored where the writing is, which is not a nicety — it is the
|
|
42
|
+
* whole of what the arrows mean. A row of tabs in Arabic runs right to
|
|
43
|
+
* left, so the tab to the *left* of the current one is the next one, and a
|
|
44
|
+
* handler that reads `ArrowRight` as "forward" walks backwards through
|
|
45
|
+
* every roving-tabindex control in the kit at once. WAI-ARIA says so
|
|
46
|
+
* explicitly, and it is invisible to every check: the code type-checks, the
|
|
47
|
+
* styles are all logical, and the keys are the same keys.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* function onKeydown(event: KeyboardEvent) {
|
|
52
|
+
* const step = horizontalStep(event.key, event.currentTarget as Element)
|
|
53
|
+
* if (step === 0) return
|
|
54
|
+
* event.preventDefault()
|
|
55
|
+
* moveTo(index + step)
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export declare function horizontalStep(key: string, from: Element | null | undefined): -1 | 0 | 1;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the keyboard can reach, as one selector.
|
|
3
|
+
*
|
|
4
|
+
* Three parts of the kit ask this question and each had written its own
|
|
5
|
+
* answer, which is how three answers came to differ: the dialog trap and
|
|
6
|
+
* the popover agreed, and `ScrollArea` left out `:not([disabled])`, so a
|
|
7
|
+
* box holding nothing but disabled buttons counted as reachable and was
|
|
8
|
+
* denied the focus stop that would have been its only way in.
|
|
9
|
+
*
|
|
10
|
+
* `summary` belongs here for the same reason it belonged in `ScrollArea`:
|
|
11
|
+
* a `<details>` heading takes focus, and a dialog whose trap skipped one
|
|
12
|
+
* would let Tab out of itself.
|
|
13
|
+
*
|
|
14
|
+
* Two things it deliberately does not do. It does not check whether an
|
|
15
|
+
* element is visible or inside an `inert` subtree — a selector cannot, and
|
|
16
|
+
* every caller here is already scoped to something on screen. And it is
|
|
17
|
+
* not what a roving-tabindex container should use to find its own
|
|
18
|
+
* controls: `[tabindex]:not([tabindex="-1"])` matches exactly the one
|
|
19
|
+
* control such a container has already put in the tab order, so it would
|
|
20
|
+
* find one item and then stop. `BaseToolbar` says so where it keeps its
|
|
21
|
+
* own.
|
|
22
|
+
*/
|
|
23
|
+
export declare const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex=\"-1\"])";
|
|
24
|
+
/** The reachable elements inside `root`, in document order. */
|
|
25
|
+
export declare function focusableWithin(root: Element | null | undefined): HTMLElement[];
|
|
@@ -43,6 +43,7 @@ type __VLS_Props = {
|
|
|
43
43
|
openDelay?: number | undefined;
|
|
44
44
|
/** How long it survives the pointer leaving, so the card can be reached. */
|
|
45
45
|
closeDelay?: number | undefined;
|
|
46
|
+
/** Which side it prefers. It opens on the other when there is more room there. */
|
|
46
47
|
placement?: 'top' | 'bottom' | undefined;
|
|
47
48
|
};
|
|
48
49
|
type __VLS_Slots = {
|
package/dist/web.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { a as useMediaQuery, i as
|
|
1
|
+
import { a as BaseCheckbox_default, c as useBoundValue, d as ensureSheetRoot, n as elementDirection, o as BaseSheet_default, r as horizontalStep, s as inertOutside, t as focusTarget } from "./focus-target-DRgd8UnQ.js";
|
|
2
|
+
import { a as BaseSkeleton_default, c as useMediaQuery, i as focusableWithin, n as ScrollArea_default, o as useMenuKeys, s as useAnchoredPanel, t as BaseListbox_default } from "./BaseListbox-BQoSwYdJ.js";
|
|
3
3
|
import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
|
|
4
4
|
import { t as BaseButton_default } from "./BaseButton-RN2an975.js";
|
|
5
5
|
import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, openBlock, ref, renderList, renderSlot, resolveDynamicComponent, shallowRef, toDisplayString, unref, useId, useModel, useTemplateRef, vModelText, vShow, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
|
|
@@ -310,6 +310,14 @@ var BaseHoverCard_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @
|
|
|
310
310
|
setup(__props) {
|
|
311
311
|
const id = useId();
|
|
312
312
|
const open = ref(false);
|
|
313
|
+
const root = ref(null);
|
|
314
|
+
const panel = ref(null);
|
|
315
|
+
const { placed, shift } = useAnchoredPanel({
|
|
316
|
+
root,
|
|
317
|
+
panel,
|
|
318
|
+
open,
|
|
319
|
+
side: __props.placement
|
|
320
|
+
});
|
|
313
321
|
let timer;
|
|
314
322
|
function clear() {
|
|
315
323
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -349,6 +357,8 @@ var BaseHoverCard_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @
|
|
|
349
357
|
onBeforeUnmount(clear);
|
|
350
358
|
return (_ctx, _cache) => {
|
|
351
359
|
return openBlock(), createElementBlock("div", {
|
|
360
|
+
ref_key: "root",
|
|
361
|
+
ref: root,
|
|
352
362
|
class: "rk-hovercard",
|
|
353
363
|
onPointerenter: show,
|
|
354
364
|
onPointerleave: hide,
|
|
@@ -358,37 +368,22 @@ var BaseHoverCard_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @
|
|
|
358
368
|
}, [renderSlot(_ctx.$slots, "default", { props: triggerProps.value }, void 0, true), open.value ? (openBlock(), createElementBlock("div", {
|
|
359
369
|
key: 0,
|
|
360
370
|
id: unref(id),
|
|
361
|
-
|
|
371
|
+
ref_key: "panel",
|
|
372
|
+
ref: panel,
|
|
373
|
+
class: normalizeClass(["rk-hovercard-panel surface-raised", `is-${unref(placed)}`]),
|
|
374
|
+
style: normalizeStyle(unref(shift) ? { "--rk-hovercard-shift": `${unref(shift)}px` } : void 0),
|
|
362
375
|
role: "group",
|
|
363
376
|
"aria-label": __props.label
|
|
364
|
-
}, [renderSlot(_ctx.$slots, "card", {}, void 0, true)],
|
|
377
|
+
}, [renderSlot(_ctx.$slots, "card", {}, void 0, true)], 14, _hoisted_1$15)) : createCommentVNode("", true)], 544);
|
|
365
378
|
};
|
|
366
379
|
}
|
|
367
|
-
}), [["__scopeId", "data-v-
|
|
380
|
+
}), [["__scopeId", "data-v-d3c3b40f"]]);
|
|
368
381
|
//#endregion
|
|
369
382
|
//#region src/composables/use-dialog-shell.ts
|
|
370
|
-
/**
|
|
371
|
-
* What every modal surface has to do, in one place.
|
|
372
|
-
*
|
|
373
|
-
* A dialog, a drawer and anything else that takes the whole page share a
|
|
374
|
-
* list that is easy to get nearly right: trap Tab, stop Escape, make the
|
|
375
|
-
* rest of the page inert, stop it scrolling underneath, and hand focus back
|
|
376
|
-
* to whatever opened it. Each of those has an order that matters, and the
|
|
377
|
-
* orders are not obvious — releasing `inert` *after* restoring focus drops
|
|
378
|
-
* it on the body, because an inert element cannot take focus. That was
|
|
379
|
-
* written once, correctly, in `BaseModal`; a drawer that copied it would be
|
|
380
|
-
* a second copy to keep correct.
|
|
381
|
-
*
|
|
382
|
-
* The Tab wrap has to be done by hand: the browser's own Tab order is the
|
|
383
|
-
* whole document, and a dialog is only part of it. `inert` is what also
|
|
384
|
-
* stops a screen reader's virtual cursor, which walks straight past a
|
|
385
|
-
* keydown handler.
|
|
386
|
-
*/
|
|
387
|
-
var FOCUSABLE$1 = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
388
383
|
function useDialogShell(open, panel, { dismissible = () => true, onClose }) {
|
|
389
384
|
let releaseInert = null;
|
|
390
385
|
let restoreTo = null;
|
|
391
|
-
const focusable = () => panel.value ?
|
|
386
|
+
const focusable = () => panel.value ? focusableWithin(panel.value) : [];
|
|
392
387
|
function onKeydown(event) {
|
|
393
388
|
if (event.key === "Escape" && dismissible()) {
|
|
394
389
|
event.stopPropagation();
|
|
@@ -1123,7 +1118,8 @@ var BaseSplitter_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @_
|
|
|
1123
1118
|
function moveTo(point) {
|
|
1124
1119
|
const box = root.value?.getBoundingClientRect();
|
|
1125
1120
|
if (!box) return;
|
|
1126
|
-
const
|
|
1121
|
+
const alongX = elementDirection(root.value) === "rtl" ? (box.right - point) / box.width : (point - box.left) / box.width;
|
|
1122
|
+
const ratio = horizontal.value ? alongX : (point - box.top) / box.height;
|
|
1127
1123
|
split.value = clamp(Math.round(ratio * 100));
|
|
1128
1124
|
}
|
|
1129
1125
|
function onPointerDown(event) {
|
|
@@ -1140,15 +1136,16 @@ var BaseSplitter_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @_
|
|
|
1140
1136
|
event.target.releasePointerCapture?.(event.pointerId);
|
|
1141
1137
|
}
|
|
1142
1138
|
function onKeydown(event) {
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1139
|
+
const along = horizontal.value ? horizontalStep(event.key, event.currentTarget) : {
|
|
1140
|
+
ArrowDown: 1,
|
|
1141
|
+
ArrowUp: -1
|
|
1142
|
+
}[event.key] ?? 0;
|
|
1143
|
+
const moves = {
|
|
1148
1144
|
Home: __props.min,
|
|
1149
1145
|
End: __props.max,
|
|
1150
1146
|
Enter: startedAt
|
|
1151
|
-
}
|
|
1147
|
+
};
|
|
1148
|
+
const next = along === 0 ? moves[event.key] : split.value + along * __props.step;
|
|
1152
1149
|
if (next === void 0) return;
|
|
1153
1150
|
event.preventDefault();
|
|
1154
1151
|
split.value = clamp(next);
|
|
@@ -1184,7 +1181,7 @@ var BaseSplitter_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @_
|
|
|
1184
1181
|
], 6);
|
|
1185
1182
|
};
|
|
1186
1183
|
}
|
|
1187
|
-
}), [["__scopeId", "data-v-
|
|
1184
|
+
}), [["__scopeId", "data-v-977911a2"]]);
|
|
1188
1185
|
//#endregion
|
|
1189
1186
|
//#region src/web/BaseToolbar.vue?vue&type=script&setup=true&lang.ts
|
|
1190
1187
|
var _hoisted_1$8 = ["aria-label", "aria-orientation"];
|
|
@@ -1231,16 +1228,15 @@ var BaseToolbar_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__
|
|
|
1231
1228
|
list[at.value]?.focus();
|
|
1232
1229
|
}
|
|
1233
1230
|
function onKeydown(event) {
|
|
1234
|
-
const back = __props.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
1235
|
-
const forward = __props.orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
1236
1231
|
const list = controls();
|
|
1237
1232
|
const current = list.indexOf(document.activeElement);
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
}
|
|
1233
|
+
const step = __props.orientation === "horizontal" ? horizontalStep(event.key, event.currentTarget) : {
|
|
1234
|
+
ArrowDown: 1,
|
|
1235
|
+
ArrowUp: -1
|
|
1236
|
+
}[event.key] ?? 0;
|
|
1237
|
+
if (step !== 0) {
|
|
1242
1238
|
event.preventDefault();
|
|
1243
|
-
focusAt(current +
|
|
1239
|
+
focusAt(current + step);
|
|
1244
1240
|
} else if (event.key === "Home") {
|
|
1245
1241
|
event.preventDefault();
|
|
1246
1242
|
focusAt(0);
|
|
@@ -1270,7 +1266,7 @@ var BaseToolbar_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__
|
|
|
1270
1266
|
}, [renderSlot(_ctx.$slots, "default", {}, void 0, true)], 42, _hoisted_1$8);
|
|
1271
1267
|
};
|
|
1272
1268
|
}
|
|
1273
|
-
}), [["__scopeId", "data-v-
|
|
1269
|
+
}), [["__scopeId", "data-v-ad876ad1"]]);
|
|
1274
1270
|
//#endregion
|
|
1275
1271
|
//#region src/web/BaseTree.vue?vue&type=script&setup=true&lang.ts
|
|
1276
1272
|
var _hoisted_1$7 = ["aria-label", "aria-multiselectable"];
|
|
@@ -1364,12 +1360,13 @@ var BaseTree_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
1364
1360
|
moveTo(active.value - 1);
|
|
1365
1361
|
break;
|
|
1366
1362
|
case "ArrowRight":
|
|
1367
|
-
event.preventDefault();
|
|
1368
|
-
if (hasChildren(row.node) && !isOpen(row.node)) setOpen(row.node, true);
|
|
1369
|
-
else if (hasChildren(row.node)) moveTo(active.value + 1);
|
|
1370
|
-
break;
|
|
1371
1363
|
case "ArrowLeft": {
|
|
1372
1364
|
event.preventDefault();
|
|
1365
|
+
if (horizontalStep(event.key, event.currentTarget) === 1) {
|
|
1366
|
+
if (hasChildren(row.node) && !isOpen(row.node)) setOpen(row.node, true);
|
|
1367
|
+
else if (hasChildren(row.node)) moveTo(active.value + 1);
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1373
1370
|
if (hasChildren(row.node) && isOpen(row.node)) {
|
|
1374
1371
|
setOpen(row.node, false);
|
|
1375
1372
|
break;
|
|
@@ -1438,7 +1435,7 @@ var BaseTree_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
1438
1435
|
}), 128))], 40, _hoisted_1$7);
|
|
1439
1436
|
};
|
|
1440
1437
|
}
|
|
1441
|
-
}), [["__scopeId", "data-v-
|
|
1438
|
+
}), [["__scopeId", "data-v-297d9dad"]]);
|
|
1442
1439
|
//#endregion
|
|
1443
1440
|
//#region src/web/TransferList.vue?vue&type=script&setup=true&lang.ts
|
|
1444
1441
|
var _hoisted_1$6 = { class: "rk-transfer" };
|
|
@@ -1643,8 +1640,9 @@ var BaseTabs_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
1643
1640
|
if (index < 0) return;
|
|
1644
1641
|
const last = __props.items.length - 1;
|
|
1645
1642
|
let next;
|
|
1646
|
-
|
|
1647
|
-
|
|
1643
|
+
const step = horizontalStep(event.key, event.currentTarget);
|
|
1644
|
+
if (step === 1) next = index === last ? 0 : index + 1;
|
|
1645
|
+
else if (step === -1) next = index === 0 ? last : index - 1;
|
|
1648
1646
|
else if (event.key === "Home") next = 0;
|
|
1649
1647
|
else if (event.key === "End") next = last;
|
|
1650
1648
|
else return;
|
|
@@ -1688,7 +1686,7 @@ var BaseTabs_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
1688
1686
|
}), 128))]);
|
|
1689
1687
|
};
|
|
1690
1688
|
}
|
|
1691
|
-
}), [["__scopeId", "data-v-
|
|
1689
|
+
}), [["__scopeId", "data-v-dc56244f"]]);
|
|
1692
1690
|
//#endregion
|
|
1693
1691
|
//#region src/web/BaseTooltip.vue?vue&type=script&setup=true&lang.ts
|
|
1694
1692
|
var _hoisted_1$3 = ["id"];
|