@leavepulse/ui 0.53.1 → 0.55.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.
@@ -15,6 +15,7 @@ export interface RevealOrigin {
15
15
  x: number;
16
16
  y: number;
17
17
  }
18
+ export declare function revealTransition(update: () => void | Promise<void>, origin?: RevealOrigin, durationMs?: number): void;
18
19
  export declare function applyThemeWithTransition(theme: TokenSet, origin?: RevealOrigin, root?: HTMLElement, durationMs?: number): void;
19
20
  export declare function serializeTheme(theme: TokenSet): string;
20
21
  export declare function themeToCssVars(theme: TokenSet): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.53.1",
3
+ "version": "0.55.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -14,12 +14,16 @@ export default { inheritAttrs: false }
14
14
  * The kit-facing model is a single STRING (the joined code), not reka's per-cell
15
15
  * array: `v-model` is "123456", and @complete fires that string once every cell
16
16
  * is filled. Digits-only by default (TOTP); flip `alphanumeric` for backup codes.
17
+ *
18
+ * The cells share the row rather than keeping a fixed size: each grows to its
19
+ * size's width and no further, and shrinks with the row when the container is
20
+ * narrower, so eight cells fit a phone instead of running off its edge.
17
21
  */
18
22
  import { PinInputInput, PinInputRoot } from "reka-ui"
19
23
  import { computed, ref, watch } from "vue"
20
24
  import { useFieldControl } from "../composables/useField"
21
25
  import { useMergedAttrs } from "../composables/useMergedClass"
22
- import { joinCells, splitCode, type OtpCells } from "./otp"
26
+ import { groupBreaks, joinCells, splitCode, upperCells, type OtpCells } from "./otp"
23
27
 
