@celestia-island/hikari 0.55.117 → 0.55.120
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/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/HkSectionCard.scss +22 -0
- package/src/components/HkSectionCard.styles.test.ts +42 -0
- package/src/components/HkSectionCard.test.tsx +156 -0
- package/src/components/HkSectionCard.tsx +122 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -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) =>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* HkSectionCard — the standard titled section: heading + hint above one
|
|
2
|
+
* card. The ONLY spacing definition for that anatomy; consumers must not
|
|
3
|
+
* add their own margins/wrappers around it (2026-10-05 unification: two
|
|
4
|
+
* tabs of one page had drifted apart in hint position and body padding). */
|
|
5
|
+
.hk-section-card {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
/* Heading block → body. One token, both axes of the rhythm (the header
|
|
9
|
+
carries its own 4px title→hint gap). */
|
|
10
|
+
gap: var(--space-12, 0.75rem);
|
|
11
|
+
min-width: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Trailing notes under the card (the section's own footnote slot). No font
|
|
15
|
+
override: a footer may carry prose or a control — it only owns the
|
|
16
|
+
rhythm, so consumers stop inventing their own top margin. */
|
|
17
|
+
.hk-section-card-footer {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
gap: var(--space-4, 0.25rem);
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style contract (house pattern: HkAboutModal.styles.test.ts) — the
|
|
3
|
+
* section card's class↔rule wiring. `sheetCompile.test.ts` proves every
|
|
4
|
+
* sheet COMPILES; nothing else proves the classes the component emits are
|
|
5
|
+
* the ones the sheet styles, so a rename on either side would silently
|
|
6
|
+
* orphan the rule (the #493 failure mode).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const tsx = readFileSync(join(here, "HkSectionCard.tsx"), "utf-8");
|
|
15
|
+
const scss = readFileSync(join(here, "HkSectionCard.scss"), "utf-8");
|
|
16
|
+
|
|
17
|
+
/** Every `hk-section-card*` class the component emits. */
|
|
18
|
+
function emittedClasses(): string[] {
|
|
19
|
+
const found = new Set<string>();
|
|
20
|
+
for (const m of tsx.matchAll(/"(hk-section-card[a-z-]*)"/g)) found.add(m[1]!);
|
|
21
|
+
for (const m of tsx.matchAll(/\bhk-section-card[a-z-]*\b/g)) found.add(m[0]);
|
|
22
|
+
return [...found].sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("HkSectionCard styles", () => {
|
|
26
|
+
it("emits at least the wrapper and the footer (the guard is not vacuous)", () => {
|
|
27
|
+
const classes = emittedClasses();
|
|
28
|
+
expect(classes).toContain("hk-section-card");
|
|
29
|
+
expect(classes).toContain("hk-section-card-footer");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("styles every class the component emits", () => {
|
|
33
|
+
for (const cls of emittedClasses()) {
|
|
34
|
+
expect(scss, `${cls} must have a rule`).toMatch(new RegExp(`\\.${cls}\\s*\\{`));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps the intra-section rhythm token on the wrapper and the footer", () => {
|
|
39
|
+
expect(scss).toMatch(/\.hk-section-card\s*\{[^}]*gap:\s*var\(--space-12/);
|
|
40
|
+
expect(scss).toMatch(/\.hk-section-card-footer\s*\{[^}]*gap:\s*var\(--space-4/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkSectionCard } from "./HkSectionCard";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkSectionCard contract — the section-level chrome standard:
|
|
8
|
+
* - the heading block carries the title and the hint (its ONLY place: the
|
|
9
|
+
* drift this component exists to end was one section printing its hint
|
|
10
|
+
* above the table and another below it)
|
|
11
|
+
* - the body is ONE card: flush for tables (padded default false), padded
|
|
12
|
+
* when the body is not a table
|
|
13
|
+
* - `error` replaces the body with an alert and `loading` with a spinner,
|
|
14
|
+
* both inside a PADDED card — a state is content, not a table
|
|
15
|
+
* - the optional `actions` slot reaches the header (the heading row's
|
|
16
|
+
* trailing affordances), not the card
|
|
17
|
+
*
|
|
18
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
19
|
+
* @vue/test-utils.)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (mounts.length) {
|
|
26
|
+
const { app, container } = mounts.pop()!;
|
|
27
|
+
app.unmount();
|
|
28
|
+
container.remove();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function mountCard(props: Record<string, unknown>, slots: Record<string, () => unknown> = {}) {
|
|
33
|
+
const container = document.createElement("div");
|
|
34
|
+
document.body.appendChild(container);
|
|
35
|
+
const app = createApp({
|
|
36
|
+
render: () => h(HkSectionCard, props as never, slots as never),
|
|
37
|
+
});
|
|
38
|
+
mounts.push({ app, container });
|
|
39
|
+
app.mount(container);
|
|
40
|
+
return container;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("HkSectionCard", () => {
|
|
44
|
+
it("renders the title and the hint in the heading block, above the card", () => {
|
|
45
|
+
const c = mountCard({ title: "Public domains", hint: "Where this instance is reachable." }, {
|
|
46
|
+
default: () => h("table", { class: "body-table" }, h("tbody")),
|
|
47
|
+
});
|
|
48
|
+
const header = c.querySelector(".hk-section-header")!;
|
|
49
|
+
expect(header.textContent).toContain("Public domains");
|
|
50
|
+
expect(header.textContent).toContain("Where this instance is reachable.");
|
|
51
|
+
// The hint belongs to the HEADER, never to the card body.
|
|
52
|
+
const card = c.querySelector(".hk-card")!;
|
|
53
|
+
expect(card.textContent).not.toContain("Where this instance is reachable.");
|
|
54
|
+
expect(card.querySelectorAll(".body-table")).toHaveLength(1);
|
|
55
|
+
// Header precedes the card in document order (the one rhythm).
|
|
56
|
+
expect(header.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a table body flush in the card and pads a non-table body on request", () => {
|
|
60
|
+
const flush = mountCard({ title: "T" }, { default: () => h("span", "row") });
|
|
61
|
+
// HkCard spells "no padding" as a body modifier — the body carries the
|
|
62
|
+
// padding decision, not the card shell.
|
|
63
|
+
expect(flush.querySelector(".hk-card-body")!.className).toContain("hk-card-body-unpadded");
|
|
64
|
+
const padded = mountCard({ title: "T", padded: true }, { default: () => h("span", "row") });
|
|
65
|
+
// HkCard carries the padding itself — no caller wrapper.
|
|
66
|
+
expect(padded.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("replaces the body with an alert on error, inside a padded card", () => {
|
|
70
|
+
const c = mountCard({ title: "T", error: "boom" }, { default: () => h("span", "row") });
|
|
71
|
+
expect(c.querySelector(".hk-alert")).not.toBeNull();
|
|
72
|
+
expect(c.textContent).toContain("boom");
|
|
73
|
+
expect(c.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
|
|
74
|
+
expect(c.textContent).not.toContain("row");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("replaces the body with a centred spinner while loading", () => {
|
|
78
|
+
const c = mountCard({ title: "T", loading: true }, { default: () => h("span", "row") });
|
|
79
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
80
|
+
expect(c.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
|
|
81
|
+
expect(c.textContent).not.toContain("row");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lets error win when both error and loading are set", () => {
|
|
85
|
+
const c = mountCard({ title: "T", error: "boom", loading: true }, {
|
|
86
|
+
default: () => h("span", "row"),
|
|
87
|
+
});
|
|
88
|
+
expect(c.querySelector(".hk-alert")).not.toBeNull();
|
|
89
|
+
expect(c.querySelector(".hk-spinner")).toBeNull();
|
|
90
|
+
expect(c.textContent).toContain("boom");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("forwards the description slot into the hint position", () => {
|
|
94
|
+
const c = mountCard({ title: "T" }, { description: () => h("span", "own hint") });
|
|
95
|
+
const header = c.querySelector(".hk-section-header")!;
|
|
96
|
+
expect(header.querySelector(".hk-section-header-description")!.textContent).toContain("own hint");
|
|
97
|
+
expect(c.querySelector(".hk-card")!.textContent).not.toContain("own hint");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("forwards icon, level and dense to the heading", () => {
|
|
101
|
+
const Icon = () => h("svg", { class: "own-icon" });
|
|
102
|
+
const c = mountCard({ title: "T", icon: Icon, level: "h2", dense: true });
|
|
103
|
+
const header = c.querySelector(".hk-section-header")!;
|
|
104
|
+
expect(header.classList.contains("hk-section-header-dense")).toBe(true);
|
|
105
|
+
expect(header.querySelector("h2")).not.toBeNull();
|
|
106
|
+
expect(header.querySelector(".own-icon")).not.toBeNull();
|
|
107
|
+
const plain = mountCard({ title: "T" });
|
|
108
|
+
expect(plain.querySelector(".hk-section-header h3")).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("forwards the actions slot into the heading row", () => {
|
|
112
|
+
const c = mountCard({ title: "T" }, { actions: () => h("button", { class: "hdr-act" }, "x") });
|
|
113
|
+
expect(c.querySelector(".hk-section-header")!.querySelector(".hdr-act")).not.toBeNull();
|
|
114
|
+
expect(c.querySelector(".hk-card")!.querySelector(".hdr-act")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders the footer under the card — and nothing when no footer is given", () => {
|
|
118
|
+
const c = mountCard({ title: "T" }, {
|
|
119
|
+
default: () => h("span", "row"),
|
|
120
|
+
footer: () => h("p", { class: "note" }, "keep this"),
|
|
121
|
+
});
|
|
122
|
+
const footer = c.querySelector(".hk-section-card-footer")!;
|
|
123
|
+
expect(footer.querySelector(".note")!.textContent).toBe("keep this");
|
|
124
|
+
// Under the CARD, not inside it, and after it in document order.
|
|
125
|
+
const card = c.querySelector(".hk-card")!;
|
|
126
|
+
expect(card.querySelector(".note")).toBeNull();
|
|
127
|
+
expect(card.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
128
|
+
|
|
129
|
+
const bare = mountCard({ title: "T" });
|
|
130
|
+
expect(bare.querySelector(".hk-section-card-footer")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders no empty header wrapper when only one of the two slots is given", () => {
|
|
134
|
+
// R3 finding: forwarding an absent slot as a function returning
|
|
135
|
+
// undefined left an empty `.hk-section-header-actions` /
|
|
136
|
+
// `.hk-section-header-description` node behind — and in the column-flex
|
|
137
|
+
// heading a zero-height item still eats a gap slot.
|
|
138
|
+
const actionsOnly = mountCard({ title: "T" }, { actions: () => h("button", "x") });
|
|
139
|
+
expect(actionsOnly.querySelector(".hk-section-header-actions")).not.toBeNull();
|
|
140
|
+
expect(actionsOnly.querySelector(".hk-section-header-description")).toBeNull();
|
|
141
|
+
|
|
142
|
+
const descriptionOnly = mountCard({ title: "T" }, { description: () => h("span", "hint") });
|
|
143
|
+
expect(descriptionOnly.querySelector(".hk-section-header-description")).not.toBeNull();
|
|
144
|
+
expect(descriptionOnly.querySelector(".hk-section-header-actions")).toBeNull();
|
|
145
|
+
|
|
146
|
+
const neither = mountCard({ title: "T" });
|
|
147
|
+
expect(neither.querySelector(".hk-section-header-actions")).toBeNull();
|
|
148
|
+
expect(neither.querySelector(".hk-section-header-description")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("carries the count into the title and renders no hint paragraph when none is given", () => {
|
|
152
|
+
const c = mountCard({ title: "T", count: "3" });
|
|
153
|
+
expect(c.querySelector(".hk-section-header-count")!.textContent).toBe("3");
|
|
154
|
+
expect(c.querySelector(".hk-section-header-description")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import HkAlert from "./HkAlert";
|
|
4
|
+
import HkCard from "./HkCard";
|
|
5
|
+
import { HkSectionHeader } from "./HkSectionHeader";
|
|
6
|
+
import HkSpinner from "./HkSpinner";
|
|
7
|
+
import "./HkSectionCard.scss";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HkSectionCard — the standard titled SECTION: a heading (with an optional
|
|
11
|
+
* hint line) above ONE card that carries the body.
|
|
12
|
+
*
|
|
13
|
+
* This is the section-level twin of the page-level scaffold
|
|
14
|
+
* (`HkAdminTablePage`): a page whose content is several lists (settings
|
|
15
|
+
* categories, tab panels, dashboards) renders each as an HkSectionCard, so
|
|
16
|
+
* the heading→body rhythm, the hint typography and the body padding come
|
|
17
|
+
* from ONE definition instead of every caller hand-rolling `mt-3`,
|
|
18
|
+
* `p-4`/`padded={false}` wrappers and its own hint classes — the drift the
|
|
19
|
+
* 2026-10-05 audit found between two tabs of the same page (one table flush
|
|
20
|
+
* in the card with its hint above, the other wrapped in a padded div with
|
|
21
|
+
* its hint below).
|
|
22
|
+
*
|
|
23
|
+
* The body states follow `HkAdminTablePage`'s contract: `error` replaces
|
|
24
|
+
* the body with an alert, `loading` with a centred spinner — both inside a
|
|
25
|
+
* PADDED card (a state is content, not a table), while the normal body
|
|
26
|
+
* honours `padded` (default false: tables carry their own cell padding and
|
|
27
|
+
* sit flush in the card). `error` wins when both are set, as on the
|
|
28
|
+
* scaffold.
|
|
29
|
+
*
|
|
30
|
+
* The section owns the INTRA-section rhythm only (heading block → card). The
|
|
31
|
+
* page owns the gap BETWEEN sections: several siblings on one page need the
|
|
32
|
+
* parent's own `gap` (a flex/grid column), because this component adds no
|
|
33
|
+
* outer margin by design — otherwise a page that renders three sections
|
|
34
|
+
* would stack them flush.
|
|
35
|
+
*
|
|
36
|
+
* A `footer` slot renders trailing notes under the card (placeholder
|
|
37
|
+
* grammars, "what this list feeds" details) with the same rhythm, so those
|
|
38
|
+
* notes stop being a per-consumer margin.
|
|
39
|
+
*
|
|
40
|
+
* One deliberate difference from the scaffold: `loading` replaces the body
|
|
41
|
+
* whenever it is true, where `HkAdminTablePage` only replaces an EMPTY body
|
|
42
|
+
* (`loading && !rows.length`) and keeps populated rows on screen through a
|
|
43
|
+
* refresh. A section therefore passes `loading` for its FIRST load, not for
|
|
44
|
+
* every refetch — or it blanks a table the user is reading.
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* <HkSectionCard title={t("…")} hint={t("…")} loading={loading}>
|
|
48
|
+
* <HkTable columns={columns} rows={rows} rowKey="id">{…}</HkTable>
|
|
49
|
+
* </HkSectionCard>
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export const HkSectionCard = defineComponent({
|
|
53
|
+
name: "HkSectionCard",
|
|
54
|
+
props: {
|
|
55
|
+
title: { type: String, required: true },
|
|
56
|
+
/** Explanatory line under the heading — the standard place for the
|
|
57
|
+
* "what this list is for" copy every section used to spell its own. */
|
|
58
|
+
hint: { type: String, default: undefined },
|
|
59
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
60
|
+
/** Heading level only; the visual size is fixed by the header styles. */
|
|
61
|
+
level: { type: String as PropType<"h2" | "h3" | "div">, default: "h3" },
|
|
62
|
+
/** Trailing count beside the title (e.g. "3"). */
|
|
63
|
+
count: { type: String, default: undefined },
|
|
64
|
+
/** Tighter header spacing for dense panels. */
|
|
65
|
+
dense: { type: Boolean, default: false },
|
|
66
|
+
/** Hard failure: replaces the body with an alert. */
|
|
67
|
+
error: { type: String, default: undefined },
|
|
68
|
+
/** In-flight: replaces the body with a centred spinner. */
|
|
69
|
+
loading: { type: Boolean, default: false },
|
|
70
|
+
/** Body padding for non-table content (tables stay flush). */
|
|
71
|
+
padded: { type: Boolean, default: false },
|
|
72
|
+
},
|
|
73
|
+
setup(props, { slots }) {
|
|
74
|
+
return () => {
|
|
75
|
+
const state = props.error ? "error" : props.loading ? "loading" : "body";
|
|
76
|
+
// Hoisted to an identifier: the CONDITIONAL inline form
|
|
77
|
+
// (`{slots.actions ? { actions: … } : undefined}` as children) lands in
|
|
78
|
+
// a child array, where the plain object is no longer normalized to
|
|
79
|
+
// slots and the button silently disappears. Hoisting fixes it; the
|
|
80
|
+
// bare inline literal happens to work, which is exactly why the
|
|
81
|
+
// conditional one is a trap.
|
|
82
|
+
// Each slot is spread CONDITIONALLY: forwarding an absent one as a
|
|
83
|
+
// function that returns undefined defeats HkSectionHeader's own
|
|
84
|
+
// empty-node guard, which renders an empty wrapper (and, in the
|
|
85
|
+
// column-flex heading, a phantom gap slot) — a whitespace-only
|
|
86
|
+
// regression the R3 review caught.
|
|
87
|
+
const headerSlots =
|
|
88
|
+
slots.actions || slots.description
|
|
89
|
+
? {
|
|
90
|
+
...(slots.actions ? { actions: () => slots.actions?.() } : {}),
|
|
91
|
+
...(slots.description ? { description: () => slots.description?.() } : {}),
|
|
92
|
+
}
|
|
93
|
+
: undefined;
|
|
94
|
+
return (
|
|
95
|
+
<section class="hk-section-card">
|
|
96
|
+
<HkSectionHeader
|
|
97
|
+
title={props.title}
|
|
98
|
+
description={props.hint}
|
|
99
|
+
icon={props.icon}
|
|
100
|
+
level={props.level}
|
|
101
|
+
count={props.count}
|
|
102
|
+
dense={props.dense}
|
|
103
|
+
>
|
|
104
|
+
{headerSlots}
|
|
105
|
+
</HkSectionHeader>
|
|
106
|
+
<HkCard padded={state === "body" ? props.padded : true}>
|
|
107
|
+
{state === "error" ? (
|
|
108
|
+
<HkAlert message={props.error!} />
|
|
109
|
+
) : state === "loading" ? (
|
|
110
|
+
<HkSpinner center />
|
|
111
|
+
) : (
|
|
112
|
+
slots.default?.()
|
|
113
|
+
)}
|
|
114
|
+
</HkCard>
|
|
115
|
+
{slots.footer ? <div class="hk-section-card-footer">{slots.footer()}</div> : null}
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export default HkSectionCard;
|
package/src/index.ts
CHANGED
|
@@ -818,6 +818,8 @@ export { HkCardList, HkListRow } from "./components/HkCardList";
|
|
|
818
818
|
export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
|
|
819
819
|
export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
|
|
820
820
|
export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
|
|
821
|
+
export { HkSectionCard } from "./components/HkSectionCard";
|
|
822
|
+
export { HkSectionCard as HSectionCard } from "./components/HkSectionCard";
|
|
821
823
|
export { default as HFilterBar } from "./components/HkFilterBar";
|
|
822
824
|
export { HkShareBar as HkShareBar } from "./components/HkShareBar";
|
|
823
825
|
export { HkSecretRevealModal as HkSecretRevealModal } from "./components/HkSecretRevealModal";
|