@celestia-island/hikari 0.55.66 → 0.55.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.66",
3
+ "version": "0.55.67",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, defineComponent, h, nextTick, ref } from "vue";
2
+ import { createApp, defineComponent, h, nextTick, onMounted, ref } from "vue";
3
3
 
4
4
  import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
5
5
  import type { StepFlowSlotProps } from "./HkStepFlow";
@@ -198,6 +198,138 @@ describe("HkStepFlow", () => {
198
198
  });
199
199
  });
200
200
 
201
+ describe("HkStepFlow focus handoff on swap", () => {
202
+ /** Mount with an input in `b`; `a` optionally autofocuses its own field
203
+ * the moment the entering body mounts. */
204
+ function mountFocusRig(options: {
205
+ initial: string;
206
+ autoFocusEntry?: boolean;
207
+ hideTimeline?: boolean;
208
+ timelineClickable?: boolean;
209
+ }): { container: HTMLElement; setCurrent: (key: string) => void } {
210
+ const container = document.createElement("div");
211
+ document.body.appendChild(container);
212
+ containers.push(container);
213
+ const current = ref(options.initial);
214
+ const AutoField = defineComponent({
215
+ setup() {
216
+ const el = ref<HTMLInputElement | null>(null);
217
+ onMounted(() => el.value?.focus());
218
+ return () => h("input", { ref: el, class: "auto-input" });
219
+ },
220
+ });
221
+ const Wrapper = defineComponent({
222
+ setup() {
223
+ return () =>
224
+ h(
225
+ HkStepFlow,
226
+ {
227
+ steps: STEPS,
228
+ modelValue: current.value,
229
+ hideTimeline: options.hideTimeline ?? true,
230
+ timelineClickable: options.timelineClickable ?? false,
231
+ "onUpdate:modelValue": (key: string) => { current.value = key; },
232
+ },
233
+ {
234
+ a: () =>
235
+ options.autoFocusEntry
236
+ ? h(AutoField)
237
+ : h("p", { class: "step-body" }, "a-body"),
238
+ b: () => h("input", { class: "step-input" }),
239
+ c: () => h("p", { class: "step-body" }, "c-body"),
240
+ d: () => h("p", { class: "step-body" }, "d-body"),
241
+ },
242
+ );
243
+ },
244
+ });
245
+ const app = createApp(Wrapper);
246
+ mounts.push(app);
247
+ app.mount(container);
248
+ return { container, setCurrent: (key) => { current.value = key; } };
249
+ }
250
+
251
+ it("drops focus out of the leaving body when an animated swap starts", async () => {
252
+ // Round-22 chest report: the leaving body keeps its DOM node — and so
253
+ // its focus — through the whole slide, so a focused endpoint field had
254
+ // Android re-anchoring its autofill suggestion panel on every frame of
255
+ // the height morph (the panel strobed for the length of the swap). The
256
+ // motion stub matters: on the instant path the node is recycled at
257
+ // once and the browser drops focus by itself, which would make this
258
+ // assertion pass for the wrong reason.
259
+ stubMotion("0.15s");
260
+ const t = mountFocusRig({ initial: "b" });
261
+ const input = t.container.querySelector<HTMLInputElement>(".step-input");
262
+ expect(input).not.toBeNull();
263
+ input!.focus();
264
+ expect(document.activeElement).toBe(input);
265
+
266
+ t.setCurrent("a");
267
+ await flushSwap();
268
+
269
+ // The slide is still in flight, so the leaving body — and its input —
270
+ // are still mounted; that is exactly the window that used to strobe.
271
+ expect(t.container.contains(input)).toBe(true);
272
+ expect(document.activeElement).not.toBe(input);
273
+ });
274
+
275
+ it("keeps focus on the timeline step that drove the change", async () => {
276
+ // The timeline sits inside the flow but is NOT leaving the stage: a
277
+ // keyboard or pointer user who activated a completed step must keep
278
+ // their focus. Blurring it drops them to <body> — the next Tab
279
+ // restarts at the top of the document, and HkModal's Tab trap stops
280
+ // engaging, so focus can escape the dialog.
281
+ stubMotion("0.15s");
282
+ const t = mountFocusRig({
283
+ initial: "c",
284
+ hideTimeline: false,
285
+ timelineClickable: true,
286
+ });
287
+ const stepEl = t.container.querySelector<HTMLElement>(
288
+ ".hk-timeline-step[data-clickable]",
289
+ );
290
+ expect(stepEl).not.toBeNull();
291
+ stepEl!.focus();
292
+ expect(document.activeElement).toBe(stepEl);
293
+
294
+ t.setCurrent("a");
295
+ await flushSwap();
296
+ expect(document.activeElement).toBe(stepEl);
297
+ });
298
+
299
+ it("lets the entering step's own autofocus win over the blur", async () => {
300
+ // The blur runs at the top of the swap watcher, before the entering
301
+ // body mounts, so a step that focuses its own first field on entry
302
+ // still wins. Moving the call below the mount turns this red.
303
+ stubMotion("0.15s");
304
+ const t = mountFocusRig({ initial: "b", autoFocusEntry: true });
305
+ const leaving = t.container.querySelector<HTMLInputElement>(".step-input");
306
+ expect(leaving).not.toBeNull();
307
+ leaving!.focus();
308
+ expect(document.activeElement).toBe(leaving);
309
+
310
+ t.setCurrent("a");
311
+ await flushSwap();
312
+
313
+ const entered = t.container.querySelector<HTMLInputElement>(".auto-input");
314
+ expect(entered).not.toBeNull();
315
+ expect(document.activeElement).toBe(entered);
316
+ });
317
+
318
+ it("leaves focus outside the flow alone", async () => {
319
+ const t = mountFocusRig({ initial: "a" });
320
+ const outside = document.createElement("input");
321
+ document.body.appendChild(outside);
322
+ containers.push(outside);
323
+
324
+ outside.focus();
325
+ expect(document.activeElement).toBe(outside);
326
+ t.setCurrent("b");
327
+ await flushSwap();
328
+ // Containment, not a blanket blur.
329
+ expect(document.activeElement).toBe(outside);
330
+ });
331
+ });
332
+
201
333
  describe("HkStepFlow split-window swap (motion enabled)", () => {
202
334
  it("slides both bodies across one window, then morphs the sheet after", async () => {
203
335
  stubMotion();
@@ -287,10 +287,39 @@ export default defineComponent({
287
287
  // Sticky-header whitespace strategy (2026-09-14): see HkScrollPin.
288
288
  const pinStrategy = ref<"offset" | "bleed">("bleed");
289
289
 
290
+ /** Drop focus out of the step bodies the moment a swap starts.
291
+ *
292
+ * The leaving body keeps its DOM node — and therefore its focus —
293
+ * for the whole slide window, and its input rides the cell while
294
+ * the sheet morphs its height. Android re-anchors an open autofill
295
+ * suggestion panel on every layout change, so a focused endpoint
296
+ * field made the system's autofill sheet strobe at roughly the
297
+ * frame rate for the entire animation (round-22 chest report: the
298
+ * panel toggled over ~94% of the sheet body for ~250ms while the
299
+ * step changed). Blurring before the first animated frame dismisses
300
+ * the panel outright; the field the user was typing in is leaving
301
+ * the stage anyway, so nothing is taken from them.
302
+ *
303
+ * Containment is the BODIES, not the flow: HkTimeline renders the
304
+ * completed steps as `role="button" tabindex="0"` inside this same
305
+ * element, and a timeline step that drove the change is not leaving
306
+ * the stage. Blurring it would drop a keyboard or pointer user to
307
+ * `<body>` — Tab restarts at the top of the document, and HkModal's
308
+ * Tab trap stops engaging so focus can escape the dialog. */
309
+ function dropFlowFocus(): void {
310
+ if (typeof document === "undefined") return; // SSR guard
311
+ const active = document.activeElement;
312
+ if (!(active instanceof HTMLElement)) return;
313
+ const body = active.closest(".hk-stepflow-body");
314
+ if (!body || !flowRef.value?.contains(body)) return;
315
+ active.blur();
316
+ }
317
+
290
318
  watch(
291
319
  () => props.modelValue,
292
320
  async (next, prev) => {
293
321
  if (next === prev) return;
322
+ dropFlowFocus();
294
323
  preemptSwap();
295
324
  const leaving = bodies.value.find((b) => b.phase === "active");
296
325
  if (!leaving) return;