24
28
  const props = withDefaults(
25
29
  defineProps<{
@@ -32,11 +36,19 @@ const props = withDefaults(
32
36
  mask?: boolean
33
37
  /** Allow letters too (backup codes). Default: digits only (TOTP). */
34
38
  alphanumeric?: boolean
39
+ /**
40
+ * Turn typed letters into capitals, and open a phone's keyboard in capitals.
41
+ * Default: on with `alphanumeric`, where codes are printed in capitals and
42
+ * compared without regard to case; pass `false` for a case-sensitive code.
43
+ */
44
+ uppercase?: boolean
45
+ /** Draw a separator after every `group` cells (e.g. 3 for "123-456"). */
46
+ group?: number
35
47
  /** Error state — tints the cells red (e.g. wrong code). */
36
48
  invalid?: boolean
37
49
  size?: "sm" | "md" | "lg"
38
50
  }>(),
39
- { length: 6, size: "md", alphanumeric: false },
51
+ { length: 6, size: "md", alphanumeric: false, uppercase: undefined },
40
52
  )
41
53
 
42
54
  const emit = defineEmits<{
@@ -45,6 +57,8 @@ const emit = defineEmits<{
45
57
  (e: "complete", value: string): void
46
58
  }>()
47
59
 
60
+ const upper = computed(() => props.alphanumeric && (props.uppercase ?? true))
61
+
48
62
  // reka models the value as an array of per-cell strings; we bridge to a single
49
63
  // string at the boundary so callers never touch the array shape.
50
64
  //
@@ -52,7 +66,7 @@ const emit = defineEmits<{
52
66
  // string on every render: clearing a middle cell leaves a gap the string cannot
53
67
  // express, and re-splitting it shifted every later digit one cell left. The
54
68
  // cells are only rebuilt when the model changes from outside.
55
- const local = ref<string[]>(splitCode(props.modelValue, props.length))
69
+ const local = ref<OtpCells>(splitCode(props.modelValue, props.length))
56
70
  let emitted = props.modelValue ?? ""
57
71
  watch(
58
72
  () => props.modelValue ?? "",
@@ -68,11 +82,13 @@ watch(
68
82
  local.value = local.value.slice(0, length)
69
83
  },
70
84
  )
85
+ // Typed as reka's text-mode array; numeric mode holds numbers and holes, which
86
+ // the helpers in ./otp are written for.
71
87
  const cells = computed<string[]>({
72
- get: () => local.value,
88
+ get: () => local.value as string[],
73
89
  set: (arr) => {
74
- local.value = arr
75
- emitted = joinCells(arr)
90
+ local.value = upper.value ? upperCells(arr) : arr
91
+ emitted = joinCells(local.value)
76
92
  emit("update:modelValue", emitted)
77
93
  },
78
94
  })
@@ -84,23 +100,53 @@ const field = useFieldControl(() => ({
84
100
  }))
85
101
 
86
102
  function onComplete(arr: OtpCells) {
87
- emit("complete", joinCells(arr))
103
+ emit("complete", joinCells(upper.value ? upperCells(arr) : arr))
104
+ }
105
+
106
+ const breaks = computed(() => groupBreaks(props.length, props.group))
107
+
108
+ /*
109
+ * Backspace, as a phone's keyboard sends it.
110
+ *
111
+ * reka handles Backspace on keydown, but an on-screen keyboard reports most
112
+ * keys as "Unidentified", so its handler never ran there: the browser deleted
113
+ * the character itself, and reka read the now-empty cell as typed input and
114
+ * moved the caret FORWARD — and an empty cell could not be backed out of at
115
+ * all. `beforeinput` names the deletion on every keyboard, so it is done here
116
+ * the way a hardware Backspace does it: clear this cell, or step back into
117
+ * the previous one and clear that. A hardware key never reaches this, since
118
+ * reka's keydown handler cancels it first.
119
+ */
120
+ const root = ref<{ $el: HTMLElement } | null>(null)
121
+ function onBeforeInput(event: InputEvent, index: number) {
122
+ if (event.inputType !== "deleteContentBackward") return
123
+ event.preventDefault()
124
+ const target = event.target as HTMLInputElement
125
+ const next = [...local.value]
126
+ let clear = index
127
+ if (!target.value && index > 0) {
128
+ clear = index - 1
129
+ root.value?.$el.querySelectorAll("input")[clear]?.focus()
130
+ }
131
+ // Emptied the way reka empties a cell itself: numeric mode leaves a hole.
132
+ next[clear] = props.alphanumeric ? "" : undefined
133
+ cells.value = next as string[]
88
134
  }
89
135
 
90
136
  const cellSize = {
91
- sm: "size-9 text-base",
92
- md: "size-11 text-lg",
93
- lg: "size-13 text-xl",
137
+ sm: "max-w-9 text-base",
138
+ md: "max-w-11 text-lg",
139
+ lg: "max-w-13 text-xl",
94
140
  }
95
141
 
96
- // The boxes sit on a fixed gap that a narrow form has to tighten.
97
142
  const { class: rootClass, attrs: rest } = useMergedAttrs(
98
- "flex items-center gap-2",
143
+ "flex min-w-0 max-w-full items-center gap-1.5 sm:gap-2",
99
144
  )
100
145
  </script>
101
146
 
102
147
  <template>
103
148
  <PinInputRoot
149
+ ref="root"
104
150
  v-model="cells"
105
151
  :type="alphanumeric ? 'text' : 'number'"
106
152
  :mask="mask"
@@ -110,21 +156,31 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
110
156
  v-bind="rest"
111
157
  @complete="onComplete"
112
158
  >
113
- <PinInputInput
114
- v-for="i in length"
115
- :key="i"
116
- :index="i - 1"
117
- :id="i === 1 ? field.id : undefined"
118
- :aria-invalid="field.invalid || undefined"
119
- :aria-describedby="field.describedBy"
120
- :class="[
121
- cellSize[size],
122
- 'rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand',
123
- 'outline-none transition-colors duration-[var(--duration-fast)]',
124
- 'focus:border-brand focus:ring-2 focus:ring-ring',
125
- 'disabled:cursor-not-allowed disabled:opacity-55',
126
- field.invalid ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line',
127
- ]"
128
- />
159
+ <template v-for="i in length" :key="i">
160
+ <PinInputInput
161
+ :index="i - 1"
162
+ :id="i === 1 ? field.id : undefined"
163
+ :aria-invalid="field.invalid || undefined"
164
+ :aria-describedby="field.describedBy"
165
+ :autocapitalize="upper ? 'characters' : 'none'"
166
+ autocorrect="off"
167
+ :spellcheck="false"
168
+ :class="[
169
+ cellSize[size],
170
+ 'aspect-square h-auto min-w-0 flex-1 basis-0 p-0',
171
+ 'rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand',
172
+ 'outline-none transition-colors duration-[var(--duration-fast)]',
173
+ 'focus:border-brand focus:ring-2 focus:ring-ring',
174
+ 'disabled:cursor-not-allowed disabled:opacity-55',
175
+ field.invalid ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line',
176
+ ]"
177
+ @beforeinput="onBeforeInput($event as InputEvent, i - 1)"
178
+ />
179
+ <span
180
+ v-if="breaks.has(i - 1)"
181
+ aria-hidden="true"
182
+ class="h-0.5 w-2 shrink-0 rounded-full bg-muted opacity-60"
183
+ />
184
+ </template>
129
185
  </PinInputRoot>
130
186
  </template>
@@ -10,6 +10,8 @@ export interface TabItem {
10
10
  label: string
11
11
  /** Optional iconify name (e.g. "lucide:palette"), shown left of the label. */
12
12
  icon?: string
13
+ /** A small dot on the tab: something behind it wants a look (unread, a problem). */
14
+ dot?: boolean
13
15
  }
14
16
 
15
17
  const props = withDefaults(
@@ -24,15 +26,23 @@ const props = withDefaults(
24
26
  * sliding pill marking the active one (for nav bars over a custom surface).
25
27
  * "underline" is a flat row over a hairline baseline, marking the active
26
28
  * tab with a sliding bottom bar instead of a pill (page-section nav style).
29
+ * "bottom" is a phone's bottom navigation: full width, equal-share tabs
30
+ * with the icon over the label, a sliding bar along the top edge marking
31
+ * the active one, and padding for the gesture strip and side insets of an
32
+ * edge-to-edge screen. Place it as the last row of the layout.
27
33
  */
28
- variant?: "contained" | "plain" | "underline"
34
+ variant?: "contained" | "plain" | "underline" | "bottom"
29
35
  /** Tint the active pill + label with the brand colour (nav-bar style). */
30
36
  accent?: boolean
31
37
  /** Stretch the bar to full width with equal-share triggers. */
32
38
  block?: boolean
39
+ /** Accessible name of the tab list, e.g. "Main" for an app's navigation. */
40
+ label?: string
33
41
  }>(),
34
42
  { variant: "contained", accent: false, block: false },
35
43
  )
44
+
45
+ const bottom = computed(() => props.variant === "bottom")
36
46
  const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>()
37
47
 
38
48
  // Held here rather than left to reka, so the active marker follows the active
@@ -88,7 +98,11 @@ const panelLayoutTransition = computed(() =>
88
98
  : { type: "spring" as const, stiffness: 520, damping: 42, mass: 0.9 },
89
99
  )
90
100
 
101
+ // A bottom bar marks only the active tab: on a phone a tap is the hover, and
102
+ // a bar that chased the mouse across a desktop window would point at a tab
103
+ // nobody chose.
91
104
  function pillUnder(value: string, active?: string): boolean {
105
+ if (bottom.value) return active === value
92
106
  return hovered.value ? hovered.value === value : active === value
93
107
  }
94
108
 
@@ -119,30 +133,38 @@ function releaseFocusAfterPointer(event: PointerEvent) {
119
133
  >
120
134
  <TabsList
121
135
  ref="list"
122
- class="max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
123
- :class="[
124
- block ? 'flex w-full' : 'inline-flex',
125
- variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
126
- variant === 'underline' ? 'border-b border-line' : '',
127
- ]"
136
+ :aria-label="label"
137
+ :class="bottom
138
+ ? 'grid w-full auto-cols-fr grid-flow-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]'
139
+ : [
140
+ 'max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
141
+ block ? 'flex w-full' : 'inline-flex',
142
+ variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
143
+ variant === 'underline' ? 'border-b border-line' : '',
144
+ ]"
128
145
  @pointerleave="hovered = null"
129
146
  >
130
147
  <TabsTrigger
131
148
  v-for="item in items"
132
149
  :key="item.value"
133
150
  :value="item.value"
134
- class="relative inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
151
+ class="relative inline-flex items-center justify-center outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
135
152
  :class="[
136
- block ? 'flex-1' : '',
137
- variant === 'underline' ? 'rounded-t-md px-3 pt-1.5 pb-2' : 'rounded-md px-3 py-1.5',
153
+ bottom
154
+ ? 'min-h-14 min-w-0 flex-col gap-0.5 px-1 py-1.5 text-[11px] font-medium focus-visible:ring-inset'
155
+ : 'shrink-0 gap-1.5 whitespace-nowrap text-sm',
156
+ block && !bottom ? 'flex-1' : '',
157
+ variant === 'underline' ? 'rounded-t-md px-3 pt-1.5 pb-2' : '',
158
+ variant === 'contained' || variant === 'plain' ? 'rounded-md px-3 py-1.5' : '',
138
159
  accent ? 'data-[state=active]:text-brand' : 'data-[state=active]:text-ink',
139
- hovered === item.value ? 'text-ink' : 'text-muted',
160
+ hovered === item.value && !bottom ? 'text-ink' : 'text-muted',
140
161
  ]"
