rei-kit 0.14.1 → 0.16.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.
@@ -0,0 +1,730 @@
1
+ import { n as _plugin_vue_export_helper_default, r as BaseButton_default } from "./SettingsRow-MnVleeTR.js";
2
+ import { Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createStaticVNode, createTextVNode, createVNode, defineComponent, mergeModels, mergeProps, nextTick, normalizeClass, normalizeStyle, onMounted, onScopeDispose, onUnmounted, openBlock, readonly, ref, renderSlot, toDisplayString, unref, useId, useModel, vModelCheckbox, vModelDynamic, watch, withCtx, withDirectives } from "vue";
3
+ import { X } from "lucide-vue-next";
4
+ //#region src/utils/redirect.ts
5
+ /**
6
+ * Resolves a `?redirect=` query value into a safe in-app path.
7
+ *
8
+ * Only same-origin paths are accepted. Anything else falls back to `/`,
9
+ * so a crafted link cannot bounce a user from the real login page to a
10
+ * phishing clone.
11
+ *
12
+ * Pure: takes the query value instead of reading the router, so it also
13
+ * works inside navigation guards and can be unit tested.
14
+ *
15
+ * @param target - Raw `route.query.redirect` value. May be a string, an
16
+ * array (repeated query key), `null`, or `undefined`.
17
+ * @returns A path starting with a single `/`. Defaults to `/`.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * // in a view
22
+ * await router.push(safeRedirect(route.query.redirect))
23
+ *
24
+ * // in a guard
25
+ * return safeRedirect(to.query.redirect)
26
+ * ```
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * safeRedirect('/week') // '/week'
31
+ * safeRedirect('https://evil.com') // '/'
32
+ * safeRedirect('//evil.com') // '/' (protocol-relative URL)
33
+ * safeRedirect(['/a', '/b']) // '/'
34
+ * safeRedirect(undefined) // '/'
35
+ * ```
36
+ */
37
+ function safeRedirect(target) {
38
+ if (typeof target === "string" && target.startsWith("/") && !target.startsWith("//")) return target;
39
+ return "/";
40
+ }
41
+ /**
42
+ * Where to send somebody back to after signing in, without the fragment.
43
+ *
44
+ * Supabase's implicit OAuth flow hands the browser back with the access and
45
+ * refresh tokens in the URL fragment. A fragment is client-side only — it is
46
+ * never sent to a server. Copied into a query parameter it stops being one:
47
+ * `/login?redirect=/%23access_token=…` is sent on the very next request and
48
+ * lands in the host's access logs, in `Referer` headers and in browser history.
49
+ *
50
+ * So the redirect keeps the path and the query and drops everything from the
51
+ * `#`. There is nothing after it worth returning to anyway.
52
+ *
53
+ * One of the two phone apps had this and the other was passing `fullPath`
54
+ * straight through, which is the leak above with nothing in the way of it. That
55
+ * is the shape of bug a shared kit exists to end: it was fixed once, in the app
56
+ * whose author happened to think of it.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * toRedirectPath('/ledger?direction=out') // '/ledger?direction=out'
61
+ * toRedirectPath('/#access_token=abc') // '/'
62
+ * ```
63
+ */
64
+ function toRedirectPath(fullPath) {
65
+ const [path] = fullPath.split("#");
66
+ return path === void 0 || path === "" ? "/" : path;
67
+ }
68
+ //#endregion
69
+ //#region src/utils/haptics.ts
70
+ /**
71
+ * A short vibration for a confirmed tap.
72
+ *
73
+ * Optional chaining is not decoration: iOS Safari has no `vibrate` at all, and
74
+ * calling it unguarded would throw on every marked day.
75
+ *
76
+ * @param duration - Milliseconds. Keep it under ~15ms; longer reads as an alert.
77
+ */
78
+ function tapFeedback(duration = 10) {
79
+ navigator.vibrate?.(duration);
80
+ }
81
+ //#endregion
82
+ //#region src/composables/use-theme.ts
83
+ /**
84
+ * Namespaced by the app, not by this package.
85
+ *
86
+ * Two rei-kit apps served from the same origin would otherwise share one theme
87
+ * setting — and during development on localhost, they will be.
88
+ */
89
+ var storageKey = "rei-theme";
90
+ function isThemePreference(value) {
91
+ return value === "system" || value === "light" || value === "dark";
92
+ }
93
+ /** Reads the stored preference, falling back to `system`. */
94
+ function readStoredTheme() {
95
+ try {
96
+ const stored = localStorage.getItem(storageKey);
97
+ return isThemePreference(stored) ? stored : "system";
98
+ } catch {
99
+ return "system";
100
+ }
101
+ }
102
+ function storeTheme(preference) {
103
+ try {
104
+ localStorage.setItem(storageKey, preference);
105
+ } catch {}
106
+ }
107
+ /**
108
+ * Does the environment prefer a dark scheme?
109
+ *
110
+ * `matchMedia` is checked for on its own rather than inferred from `document`.
111
+ * Having one does not imply having the other: jsdom supplies a document and no
112
+ * `matchMedia`, so a consumer's component test that so much as mounts something
113
+ * calling `useTheme` threw — and some embedded webviews are the same. Where
114
+ * there is nothing to ask, the answer is no rather than an exception.
115
+ */
116
+ function prefersDarkScheme() {
117
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)").matches : false;
118
+ }
119
+ /**
120
+ * Adds or removes `.dark` on `<html>`, resolving `system` against the OS.
121
+ *
122
+ * A no-op without a document. There is no OS preference to read on a server and
123
+ * no `<html>` to write to, so a prerender leaves the class off and the app
124
+ * decides the theme before hydration — see the note in the README.
125
+ */
126
+ function applyTheme(preference) {
127
+ if (typeof document === "undefined") return;
128
+ const isDark = preference === "dark" || preference === "system" && prefersDarkScheme();
129
+ document.documentElement.classList.toggle("dark", isDark);
130
+ }
131
+ /**
132
+ * The shared preference, created on first use rather than at import.
133
+ *
134
+ * Lazy on purpose: reading storage at import time would lock in the default key
135
+ * before an app had a chance to set its own, leaving the controller reading one
136
+ * key and writing another.
137
+ */
138
+ var preference = null;
139
+ function controller() {
140
+ if (preference) return preference;
141
+ preference = ref(readStoredTheme());
142
+ watch(preference, (next) => {
143
+ storeTheme(next);
144
+ applyTheme(next);
145
+ }, { immediate: true });
146
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
147
+ if (preference?.value === "system") applyTheme("system");
148
+ });
149
+ return preference;
150
+ }
151
+ /**
152
+ * Sets where the preference is stored.
153
+ *
154
+ * Safe in either order: called before the first `useTheme()` it simply changes
155
+ * the key, and called after it re-reads under the new one, so the controller
156
+ * never reads from one key while writing to another.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * setThemeStorageKey('hibi-theme') // once, at startup
161
+ * ```
162
+ */
163
+ function setThemeStorageKey(key) {
164
+ storageKey = key;
165
+ if (preference) preference.value = readStoredTheme();
166
+ }
167
+ /** @returns The shared preference ref; assigning to it stores and applies it. */
168
+ function useTheme() {
169
+ return controller();
170
+ }
171
+ //#endregion
172
+ //#region src/composables/use-online.ts
173
+ /**
174
+ * Tracks whether the browser thinks it has a network connection.
175
+ *
176
+ * Note the limit: `navigator.onLine` only reports whether a network interface
177
+ * is up, not whether requests actually succeed. Treat it as a hint for the UI,
178
+ * never as a reason to skip error handling.
179
+ *
180
+ * Listeners are removed on unmount, so the composable is safe to call per view.
181
+ *
182
+ * @returns A readonly ref that flips with the browser's online/offline events.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * const isOnline = useOnline()
187
+ * // <p v-if="!isOnline">You're offline.</p>
188
+ * ```
189
+ */
190
+ function useOnline() {
191
+ const isOnline = ref(true);
192
+ function update() {
193
+ isOnline.value = navigator.onLine;
194
+ }
195
+ onMounted(() => {
196
+ update();
197
+ window.addEventListener("online", update);
198
+ window.addEventListener("offline", update);
199
+ });
200
+ onUnmounted(() => {
201
+ window.removeEventListener("online", update);
202
+ window.removeEventListener("offline", update);
203
+ });
204
+ return readonly(isOnline);
205
+ }
206
+ //#endregion
207
+ //#region src/composables/use-visual-viewport.ts
208
+ /**
209
+ * Tracks the visual viewport.
210
+ *
211
+ * Chrome and Android browsers honour `interactive-widget=resizes-content`, so
212
+ * the layout viewport already shrinks for the keyboard there. Safari on iOS
213
+ * does not implement it: it shrinks only the *visual* viewport, leaving a sheet
214
+ * sized in `dvh` sitting partly underneath the keyboard.
215
+ *
216
+ * `null` means the API is unavailable, which callers should read as "trust the
217
+ * layout viewport" rather than as zero. A server has no viewport at all, so it
218
+ * gets that same `null` — this runs during `setup`, and a component using it
219
+ * has to survive being rendered there.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const viewport = useVisualViewport()
224
+ * // :style="viewport ? { height: `${viewport.height}px` } : undefined"
225
+ * ```
226
+ */
227
+ function useVisualViewport() {
228
+ const rect = ref(null);
229
+ const viewport = typeof window === "undefined" ? void 0 : window.visualViewport;
230
+ if (!viewport) return readonly(rect);
231
+ function read() {
232
+ if (!viewport) return;
233
+ rect.value = {
234
+ height: viewport.height,
235
+ offsetTop: viewport.offsetTop
236
+ };
237
+ }
238
+ read();
239
+ viewport.addEventListener("resize", read);
240
+ viewport.addEventListener("scroll", read);
241
+ onScopeDispose(() => {
242
+ viewport.removeEventListener("resize", read);
243
+ viewport.removeEventListener("scroll", read);
244
+ });
245
+ return readonly(rect);
246
+ }
247
+ //#endregion
248
+ //#region src/composables/use-toast.ts
249
+ /**
250
+ * Four seconds: long enough to read a short sentence twice, short enough that
251
+ * a second action does not queue behind it.
252
+ */
253
+ var DEFAULT_DURATION = 4e3;
254
+ /**
255
+ * A failure is read more slowly than a confirmation, and more often twice.
256
+ */
257
+ var DANGER_DURATION = 7e3;
258
+ /**
259
+ * Three at once. A fourth pushes the oldest out rather than growing the stack
260
+ * off the top of the screen — an action that produces ten toasts is a loop,
261
+ * and a loop should not be able to cover the app it is running in.
262
+ */
263
+ var MAX_VISIBLE = 3;
264
+ var items = ref([]);
265
+ var nextId = 0;
266
+ var countdowns = /* @__PURE__ */ new Map();
267
+ function clearCountdown(id) {
268
+ const countdown = countdowns.get(id);
269
+ if (countdown === void 0) return;
270
+ clearTimeout(countdown.handle);
271
+ countdowns.delete(id);
272
+ }
273
+ /** Removes a toast, whether it timed out or was dismissed. */
274
+ function dismiss(id) {
275
+ clearCountdown(id);
276
+ items.value = items.value.filter((item) => item.id !== id);
277
+ }
278
+ /** Removes everything on screen. For a route change, or a sign-out. */
279
+ function dismissAll() {
280
+ for (const id of countdowns.keys()) clearCountdown(id);
281
+ items.value = [];
282
+ }
283
+ function arm(id, remaining) {
284
+ if (typeof window === "undefined" || remaining <= 0) return;
285
+ countdowns.set(id, {
286
+ handle: setTimeout(() => dismiss(id), remaining),
287
+ remaining,
288
+ startedAt: Date.now()
289
+ });
290
+ }
291
+ /**
292
+ * Stops the clock on a toast the reader is pointing at.
293
+ *
294
+ * Somebody who has moved the pointer onto it is reading it, and taking it away
295
+ * mid-sentence is the one thing a notification must not do.
296
+ */
297
+ function pause(id) {
298
+ const countdown = countdowns.get(id);
299
+ if (countdown === void 0) return;
300
+ clearTimeout(countdown.handle);
301
+ countdowns.set(id, {
302
+ ...countdown,
303
+ remaining: Math.max(0, countdown.remaining - (Date.now() - countdown.startedAt))
304
+ });
305
+ }
306
+ /** Starts it again, from where it stopped rather than from the beginning. */
307
+ function resume(id) {
308
+ const countdown = countdowns.get(id);
309
+ if (countdown === void 0) return;
310
+ arm(id, countdown.remaining);
311
+ }
312
+ function push(tone, message, options = {}) {
313
+ const id = ++nextId;
314
+ const duration = options.duration ?? (tone === "danger" ? DANGER_DURATION : DEFAULT_DURATION);
315
+ const next = [...items.value, {
316
+ id,
317
+ message,
318
+ tone,
319
+ duration
320
+ }];
321
+ while (next.length > MAX_VISIBLE) {
322
+ const oldest = next.shift();
323
+ if (oldest !== void 0) clearCountdown(oldest.id);
324
+ }
325
+ items.value = next;
326
+ arm(id, duration);
327
+ return id;
328
+ }
329
+ /**
330
+ * The stack, and the four ways to add to it.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * const toast = useToast()
335
+ *
336
+ * toast.success(t('habit.saved'))
337
+ * toast.danger(t('common.failed'), { duration: 0 }) // stays until dismissed
338
+ *
339
+ * const id = toast.info(t('export.preparing'), { duration: 0 })
340
+ * toast.dismiss(id)
341
+ * ```
342
+ */
343
+ function useToast() {
344
+ return {
345
+ /** Every toast on screen, oldest first. `ToastHost` renders this. */
346
+ toasts: readonly(items),
347
+ info: (message, options) => push("info", message, options),
348
+ success: (message, options) => push("success", message, options),
349
+ warning: (message, options) => push("warning", message, options),
350
+ danger: (message, options) => push("danger", message, options),
351
+ dismiss,
352
+ dismissAll,
353
+ pause,
354
+ resume
355
+ };
356
+ }
357
+ //#endregion
358
+ //#region src/components/BaseAlert.vue?vue&type=script&setup=true&lang.ts
359
+ var _hoisted_1$5 = ["role", "aria-live"];
360
+ var _hoisted_2$2 = { class: "min-w-0 flex-1" };
361
+ var _hoisted_3$2 = {
362
+ key: 0,
363
+ class: "text-ink font-semibold"
364
+ };
365
+ //#endregion
366
+ //#region src/components/BaseAlert.vue
367
+ var BaseAlert_default = /* @__PURE__ */ defineComponent({
368
+ __name: "BaseAlert",
369
+ props: {
370
+ tone: { default: "info" },
371
+ assertive: {
372
+ type: Boolean,
373
+ default: false
374
+ }
375
+ },
376
+ setup(__props) {
377
+ const TONES = {
378
+ info: "border-hair bg-muted/40 text-ink",
379
+ success: "border-positive/35 bg-positive/8 text-ink",
380
+ warning: "border-warning/40 bg-warning/8 text-ink",
381
+ danger: "border-negative/35 bg-negative/8 text-ink"
382
+ };
383
+ const MARKS = {
384
+ info: "bg-ink-soft/15 text-ink-soft",
385
+ success: "bg-positive/15 text-positive",
386
+ warning: "bg-warning/15 text-warning",
387
+ danger: "bg-negative/15 text-negative"
388
+ };
389
+ const skin = computed(() => TONES[__props.tone]);
390
+ const mark = computed(() => MARKS[__props.tone]);
391
+ return (_ctx, _cache) => {
392
+ return openBlock(), createElementBlock("div", {
393
+ class: normalizeClass(["rounded-card flex items-start gap-3 border px-4 py-3.5 text-sm leading-relaxed", skin.value]),
394
+ role: __props.assertive ? "alert" : "status",
395
+ "aria-live": __props.assertive ? "assertive" : "polite"
396
+ }, [
397
+ _ctx.$slots.mark ? (openBlock(), createElementBlock("span", {
398
+ key: 0,
399
+ class: normalizeClass(["mt-px grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold", mark.value]),
400
+ "aria-hidden": "true"
401
+ }, [renderSlot(_ctx.$slots, "mark")], 2)) : createCommentVNode("", true),
402
+ createElementVNode("div", _hoisted_2$2, [_ctx.$slots.title ? (openBlock(), createElementBlock("p", _hoisted_3$2, [renderSlot(_ctx.$slots, "title")])) : createCommentVNode("", true), createElementVNode("div", { class: normalizeClass(_ctx.$slots.title ? "mt-1" : "") }, [renderSlot(_ctx.$slots, "default")], 2)]),
403
+ renderSlot(_ctx.$slots, "action")
404
+ ], 10, _hoisted_1$5);
405
+ };
406
+ }
407
+ });
408
+ //#endregion
409
+ //#region src/components/FormField.vue?vue&type=script&setup=true&lang.ts
410
+ var _hoisted_1$4 = ["for"];
411
+ //#endregion
412
+ //#region src/components/FormField.vue
413
+ var FormField_default = /* @__PURE__ */ defineComponent({
414
+ __name: "FormField",
415
+ props: {
416
+ label: {},
417
+ error: { default: "" },
418
+ hint: { default: "" },
419
+ size: { default: "md" },
420
+ labelHidden: {
421
+ type: Boolean,
422
+ default: false
423
+ }
424
+ },
425
+ setup(__props) {
426
+ const id = useId();
427
+ const errorId = `${id}-error`;
428
+ const hintId = `${id}-hint`;
429
+ const describedBy = computed(() => {
430
+ if (__props.error) return errorId;
431
+ if (__props.hint) return hintId;
432
+ });
433
+ return (_ctx, _cache) => {
434
+ return openBlock(), createElementBlock("div", { class: normalizeClass(["flex flex-col", __props.size === "sm" ? "gap-1" : "gap-1.5"]) }, [
435
+ createElementVNode("label", {
436
+ for: unref(id),
437
+ class: normalizeClass(["font-medium", [__props.labelHidden ? "sr-only" : "", __props.size === "sm" ? "text-ink-soft text-xs" : "text-ink text-sm"]])
438
+ }, toDisplayString(__props.label), 11, _hoisted_1$4),
439
+ renderSlot(_ctx.$slots, "default", {
440
+ id: unref(id),
441
+ describedBy: describedBy.value,
442
+ invalid: Boolean(__props.error),
443
+ size: __props.size
444
+ }),
445
+ __props.error ? (openBlock(), createElementBlock("p", {
446
+ key: 0,
447
+ id: errorId,
448
+ class: "text-negative text-xs"
449
+ }, toDisplayString(__props.error), 1)) : __props.hint ? (openBlock(), createElementBlock("p", {
450
+ key: 1,
451
+ id: hintId,
452
+ class: "text-ink-soft text-xs"
453
+ }, toDisplayString(__props.hint), 1)) : createCommentVNode("", true)
454
+ ], 2);
455
+ };
456
+ }
457
+ });
458
+ //#endregion
459
+ //#region src/components/BaseInput.vue?vue&type=script&setup=true&lang.ts
460
+ var _hoisted_1$3 = [
461
+ "id",
462
+ "type",
463
+ "aria-invalid",
464
+ "aria-describedby"
465
+ ];
466
+ var CONTROL_CLASS = "h-11 text-base";
467
+ //#endregion
468
+ //#region src/components/BaseInput.vue
469
+ var BaseInput_default = /* @__PURE__ */ defineComponent({
470
+ inheritAttrs: false,
471
+ __name: "BaseInput",
472
+ props: /*@__PURE__*/ mergeModels({
473
+ label: {},
474
+ error: { default: "" },
475
+ hint: { default: "" },
476
+ labelHidden: {
477
+ type: Boolean,
478
+ default: false
479
+ },
480
+ type: { default: "text" },
481
+ size: { default: "md" },
482
+ variant: { default: "default" }
483
+ }, {
484
+ "modelValue": {},
485
+ "modelModifiers": {}
486
+ }),
487
+ emits: ["update:modelValue"],
488
+ setup(__props) {
489
+ const model = useModel(__props, "modelValue");
490
+ return (_ctx, _cache) => {
491
+ return openBlock(), createBlock(FormField_default, {
492
+ label: __props.label,
493
+ error: __props.error,
494
+ hint: __props.hint,
495
+ "label-hidden": __props.labelHidden,
496
+ size: __props.size
497
+ }, {
498
+ default: withCtx(({ id, describedBy, invalid }) => [withDirectives(createElementVNode("input", mergeProps({
499
+ id,
500
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => model.value = $event),
501
+ type: __props.type,
502
+ "aria-invalid": invalid,
503
+ "aria-describedby": describedBy
504
+ }, _ctx.$attrs, { class: [
505
+ __props.variant === "unstyled" ? "" : "border-hair bg-surface text-ink rounded-card focus-visible:outline-primary border px-3 focus-visible:outline-2 focus-visible:outline-offset-1",
506
+ __props.variant === "unstyled" ? "text-base" : CONTROL_CLASS,
507
+ __props.variant !== "unstyled" && invalid ? "border-negative" : ""
508
+ ] }), null, 16, _hoisted_1$3), [[vModelDynamic, model.value]])]),
509
+ _: 1
510
+ }, 8, [
511
+ "label",
512
+ "error",
513
+ "hint",
514
+ "label-hidden",
515
+ "size"
516
+ ]);
517
+ };
518
+ }
519
+ });
520
+ //#endregion
521
+ //#region src/components/BaseSheet.vue?vue&type=script&setup=true&lang.ts
522
+ var _hoisted_1$2 = { class: "shell-frame md:rounded-shell relative flex max-h-full flex-col justify-end overflow-hidden" };
523
+ var _hoisted_2$1 = ["aria-label"];
524
+ var _hoisted_3$1 = { class: "flex shrink-0 items-start gap-3 px-6 pt-4 pb-5" };
525
+ var _hoisted_4 = { class: "min-w-0 flex-1" };
526
+ var _hoisted_5 = { class: "text-ink text-xl leading-tight font-semibold" };
527
+ var _hoisted_6 = {
528
+ key: 0,
529
+ class: "text-ink-soft mt-1 text-sm leading-snug"
530
+ };
531
+ var _hoisted_7 = { class: "min-h-0 flex-1 overflow-y-auto px-6 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]" };
532
+ //#endregion
533
+ //#region src/components/BaseSheet.vue
534
+ var BaseSheet_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
535
+ __name: "BaseSheet",
536
+ props: /*@__PURE__*/ mergeModels({
537
+ title: {},
538
+ subtitle: { default: "" },
539
+ closeLabel: { default: "Close" }
540
+ }, {
541
+ "modelValue": {
542
+ type: Boolean,
543
+ required: true
544
+ },
545
+ "modelModifiers": {}
546
+ }),
547
+ emits: ["update:modelValue"],
548
+ setup(__props) {
549
+ const open = useModel(__props, "modelValue");
550
+ const viewport = useVisualViewport();
551
+ /**
552
+ * Pins the sheet to the area the keyboard has left visible.
553
+ *
554
+ * Only needed where the layout viewport does not shrink on its own — iOS. On
555
+ * Android the numbers already agree, so this is a no-op there rather than a
556
+ * second, competing adjustment.
557
+ */
558
+ const viewportStyle = computed(() => viewport.value ? {
559
+ height: `${viewport.value.height}px`,
560
+ top: `${viewport.value.offsetTop}px`
561
+ } : void 0);
562
+ const panel = ref(null);
563
+ let lastFocused = null;
564
+ function close() {
565
+ open.value = false;
566
+ }
567
+ function onKeydown(event) {
568
+ if (event.key === "Escape") close();
569
+ }
570
+ watch(open, async (isOpen) => {
571
+ if (isOpen) {
572
+ setBackgroundInert(true);
573
+ lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
574
+ window.addEventListener("keydown", onKeydown);
575
+ await nextTick();
576
+ panel.value?.focus();
577
+ } else {
578
+ window.removeEventListener("keydown", onKeydown);
579
+ lastFocused?.focus();
580
+ lastFocused = null;
581
+ setBackgroundInert(false);
582
+ }
583
+ });
584
+ /**
585
+ * `inert` takes the whole app out of tab order and pointer events while the
586
+ * sheet is open — a real focus trap without keydown bookkeeping.
587
+ *
588
+ * The sheet itself is teleported to `#sheet-root`, a sibling of `#app`, so it
589
+ * stays interactive.
590
+ */
591
+ function setBackgroundInert(isInert) {
592
+ document.getElementById("app")?.toggleAttribute("inert", isInert);
593
+ }
594
+ onUnmounted(() => {
595
+ window.removeEventListener("keydown", onKeydown);
596
+ setBackgroundInert(false);
597
+ });
598
+ return (_ctx, _cache) => {
599
+ return openBlock(), createBlock(Teleport, { to: "#sheet-root" }, [createVNode(Transition, { name: "sheet" }, {
600
+ default: withCtx(() => [open.value ? (openBlock(), createElementBlock("div", {
601
+ key: 0,
602
+ class: "fixed inset-x-0 top-0 bottom-0 z-50 flex items-center justify-center",
603
+ style: normalizeStyle(viewportStyle.value)
604
+ }, [createElementVNode("div", _hoisted_1$2, [createElementVNode("div", {
605
+ class: "bg-ink/45 absolute inset-0 backdrop-blur-[2px]",
606
+ onClick: close
607
+ }), createElementVNode("section", {
608
+ ref_key: "panel",
609
+ ref: panel,
610
+ role: "dialog",
611
+ "aria-modal": "true",
612
+ "aria-label": __props.title,
613
+ tabindex: "-1",
614
+ class: "sheet-panel bg-surface relative flex max-h-[94%] min-h-[56dvh] flex-col rounded-t-[28px] shadow-2xl outline-none"
615
+ }, [
616
+ _cache[0] || (_cache[0] = createElementVNode("div", {
617
+ class: "flex shrink-0 justify-center pt-3",
618
+ "aria-hidden": "true"
619
+ }, [createElementVNode("span", { class: "bg-hair h-1.5 w-10 rounded-full" })], -1)),
620
+ createElementVNode("header", _hoisted_3$1, [createElementVNode("div", _hoisted_4, [createElementVNode("h2", _hoisted_5, toDisplayString(__props.title), 1), __props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_6, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)]), createVNode(BaseButton_default, {
621
+ variant: "unstyled",
622
+ class: "text-ink-soft hover:bg-muted hover:text-ink -mt-1 flex size-10 shrink-0 items-center justify-center rounded-full transition-colors active:scale-90",
623
+ "aria-label": __props.closeLabel,
624
+ onClick: close
625
+ }, {
626
+ default: withCtx(() => [createVNode(unref(X), { class: "size-5" })]),
627
+ _: 1
628
+ }, 8, ["aria-label"])]),
629
+ createElementVNode("div", _hoisted_7, [renderSlot(_ctx.$slots, "default", {}, void 0, true)])
630
+ ], 8, _hoisted_2$1)])], 4)) : createCommentVNode("", true)]),
631
+ _: 3
632
+ })]);
633
+ };
634
+ }
635
+ }), [["__scopeId", "data-v-51709579"]]);
636
+ //#endregion
637
+ //#region src/components/BaseCheckbox.vue?vue&type=script&setup=true&lang.ts
638
+ var _hoisted_1$1 = { class: "flex flex-col gap-1.5" };
639
+ var _hoisted_2 = ["for"];
640
+ var _hoisted_3 = [
641
+ "id",
642
+ "disabled",
643
+ "aria-invalid",
644
+ "aria-describedby"
645
+ ];
646
+ //#endregion
647
+ //#region src/components/BaseCheckbox.vue
648
+ var BaseCheckbox_default = /* @__PURE__ */ defineComponent({
649
+ __name: "BaseCheckbox",
650
+ props: /*@__PURE__*/ mergeModels({
651
+ label: {},
652
+ error: { default: "" },
653
+ hint: { default: "" },
654
+ disabled: {
655
+ type: Boolean,
656
+ default: false
657
+ },
658
+ size: { default: "md" }
659
+ }, {
660
+ "modelValue": {
661
+ type: Boolean,
662
+ default: false
663
+ },
664
+ "modelModifiers": {}
665
+ }),
666
+ emits: ["update:modelValue"],
667
+ setup(__props) {
668
+ const model = useModel(__props, "modelValue");
669
+ const id = useId();
670
+ const errorId = `${id}-error`;
671
+ const hintId = `${id}-hint`;
672
+ const describedBy = computed(() => {
673
+ if (__props.error) return errorId;
674
+ if (__props.hint) return hintId;
675
+ });
676
+ return (_ctx, _cache) => {
677
+ return openBlock(), createElementBlock("div", _hoisted_1$1, [createElementVNode("label", {
678
+ for: unref(id),
679
+ class: normalizeClass(["flex items-center", [__props.size === "sm" ? "gap-2" : "gap-3", __props.disabled ? "opacity-50" : "cursor-pointer"]])
680
+ }, [withDirectives(createElementVNode("input", {
681
+ id: unref(id),
682
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => model.value = $event),
683
+ type: "checkbox",
684
+ disabled: __props.disabled,
685
+ "aria-invalid": Boolean(__props.error),
686
+ "aria-describedby": describedBy.value,
687
+ class: "accent-primary focus-visible:outline-primary size-4 shrink-0 focus-visible:outline-2 focus-visible:outline-offset-2"
688
+ }, null, 8, _hoisted_3), [[vModelCheckbox, model.value]]), createElementVNode("span", { class: normalizeClass(["text-sm", __props.size === "sm" ? "text-ink-soft" : "text-ink"]) }, toDisplayString(__props.label), 3)], 10, _hoisted_2), __props.error ? (openBlock(), createElementBlock("p", {
689
+ key: 0,
690
+ id: errorId,
691
+ class: "text-negative text-xs"
692
+ }, toDisplayString(__props.error), 1)) : __props.hint ? (openBlock(), createElementBlock("p", {
693
+ key: 1,
694
+ id: hintId,
695
+ class: "text-ink-soft text-xs"
696
+ }, toDisplayString(__props.hint), 1)) : createCommentVNode("", true)]);
697
+ };
698
+ }
699
+ });
700
+ //#endregion
701
+ //#region src/components/GoogleButton.vue?vue&type=script&setup=true&lang.ts
702
+ var _hoisted_1 = ["disabled"];
703
+ //#endregion
704
+ //#region src/components/GoogleButton.vue
705
+ var GoogleButton_default = /* @__PURE__ */ defineComponent({
706
+ __name: "GoogleButton",
707
+ props: {
708
+ label: {},
709
+ disabled: {
710
+ type: Boolean,
711
+ default: false
712
+ }
713
+ },
714
+ emits: ["click"],
715
+ setup(__props, { emit: __emit }) {
716
+ const emit = __emit;
717
+ return (_ctx, _cache) => {
718
+ return openBlock(), createElementBlock("button", {
719
+ type: "button",
720
+ disabled: __props.disabled,
721
+ class: "border-hair bg-surface text-ink rounded-card hover:bg-muted flex h-11 w-full items-center justify-center gap-2 border text-sm font-medium transition-colors active:scale-95 disabled:pointer-events-none disabled:opacity-50",
722
+ onClick: _cache[0] || (_cache[0] = ($event) => emit("click"))
723
+ }, [_cache[1] || (_cache[1] = createStaticVNode("<svg class=\"size-4\" viewBox=\"0 0 48 48\" aria-hidden=\"true\"><path fill=\"#EA4335\" 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\"></path><path fill=\"#4285F4\" 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\"></path><path fill=\"#FBBC05\" 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\"></path><path fill=\"#34A853\" 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\"></path></svg>", 1)), createTextVNode(" " + toDisplayString(__props.label), 1)], 8, _hoisted_1);
724
+ };
725
+ }
726
+ });
727
+ //#endregion
728
+ export { toRedirectPath as _, FormField_default as a, useVisualViewport as c, isThemePreference as d, readStoredTheme as f, safeRedirect as g, tapFeedback as h, BaseInput_default as i, useOnline as l, useTheme as m, BaseCheckbox_default as n, BaseAlert_default as o, setThemeStorageKey as p, BaseSheet_default as r, useToast as s, GoogleButton_default as t, applyTheme as u };
729
+
730
+ //# sourceMappingURL=GoogleButton-BxsrxZdX.js.map