@celestia-island/hikari 0.55.66 → 0.55.68
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,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;
|
|
@@ -332,9 +332,12 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
332
332
|
await settle();
|
|
333
333
|
// The pin landed at the new height with no height animation staged.
|
|
334
334
|
expect(h.frame.style.height).toBe("360px");
|
|
335
|
-
// The sweep runs: end-state clip (
|
|
335
|
+
// The sweep runs: end-state clip, carried on a DEMOTED frame (see the
|
|
336
|
+
// demotion note in startRevealSweep). The clip contract is unchanged —
|
|
337
|
+
// only the promotion comes off, so the edge travels as a main-thread
|
|
338
|
+
// paint that a phone GPU cannot drop the sheet's tiles out from under.
|
|
336
339
|
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
337
|
-
expect(h.frame.style.willChange).toBe("
|
|
340
|
+
expect(h.frame.style.willChange).toBe("");
|
|
338
341
|
expect(h.frame.style.transition).toBe("");
|
|
339
342
|
|
|
340
343
|
fireTransitionEnd(h.frame, "clip-path");
|
|
@@ -641,7 +644,9 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
641
644
|
h.remeasure();
|
|
642
645
|
expect(h.frame.style.height).toBe("450px");
|
|
643
646
|
expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
|
|
644
|
-
|
|
647
|
+
// Re-staging demotes again, and the remember-what-to-restore answer
|
|
648
|
+
// survives the re-stage (see startRevealSweep's idempotence note).
|
|
649
|
+
expect(h.frame.style.willChange).toBe("");
|
|
645
650
|
});
|
|
646
651
|
|
|
647
652
|
it("cancels a pending warmup when a second growth re-stages mid-warmup", async () => {
|
|
@@ -256,6 +256,11 @@ export function useSizeMorph(
|
|
|
256
256
|
let morphDemoted = false;
|
|
257
257
|
let morphSavedFrameWill = "";
|
|
258
258
|
let morphSavedRiderWills: Array<[HTMLElement, string]> = [];
|
|
259
|
+
/** The frame's promotion as it stood when the live sweep demoted it (see
|
|
260
|
+
* startRevealSweep): put back on landing, dropped outright when the open
|
|
261
|
+
* cycle unwinds first (clearResidentWill) so a sheet that is closing is
|
|
262
|
+
* never re-promoted by a sweep's own settle. */
|
|
263
|
+
let revealSavedWill = "";
|
|
259
264
|
/** Identity of the sweep currently staged/folding, echoed to consumers. */
|
|
260
265
|
let sweepSeq = 0;
|
|
261
266
|
let activeSweep = 0;
|
|
@@ -422,6 +427,12 @@ export function useSizeMorph(
|
|
|
422
427
|
} else {
|
|
423
428
|
revealEl.style.clipPath = "";
|
|
424
429
|
}
|
|
430
|
+
// Put the sweep's demotion back (see startRevealSweep). Empty means
|
|
431
|
+
// the frame carried no promotion to begin with — nothing to restore.
|
|
432
|
+
if (revealSavedWill) {
|
|
433
|
+
revealEl.style.willChange = revealSavedWill;
|
|
434
|
+
revealSavedWill = "";
|
|
435
|
+
}
|
|
425
436
|
}
|
|
426
437
|
const hadSweep = revealEl !== null;
|
|
427
438
|
const sweptId = activeSweep;
|
|
@@ -459,6 +470,9 @@ export function useSizeMorph(
|
|
|
459
470
|
morphSavedRiderWills = [];
|
|
460
471
|
morphSavedFrameWill = "";
|
|
461
472
|
morphDemoted = false;
|
|
473
|
+
// A sweep still in flight must not put its promotion back on a sheet
|
|
474
|
+
// whose open cycle is already unwinding (mirrors morphSavedFrameWill).
|
|
475
|
+
revealSavedWill = "";
|
|
462
476
|
for (const el of residentRideEls) {
|
|
463
477
|
el.style.willChange = "";
|
|
464
478
|
}
|
|
@@ -779,6 +793,31 @@ export function useSizeMorph(
|
|
|
779
793
|
});
|
|
780
794
|
const dir = revealDir;
|
|
781
795
|
const insetPx = Math.abs(delta);
|
|
796
|
+
// Demote the frame for the whole sweep window — staging, warmup and
|
|
797
|
+
// edge travel. A promoted layer runs the per-frame clip re-raster on
|
|
798
|
+
// the GPU, and a phone GPU drops tiles under it, so the sheet's
|
|
799
|
+
// surface goes missing for those frames and the page behind shows
|
|
800
|
+
// through it (round-23 chest report: 变高必闪、变矮几乎不闪, and the
|
|
801
|
+
// same viewport on a desktop engine does not reproduce at all). The
|
|
802
|
+
// height-morph path has always demoted for exactly this reason
|
|
803
|
+
// ("the height animation's per-frame paint happens on the main
|
|
804
|
+
// thread, which is slower but never leaves a frame where the sheet's
|
|
805
|
+
// surface is missing"); the clip sweep kept its promotion and was
|
|
806
|
+
// the last per-frame GPU re-raster of the full-width sheet. The
|
|
807
|
+
// demotion lands BEFORE the warmup on purpose: those two bus frames
|
|
808
|
+
// are the window the demoted layout's raster needs, exactly as they
|
|
809
|
+
// already are for the staged pin. The promotion goes back on landing
|
|
810
|
+
// (stopReveal). Note this is a per-sweep DEMOTION of a promotion that
|
|
811
|
+
// is still resident from arm time — not the per-sweep promotion
|
|
812
|
+
// #620's round-5 finding ruled out.
|
|
813
|
+
// Idempotent across a re-staged dance: the FIRST staging remembers
|
|
814
|
+
// what to put back and later ones keep that answer — the frame is a
|
|
815
|
+
// clip-mode sheet by construction here, so the resident promotion is
|
|
816
|
+
// `clip-path`. clearResidentWill blanks it outright when the open
|
|
817
|
+
// cycle unwinds first, which is what keeps a closing sheet from
|
|
818
|
+
// being re-promoted by its own sweep's settle.
|
|
819
|
+
if (!revealSavedWill) revealSavedWill = f.style.willChange || "clip-path";
|
|
820
|
+
f.style.willChange = "";
|
|
782
821
|
let framesLeft = REVEAL_WARMUP_FRAMES;
|
|
783
822
|
const armWarmup = (): void => {
|
|
784
823
|
revealWarmup = scheduleFrame(() => {
|