141
162
  @pointerenter="hovered = item.value"
142
163
  @pointerup="releaseFocusAfterPointer"
143
164
  >
144
- <!-- Active marker: a bottom bar in "underline", a filled pill otherwise.
145
- Both slide between tabs via the shared motion layoutId. -->
165
+ <!-- Active marker: a bar under the tab in "underline", over it in
166
+ "bottom", a filled pill otherwise. All slide between tabs via the
167
+ shared motion layoutId. -->
146
168
  <Motion
147
169
  v-if="pillUnder(item.value, current)"
148
170
  :layout-id="pillId"
@@ -152,15 +174,32 @@ function releaseFocusAfterPointer(event: PointerEvent) {
152
174
  'absolute inset-x-0 -bottom-px z-0 h-0.5 rounded-full',
153
175
  accent ? 'bg-brand' : 'bg-ink',
154
176
  ]
155
- : [
156
- 'absolute inset-0 z-0 rounded-md shadow-sm',
157
- accent
158
- ? 'border border-brand/35 bg-brand/12'
159
- : 'border border-line bg-surface-raised',
160
- ]"
177
+ : bottom
178
+ ? [
179
+ 'absolute inset-x-[22%] top-0 z-0 h-0.5 rounded-b-full',
180
+ accent ? 'bg-brand' : 'bg-ink',
181
+ ]
182
+ : [
183
+ 'absolute inset-0 z-0 rounded-md shadow-sm',
184
+ accent
185
+ ? 'border border-brand/35 bg-brand/12'
186
+ : 'border border-line bg-surface-raised',
187
+ ]"
188
+ />
189
+ <span v-if="item.icon" class="relative z-10 inline-flex">
190
+ <LpIcon :name="item.icon" :size="bottom ? 22 : 14" />
191
+ <span
192
+ v-if="item.dot"
193
+ aria-hidden="true"
194
+ class="absolute -right-1 -top-0.5 size-2 rounded-full bg-accent"
195
+ />
196
+ </span>
197
+ <span class="relative z-10" :class="bottom ? 'max-w-full truncate' : ''">{{ item.label }}</span>
198
+ <span
199
+ v-if="item.dot && !item.icon"
200
+ aria-hidden="true"
201
+ class="relative z-10 size-1.5 self-start rounded-full bg-accent"
161
202
  />
