@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
|
@@ -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
|
|
113
|
-
|
|
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
|
-
/*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.hk-modal-breadcrumb-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
margin:
|
|
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
|
|
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
|
},
|