@celestia-island/hikari 0.55.0 → 0.55.2
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 +1 -1
- package/src/components/HkDrawer.tsx +13 -5
- package/src/components/HkModal.contenthold.test.tsx +156 -0
- package/src/components/HkModal.leavepin.test.tsx +169 -0
- package/src/components/HkModal.tsx +61 -11
- package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +47 -2
- package/src/components/HkModalBreadcrumb.scss +52 -11
- package/src/components/HkModalBreadcrumb.test.tsx +254 -5
- package/src/components/HkModalBreadcrumb.tsx +119 -10
- package/src/composables/useSizeMorph.test.ts +75 -3
- package/src/composables/useSizeMorph.ts +51 -4
- package/src/composables/useSurfaceContentHold.ts +58 -0
- package/src/composables/useSurfaceMachine.test.ts +50 -0
- package/src/composables/useSurfaceMachine.ts +35 -1
|
@@ -218,16 +218,20 @@ describe("HkModalBreadcrumb label budget", () => {
|
|
|
218
218
|
expect(labels()).toEqual(["abcdefghijklmnopqrst", "abcdefghijklmnopqr…"]);
|
|
219
219
|
});
|
|
220
220
|
|
|
221
|
-
it("keeps the whole layer name
|
|
221
|
+
it("keeps the whole layer name on a cut label", async () => {
|
|
222
222
|
manager.register("modal", true, LONG);
|
|
223
223
|
manager.register("modal", true, "细节");
|
|
224
224
|
await mountStrip();
|
|
225
225
|
const nav = strip()!;
|
|
226
|
-
const first = nav.querySelector<
|
|
227
|
-
":scope > .hk-modal-breadcrumb-crumb .hk-modal-breadcrumb-item",
|
|
226
|
+
const first = nav.querySelector<HTMLButtonElement>(
|
|
227
|
+
":scope > .hk-modal-breadcrumb-crumb button.hk-modal-breadcrumb-item",
|
|
228
228
|
)!;
|
|
229
|
-
|
|
230
|
-
expect(first.
|
|
229
|
+
// Rendered cut, announced whole: a truncated string is not a name.
|
|
230
|
+
expect(first.textContent).toBe(LONG_TAIL);
|
|
231
|
+
expect(first.getAttribute("aria-label")).toBe(LONG);
|
|
232
|
+
expect(first.getAttribute("aria-expanded")).toBe("false");
|
|
233
|
+
// An uncut label stays plain text — no new interaction surface.
|
|
234
|
+
expect(nav.querySelectorAll("button.hk-modal-breadcrumb-item")).toHaveLength(1);
|
|
231
235
|
// The measurement clone must never join the a11y tree.
|
|
232
236
|
expect(
|
|
233
237
|
nav.querySelector(".hk-modal-breadcrumb-measure")!.getAttribute("aria-hidden"),
|
|
@@ -252,6 +256,251 @@ describe("HkModalBreadcrumb label budget", () => {
|
|
|
252
256
|
}
|
|
253
257
|
});
|
|
254
258
|
|
|
259
|
+
it("reveals a cut label on the same crumb's popover", async () => {
|
|
260
|
+
setViewport(1200);
|
|
261
|
+
manager.register("modal", true, LONG);
|
|
262
|
+
manager.register("modal", true, "细节");
|
|
263
|
+
await mountStrip();
|
|
264
|
+
const cut = () =>
|
|
265
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!;
|
|
266
|
+
expect(document.body.querySelector(".hk-popover-panel")).toBeNull();
|
|
267
|
+
|
|
268
|
+
cut().click();
|
|
269
|
+
await nextTick();
|
|
270
|
+
await nextTick();
|
|
271
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
272
|
+
expect(panel).toBeTruthy();
|
|
273
|
+
// Desktop: an anchored popover carrying the WHOLE name, wrapping.
|
|
274
|
+
expect(panel.classList.contains("hk-is-sheet")).toBe(false);
|
|
275
|
+
expect(panel.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(LONG);
|
|
276
|
+
expect(cut().getAttribute("aria-expanded")).toBe("true");
|
|
277
|
+
|
|
278
|
+
// Tapping the same crumb again puts it away.
|
|
279
|
+
cut().click();
|
|
280
|
+
await nextTick();
|
|
281
|
+
await nextTick();
|
|
282
|
+
expect(cut().getAttribute("aria-expanded")).toBe("false");
|
|
283
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("docks the revealed name as a bottom sheet on mobile", async () => {
|
|
287
|
+
setViewport(360);
|
|
288
|
+
manager.register("modal", true, LONG);
|
|
289
|
+
manager.register("modal", true, "细节");
|
|
290
|
+
await mountStrip();
|
|
291
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
292
|
+
// Settle before asserting: the sheet's own registration re-folds the
|
|
293
|
+
// strip, and the surface must outlive that (see the phone test above).
|
|
294
|
+
await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") !== null);
|
|
295
|
+
await until(() => false, 150);
|
|
296
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
297
|
+
expect(panel.classList.contains("hk-is-sheet")).toBe(true);
|
|
298
|
+
expect(panel.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(LONG);
|
|
299
|
+
// It registers as a window layer like any other blocking sheet, and the
|
|
300
|
+
// name it reveals is the name it carries.
|
|
301
|
+
const blocking = [...manager.registry.value.values()].filter((entry) => entry.blocking);
|
|
302
|
+
expect(blocking.map((entry) => entry.title)).toContain(LONG);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("keeps the revealed name open on a phone, where its own sheet moves the fold", async () => {
|
|
306
|
+
// Opening the reveal docks a blocking sheet, the sheet joins the stack
|
|
307
|
+
// the strip lists, and the tail is re-decided underneath: the tapped
|
|
308
|
+
// crumb can end up behind the trigger in the same frame. The surface
|
|
309
|
+
// belongs to the LAYER, not to the fold — closing it there made the
|
|
310
|
+
// reveal cancel itself on every phone (2026-09-16 review).
|
|
311
|
+
setViewport(360);
|
|
312
|
+
manager.register("modal", true, LONG); // cut, and visible before the tap
|
|
313
|
+
manager.register("modal", true, "第二层");
|
|
314
|
+
manager.register("modal", true, "第三层");
|
|
315
|
+
await mountStrip();
|
|
316
|
+
const crumb = strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!;
|
|
317
|
+
expect(crumb.getAttribute("aria-label")).toBe(LONG);
|
|
318
|
+
|
|
319
|
+
crumb.click();
|
|
320
|
+
await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") !== null);
|
|
321
|
+
// Let every deferred consequence land (the sheet's registration, the
|
|
322
|
+
// re-measure, the fold) and assert the surface is STILL there.
|
|
323
|
+
await until(() => false, 200);
|
|
324
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(LONG);
|
|
325
|
+
expect(document.body.querySelector<HTMLElement>(".hk-popover-panel")!.classList.contains("hk-is-sheet")).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("closes the reveal when its layer stops being cut (retitle)", async () => {
|
|
329
|
+
setViewport(1200);
|
|
330
|
+
const layer = manager.register("modal", true, LONG);
|
|
331
|
+
manager.register("modal", true, "细节");
|
|
332
|
+
await mountStrip();
|
|
333
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
334
|
+
await nextTick();
|
|
335
|
+
await nextTick();
|
|
336
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
|
|
337
|
+
|
|
338
|
+
// The owner renames the window to something that fits: the crumb is
|
|
339
|
+
// plain text again, so an open panel would be anchored to nothing while
|
|
340
|
+
// showing a name that no longer exists.
|
|
341
|
+
manager.setTitle(layer.id, "短标题");
|
|
342
|
+
await nextTick();
|
|
343
|
+
await nextTick();
|
|
344
|
+
expect(strip()!.querySelectorAll("button.hk-modal-breadcrumb-item")).toHaveLength(0);
|
|
345
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("follows a retitle that is still cut", async () => {
|
|
349
|
+
setViewport(1200);
|
|
350
|
+
const layer = manager.register("modal", true, LONG);
|
|
351
|
+
manager.register("modal", true, "细节");
|
|
352
|
+
await mountStrip();
|
|
353
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
354
|
+
await nextTick();
|
|
355
|
+
await nextTick();
|
|
356
|
+
const longer = "另一个非常长的层级标题占位一二三四五";
|
|
357
|
+
manager.setTitle(layer.id, longer);
|
|
358
|
+
await nextTick();
|
|
359
|
+
await nextTick();
|
|
360
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")!.textContent).toBe(longer);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does not reopen the reveal after the strip comes back", async () => {
|
|
364
|
+
// The strip unmounts when the stack drops to one layer: the reveal goes
|
|
365
|
+
// with it (its surface is rendered inside), and a stale `revealed` would
|
|
366
|
+
// otherwise spring back — anchored to the element that no longer exists
|
|
367
|
+
// — the moment another window opens (review round two).
|
|
368
|
+
setViewport(1200);
|
|
369
|
+
manager.register("modal", true, LONG);
|
|
370
|
+
const other = manager.register("modal", true, "细节");
|
|
371
|
+
await mountStrip();
|
|
372
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
373
|
+
await nextTick();
|
|
374
|
+
await nextTick();
|
|
375
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
|
|
376
|
+
|
|
377
|
+
manager.unregister(other.id); // one layer left: the strip goes away
|
|
378
|
+
await nextTick();
|
|
379
|
+
expect(strip()).toBeNull();
|
|
380
|
+
manager.register("modal", true, "新来的层级");
|
|
381
|
+
await nextTick();
|
|
382
|
+
await nextTick();
|
|
383
|
+
expect(strip()).not.toBeNull();
|
|
384
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("closes the anchored reveal when its crumb folds behind the trigger", async () => {
|
|
388
|
+
// A fold that unmounts the anchor would leave the anchored panel
|
|
389
|
+
// measuring a detached 0×0 rect on its next reposition, which parks it
|
|
390
|
+
// in the viewport corner (review round two). The sheet form needs no
|
|
391
|
+
// anchor and keeps the name (see the phone test above).
|
|
392
|
+
setViewport(1200);
|
|
393
|
+
manager.register("modal", true, LONG);
|
|
394
|
+
manager.register("modal", true, "第二个很长的层级标题占位一二三");
|
|
395
|
+
manager.register("modal", true, "第三个很长的层级标题占位一二三");
|
|
396
|
+
manager.register("modal", true, "细节");
|
|
397
|
+
await mountStrip();
|
|
398
|
+
expect(more()).toBeNull();
|
|
399
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
400
|
+
await nextTick();
|
|
401
|
+
await nextTick();
|
|
402
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
|
|
403
|
+
|
|
404
|
+
// More windows push the tapped crumb behind the trigger (the fold is
|
|
405
|
+
// re-decided a tick after the labels land, then rendered).
|
|
406
|
+
STACK_TITLES.forEach((title) => manager.register("modal", true, title));
|
|
407
|
+
await until(() => more() !== null, 300);
|
|
408
|
+
expect(more()).not.toBeNull();
|
|
409
|
+
expect(strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")).not.toBeNull();
|
|
410
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("closes an open reveal when the folded-layers menu opens", async () => {
|
|
414
|
+
// On a phone the strip paints above the sheet's scrim, so its trigger
|
|
415
|
+
// stays tappable while a reveal is docked (review round two). The menu
|
|
416
|
+
// must take over from the reveal, not stack on top of it: one Escape,
|
|
417
|
+
// one surface.
|
|
418
|
+
setViewport(360);
|
|
419
|
+
[LONG, LONG, LONG, LONG].forEach((title, i) =>
|
|
420
|
+
manager.register("modal", true, `${i}:${title}`),
|
|
421
|
+
);
|
|
422
|
+
await mountStrip();
|
|
423
|
+
expect(more()).not.toBeNull();
|
|
424
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
425
|
+
await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") !== null);
|
|
426
|
+
expect(more()!.getAttribute("aria-expanded")).toBe("false");
|
|
427
|
+
|
|
428
|
+
more()!.click();
|
|
429
|
+
await nextTick();
|
|
430
|
+
await nextTick();
|
|
431
|
+
expect(more()!.getAttribute("aria-expanded")).toBe("true");
|
|
432
|
+
// The reveal's own sheet plays its leave out before the DOM drops it —
|
|
433
|
+
// and with nothing closing it, it never does (this assertion is what
|
|
434
|
+
// pins the symmetric close).
|
|
435
|
+
await until(() => document.body.querySelector(".hk-modal-breadcrumb-reveal") === null, 900);
|
|
436
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("closes whichever surface is open on Escape", async () => {
|
|
440
|
+
setViewport(1200);
|
|
441
|
+
STACK_TITLES.forEach((title) => manager.register("modal", true, title));
|
|
442
|
+
manager.register("modal", true, "细节");
|
|
443
|
+
await mountStrip();
|
|
444
|
+
|
|
445
|
+
// The folded-layers menu (anchored: its panel takes no focus, so the
|
|
446
|
+
// strip owns the key).
|
|
447
|
+
more()!.click();
|
|
448
|
+
await nextTick();
|
|
449
|
+
await nextTick();
|
|
450
|
+
expect(more()!.getAttribute("aria-expanded")).toBe("true");
|
|
451
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
452
|
+
await nextTick();
|
|
453
|
+
expect(more()!.getAttribute("aria-expanded")).toBe("false");
|
|
454
|
+
|
|
455
|
+
// The revealed name.
|
|
456
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
457
|
+
await nextTick();
|
|
458
|
+
await nextTick();
|
|
459
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
|
|
460
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
461
|
+
await nextTick();
|
|
462
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("closes the reveal when its layer leaves the stack", async () => {
|
|
466
|
+
setViewport(1200);
|
|
467
|
+
const layer = manager.register("modal", true, LONG);
|
|
468
|
+
manager.register("modal", true, "细节");
|
|
469
|
+
await mountStrip();
|
|
470
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
471
|
+
await nextTick();
|
|
472
|
+
await nextTick();
|
|
473
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
|
|
474
|
+
|
|
475
|
+
// The window closes under the open panel: there is no name left to
|
|
476
|
+
// reveal.
|
|
477
|
+
manager.unregister(layer.id);
|
|
478
|
+
await nextTick();
|
|
479
|
+
await nextTick();
|
|
480
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("opens the reveal instead of the hidden-layers menu", async () => {
|
|
484
|
+
// The two surfaces are never open together: tapping a crumb closes the
|
|
485
|
+
// menu it was opened from (its leave plays out, so the logical state is
|
|
486
|
+
// what the assertion reads).
|
|
487
|
+
setViewport(800);
|
|
488
|
+
STACK_TITLES.forEach((title) => manager.register("modal", true, title));
|
|
489
|
+
manager.register("modal", true, "细节");
|
|
490
|
+
await mountStrip();
|
|
491
|
+
more()!.click();
|
|
492
|
+
await nextTick();
|
|
493
|
+
await nextTick();
|
|
494
|
+
expect(menuRows().length).toBeGreaterThan(0);
|
|
495
|
+
expect(more()!.getAttribute("aria-expanded")).toBe("true");
|
|
496
|
+
|
|
497
|
+
strip()!.querySelector<HTMLButtonElement>("button.hk-modal-breadcrumb-item")!.click();
|
|
498
|
+
await nextTick();
|
|
499
|
+
await nextTick();
|
|
500
|
+
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeTruthy();
|
|
501
|
+
expect(more()!.getAttribute("aria-expanded")).toBe("false");
|
|
502
|
+
});
|
|
503
|
+
|
|
255
504
|
it("honours a narrower budget from the host", async () => {
|
|
256
505
|
manager.register("modal", true, "自动化测试流水线冒烟");
|
|
257
506
|
manager.register("modal", true, "细节");
|
|
@@ -14,6 +14,7 @@ import { useReportedTransition } from "../composables/useReportedTransition";
|
|
|
14
14
|
import { scheduleEvery } from "../runtime/animationBus";
|
|
15
15
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
16
16
|
import { viewportGutterPx } from "../runtime/viewportGutter";
|
|
17
|
+
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
17
18
|
import { clampToDisplayWidth, displayWidthUnits, ELLIPSIS } from "../runtime/displayWidth";
|
|
18
19
|
import HkPopover from "./HkPopover";
|
|
19
20
|
import HkMenuPanel from "./HkMenuPanel";
|
|
@@ -69,6 +70,7 @@ export default defineComponent({
|
|
|
69
70
|
setup(props) {
|
|
70
71
|
const manager = usePopupManager();
|
|
71
72
|
const { t } = useI18n();
|
|
73
|
+
const { isMobile } = useBreakpoint();
|
|
72
74
|
|
|
73
75
|
/** Which entries the strip navigates. Windows (modal/drawer) always;
|
|
74
76
|
* dropdown-kind surfaces only while they BLOCK like a window — the
|
|
@@ -274,6 +276,12 @@ export default defineComponent({
|
|
|
274
276
|
|
|
275
277
|
function openMenu(): void {
|
|
276
278
|
if (!hidden.value.length) return;
|
|
279
|
+
// The two surfaces are never open together — and the symmetric close
|
|
280
|
+
// matters on a phone: the strip paints above the sheet's scrim, so
|
|
281
|
+
// its trigger stays tappable while a reveal is docked (review round
|
|
282
|
+
// two), and a menu opening underneath it would leave the pair
|
|
283
|
+
// fighting over one Escape.
|
|
284
|
+
revealed.value = null;
|
|
277
285
|
menuItems.value = hidden.value.map(({ id, label }) => ({ id, label }));
|
|
278
286
|
menuOpen.value = true;
|
|
279
287
|
}
|
|
@@ -294,6 +302,79 @@ export default defineComponent({
|
|
|
294
302
|
if (!stillFolded) menuOpen.value = false;
|
|
295
303
|
});
|
|
296
304
|
|
|
305
|
+
// ── Truncated-name reveal ─────────────────────────────────────────
|
|
306
|
+
// A cut label travels whole to assistive tech, and the folded layers
|
|
307
|
+
// get the menu above — but a layer that is VISIBLE and still cut had no
|
|
308
|
+
// way to read it. Tapping such a crumb opens the same popover family
|
|
309
|
+
// (anchored under the crumb on desktop, bottom-up sheet on mobile) with
|
|
310
|
+
// the whole name, because the strip's own bar is not a place long text
|
|
311
|
+
// can be read from.
|
|
312
|
+
const revealed = ref<{ id: string; label: string } | null>(null);
|
|
313
|
+
const revealAnchor = ref<HTMLElement | null>(null);
|
|
314
|
+
const crumbEls = new Map<string, HTMLElement>();
|
|
315
|
+
|
|
316
|
+
function setCrumbEl(id: string, el: Element | null): void {
|
|
317
|
+
if (el) crumbEls.set(id, el as HTMLElement);
|
|
318
|
+
else crumbEls.delete(id);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function toggleReveal(crumb: Crumb): void {
|
|
322
|
+
if (revealed.value?.id === crumb.id) {
|
|
323
|
+
revealed.value = null;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!crumbEls.has(crumb.id)) return;
|
|
327
|
+
menuOpen.value = false;
|
|
328
|
+
revealAnchor.value = crumbEls.get(crumb.id) ?? null;
|
|
329
|
+
revealed.value = { id: crumb.id, label: crumb.label };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// What keeps the reveal alive is the LAYER, never the fold: opening it
|
|
333
|
+
// on a phone docks a blocking sheet, which joins the stack the strip
|
|
334
|
+
// lists and re-decides the tail underneath — a fold that carried the
|
|
335
|
+
// tapped crumb behind the trigger used to close the surface in the same
|
|
336
|
+
// frame it opened (2026-09-16 review). It goes away when the layer
|
|
337
|
+
// itself does, and when the layer stops being cut in the first place
|
|
338
|
+
// (a retitle makes its crumb plain text again — an open panel would
|
|
339
|
+
// then be anchored to nothing while showing a name that no longer
|
|
340
|
+
// exists); a retitle that keeps cutting follows the live name.
|
|
341
|
+
watch([crumbs, tail], ([list, visibleTail]) => {
|
|
342
|
+
const open = revealed.value;
|
|
343
|
+
if (!open) return;
|
|
344
|
+
const crumb = list.find((entry) => entry.id === open.id);
|
|
345
|
+
if (!crumb || !crumb.truncated) {
|
|
346
|
+
revealed.value = null;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// An ANCHORED panel whose crumb folded behind the trigger would keep
|
|
350
|
+
// a detached element as its anchor, and the next reposition (a
|
|
351
|
+
// resize, a panel resize) would measure a 0×0 rect and fly the panel
|
|
352
|
+
// into the viewport corner (review round two). The mobile sheet
|
|
353
|
+
// needs no anchor and keeps showing the name.
|
|
354
|
+
if (!isMobile.value && !visibleTail.some((entry) => entry.id === open.id)) {
|
|
355
|
+
revealed.value = null;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (crumb.label !== open.label) {
|
|
359
|
+
revealed.value = { id: crumb.id, label: crumb.label };
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Escape closes whichever strip surface is open. HkPopover owns Escape
|
|
364
|
+
// for its SHEET form (the panel takes focus there); the anchored form
|
|
365
|
+
// has no focusable panel, so a document listener covers both.
|
|
366
|
+
function onSurfaceKeydown(e: KeyboardEvent) {
|
|
367
|
+
if (e.key !== "Escape") return;
|
|
368
|
+
if (revealed.value) revealed.value = null;
|
|
369
|
+
else if (menuOpen.value) menuOpen.value = false;
|
|
370
|
+
}
|
|
371
|
+
const surfaceOpen = computed(() => revealed.value !== null || menuOpen.value);
|
|
372
|
+
watch(surfaceOpen, (open) => {
|
|
373
|
+
if (open) document.addEventListener("keydown", onSurfaceKeydown);
|
|
374
|
+
else document.removeEventListener("keydown", onSurfaceKeydown);
|
|
375
|
+
});
|
|
376
|
+
onBeforeUnmount(() => document.removeEventListener("keydown", onSurfaceKeydown));
|
|
377
|
+
|
|
297
378
|
const topPx = ref(24);
|
|
298
379
|
function resyncTop() {
|
|
299
380
|
const app = document.getElementById(props.appRootId);
|
|
@@ -394,6 +475,7 @@ export default defineComponent({
|
|
|
394
475
|
window.removeEventListener("resize", onViewportChange);
|
|
395
476
|
releaseClone();
|
|
396
477
|
menuOpen.value = false;
|
|
478
|
+
revealed.value = null;
|
|
397
479
|
hiddenCount.value = 0;
|
|
398
480
|
}
|
|
399
481
|
},
|
|
@@ -502,16 +584,23 @@ export default defineComponent({
|
|
|
502
584
|
{/* A chevron separates two items — the FIRST rendered item
|
|
503
585
|
carries none, whether or not the trigger precedes it. */}
|
|
504
586
|
{(triggerShown.value || i > 0) && separator()}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
587
|
+
{crumb.truncated ? (
|
|
588
|
+
// Cut label: tappable, and the full name is the button's
|
|
589
|
+
// accessible name (a cut string is not a name).
|
|
590
|
+
<button
|
|
591
|
+
type="button"
|
|
592
|
+
ref={(el) => setCrumbEl(crumb.id, el as Element | null)}
|
|
593
|
+
class={`${itemClass(crumb)} hk-modal-breadcrumb-item-reveal`}
|
|
594
|
+
aria-haspopup="dialog"
|
|
595
|
+
aria-expanded={revealed.value?.id === crumb.id}
|
|
596
|
+
aria-label={crumb.label}
|
|
597
|
+
onClick={() => toggleReveal(crumb)}
|
|
598
|
+
>
|
|
599
|
+
{crumb.text}
|
|
600
|
+
</button>
|
|
601
|
+
) : (
|
|
602
|
+
<span class={itemClass(crumb)}>{crumb.text}</span>
|
|
603
|
+
)}
|
|
515
604
|
</span>
|
|
516
605
|
))}
|
|
517
606
|
</nav>
|
|
@@ -542,6 +631,26 @@ export default defineComponent({
|
|
|
542
631
|
))}
|
|
543
632
|
</HkMenuPanel>
|
|
544
633
|
</HkPopover>
|
|
634
|
+
<HkPopover
|
|
635
|
+
modelValue={revealed.value !== null}
|
|
636
|
+
onUpdate:modelValue={(v: boolean) => {
|
|
637
|
+
if (!v) revealed.value = null;
|
|
638
|
+
}}
|
|
639
|
+
anchorRef={revealAnchor.value}
|
|
640
|
+
placement="bottom-start"
|
|
641
|
+
// Same clearance as the menu: the crumb sits inside the strip's
|
|
642
|
+
// own padding box, which paints above the anchored band.
|
|
643
|
+
offset={16}
|
|
644
|
+
sheetOnMobile
|
|
645
|
+
// The name is the surface's reason to exist, so it names it.
|
|
646
|
+
// The sheet heading ellipsises (it is chrome) — the panel body
|
|
647
|
+
// below carries the whole name and wraps.
|
|
648
|
+
title={revealed.value?.label ?? ""}
|
|
649
|
+
>
|
|
650
|
+
{revealed.value && (
|
|
651
|
+
<p class="hk-modal-breadcrumb-reveal">{revealed.value.label}</p>
|
|
652
|
+
)}
|
|
653
|
+
</HkPopover>
|
|
545
654
|
</Teleport>
|
|
546
655
|
) : null;
|
|
547
656
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
import { useSizeMorph } from "./useSizeMorph";
|
|
4
|
+
import { useSizeMorph, type SizeMorphOptions } from "./useSizeMorph";
|
|
5
5
|
|
|
6
6
|
/** Injectable ResizeObserver: captures the callback so tests can fire
|
|
7
7
|
* content changes deterministically (happy-dom's own RO never fires —
|
|
@@ -35,10 +35,11 @@ interface Harness {
|
|
|
35
35
|
setContentNatural(height: number): void;
|
|
36
36
|
start(): void;
|
|
37
37
|
stop(): void;
|
|
38
|
+
hold(): void;
|
|
38
39
|
remeasure(): void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function mountHarness(initialHeight: number, initialContentHeight = 0): Harness {
|
|
42
|
+
function mountHarness(initialHeight: number, initialContentHeight = 0, options?: SizeMorphOptions): Harness {
|
|
42
43
|
const container = document.createElement("div");
|
|
43
44
|
document.body.appendChild(container);
|
|
44
45
|
let frameEl: HTMLElement | null = null;
|
|
@@ -50,7 +51,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness
|
|
|
50
51
|
setup() {
|
|
51
52
|
const frame = ref<HTMLElement | null>(null);
|
|
52
53
|
const content = ref<HTMLElement | null>(null);
|
|
53
|
-
morph = useSizeMorph(frame, content);
|
|
54
|
+
morph = useSizeMorph(frame, content, options);
|
|
54
55
|
return () =>
|
|
55
56
|
h("div", [
|
|
56
57
|
h("div", {
|
|
@@ -96,6 +97,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness
|
|
|
96
97
|
},
|
|
97
98
|
start: () => morph!.start(),
|
|
98
99
|
stop: () => morph!.stop(),
|
|
100
|
+
hold: () => morph!.hold(),
|
|
99
101
|
remeasure: () => morph!.remeasure(),
|
|
100
102
|
};
|
|
101
103
|
}
|
|
@@ -427,3 +429,73 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
427
429
|
expect(h.frame.style.clipPath).toBe("");
|
|
428
430
|
});
|
|
429
431
|
});
|
|
432
|
+
|
|
433
|
+
// ── Leave-window hold + enter-window defer (2026-09-16 modal report) ──
|
|
434
|
+
// The modal's close fold owns the frame's geometry for the whole leave:
|
|
435
|
+
// hold() keeps the pin (a mid-leave content change must not resize the
|
|
436
|
+
// folding frame) and the reopen-interrupt path animates FROM that pin.
|
|
437
|
+
// The enter unfold is height-relative geometry (translateY 5% + bottom
|
|
438
|
+
// 10% clip of the frame height), so deferRemeasure freezes resize-driven
|
|
439
|
+
// re-pins through the enter and the open edge flushes them.
|
|
440
|
+
|
|
441
|
+
describe("useSizeMorph hold + deferRemeasure", () => {
|
|
442
|
+
it("hold keeps the pin and stops observing (leave-window stability)", () => {
|
|
443
|
+
const h = mountHarness(120, 100);
|
|
444
|
+
h.start();
|
|
445
|
+
expect(h.frame.style.height).toBe("120px");
|
|
446
|
+
|
|
447
|
+
h.hold();
|
|
448
|
+
// The pin STAYS (unlike stop's release to auto)…
|
|
449
|
+
expect(h.frame.style.height).toBe("120px");
|
|
450
|
+
// …and the observer is disarmed: no content change can re-pin.
|
|
451
|
+
expect(FakeResizeObserver.instances[0]!.disconnected).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("stop after a hold still releases (full-close bookkeeping)", () => {
|
|
455
|
+
const h = mountHarness(120, 100);
|
|
456
|
+
h.start();
|
|
457
|
+
h.hold();
|
|
458
|
+
h.stop();
|
|
459
|
+
expect(h.frame.style.height).toBe("");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("start after a hold re-arms and animates from the held pin", () => {
|
|
463
|
+
const h = mountHarness(120, 100);
|
|
464
|
+
h.start();
|
|
465
|
+
h.hold();
|
|
466
|
+
// Reopen interrupt: the natural height changed while held — the next
|
|
467
|
+
// arm re-pins to it (from the held 120px, per the dance's re-pin).
|
|
468
|
+
h.setNatural(200);
|
|
469
|
+
h.start();
|
|
470
|
+
expect(h.frame.style.height).toBe("200px");
|
|
471
|
+
// A FRESH observer owns the new cycle.
|
|
472
|
+
expect(FakeResizeObserver.instances.length).toBe(2);
|
|
473
|
+
expect(FakeResizeObserver.instances[1]!.disconnected).toBe(false);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("deferRemeasure freezes RO-driven re-pins; explicit remeasure flushes", async () => {
|
|
477
|
+
let gated = true;
|
|
478
|
+
const h = mountHarness(120, 100, { deferRemeasure: () => gated });
|
|
479
|
+
h.start();
|
|
480
|
+
// The initial pin is NOT gated (arming must pin immediately).
|
|
481
|
+
expect(h.frame.style.height).toBe("120px");
|
|
482
|
+
|
|
483
|
+
// Content streams in mid-enter: the RO fires but the pin must not
|
|
484
|
+
// move — the unfold's height-relative geometry stays put.
|
|
485
|
+
h.setNatural(160);
|
|
486
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
487
|
+
await settle();
|
|
488
|
+
expect(h.frame.style.height).toBe("120px");
|
|
489
|
+
|
|
490
|
+
// The open edge flushes the deferred growth.
|
|
491
|
+
h.remeasure();
|
|
492
|
+
expect(h.frame.style.height).toBe("160px");
|
|
493
|
+
|
|
494
|
+
// Gate off: RO-driven updates flow again.
|
|
495
|
+
gated = false;
|
|
496
|
+
h.setNatural(200);
|
|
497
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
498
|
+
await settle();
|
|
499
|
+
expect(h.frame.style.height).toBe("200px");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
@@ -17,16 +17,41 @@ export interface SizeMorph {
|
|
|
17
17
|
/** Arm the morph: observe the content and pin the frame's natural
|
|
18
18
|
* height on every change. Call once the surface finished its open
|
|
19
19
|
* enter transition — pinning during enter would override the
|
|
20
|
-
* choreography's own height animation.
|
|
20
|
+
* choreography's own height animation. (Surfaces whose enter is
|
|
21
|
+
* height-INDEPENDENT — HkModal's clip+transform unfold — may arm
|
|
22
|
+
* earlier with a `deferRemeasure` gate, see below.) */
|
|
21
23
|
start(): void;
|
|
22
24
|
/** Disarm the morph and release the frame to `height: auto` — call
|
|
23
25
|
* before a surface's leave/close so the exit animation owns the
|
|
24
26
|
* height again. */
|
|
25
27
|
stop(): void;
|
|
28
|
+
/** Leave-window hold: disarm the observer/timers and cancel any
|
|
29
|
+
* mid-flight reveal like stop(), but KEEP the height pin — the close
|
|
30
|
+
* choreography owns the frame's geometry through the whole leave and
|
|
31
|
+
* the pin keeps it stable (a mid-leave content change must not resize
|
|
32
|
+
* the folding frame). The next start() re-arms and, because the pin
|
|
33
|
+
* was kept, a re-pin to the SAME natural height is a no-op (the
|
|
34
|
+
* typical reopen-interrupt case — held content is unchanged, delta
|
|
35
|
+
* zero; a re-pin to a CHANGED natural while an enter's transition
|
|
36
|
+
* classes own the frame lands without a height animation, i.e. snaps
|
|
37
|
+
* — acceptable, the enter's own choreography owns that moment). */
|
|
38
|
+
hold(): void;
|
|
26
39
|
/** Re-measure and pin now (resize events, open flows). */
|
|
27
40
|
remeasure(): void;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
export interface SizeMorphOptions {
|
|
44
|
+
/** While this returns true, resize-driven re-measurements are deferred
|
|
45
|
+
* (the current pin stays). HkModal freezes the frame's height during
|
|
46
|
+
* the enter unfold: the choreography's translateY(5%) + bottom-10%
|
|
47
|
+
* clip are fractions of the frame height, so late-streaming content
|
|
48
|
+
* resizing the frame mid-enter would recompute the geometry under the
|
|
49
|
+
* running animation (2026-09-16 chest field report — the frame snapped
|
|
50
|
+
* 459→697px mid-unfold). The caller flushes the deferred growth with
|
|
51
|
+
* an explicit remeasure() at the open edge. */
|
|
52
|
+
deferRemeasure?: () => boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
/**
|
|
31
56
|
* useSizeMorph — smooth size morphing for content-hugging surfaces.
|
|
32
57
|
*
|
|
@@ -71,6 +96,7 @@ export interface SizeMorph {
|
|
|
71
96
|
export function useSizeMorph(
|
|
72
97
|
frame: Ref<HTMLElement | null | undefined>,
|
|
73
98
|
content: Ref<HTMLElement | null | undefined>,
|
|
99
|
+
options: SizeMorphOptions = {},
|
|
74
100
|
): SizeMorph {
|
|
75
101
|
let ro: ResizeObserver | null = null;
|
|
76
102
|
let raf = 0;
|
|
@@ -155,7 +181,13 @@ export function useSizeMorph(
|
|
|
155
181
|
* finished) frame — the only moment guaranteed free of transition-class
|
|
156
182
|
* flex rules. Never calibrate from a remeasure sample: the first
|
|
157
183
|
* remeasure can itself be the contaminated one (a frozen enter leaves
|
|
158
|
-
* the flex rules behind when the surface is mid-repair).
|
|
184
|
+
* the flex rules behind when the surface is mid-repair). HkModal's
|
|
185
|
+
* reopen-interrupt arm violates the precondition on purpose (it
|
|
186
|
+
* calibrates a pinned, enter-classed frame): benign today because no
|
|
187
|
+
* current enter/leave class carries height/flex rules (the inline pin
|
|
188
|
+
* beats the mobile sheet's static height:auto), and the contamination
|
|
189
|
+
* guard below degrades any future SCSS regression to a dropped pin
|
|
190
|
+
* rather than a corrupted one. */
|
|
159
191
|
function calibrate(): void {
|
|
160
192
|
const f = frame.value;
|
|
161
193
|
const c = content.value;
|
|
@@ -271,6 +303,10 @@ export function useSizeMorph(
|
|
|
271
303
|
}
|
|
272
304
|
|
|
273
305
|
function onResize(): void {
|
|
306
|
+
// Frozen window (e.g. HkModal's enter unfold): keep the current pin;
|
|
307
|
+
// the caller flushes the accumulated change with an explicit
|
|
308
|
+
// remeasure() once the choreography hands the height back.
|
|
309
|
+
if (options.deferRemeasure?.()) return;
|
|
274
310
|
// Debounce the choreography: content can change in a burst (a list
|
|
275
311
|
// transition shrinking rows over several frames, a textarea growing
|
|
276
312
|
// per keystroke). Dancing to every intermediate would restart the
|
|
@@ -304,7 +340,7 @@ export function useSizeMorph(
|
|
|
304
340
|
remeasure();
|
|
305
341
|
}
|
|
306
342
|
|
|
307
|
-
function
|
|
343
|
+
function hold(): void {
|
|
308
344
|
if (!armed) return;
|
|
309
345
|
armed = false;
|
|
310
346
|
ro?.disconnect();
|
|
@@ -320,6 +356,17 @@ export function useSizeMorph(
|
|
|
320
356
|
// The next start() re-calibrates against whatever chrome that open
|
|
321
357
|
// cycle carries.
|
|
322
358
|
chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
359
|
+
stopReveal();
|
|
360
|
+
// Deliberately no release(): the pin stays on the frame so the close
|
|
361
|
+
// fold plays on a stable box, and a reopen interrupt animates from it.
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function stop(): void {
|
|
365
|
+
// Disarm only when armed — after hold() the morph is already
|
|
366
|
+
// disarmed and only the release is owed. release() is internally
|
|
367
|
+
// guarded (no pin / no frame → no-op), so a never-armed stop()
|
|
368
|
+
// stays the no-op it always was.
|
|
369
|
+
if (armed) hold();
|
|
323
370
|
release();
|
|
324
371
|
}
|
|
325
372
|
|
|
@@ -330,5 +377,5 @@ export function useSizeMorph(
|
|
|
330
377
|
stopReveal();
|
|
331
378
|
});
|
|
332
379
|
|
|
333
|
-
return { start, stop, remeasure };
|
|
380
|
+
return { start, stop, hold, remeasure };
|
|
334
381
|
}
|