162
- <LpIcon v-if="item.icon" :name="item.icon" :size="14" class="relative z-10" />
163
- <span class="relative z-10">{{ item.label }}</span>
164
203
  </TabsTrigger>
165
204
  </TabsList>
166
205
 
@@ -1,4 +1,16 @@
1
1
  <script lang="ts">
2
+ // The last press anywhere on the page: a theme change triggered by a toggle
3
+ // reveals from the toggle the user just pressed.
4
+ const PRESS_FRESH_MS = 1500
5
+ let lastPress: { x: number; y: number; at: number } | null = null
6
+ if (typeof window !== "undefined") {
7
+ window.addEventListener(
8
+ "pointerdown",
9
+ (event) => (lastPress = { x: event.clientX, y: event.clientY, at: performance.now() }),
10
+ { capture: true, passive: true },
11
+ )
12
+ }
13
+
2
14
  export default { inheritAttrs: false }
3
15
  </script>
4
16
 
@@ -9,10 +21,14 @@ export default { inheritAttrs: false }
9
21
  * keeps its own look. Every token is declared on this element, derived ones
10
22
  * included, so kit components inside re-skin while the page around them keeps
11
23
  * the global theme. Nothing is persisted; this is presentation only.
24
+ *
25
+ * With `transition`, a change of `theme` repaints the subtree behind the same
26
+ * circular reveal the global theme switch uses, growing from where the user
27
+ * last pressed, or from the subtree's centre.
12
28
  */
