@celestia-island/hikari 0.55.316 → 0.55.319
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.plate.contract.test.ts +157 -0
- package/src/components/HkModal.scss +62 -0
- package/src/components/HkModal.sheetmorph.test.ts +6 -1
- package/src/components/HkModal.test.tsx +78 -0
- package/src/components/HkModal.tsx +20 -0
- package/src/components/HkScrollContainer.tsx +7 -1
- package/src/components/HkScrollProgress.test.tsx +19 -0
- package/src/components/HkScrollProgress.tsx +6 -0
- package/src/components/HkSelect.scss +19 -14
- package/src/components/HkTable.scss +65 -0
- package/src/components/HkTable.sections.test.tsx +764 -0
- package/src/components/HkTable.tsx +440 -152
- package/src/components/sheetfamily-morph-invariant.test.ts +136 -0
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled-CSS contract for the modal BLUR PLATE — the open-stutter fix.
|
|
3
|
+
*
|
|
4
|
+
* Measured root cause (2026-11-07 open-jank spike, headless rAF matrix):
|
|
5
|
+
* a backdrop-filter re-filters its full-viewport backdrop on EVERY
|
|
6
|
+
* composited frame while any animation is live anywhere — the enter
|
|
7
|
+
* storm (frame + dim + strip + crumbs mounting together) ran at
|
|
8
|
+
* 26-55% dropped frames with live plates, while the steady state
|
|
9
|
+
* (nothing animating) is cached and runs 60fps. The architecture:
|
|
10
|
+
*
|
|
11
|
+
* 1. the blur lives on a STATIC plate that never animates and never
|
|
12
|
+
* carries a transition;
|
|
13
|
+
* 2. the eye-tracked transition is the solid-color dim curtain
|
|
14
|
+
* (opacity-only composite);
|
|
15
|
+
* 3. the plate is REMOVED on stack-covered windows (a present
|
|
16
|
+
* backdrop-filter element still forces the per-frame backdrop
|
|
17
|
+
* surface even computed to none — measured ~44pp worse than
|
|
18
|
+
* hidden);
|
|
19
|
+
* 4. the overlay carries the default blur only for plate-less markup
|
|
20
|
+
* (the Rust-rendered modals share this compiled sheet without a
|
|
21
|
+
* plate element) and opts down to dim-only document-globally when
|
|
22
|
+
* a Vue plate exists.
|
|
23
|
+
*
|
|
24
|
+
* Regression risk this file pins: a future edit re-adding a
|
|
25
|
+
* backdrop-filter to `.hk-modal-overlay` would double-filter the Vue
|
|
26
|
+
* path and reintroduce the jank; re-adding an enter/leave-scoped
|
|
27
|
+
* suppression leg would reintroduce the settle hitch; relaxing the
|
|
28
|
+
* covered drop would reintroduce the stacked-blur storm.
|
|
29
|
+
*/
|
|
30
|
+
import { describe, expect, it } from "vitest";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { compile } from "sass";
|
|
34
|
+
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const css = compile(join(here, "HkModal.scss"), {
|
|
37
|
+
loadPaths: [here, join(here, "..", "styles")],
|
|
38
|
+
style: "expanded",
|
|
39
|
+
}).css;
|
|
40
|
+
|
|
41
|
+
/** Brace-matched body of the first top-level block whose head starts
|
|
42
|
+
* with the given prefix (`@media …` or a selector). */
|
|
43
|
+
function topBlock(cssText: string, headPrefix: string): string {
|
|
44
|
+
const start = cssText.indexOf(headPrefix);
|
|
45
|
+
if (start === -1) return "";
|
|
46
|
+
const open = cssText.indexOf("{", start);
|
|
47
|
+
let depth = 0;
|
|
48
|
+
for (let i = open; i < cssText.length; i++) {
|
|
49
|
+
if (cssText[i] === "{") depth += 1;
|
|
50
|
+
else if (cssText[i] === "}") {
|
|
51
|
+
depth -= 1;
|
|
52
|
+
if (depth === 0) return cssText.slice(open + 1, i);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** All `selector { body }` frames inside a block (one nesting level deep
|
|
59
|
+
* is enough). Selector parts carry preceding block-glue in expanded
|
|
60
|
+
* output (media heads, comment remains) — trimmed to the bare
|
|
61
|
+
* selector. */
|
|
62
|
+
function frames(block: string): { selectors: string[]; body: string }[] {
|
|
63
|
+
const out: { selectors: string[]; body: string }[] = [];
|
|
64
|
+
const re = /([^{}]+)\{([^{}]*)\}/g;
|
|
65
|
+
let m: RegExpExecArray | null;
|
|
66
|
+
while ((m = re.exec(block))) {
|
|
67
|
+
out.push({
|
|
68
|
+
selectors: m[1]
|
|
69
|
+
.split(",")
|
|
70
|
+
.map((part) => {
|
|
71
|
+
let t = part;
|
|
72
|
+
const comment = t.lastIndexOf("*/");
|
|
73
|
+
if (comment !== -1) t = t.slice(comment + 2);
|
|
74
|
+
const brace = t.lastIndexOf("{");
|
|
75
|
+
if (brace !== -1) t = t.slice(brace + 1);
|
|
76
|
+
const semi = t.lastIndexOf(";");
|
|
77
|
+
if (semi !== -1) t = t.slice(semi + 1);
|
|
78
|
+
return t.trim();
|
|
79
|
+
})
|
|
80
|
+
.filter((s) => s.startsWith(".") || s.startsWith("[") || s.startsWith(":")),
|
|
81
|
+
body: m[2].trim(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Bodies of every rule whose selector LIST includes exactly `selector`. */
|
|
88
|
+
function ruleBodies(sheet: string, selector: string): string[] {
|
|
89
|
+
return frames(sheet)
|
|
90
|
+
.filter((f) => f.selectors.includes(selector))
|
|
91
|
+
.map((f) => f.body);
|
|
92
|
+
}
|
|
93
|
+
const oneRule = (sheet: string, selector: string): string => {
|
|
94
|
+
const bodies = ruleBodies(sheet, selector);
|
|
95
|
+
expect(bodies.length, `rule ${selector} must exist`).toBeGreaterThan(0);
|
|
96
|
+
return bodies[0];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const mobileBlock = topBlock(css, "@media (max-width: 767px)");
|
|
100
|
+
|
|
101
|
+
describe("modal blur plate (static filter, stack-covered removal)", () => {
|
|
102
|
+
it("carries the blur; the fading overlay must NOT (vue path)", () => {
|
|
103
|
+
// The core invariant: the overlay is the ANIMATED layer — a
|
|
104
|
+
// backdrop-filter on it re-filters the whole viewport per frame
|
|
105
|
+
// (the reported stutter). The blur lives on the never-animated
|
|
106
|
+
// plate only.
|
|
107
|
+
const plate = oneRule(css, ".hk-modal-blur-plate");
|
|
108
|
+
expect(plate).toMatch(/backdrop-filter:\s*var\(--hk-modal-overlay-blur/);
|
|
109
|
+
expect(plate).toContain("pointer-events: none");
|
|
110
|
+
expect(plate).toContain("z-index: -1");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("gives plate-less markup (rust-rendered modals) the overlay blur", () => {
|
|
114
|
+
// The Rust-rendered modals share this compiled sheet but their
|
|
115
|
+
// markup has no plate element: the overlay keeps the default blur
|
|
116
|
+
// there.
|
|
117
|
+
const overlayDefault = ruleBodies(css, ".hk-modal-overlay").find((b) =>
|
|
118
|
+
b.includes("backdrop-filter: var(--hk-modal-overlay-blur"),
|
|
119
|
+
);
|
|
120
|
+
expect(overlayDefault, "default overlay blur must exist").toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("opts the overlay down to dim-only wherever a plate exists", () => {
|
|
124
|
+
// :root:has(plate) is document-global: one Vue modal anywhere means
|
|
125
|
+
// every overlay in the document is plate-backed and dim-only.
|
|
126
|
+
expect(css).toMatch(
|
|
127
|
+
/:root:has\(\.hk-modal-blur-plate\)\s*\.hk-modal-overlay\s*\{[^}]*backdrop-filter:\s*none/,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("removes the plate on stack-covered windows", () => {
|
|
132
|
+
// A present backdrop-filter element still forces the per-frame
|
|
133
|
+
// backdrop surface even computed to none (~44pp worse than hidden).
|
|
134
|
+
const covered = ruleBodies(css, ".hk-modal-root[data-stack-covered] .hk-modal-blur-plate");
|
|
135
|
+
expect(covered.length).toBeGreaterThanOrEqual(1);
|
|
136
|
+
for (const body of covered) expect(body).toContain("display: none");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps no enter/leave-scoped suppression legs (the top plate animates with the filter)", () => {
|
|
140
|
+
// The top plate's own enter measured 2% dropped WITH the filter
|
|
141
|
+
// live (single live blur) — suppression legs were removed with the
|
|
142
|
+
// settle hitch they created. Re-adding any leg must be a deliberate
|
|
143
|
+
// re-trade.
|
|
144
|
+
expect(css).not.toMatch(
|
|
145
|
+
/hk-modal-content-(?:enter|leave)-(?:from|to|active) ~ \.hk-modal-blur-plate/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("keeps the mobile stand-down on both faces", () => {
|
|
150
|
+
expect(mobileBlock).toContain(".hk-modal-blur-plate");
|
|
151
|
+
expect(frames(mobileBlock).some((f) => f.selectors.includes(".hk-modal-blur-plate"))).toBe(true);
|
|
152
|
+
// The Vue-path opt-out must hold on phones too.
|
|
153
|
+
expect(mobileBlock).toMatch(
|
|
154
|
+
/:root:has\(\.hk-modal-blur-plate\)\s*\.hk-modal-overlay\s*\{[^}]*backdrop-filter:\s*none/,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -23,6 +23,44 @@
|
|
|
23
23
|
// Overlay (backdrop)
|
|
24
24
|
// ------
|
|
25
25
|
|
|
26
|
+
// The BLUR is a separate static plate, NOT on the fading overlay: a
|
|
27
|
+
// backdrop-filter re-filters the whole viewport on every composited frame
|
|
28
|
+
// while ANY animation is live (the steady state is cached and clean —
|
|
29
|
+
// measured separately), and the enter storm (frame + dim + strip + crumbs
|
|
30
|
+
// mounting together) measured 36% dropped frames with the filter live vs
|
|
31
|
+
// 0% with it suppressed (2026-11-07 open-jank spike, headless rAF matrix;
|
|
32
|
+
// one modal, blur 3px). The plate holds the filter and never animates;
|
|
33
|
+
// the overlay below is a solid-color dim curtain whose opacity ramp is
|
|
34
|
+
// the cheapest possible layer to animate — and the ramp is what the eye
|
|
35
|
+
// tracks, so the blur cutting in with the first frame (instead of
|
|
36
|
+
// ramping over 0.42s) reads as focus pull, not as a missing layer.
|
|
37
|
+
.hk-modal-blur-plate {
|
|
38
|
+
position: fixed;
|
|
39
|
+
inset: 0;
|
|
40
|
+
// Below the overlay (0) and the content frame (1) inside the root's own
|
|
41
|
+
// stacking context — the plate is DOM-last but paints first.
|
|
42
|
+
z-index: -1;
|
|
43
|
+
@include backdrop-filter(var(--hk-modal-overlay-blur, blur(3px)));
|
|
44
|
+
// The root is pointer-events: none; the plate must never take clicks.
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// STACK-COVERED: a covered (non-topmost) window's plate is REMOVED
|
|
49
|
+
// outright (display:none, not merely filter:none — a present
|
|
50
|
+
// backdrop-filter element still forces the per-frame backdrop surface
|
|
51
|
+
// during any animation even with the filter computed to none, measured
|
|
52
|
+
// at ~44pp dropped frames vs 2% hidden; 2026-11-07 open-jank spike).
|
|
53
|
+
// Blur reads as a property of the TOP surface anyway: lower windows sit
|
|
54
|
+
// under the top window's own dim + blur, so their own filter is
|
|
55
|
+
// visually redundant (blur-of-blur). The dim curtains are untouched:
|
|
56
|
+
// depth still darkens, only the redundant blur legs drop. (The
|
|
57
|
+
// breadcrumb strip — z band 250 — paints ABOVE these roots: the strip
|
|
58
|
+
// band is not part of any plate's backdrop, which is why removing the
|
|
59
|
+
// covered plates, not the strip, was the fix that measured.)
|
|
60
|
+
.hk-modal-root[data-stack-covered] .hk-modal-blur-plate {
|
|
61
|
+
display: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
26
64
|
.hk-modal-overlay {
|
|
27
65
|
position: fixed;
|
|
28
66
|
inset: 0;
|
|
@@ -32,10 +70,20 @@
|
|
|
32
70
|
// handle hasn't resolved yet — zIndex briefly starts at 0).
|
|
33
71
|
z-index: 0;
|
|
34
72
|
background: var(--hk-modal-overlay-bg, rgba(0, 0, 0, 0.45));
|
|
73
|
+
// The overlay itself carries the blur ONLY where no plate exists (the
|
|
74
|
+
// Rust-rendered modals share this compiled sheet but their markup has
|
|
75
|
+
// no plate element — dropping the property from the overlay would
|
|
76
|
+
// silently strip their desktop blur). The Vue path always renders a
|
|
77
|
+
// plate, so :root:has(...) opts the overlay down to dim-only there and
|
|
78
|
+
// the plate owns the blur (including its stack-covered removal).
|
|
35
79
|
@include backdrop-filter(var(--hk-modal-overlay-blur, blur(3px)));
|
|
36
80
|
pointer-events: auto;
|
|
37
81
|
}
|
|
38
82
|
|
|
83
|
+
:root:has(.hk-modal-blur-plate) .hk-modal-overlay {
|
|
84
|
+
@include backdrop-filter(none);
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
// Overlay enter/leave — fade-only classes emitted from the shared
|
|
40
88
|
// window-layer contract (./_scrim-fade.scss): the curtain dims the whole
|
|
41
89
|
// viewport in place, never sliding or scaling with the content frame.
|
|
@@ -153,6 +201,11 @@
|
|
|
153
201
|
.hk-modal-content-enter-from,
|
|
154
202
|
.hk-modal-content-leave-to {
|
|
155
203
|
opacity: 0;
|
|
204
|
+
// Pre-promote at insert: enter-from lands in the same frame the
|
|
205
|
+
// content mounts — carrying will-change HERE (shared with the leave's
|
|
206
|
+
// end pose) means the layer is composited before the transition's
|
|
207
|
+
// first measured frame, not one hitch later.
|
|
208
|
+
will-change: opacity, transform;
|
|
156
209
|
transform: translate(
|
|
157
210
|
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
158
211
|
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
@@ -443,10 +496,18 @@
|
|
|
443
496
|
// the page, so an opaque-ish sheet reads identically. Hosts restore
|
|
444
497
|
// the finish per surface with --hk-modal-blur-mobile /
|
|
445
498
|
// --hk-modal-bg-mobile.
|
|
499
|
+
.hk-modal-blur-plate {
|
|
500
|
+
@include backdrop-filter(var(--hk-modal-overlay-blur-mobile, none));
|
|
501
|
+
}
|
|
502
|
+
|
|
446
503
|
.hk-modal-overlay {
|
|
447
504
|
@include backdrop-filter(var(--hk-modal-overlay-blur-mobile, none));
|
|
448
505
|
}
|
|
449
506
|
|
|
507
|
+
:root:has(.hk-modal-blur-plate) .hk-modal-overlay {
|
|
508
|
+
@include backdrop-filter(none);
|
|
509
|
+
}
|
|
510
|
+
|
|
450
511
|
// Height-morph window (round 20): while the sheet's height animates
|
|
451
512
|
// every frame, a backdrop-filter anywhere in the resizing subtree has
|
|
452
513
|
// to re-render its backdrop surface every frame — the same per-frame
|
|
@@ -565,6 +626,7 @@
|
|
|
565
626
|
.hk-modal-content-leave-to {
|
|
566
627
|
opacity: 0;
|
|
567
628
|
height: auto;
|
|
629
|
+
will-change: opacity, transform;
|
|
568
630
|
transform: translateY(100%);
|
|
569
631
|
}
|
|
570
632
|
|
|
@@ -99,9 +99,14 @@ describe("sheet family morph-performance contract", () => {
|
|
|
99
99
|
const panel = select.match(/\.hk-select-sheet-panel\s*{[\s\S]*?^}/m)?.[0] ?? "";
|
|
100
100
|
expect(panel).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
|
|
101
101
|
expect(panel).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
|
|
102
|
+
// 2026-11-07 residue sweep: the panel rides the modal's ≤767px
|
|
103
|
+
// grammar — clip-path is the ONLY leg (clip mode pins instantly and
|
|
104
|
+
// folds transition-off, and a max-height leg on a dvh-capped sheet
|
|
105
|
+
// is the IME cap-chase the modal block fixed on 2026-09-21).
|
|
102
106
|
expect(panel).toMatch(
|
|
103
|
-
/^[ \t]*clip-path var\(--duration-fast, 0\.15s\)
|
|
107
|
+
/^[ \t]*transition: clip-path var\(--duration-fast, 0\.15s\)[\s]*var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
|
|
104
108
|
);
|
|
109
|
+
expect(panel).not.toMatch(/\bmax-height var\(--duration-fast/m);
|
|
105
110
|
// Opaque already — the panel must not regress to a translucent
|
|
106
111
|
// finish that re-composites against the streaming page.
|
|
107
112
|
expect(panel).toMatch(/^[ \t]*background:[ \t]*rgb\(var\(--color-surface\)\);$/m);
|
|
@@ -815,3 +815,81 @@ describe("HkModal Escape precedence", () => {
|
|
|
815
815
|
expect(document.activeElement, "focus wraps back to the first focusable").toBe(first);
|
|
816
816
|
});
|
|
817
817
|
});
|
|
818
|
+
|
|
819
|
+
describe("HkModal blur plate", () => {
|
|
820
|
+
it("renders the plate as the aria-hidden last child of the root", async () => {
|
|
821
|
+
// The plate is DOM-last so the sheet's stack-covered drop can be keyed
|
|
822
|
+
// by attribute scoping without depending on paint order (z-indexes
|
|
823
|
+
// carry that). Structural pin: last child + hidden from a11y.
|
|
824
|
+
const container = document.createElement("div");
|
|
825
|
+
document.body.appendChild(container);
|
|
826
|
+
containers.push(container);
|
|
827
|
+
const open = ref(true);
|
|
828
|
+
const Wrapper = defineComponent({
|
|
829
|
+
setup() {
|
|
830
|
+
return () =>
|
|
831
|
+
h(HkModal, { modelValue: open.value }, { default: () => h("div", "content") });
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
const app = createApp(Wrapper);
|
|
835
|
+
mounts.push(app);
|
|
836
|
+
app.mount(container);
|
|
837
|
+
await nextTick();
|
|
838
|
+
await nextTick();
|
|
839
|
+
const root = document.querySelector(".hk-modal-root")!;
|
|
840
|
+
const last = root.lastElementChild as HTMLElement | null;
|
|
841
|
+
expect(last?.classList.contains("hk-modal-blur-plate")).toBe(true);
|
|
842
|
+
expect(last?.getAttribute("aria-hidden")).toBe("true");
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it("marks covered roots while a higher window is open", async () => {
|
|
846
|
+
// TOP-ONLY BLUR wiring: the covered (lower) root carries
|
|
847
|
+
// data-stack-covered; the top root does not. Flips live with the
|
|
848
|
+
// registry: closing the top window un-covers the lower one.
|
|
849
|
+
const mounts2: ReturnType<typeof createApp>[] = [];
|
|
850
|
+
const containers2: HTMLElement[] = [];
|
|
851
|
+
try {
|
|
852
|
+
const openA = ref(true);
|
|
853
|
+
const openB = ref(true);
|
|
854
|
+
const mk = (mv: { value: boolean }, title: string) => {
|
|
855
|
+
const container = document.createElement("div");
|
|
856
|
+
document.body.appendChild(container);
|
|
857
|
+
containers2.push(container);
|
|
858
|
+
const Wrapper = defineComponent({
|
|
859
|
+
setup() {
|
|
860
|
+
return () =>
|
|
861
|
+
h(HkModal, { modelValue: mv.value, title }, { default: () => h("div", "content") });
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
const app = createApp(Wrapper);
|
|
865
|
+
mounts2.push(app);
|
|
866
|
+
app.mount(container);
|
|
867
|
+
};
|
|
868
|
+
mk(openA, "下层窗");
|
|
869
|
+
mk(openB, "上层窗");
|
|
870
|
+
await nextTick();
|
|
871
|
+
await nextTick();
|
|
872
|
+
const roots = () => [...document.querySelectorAll(".hk-modal-root")];
|
|
873
|
+
expect(roots()).toHaveLength(2);
|
|
874
|
+
// The later-registered window is the top: the FIRST root is covered.
|
|
875
|
+
const attrs = roots().map((r) => r.hasAttribute("data-stack-covered"));
|
|
876
|
+
expect(attrs).toEqual([true, false]);
|
|
877
|
+
openB.value = false;
|
|
878
|
+
await nextTick();
|
|
879
|
+
// The leave settles on the surface machine's own clock.
|
|
880
|
+
const deadline = Date.now() + 1500;
|
|
881
|
+
while (roots().some((r) => r.classList.contains("hk-modal-content-leave-active")) && Date.now() < deadline) {
|
|
882
|
+
await nextTick();
|
|
883
|
+
}
|
|
884
|
+
const deadline2 = Date.now() + 1500;
|
|
885
|
+
while (roots().some((r) => r.hasAttribute("data-stack-covered")) && Date.now() < deadline2) {
|
|
886
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
887
|
+
await nextTick();
|
|
888
|
+
}
|
|
889
|
+
expect(roots().every((r) => !r.hasAttribute("data-stack-covered"))).toBe(true);
|
|
890
|
+
} finally {
|
|
891
|
+
for (const a of mounts2.splice(0)) a.unmount();
|
|
892
|
+
for (const c of containers2.splice(0)) c.remove();
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
});
|
|
@@ -566,6 +566,20 @@ export default defineComponent({
|
|
|
566
566
|
// optimization for the class flip. See runtime/surfaceMachine.ts
|
|
567
567
|
// for the axioms, the total transition table, and the invariants.
|
|
568
568
|
const overlayEl = ref<HTMLElement>();
|
|
569
|
+
// TOP-ONLY BLUR: this window's blur plate carries the filter only
|
|
570
|
+
// while it is the topmost WINDOW-kind layer of the stack. The covered
|
|
571
|
+
// window's plate drops to dim-only (see the sheet) — with N stacked
|
|
572
|
+
// full-viewport backdrop-filters every composited frame re-runs all
|
|
573
|
+
// of them, and the open storm measured 36-55% dropped frames vs 2%
|
|
574
|
+
// with a single live filter. Reacts live: covered flips with every
|
|
575
|
+
// register/unregister above this window.
|
|
576
|
+
const stackCovered = computed(() => {
|
|
577
|
+
// Reuses the shared topWindowZ authority (the same windowing filter
|
|
578
|
+
// the dial consumes below) instead of a private registry scan.
|
|
579
|
+
const top = topWindowZ.value;
|
|
580
|
+
if (!handle.value || top === null) return false;
|
|
581
|
+
return handle.value.zIndex < top;
|
|
582
|
+
});
|
|
569
583
|
const machine = useSurfaceMachine({
|
|
570
584
|
layers: [
|
|
571
585
|
// Budgets are the starvation-era upper bound; the driver probes
|
|
@@ -1071,6 +1085,7 @@ export default defineComponent({
|
|
|
1071
1085
|
<Teleport to={ensureOverlayRoot()}>
|
|
1072
1086
|
<div
|
|
1073
1087
|
class="hk-modal-root"
|
|
1088
|
+
data-stack-covered={stackCovered.value || undefined}
|
|
1074
1089
|
style={{ zIndex: overlayZ.value, ...hkPadStyle(props.padding) }}
|
|
1075
1090
|
onKeydown={onKeydown}
|
|
1076
1091
|
>
|
|
@@ -1195,6 +1210,11 @@ export default defineComponent({
|
|
|
1195
1210
|
renderFooter(),
|
|
1196
1211
|
])}
|
|
1197
1212
|
</div>
|
|
1213
|
+
{/* Static blur plate — the LAST child of hk-modal-root so the
|
|
1214
|
+
sheet's stack-covered drop ([data-stack-covered] on this
|
|
1215
|
+
root) reaches it; paint order rides the explicit z-indexes
|
|
1216
|
+
(plate -1 < overlay 0 < content 1), not DOM order. */}
|
|
1217
|
+
<div class="hk-modal-blur-plate" aria-hidden="true" />
|
|
1198
1218
|
</div>
|
|
1199
1219
|
</Teleport>
|
|
1200
1220
|
);
|
|
@@ -453,7 +453,13 @@ export default defineComponent({
|
|
|
453
453
|
}
|
|
454
454
|
const band = vp.getBoundingClientRect();
|
|
455
455
|
const active = pickActiveProgressRoot(band);
|
|
456
|
-
|
|
456
|
+
// A list may own the row grammar (HkTable's section bands are body
|
|
457
|
+
// `<tr>`s that are not data rows); its `getRows` wins over the
|
|
458
|
+
// container selector. No `getRows` (or an undefined return) keeps the
|
|
459
|
+
// existing selector path byte-for-byte.
|
|
460
|
+
const rows = active
|
|
461
|
+
? active.handle.getRows?.() ?? active.root.querySelectorAll(props.scrollProgressSelector)
|
|
462
|
+
: [];
|
|
457
463
|
if (!active || rows.length === 0 || vp.scrollHeight <= vp.clientHeight + 1) {
|
|
458
464
|
setProgressHidden();
|
|
459
465
|
return;
|
|
@@ -11,6 +11,7 @@ import { createApp, defineComponent, h, inject, nextTick, onMounted, onUnmounted
|
|
|
11
11
|
|
|
12
12
|
import HkScrollContainer from "./HkScrollContainer";
|
|
13
13
|
import { HkAdminTablePage } from "./HkAdminTablePage";
|
|
14
|
+
import HTable from "./HkTable";
|
|
14
15
|
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressRegistrar } from "./HkScrollProgress";
|
|
15
16
|
|
|
16
17
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
@@ -219,6 +220,24 @@ describe("HkScrollProgress", () => {
|
|
|
219
220
|
expect(pill.textContent).toBe("11 / 340");
|
|
220
221
|
});
|
|
221
222
|
|
|
223
|
+
it("counts only a sectioned HkTable's data rows, not its bands", async () => {
|
|
224
|
+
// The band is a `<tr>` in the section body but not a row. The table's
|
|
225
|
+
// handle `getRows` wins over the container's `tbody tr` selector, so
|
|
226
|
+
// the pill reports 2 sections x 2 rows = 4, not 6.
|
|
227
|
+
const sections = [
|
|
228
|
+
{ key: "panel-a", label: "Panel A", rows: [{ name: "a0" }, { name: "a1" }] },
|
|
229
|
+
{ key: "panel-b", label: "Panel B", rows: [{ name: "b0" }, { name: "b1" }] },
|
|
230
|
+
];
|
|
231
|
+
const scroller = mountScroller(() =>
|
|
232
|
+
h(HTable, { columns: [{ key: "name", title: "Name" }], sections }),
|
|
233
|
+
);
|
|
234
|
+
stubRootRect(scroller.container, ".hk-table-host", -100, 1900);
|
|
235
|
+
stubRows(scroller.container, 6, 0);
|
|
236
|
+
scroller.refresh();
|
|
237
|
+
await nextTick();
|
|
238
|
+
expect(scroller.pill()!.textContent).toBe("1 / 4");
|
|
239
|
+
});
|
|
240
|
+
|
|
222
241
|
it("falls back to the DOM row count when the total is absent", async () => {
|
|
223
242
|
const List = makeList({ rows: ROWS });
|
|
224
243
|
const scroller = mountScroller(() => h(List));
|
|
@@ -24,6 +24,12 @@ import "./HkScrollProgress.scss";
|
|
|
24
24
|
export interface HkScrollProgressHandle {
|
|
25
25
|
getRoot(): HTMLElement | null;
|
|
26
26
|
getTotal(): number | undefined;
|
|
27
|
+
/** The list's own row elements in display order — wins over the
|
|
28
|
+
* container's `scrollProgressSelector` when present. This is for a
|
|
29
|
+
* list whose DOM rows are not all data rows (HkTable's section bands
|
|
30
|
+
* are `<tr>`s in the body, but they are not rows). Return `undefined`
|
|
31
|
+
* to fall back to the container's selector. */
|
|
32
|
+
getRows?(): readonly Element[] | undefined;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/** Provided by every HkScrollContainer instance whose `scrollProgress`
|
|
@@ -311,24 +311,29 @@
|
|
|
311
311
|
display: flex;
|
|
312
312
|
flex-direction: column;
|
|
313
313
|
/* The panel height hugs its content (up to the cap below); the sheet
|
|
314
|
-
* morphs
|
|
315
|
-
*
|
|
314
|
+
* morphs when content grows/shrinks — a language list gaining or
|
|
315
|
+
* losing rows, a select whose options filter. `height: auto` growth
|
|
316
316
|
* cannot fire a CSS transition, so HkSelectPanel's useSizeMorph pins
|
|
317
|
-
* the measured natural height as px (see useSizeMorph) and
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
317
|
+
* the measured natural height as px (see useSizeMorph) and growth
|
|
318
|
+
* reveals through clip-path (--hk-sheet-morph: clip, see
|
|
319
|
+
* useSizeMorph): the pin lands instantly and the top edge sweeps up
|
|
320
|
+
* paint-only — the sheet is a fixed full-width layer, and an animated
|
|
321
|
+
* height relaid out + repainted it every frame (mobile
|
|
322
|
+
* patchy-flicker source, 2026-09-15). Shrink folds the same way and
|
|
323
|
+
* the atomic re-pin lands transition-off, so — exactly like the
|
|
324
|
+
* modal's ≤767px block — the clip-path leg is the ONLY animated
|
|
325
|
+
* property here. The max-height leg in particular must stay OUT: the
|
|
326
|
+
* cap below carries a live dvh/inset term, and with max-height in the
|
|
327
|
+
* list an adjustResize keyboard RETARGETS a 150ms transition every
|
|
328
|
+
* frame — cap-bound sheets chase their own viewport permanently (the
|
|
329
|
+
* IME cap-chase the modal block fixed on 2026-09-21; aligning this
|
|
330
|
+
* panel to that grammar on 2026-11-07 — inference-level evidence,
|
|
331
|
+
* same engine class, no same-day field report). */
|
|
325
332
|
interpolate-size: allow-keywords;
|
|
326
333
|
--hk-sheet-morph: clip;
|
|
327
334
|
contain: layout style;
|
|
328
|
-
transition:
|
|
329
|
-
|
|
330
|
-
max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
331
|
-
clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
335
|
+
transition: clip-path var(--duration-fast, 0.15s)
|
|
336
|
+
var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
332
337
|
max-height: min(
|
|
333
338
|
72vh,
|
|
334
339
|
calc(
|
|
@@ -95,6 +95,26 @@
|
|
|
95
95
|
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.03));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/* A section's band is the first child of its body, so the base zebra rule
|
|
99
|
+
would shade each section's FIRST data row (child 2). Restart the phase
|
|
100
|
+
after the band: even data children are unshaded, odd ones carry the
|
|
101
|
+
stripe — the same first-row-unshaded phase an unsectioned body has. */
|
|
102
|
+
.hk-table-striped .hk-table-section-body .hk-table-row:nth-child(even) {
|
|
103
|
+
background: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.hk-table-striped .hk-table-section-body .hk-table-row:nth-child(odd) {
|
|
107
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.03));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* The sectioned zebra pair above is (0,4,0) — it would out-specify the
|
|
111
|
+
shared `.hk-table-hover .hk-table-row:hover` rule (0,3,0) and kill hover
|
|
112
|
+
feedback on EVERY row of a striped sectioned table. Keep the pointer
|
|
113
|
+
feedback by re-stating it at a spec that beats the pair. */
|
|
114
|
+
.hk-table-striped.hk-table-hover .hk-table-section-body .hk-table-row:hover {
|
|
115
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.04));
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
.hk-table-bordered {
|
|
99
119
|
border: 1px solid var(--hi-color-border, rgba(100,100,100,0.1));
|
|
100
120
|
border-radius: var(--radius-md, 8px);
|
|
@@ -153,6 +173,51 @@
|
|
|
153
173
|
color: var(--hi-color-text-secondary, #666666);
|
|
154
174
|
}
|
|
155
175
|
|
|
176
|
+
/* ── Section bands (grouped tables) ───────────────────────────────────
|
|
177
|
+
One `<tbody>` per section, each led by a band row. The band is a
|
|
178
|
+
rowgroup header, not a data row: it never carries `.hk-table-row`, so
|
|
179
|
+
the copy layer (which marks and cursor-styles cells via
|
|
180
|
+
`[data-copy-cell]`), the hover paint, the striped rule, the drag strip
|
|
181
|
+
and the selection/⋯ tracks all ignore it by construction. The band cell
|
|
182
|
+
carries no `data-copy-cell`, so it needs no copy-cursor override either.
|
|
183
|
+
|
|
184
|
+
The cell itself is TYPE-NEUTRAL for consumer markup: the `section` slot
|
|
185
|
+
carries arbitrary content (badges, panel names, status chips), so only
|
|
186
|
+
the track is styled here (padding / hairline / background / start
|
|
187
|
+
alignment). `font-weight: inherit` neutralizes the UA `th { font-weight:
|
|
188
|
+
bold }`; nothing here sets `white-space` (long panel names may wrap) or
|
|
189
|
+
`cursor` (selectable band text keeps its I-beam). Sizing is delivered by
|
|
190
|
+
the consumer's own classes; the plain `label` fallback gets its own span
|
|
191
|
+
below. The hk-table-sm/lg ladder steps ONLY the band's padding —
|
|
192
|
+
typography deliberately does not scale with the table size, because the
|
|
193
|
+
consumer's slot content owns its type. */
|
|
194
|
+
.hk-table-section-cell {
|
|
195
|
+
padding: var(--space-8, 8px) var(--space-12, 12px);
|
|
196
|
+
border-bottom: 1px solid var(--hi-color-border, rgba(100,100,100,0.08));
|
|
197
|
+
text-align: start;
|
|
198
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.03));
|
|
199
|
+
font-weight: inherit;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* The default `label` branch only (the `section` slot bypasses this span
|
|
203
|
+
entirely): the previous text-sm / font-medium band look. */
|
|
204
|
+
.hk-table-section-label {
|
|
205
|
+
font-size: var(--text-sm, 0.875rem);
|
|
206
|
+
font-weight: 500;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.hk-table-sm .hk-table-section-cell {
|
|
210
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.hk-table-lg .hk-table-section-cell {
|
|
214
|
+
padding: var(--space-16, 16px) var(--space-24, 24px);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.hk-table-bordered .hk-table-section-cell {
|
|
218
|
+
border: 1px solid var(--hi-color-border, rgba(100,100,100,0.06));
|
|
219
|
+
}
|
|
220
|
+
|
|
156
221
|
/* ── Drag-to-reorder (draggable) ─────────────────────────────────────── */
|
|
157
222
|
|
|
158
223
|
.hk-table-drag-handle {
|