@celestia-island/hikari 0.55.118 → 0.55.121
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/HkCopyBadge.tsx +2 -2
- package/src/components/HkModal.scss +10 -3
- package/src/components/HkModal.sheetgap.test.ts +15 -0
- package/src/components/HkModalBreadcrumb.test.tsx +58 -0
- package/src/components/HkModalBreadcrumb.tsx +33 -1
- package/src/components/HkTooltip.test.tsx +8 -7
- package/src/components/HkTooltip.tsx +16 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import HBadge, { type BadgeVariant } from "./HkBadge";
|
|
4
|
-
import HTooltip from "./HkTooltip";
|
|
4
|
+
import HTooltip, { type HkTooltipSlotProps } from "./HkTooltip";
|
|
5
5
|
import { useI18n } from "../i18n/context";
|
|
6
6
|
import { useClipboardWithToast } from "../runtime/useClipboard";
|
|
7
7
|
import { useToast } from "../runtime/useToast";
|
|
@@ -140,7 +140,7 @@ export default defineComponent({
|
|
|
140
140
|
if (props.disabled) return buildBadge();
|
|
141
141
|
return (
|
|
142
142
|
<HTooltip text={tooltipText.value}>
|
|
143
|
-
{(tip
|
|
143
|
+
{(tip?: HkTooltipSlotProps) =>
|
|
144
144
|
buildBadge(tip?.visible ? tip.popupId : undefined)}
|
|
145
145
|
</HTooltip>
|
|
146
146
|
);
|
|
@@ -434,7 +434,10 @@
|
|
|
434
434
|
// detail sheet never carries a screenful of dead body)
|
|
435
435
|
// - never grows past the top band reserved for the secondary-window
|
|
436
436
|
// breadcrumb strip (--hk-sheet-top-inset; default 4.25rem keeps the
|
|
437
|
-
// floating breadcrumb visible above a full-height sheet
|
|
437
|
+
// floating breadcrumb visible above a full-height sheet, and the
|
|
438
|
+
// strip itself PUBLISHES this property on <body> while it is up —
|
|
439
|
+
// its live "own height + one gap above + one gap below" — so the
|
|
440
|
+
// band tracks the real chrome instead of a static guess)
|
|
438
441
|
// - rounded top corners only, full width
|
|
439
442
|
// ------
|
|
440
443
|
@media (max-width: 767px) {
|
|
@@ -536,12 +539,16 @@
|
|
|
536
539
|
// inside once the cap is hit); they no longer stretch it.
|
|
537
540
|
// Plain-vh fallback first: dvh-less engines (older Android WebView /
|
|
538
541
|
// Tauri) would drop the whole dvh calc and leave the sheet uncapped.
|
|
542
|
+
// !important on BOTH: an inline max-height (a host's `style` pass, a
|
|
543
|
+
// size prop's own cap) must lose to the family band — the strip's
|
|
544
|
+
// daylight is a guarantee, not a default (HkDrawer parity, 2026-10-05
|
|
545
|
+
// mobile report: a tall sheet covered the strip's band).
|
|
539
546
|
max-height: calc(
|
|
540
547
|
100vh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
|
|
541
|
-
);
|
|
548
|
+
) !important;
|
|
542
549
|
max-height: calc(
|
|
543
550
|
100dvh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
|
|
544
|
-
);
|
|
551
|
+
) !important;
|
|
545
552
|
}
|
|
546
553
|
|
|
547
554
|
// Sheet slide reveal replaces the height-clip reveal on mobile.
|
|
@@ -74,6 +74,21 @@ describe("HkModal mobile sheet spacing contract", () => {
|
|
|
74
74
|
expect(content).not.toMatch(/max-height:\s*none/);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
// 2026-10-05 mobile report: a tall sheet covered the breadcrumb's band.
|
|
78
|
+
// The drawer cap has carried !important for exactly this reason (the
|
|
79
|
+
// inline maxHeight from a `size` prop must lose to the family band —
|
|
80
|
+
// HkDrawer.sheetfamily pins its side); the modal cap must not be the
|
|
81
|
+
// weak sibling. Any host style/prop pass writing max-height inline
|
|
82
|
+
// would silently uncap the sheet and swallow the strip's band.
|
|
83
|
+
it("beats inline max-heights with !important on both caps", () => {
|
|
84
|
+
// [^;]+ runs to the declaration's own semicolon, so the assertion
|
|
85
|
+
// sees whatever sits between the closing paren and it.
|
|
86
|
+
const vh = content.match(/max-height: calc\(\s*100vh - var\(--hk-sheet-top-inset[^;]+/)?.[0] ?? "";
|
|
87
|
+
const dvh = content.match(/max-height: calc\(\s*100dvh - var\(--hk-sheet-top-inset[^;]+/)?.[0] ?? "";
|
|
88
|
+
expect(vh, "plain-vh fallback cap").toContain("!important");
|
|
89
|
+
expect(dvh, "dvh cap").toContain("!important");
|
|
90
|
+
});
|
|
91
|
+
|
|
77
92
|
// 2026-09-21 phone-sheet transition contract (two Xiaomi WebView
|
|
78
93
|
// chest reports the same day): the mobile list carries ONLY the
|
|
79
94
|
// clip-path reveal.
|
|
@@ -18,6 +18,9 @@ afterEach(async () => {
|
|
|
18
18
|
manager.unregister(entry.id);
|
|
19
19
|
}
|
|
20
20
|
document.body.style.overflow = "";
|
|
21
|
+
// The strip publishes its sheet top band on <body> inline while up —
|
|
22
|
+
// strip it here too, or one test's band leaks into the next.
|
|
23
|
+
document.body.style.removeProperty("--hk-sheet-top-inset");
|
|
21
24
|
window.innerWidth = originalWidth;
|
|
22
25
|
// happy-dom never fires transitionend, so a popover's leave never settles
|
|
23
26
|
// and its teleported panel lingers on <body> — strip it, or the next
|
|
@@ -680,6 +683,11 @@ describe("HkModalBreadcrumb overflow fold", () => {
|
|
|
680
683
|
// zoom) / 2 = 24, and the clearance 12 + (88 visual / 2 zoom) / 2 =
|
|
681
684
|
// 34 — the clearance sits lower and wins.
|
|
682
685
|
expect(strip()!.style.top).toBe("44px");
|
|
686
|
+
// The published sheet band rides the same conversions: strip centre
|
|
687
|
+
// 44 + its local half-height (88 visual / 2 zoom) / 2 = 22 + one gap
|
|
688
|
+
// 12 — a zoom-blind band would read 78 ± (zoom−1)·Δ and drift the
|
|
689
|
+
// sheet cap against the strip on zoomed hosts.
|
|
690
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("78px");
|
|
683
691
|
} finally {
|
|
684
692
|
vi.restoreAllMocks();
|
|
685
693
|
app.remove();
|
|
@@ -731,6 +739,51 @@ describe("HkModalBreadcrumb overflow fold", () => {
|
|
|
731
739
|
}
|
|
732
740
|
});
|
|
733
741
|
|
|
742
|
+
it("publishes the sheet top band while up and clears it with the strip", async () => {
|
|
743
|
+
// The strip is the authority on its own band: while it is up, mobile
|
|
744
|
+
// bottom sheets must stop below "strip bottom edge + one more gap" —
|
|
745
|
+
// the sheet family consumes --hk-sheet-top-inset as exactly that
|
|
746
|
+
// line, so the strip PUBLISHES it on <body> (inline beats a host's
|
|
747
|
+
// :root token while up) and withdraws it when the strip goes away.
|
|
748
|
+
// Boxless DOM → fallback geometry: band = topPx + 43/2 + gap.
|
|
749
|
+
const app = document.createElement("div");
|
|
750
|
+
app.id = "app";
|
|
751
|
+
document.body.appendChild(app);
|
|
752
|
+
const unmountAll = () => {
|
|
753
|
+
for (const a of mounts.splice(0)) a.unmount();
|
|
754
|
+
};
|
|
755
|
+
try {
|
|
756
|
+
setViewport(1200);
|
|
757
|
+
manager.register("modal", true, "第一层");
|
|
758
|
+
manager.register("modal", true, "第二层");
|
|
759
|
+
await mountStrip();
|
|
760
|
+
// 0 (app top) + max(24, 12 + 21.5) + 21.5 + 12.
|
|
761
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("67px");
|
|
762
|
+
|
|
763
|
+
// Unmounting a visible strip withdraws the band synchronously
|
|
764
|
+
// (onBeforeUnmount) — a stale band must not outlive its publisher
|
|
765
|
+
// (mutation round M5: removing the unmount clear passed everything
|
|
766
|
+
// else green).
|
|
767
|
+
unmountAll();
|
|
768
|
+
await nextTick();
|
|
769
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("");
|
|
770
|
+
|
|
771
|
+
// A host wanting more air raises the gap; the band follows it.
|
|
772
|
+
await mountStrip({ topGap: 40 });
|
|
773
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("123px");
|
|
774
|
+
|
|
775
|
+
// The promise lives with the strip: drop back to a single layer and
|
|
776
|
+
// the property is withdrawn — the host token rules again.
|
|
777
|
+
const entries = [...manager.registry.value.values()];
|
|
778
|
+
manager.unregister(entries[entries.length - 1].id);
|
|
779
|
+
await until(() => strip() === null);
|
|
780
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("");
|
|
781
|
+
} finally {
|
|
782
|
+
unmountAll();
|
|
783
|
+
app.remove();
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
734
787
|
it("paints the fallback position before any resync can run", async () => {
|
|
735
788
|
// A host whose app root id never resolves leaves resyncTop at its
|
|
736
789
|
// early-return, so the strip paints the INITIAL ref: that line must
|
|
@@ -745,11 +798,16 @@ describe("HkModalBreadcrumb overflow fold", () => {
|
|
|
745
798
|
manager.register("modal", true, "第二层");
|
|
746
799
|
await mountStrip({ appRootId: "no-such-root" });
|
|
747
800
|
expect(strip()!.style.top).toBe("33.5px");
|
|
801
|
+
// The band promise holds on the fallbacks too (no app root is no
|
|
802
|
+
// excuse for going quiet while the sheets are watching):
|
|
803
|
+
// 33.5 + 43/2 + 12.
|
|
804
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("67px");
|
|
748
805
|
for (const a of mounts.splice(0)) a.unmount();
|
|
749
806
|
|
|
750
807
|
// The init follows the prop the same way the resync does.
|
|
751
808
|
await mountStrip({ appRootId: "no-such-root", topGap: 0 });
|
|
752
809
|
expect(strip()!.style.top).toBe("21.5px");
|
|
810
|
+
expect(document.body.style.getPropertyValue("--hk-sheet-top-inset")).toBe("43px");
|
|
753
811
|
});
|
|
754
812
|
|
|
755
813
|
it("writes the viewport fence in the strip's own px", async () => {
|
|
@@ -407,9 +407,32 @@ export default defineComponent({
|
|
|
407
407
|
* layout fallbacks (48px header, 43px strip). The first nextTick
|
|
408
408
|
* resync replaces it with measured numbers. */
|
|
409
409
|
const topPx = ref(topGapPx.value + STRIP_HEIGHT_PX / 2);
|
|
410
|
+
|
|
411
|
+
/** Publish the sheet top band: while the strip is up, mobile bottom
|
|
412
|
+
* sheets must stop BELOW the strip's bottom edge plus one more gap —
|
|
413
|
+
* "the strip's own height, with one gap above and one below". The
|
|
414
|
+
* sheet family consumes this exact custom property as its mobile
|
|
415
|
+
* max-height inset (--hk-sheet-top-inset), so publishing it here
|
|
416
|
+
* turns every host's static guess into the strip's live geometry.
|
|
417
|
+
* Written on <body> inline (beats a host's :root token while up;
|
|
418
|
+
* removed when the strip goes away so the host token rules again),
|
|
419
|
+
* in the strip's LOCAL px — the sheets teleport into the same zoomed
|
|
420
|
+
* subtree, so one px space is shared. */
|
|
421
|
+
function publishBand(bandPx: number | null): void {
|
|
422
|
+
if (typeof document === "undefined") return;
|
|
423
|
+
if (bandPx == null) document.body.style.removeProperty("--hk-sheet-top-inset");
|
|
424
|
+
else document.body.style.setProperty("--hk-sheet-top-inset", `${bandPx}px`);
|
|
425
|
+
}
|
|
426
|
+
|
|
410
427
|
function resyncTop() {
|
|
411
428
|
const app = document.getElementById(props.appRootId);
|
|
412
|
-
if (!app)
|
|
429
|
+
if (!app) {
|
|
430
|
+
// No app root to measure: the strip still paints on the fallback
|
|
431
|
+
// geometry (the init ref), so keep the band promise on those same
|
|
432
|
+
// fallbacks instead of going quiet while the sheets are watching.
|
|
433
|
+
publishBand(topPx.value + STRIP_HEIGHT_PX / 2 + topGapPx.value);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
413
436
|
// The strip teleports to <body> — inside any root CSS zoom subtree
|
|
414
437
|
// — so its inline top is LOCAL px. Keep every term in that local
|
|
415
438
|
// space: the app root's computed top is already local, while the
|
|
@@ -442,6 +465,9 @@ export default defineComponent({
|
|
|
442
465
|
// viewport top can no longer push the strip's upper half against
|
|
443
466
|
// (or past) the top edge.
|
|
444
467
|
topPx.value = appTop + Math.max(headerH / 2, topGapPx.value + stripH / 2);
|
|
468
|
+
// The band: strip bottom edge plus one more gap — the lowest line a
|
|
469
|
+
// bottom sheet may reach while the strip is up.
|
|
470
|
+
publishBand(topPx.value + stripH / 2 + topGapPx.value);
|
|
445
471
|
}
|
|
446
472
|
|
|
447
473
|
/** Viewport fence, in the strip's OWN px. The stylesheet cap is
|
|
@@ -524,6 +550,9 @@ export default defineComponent({
|
|
|
524
550
|
menuOpen.value = false;
|
|
525
551
|
revealed.value = null;
|
|
526
552
|
hiddenCount.value = 0;
|
|
553
|
+
// The band is the strip's live promise to the sheet family: with
|
|
554
|
+
// the strip gone the host's own static token rules again.
|
|
555
|
+
publishBand(null);
|
|
527
556
|
}
|
|
528
557
|
},
|
|
529
558
|
{ immediate: true },
|
|
@@ -553,6 +582,9 @@ export default defineComponent({
|
|
|
553
582
|
if (handle) handle.disconnect();
|
|
554
583
|
window.removeEventListener("resize", onViewportChange);
|
|
555
584
|
releaseClone();
|
|
585
|
+
// A mounted-but-gone strip must not leave its band behind: the host's
|
|
586
|
+
// static token rules again (same contract as the visible flip).
|
|
587
|
+
publishBand(null);
|
|
556
588
|
});
|
|
557
589
|
|
|
558
590
|
const itemClass = (crumb: Crumb) =>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick } from "vue";
|
|
3
3
|
|
|
4
|
-
import HkTooltip from "./HkTooltip";
|
|
4
|
+
import HkTooltip, { type HkTooltipSlotProps } from "./HkTooltip";
|
|
5
5
|
import { POPUP_Z_BANDS, POPUP_Z_STEP, usePopupManager } from "../runtime/usePopupManager";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -176,18 +176,19 @@ describe("HkTooltip show/hide", () => {
|
|
|
176
176
|
it("hands { popupId, visible } to the default slot for focus-target wiring", async () => {
|
|
177
177
|
const container = document.createElement("div");
|
|
178
178
|
document.body.appendChild(container);
|
|
179
|
-
let seen:
|
|
179
|
+
let seen: Partial<HkTooltipSlotProps> = {};
|
|
180
180
|
const app = createApp({
|
|
181
181
|
render: () =>
|
|
182
182
|
h(
|
|
183
183
|
HkTooltip,
|
|
184
|
-
{ text: "slot props", delay: 0 }
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
184
|
+
{ text: "slot props", delay: 0 },
|
|
185
|
+
// Object-form scoped slot; the arg type comes from the local
|
|
186
|
+
// annotation against the exported contract (h()'s RawSlots has
|
|
187
|
+
// no SlotsType-aware overload), not from the slots declaration.
|
|
188
|
+
{ default: (slotProps: HkTooltipSlotProps) => {
|
|
188
189
|
seen = slotProps;
|
|
189
190
|
return h("span", { class: "anchor-probe" }, "anchor");
|
|
190
|
-
}
|
|
191
|
+
} },
|
|
191
192
|
),
|
|
192
193
|
});
|
|
193
194
|
app.mount(container);
|
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import { computed, defineComponent, onBeforeUnmount, ref, Teleport, type CSSProperties, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, ref, Teleport, type CSSProperties, type PropType, type SlotsType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
3
|
import { applyTooltipPosition, type TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
4
|
import "./HkTooltip.scss";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The default-slot payload, handed to every render of the trigger. An
|
|
8
|
+
* interactive trigger should bind `popupId` onto itself via
|
|
9
|
+
* aria-describedby while `visible` is true — the focus target is the
|
|
10
|
+
* trigger element, not this component's wrapper (HkCopyBadge is the
|
|
11
|
+
* reference consumer).
|
|
12
|
+
*/
|
|
13
|
+
export interface HkTooltipSlotProps {
|
|
14
|
+
popupId: string;
|
|
15
|
+
visible: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
let popupSeq = 0;
|
|
7
19
|
|
|
8
20
|
export default defineComponent({
|
|
@@ -13,6 +25,9 @@ export default defineComponent({
|
|
|
13
25
|
delay: { type: Number, default: 300 },
|
|
14
26
|
maxWidth: { type: String, default: undefined },
|
|
15
27
|
},
|
|
28
|
+
slots: Object as SlotsType<{
|
|
29
|
+
default?: (props: HkTooltipSlotProps) => unknown;
|
|
30
|
+
}>,
|
|
16
31
|
setup(props, { slots }) {
|
|
17
32
|
const visible = ref(false);
|
|
18
33
|
// Stable id for the aria-describedby link from the trigger wrapper to
|
package/src/index.ts
CHANGED
|
@@ -752,6 +752,7 @@ export { default as HkFileBrowserDialog } from "./components/HkFileBrowserDialog
|
|
|
752
752
|
export { default as HkFilePickerField } from "./components/HkFilePickerField";
|
|
753
753
|
export { default as HkToast } from "./components/HkToast";
|
|
754
754
|
export { default as HkTooltip } from "./components/HkTooltip";
|
|
755
|
+
export { type HkTooltipSlotProps } from "./components/HkTooltip";
|
|
755
756
|
export { default as HkTree } from "./components/HkTree";
|
|
756
757
|
export { default as HkWindowedItem } from "./components/HkWindowedItem";
|
|
757
758
|
export { default as HkWaterfall } from "./components/HkWaterfall";
|