13
- import { computed, type Component } from "vue"
29
+ import { computed, nextTick, ref, shallowRef, watch, type Component, type ComponentPublicInstance } from "vue"
14
30
  import type { TokenSet } from "../theme/tokens"
15
- import { themeEntries } from "../theme/useTheme"
31
+ import { revealTransition, themeEntries, type RevealOrigin } from "../theme/useTheme"
16
32
  import { useMergedAttrs } from "../composables/useMergedClass"
17
33
 
18
34
  const props = withDefaults(
@@ -20,22 +36,49 @@ const props = withDefaults(
20
36
  theme: TokenSet
21
37
  /** Root element. A wrapper that should not affect layout can use `class="contents"`. */
22
38
  as?: string | Component
39
+ /** Reveal a change of `theme` with the circular transition. */
40
+ transition?: boolean
23
41
  }>(),
24
- { as: "div" },
42
+ { as: "div", transition: false },
25
43
  )
26
44
 
27
45
  // Paints its own surface and text so the scoped theme reads as one; a class
28
46
  // like `bg-transparent` overrides that.
29
47
  const { class: rootClass, attrs: rest } = useMergedAttrs("bg-surface text-ink")
30
48
 
49
+ const root = ref<HTMLElement | ComponentPublicInstance | null>(null)
50
+ const shown = shallowRef(props.theme)
51
+
31
52
  const style = computed(() => ({
32
- ...Object.fromEntries(themeEntries(props.theme)),
33
- colorScheme: props.theme.mode,
53
+ ...Object.fromEntries(themeEntries(shown.value)),
54
+ colorScheme: shown.value.mode,
34
55
  }))
56
+
57
+ function originOf(): RevealOrigin | undefined {
58
+ if (lastPress && performance.now() - lastPress.at < PRESS_FRESH_MS) return lastPress
59
+ const value = root.value
60
+ const el = value instanceof HTMLElement ? value : (value?.$el as Element | undefined)
61
+ const box = el?.getBoundingClientRect?.()
62
+ return box ? { x: box.left + box.width / 2, y: box.top + box.height / 2 } : undefined
63
+ }
64
+
65
+ watch(
66
+ () => props.theme,
67
+ (next) => {
68
+ if (!props.transition) {
69
+ shown.value = next
70
+ return
71
+ }
72
+ revealTransition(async () => {
73
+ shown.value = next
74
+ await nextTick()
75
+ }, originOf())
76
+ },
77
+ )
35
78
  </script>
36
79
 
37
80
  <template>
38
- <component :is="as" :style="style" :data-theme-mode="theme.mode" :class="rootClass" v-bind="rest">
81
+ <component :is="as" ref="root" :style="style" :data-theme-mode="shown.mode" :class="rootClass" v-bind="rest">
39
82
  <slot />
40
83
  </component>
41
84
  </template>
