@celestia-island/hikari 0.55.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.1",
3
+ "version": "0.55.2",
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",
@@ -109,7 +109,52 @@ describe("HkModalBreadcrumb overflow fence", () => {
109
109
  it("keeps the fold off the accessibility tree and the names on it", () => {
110
110
  // The clone duplicates every label — it must stay invisible to AT.
111
111
  expect(tsx).toMatch(/hk-modal-breadcrumb-measure"[^>]*aria-hidden="true"/);
112
- // A cut label still announces its whole name.
113
- expect(tsx).toMatch(/hk-modal-breadcrumb-sr-only/);
112
+ // A cut label IS a button, and a cut string is not a name: the whole
113
+ // layer name travels as its accessible name.
114
+ expect(tsx).toMatch(/aria-label=\{crumb\.label\}/);
115
+ // The tappable label must reach through the pointer-transparent strip.
116
+ expect(rule(".hk-modal-breadcrumb-item-reveal")).toContain("pointer-events: auto");
117
+ });
118
+
119
+ it("keeps the tappable label the same box the ruler measures", () => {
120
+ // The clone renders a plain span per label, so padding on the button
121
+ // would make the fold under-count its own crumb; the ~17px text line is
122
+ // also under half a fingertip, and this is the phone-primary
123
+ // affordance. Grow the hit area, not the chrome.
124
+ const block = rule(".hk-modal-breadcrumb-item-reveal");
125
+ // `padding: 0` exactly — the old `padding: 0 var(--space-4, …)` also
126
+ // satisfied a bare "padding: 0" substring, so the guard was vacuous.
127
+ expect(block).toMatch(/padding:\s*0;/);
128
+ expect(block).toContain("pointer-events: auto");
129
+ expect(block).toContain("text-align: start");
130
+ const hitArea = scss.match(/\.hk-modal-breadcrumb-item-reveal::after\s*{[^}]*}/)?.[0] ?? "";
131
+ expect(hitArea).toContain("position: absolute");
132
+ expect(hitArea).toContain("inset: calc(-1 * var(--space-8");
133
+ });
134
+
135
+ it("leaves the label palette to the item classes", () => {
136
+ // The button carries the item classes too, and an equally specific rule
137
+ // later in the file wins: redeclaring `color` here repainted every cut
138
+ // crumb (and dropped the current layer's accent) in exactly the
139
+ // long-title case this feature exists for (2026-09-16 final review).
140
+ const block = rule(".hk-modal-breadcrumb-item-reveal");
141
+ expect(block).not.toContain("color:");
142
+ expect(rule(".hk-modal-breadcrumb-item")).toContain(
143
+ "color: var(--hi-color-text-secondary",
144
+ );
145
+ expect(rule(".hk-modal-breadcrumb-item-current")).toContain(
146
+ "color: var(--hi-color-primary",
147
+ );
148
+ });
149
+
150
+ it("opens the revealed name in the same popover family as the menu", () => {
151
+ // Two surfaces, one form-factor rule: both are HkPopovers that dock as
152
+ // a sheet on mobile, anchored to the crumb they belong to.
153
+ expect(tsx.match(/<HkPopover/g)).toHaveLength(2);
154
+ expect(tsx.match(/sheetOnMobile/g)).toHaveLength(2);
155
+ // A revealed name wraps instead of cutting.
156
+ const block = rule(".hk-modal-breadcrumb-reveal");
157
+ expect(block).toContain("overflow-wrap: anywhere");
158
+ expect(block).not.toContain("text-overflow: ellipsis");
114
159
  });
115
160
  });
@@ -121,19 +121,60 @@
121
121
  --hk-menu-panel-inset: var(--space-4, 0.25rem);
122
122
  }
123
123
 
124
- /* Full layer name for assistive tech when the visible label is cut — the
125
- strip is an aria-live region, and "自动化测试流水线冒烟请巡…" announces as
126
- noise unless the whole name travels alongside it. */
127
- .hk-modal-breadcrumb-sr-only {
128
- position: absolute;
129
- width: 1px;
130
- height: 1px;
131
- margin: -1px;
124
+ /* A visible-but-cut label is tappable: it opens the whole name in the same
125
+ popover family as the hidden-layers menu. The strip is pointer-
126
+ transparent, so only these buttons (and the "…" trigger) take the click. */
127
+ .hk-modal-breadcrumb-item-reveal {
128
+ /* No padding: the strip's ruler measures a plain item, so a padded
129
+ button would make the fold under-count its own crumb. The hit area is
130
+ grown by a pseudo-element instead, exactly like the "…" trigger. */
131
+ margin: 0;
132
132
  padding: 0;
133
- overflow: hidden;
134
- clip: rect(0, 0, 0, 0);
135
- white-space: nowrap;
136
133
  border: 0;
134
+ border-radius: var(--radius-sm, 0.25rem);
135
+ background: transparent;
136
+ /* font-family only: the item classes above own size, weight AND colour —
137
+ redeclaring `color` here would repaint every cut crumb (and drop the
138
+ current layer's accent) because an equally specific rule later in the
139
+ file wins. */
140
+ font-family: inherit;
141
+ text-align: start;
142
+ cursor: pointer;
143
+ pointer-events: auto;
144
+ position: relative;
145
+ }
146
+
147
+ /* A label is one text line tall (~17px) — under half a fingertip, and this
148
+ is the phone-primary affordance. Grow the hit area, not the chrome. */
149
+ .hk-modal-breadcrumb-item-reveal::after {
150
+ content: "";
151
+ position: absolute;
152
+ inset: calc(-1 * var(--space-8, 0.5rem)) 0;
153
+ }
154
+
155
+ .hk-modal-breadcrumb-item-reveal:hover,
156
+ .hk-modal-breadcrumb-item-reveal[aria-expanded="true"] {
157
+ background: var(--hk-breadcrumb-more-hover, rgba(0, 0, 0, 0.06));
158
+ color: var(--hi-color-text, #1f2328);
159
+ }
160
+
161
+ .hk-modal-breadcrumb-item-reveal:focus-visible {
162
+ outline: 2px solid var(--hi-color-primary, #7aa2f7);
163
+ outline-offset: 1px;
164
+ }
165
+
166
+ /* The revealed name itself: the strip's own type, wrapping instead of
167
+ cutting, and selectable (the strip around it is not). */
168
+ .hk-modal-breadcrumb-reveal {
169
+ margin: 0;
170
+ padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
171
+ max-width: min(24rem, calc(100vw - 2 * var(--viewport-gutter, 1rem)));
172
+ font-size: var(--text-xs, 0.75rem);
173
+ font-weight: 600;
174
+ line-height: 1.5;
175
+ color: var(--hi-color-text, #1f2328);
176
+ overflow-wrap: anywhere;
177
+ user-select: text;
137
178
  }
138
179
 
139
180
  /* The strip's own ruler: every layer laid out at its natural width so the
@@ -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 for assistive tech when it cuts", async () => {
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<HTMLElement>(
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
- expect(first.querySelector('[aria-hidden="true"]')!.textContent).toBe(LONG_TAIL);
230
- expect(first.querySelector(".hk-modal-breadcrumb-sr-only")!.textContent).toBe(LONG);
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
- <span class={itemClass(crumb)}>
506
- {crumb.truncated ? (
507
- <>
508
- <span aria-hidden="true">{crumb.text}</span>
509
- <span class="hk-modal-breadcrumb-sr-only">{crumb.label}</span>
510
- </>
511
- ) : (
512
- crumb.text
513
- )}
514
- </span>
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
  },