@@ -14,3 +14,32 @@ export function joinCells(cells: OtpCells): string {
14
14
  export function splitCode(code: string | undefined, length: number): string[] {
15
15
  return (code ?? "").split("").slice(0, length)
16
16
  }
17
+
18
+ /*
19
+ * Upper-case every filled cell, keeping the holes where they are.
20
+ *
21
+ * A phone opens its keyboard in lower case while backup and pairing codes are
22
+ * printed in capitals, so a code typed letter by letter came out "ab12cd"
23
+ * against an "AB12CD" on screen: a difference the reader cannot fix by looking,
24
+ * and one a server comparing exactly rejects.
25
+ */
26
+ export function upperCells<T extends OtpCells>(cells: T): T {
27
+ const out = [] as unknown as T
28
+ cells.forEach((c, i) => {
29
+ out[i] = typeof c === "string" ? c.toUpperCase() : c
30
+ })
31
+ out.length = cells.length
32
+ return out
33
+ }
34
+
35
+ /*
36
+ * The cell indexes a group separator follows: after every `group` cells, never
37
+ * after the last. Eight characters are read and copied as two blocks of four,
38
+ * which is how such codes are printed, far more easily than as one row.
39
+ */
40
+ export function groupBreaks(length: number, group: number | undefined): Set<number> {
41
+ const breaks = new Set<number>()
42
+ if (!group || group < 1 || group >= length) return breaks
43
+ for (let i = group; i < length; i += group) breaks.add(i - 1)
44
+ return breaks
45
+ }
package/src/index.ts CHANGED
@@ -245,6 +245,7 @@ export type { UseLayout, UseLayoutOptions } from "./layout/useLayout"
245
245
  export {
246
246
  applyTheme,
247
247
  applyThemeWithTransition,
248
+ revealTransition,
248
249
  bootstrapTheme,
249
250
  parseTheme,
250
251
  serializeTheme,
@@ -167,30 +167,28 @@ export interface RevealOrigin {
167
167
  }
168
168
 
169
169
  type ViewTransitionDocument = Document & {
170
- startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> }
170
+ startViewTransition?: (cb: () => void | Promise<void>) => { ready: Promise<void>; finished: Promise<void> }
171
171
  }
172
172
 
173
173
  /*
174
- * Apply a theme with the "circular reveal" transition — a clip-path circle
175
- * grows from `origin` (usually the toggle button's centre), repainting the page
176
- * underneath. Uses the View Transitions API; falls back to a plain applyTheme
177
- * when it's unsupported or the user prefers reduced motion (so behaviour is
178
- * identical, just without the animation). The actual token write still happens
179
- * through applyTheme, so this stays a thin wrapper — no duplicated var logic.
174
+ * Run `update` behind a "circular reveal": a clip-path circle grows from
175
+ * `origin` (viewport px, the centre when omitted) and shows the repainted page
176
+ * underneath. Only what `update` changed looks different, so repainting one
177
+ * subtree reveals just that subtree. Uses the View Transitions API and falls
178
+ * back to a plain update when it's unsupported or the user prefers reduced
179
+ * motion. `update` may be async (a framework flush); the snapshot waits for it.
180
180
  */
181
- export function applyThemeWithTransition(
182
- theme: TokenSet,
181
+ export function revealTransition(
182
+ update: () => void | Promise<void>,
183
183
  origin?: RevealOrigin,
184
- root: HTMLElement = document.documentElement,
185
184
  durationMs = 480,
186
185
  ): void {
187
186
  const doc = document as ViewTransitionDocument
188
187
  if (typeof doc.startViewTransition !== "function" || prefersReducedMotion()) {
189
- applyTheme(theme, root)
188
+ void update()
190
189
  return
191
190
  }
192
191
 
193
- // Centre of the viewport is the default origin if no click point is given.
194
192
  const x = origin?.x ?? window.innerWidth / 2
195
193
  const y = origin?.y ?? window.innerHeight / 2
196
194
  // Radius that reaches the farthest viewport corner from the origin.
@@ -199,13 +197,15 @@ export function applyThemeWithTransition(
199
197
  Math.max(y, window.innerHeight - y),
200
198
  )
201
199
 
202
- const transition = doc.startViewTransition(() => applyTheme(theme, root))
200
+ const transition = doc.startViewTransition(update)
203
201
  transition.ready.then(() => {
204
202
  // Animate the OLD snapshot shrinking to a point, revealing the (already
205
203
  // repainted) live page underneath through the growing hole. Clipping the
206
204
  // old layer — not growing the new one — avoids a one-frame flash of the new
207
205
  // theme before the circle starts, which read as the theme "changing twice".
208
- root.animate(
206
+ // The pseudo-elements belong to the document element whatever was updated,
207
+ // so the animation always targets it.
208
+ document.documentElement.animate(
209
209
  {
210
210
  clipPath: [`circle(${endRadius}px at ${x}px ${y}px)`, `circle(0px at ${x}px ${y}px)`],
211
211
  },
@@ -224,6 +224,20 @@ export function applyThemeWithTransition(
224
224
  })
225
225
  }
226
226
 
227
+ /*
228
+ * Apply a theme with the circular reveal, usually from the toggle button's
229
+ * centre. `root` scopes the theme to a subtree; the reveal still runs on the
230
+ * document, since that is where the transition snapshots live.
231
+ */
232
+ export function applyThemeWithTransition(
233
+ theme: TokenSet,
234
+ origin?: RevealOrigin,
235
+ root: HTMLElement = document.documentElement,
236
+ durationMs = 480,
237
+ ): void {
238
+ revealTransition(() => applyTheme(theme, root), origin, durationMs)
239
+ }
240
+
227
241
  export function serializeTheme(theme: TokenSet): string {
228
242
  return JSON.stringify(theme)
229
243
  }
@@ -1,99 +0,0 @@
1
- import { defineComponent, ref, watch, computed, openBlock, createBlock, unref, mergeProps, withCtx, createElementBlock, Fragment, renderList, normalizeClass } from "vue";
2
- import { PinInputRoot, PinInputInput } from "reka-ui";
3
- import { useFieldControl } from "../composables/useField.js";
4
- import { useMergedAttrs } from "../composables/useMergedClass.js";
5
- import { splitCode, joinCells } from "../components/otp.js";
6
- const __default__ = { inheritAttrs: false };
7
- const _sfc_main = /* @__PURE__ */ defineComponent({
8
- ...__default__,
9
- __name: "LpOtpInput",
10
- props: {
11
- modelValue: {},
12
- length: { default: 6 },
13
- disabled: { type: Boolean },
14
- mask: { type: Boolean },
15
- alphanumeric: { type: Boolean, default: false },
16
- invalid: { type: Boolean },
17
- size: { default: "md" }
18
- },
19
- emits: ["update:modelValue", "complete"],
20
- setup(__props, { emit: __emit }) {
21
- const props = __props;
22
- const emit = __emit;
23
- const local = ref(splitCode(props.modelValue, props.length));
24
- let emitted = props.modelValue ?? "";
25
- watch(
26
- () => props.modelValue ?? "",
27
- (value) => {
28
- if (value === emitted) return;
29
- emitted = value;
30
- local.value = splitCode(value, props.length);
31
- }
32
- );
33
- watch(
34
- () => props.length,
35
- (length) => {
36
- local.value = local.value.slice(0, length);
37
- }
38
- );
39
- const cells = computed({
40
- get: () => local.value,
41
- set: (arr) => {
42
- local.value = arr;
43
- emitted = joinCells(arr);
44
- emit("update:modelValue", emitted);
45
- }
46
- });
47
- const field = useFieldControl(() => ({
48
- wrapped: false,
49
- invalid: props.invalid,
50
- disabled: props.disabled
51
- }));
52
- function onComplete(arr) {
53
- emit("complete", joinCells(arr));
54
- }
55
- const cellSize = {
56
- sm: "size-9 text-base",
57
- md: "size-11 text-lg",
58
- lg: "size-13 text-xl"
59
- };
60
- const { class: rootClass, attrs: rest } = useMergedAttrs(
61
- "flex items-center gap-2"
62
- );
63
- return (_ctx, _cache) => {
64
- return openBlock(), createBlock(unref(PinInputRoot), mergeProps({
65
- modelValue: cells.value,
66
- "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => cells.value = $event),
67
- type: __props.alphanumeric ? "text" : "number",
68
- mask: __props.mask,
69
- disabled: unref(field).disabled,
70
- otp: "",
71
- class: unref(rootClass)
72
- }, unref(rest), { onComplete }), {
73
- default: withCtx(() => [
74
- (openBlock(true), createElementBlock(Fragment, null, renderList(__props.length, (i) => {
75
- return openBlock(), createBlock(unref(PinInputInput), {
76
- key: i,
77
- index: i - 1,
78
- id: i === 1 ? unref(field).id : void 0,
79
- "aria-invalid": unref(field).invalid || void 0,
80
- "aria-describedby": unref(field).describedBy,
81
- class: normalizeClass([
82
- cellSize[__props.size],
83
- "rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand",
84
- "outline-none transition-colors duration-[var(--duration-fast)]",
85
- "focus:border-brand focus:ring-2 focus:ring-ring",
86
- "disabled:cursor-not-allowed disabled:opacity-55",
87
- unref(field).invalid ? "border-danger focus:border-danger focus:ring-danger-soft" : "border-line"
88
- ])
89
- }, null, 8, ["index", "id", "aria-invalid", "aria-describedby", "class"]);
90
- }), 128))
91
- ]),
92
- _: 1
93
- }, 16, ["modelValue", "type", "mask", "disabled", "class"]);
94
- };
95
- }
96
- });
97
- export {
98
- _sfc_main as _
99
- };