@celestia-island/hikari 0.55.175 → 0.55.177
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/HkAlert.pad.test.tsx +67 -0
- package/src/components/HkAlert.scss +5 -3
- package/src/components/HkAlert.tsx +5 -1
- package/src/components/HkCard.pad.test.tsx +77 -0
- package/src/components/HkCard.scss +5 -3
- package/src/components/HkCard.tsx +10 -1
- package/src/components/HkDraggableList.test.tsx +163 -1
- package/src/components/HkDraggableList.tsx +99 -19
- package/src/components/HkDrawer.scss +3 -1
- package/src/components/HkDrawer.tsx +6 -1
- package/src/components/HkModal.scss +7 -4
- package/src/components/HkModal.tsx +6 -1
- package/src/components/HkPopover.scss +36 -10
- package/src/components/HkPopover.sheetdock.test.ts +6 -3
- package/src/components/HkPopover.tsx +8 -0
- package/src/components/HkPopoverSheet.test.tsx +26 -0
- package/src/components/HkScrollPin.scrollcontract.test.ts +5 -2
- package/src/components/HkStatCard.scss +2 -1
- package/src/components/HkStatCard.tsx +6 -0
- package/src/components/containerPad.styles.test.ts +122 -0
- package/src/index.ts +1 -0
- package/src/runtime/containerPad.test.ts +37 -0
- package/src/runtime/containerPad.ts +53 -0
- package/src/runtime/index.ts +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkAlert from "./HkAlert";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Component-level guard for the shared container-padding prop
|
|
8
|
+
* (runtime/containerPad) on HkAlert: the `padding` prop lands the
|
|
9
|
+
* `--hk-pad` custom property on the root element (inline — so it wins
|
|
10
|
+
* the cascade over the base and per-size padding rules without any
|
|
11
|
+
* specificity contest), and the unset prop lands nothing, leaving the
|
|
12
|
+
* size's own padding in force.
|
|
13
|
+
*
|
|
14
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
15
|
+
* @vue/test-utils dependency.)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
19
|
+
|
|
20
|
+
function mountAlert(props: Record<string, unknown> = {}): HTMLElement {
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
const Wrapper = defineComponent({
|
|
24
|
+
setup() {
|
|
25
|
+
return () => h(HkAlert, { message: "mind the gap", ...props });
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const app = createApp(Wrapper);
|
|
29
|
+
app.mount(container);
|
|
30
|
+
mounts.push({ app, container });
|
|
31
|
+
const el = container.querySelector<HTMLElement>(".hk-alert");
|
|
32
|
+
expect(el, ".hk-alert renders").toBeTruthy();
|
|
33
|
+
return el!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const { app, container } of mounts) {
|
|
38
|
+
app.unmount();
|
|
39
|
+
container.remove();
|
|
40
|
+
}
|
|
41
|
+
mounts.length = 0;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("HkAlert padding prop", () => {
|
|
45
|
+
it("unset prop lands no token — the size keeps its own padding", () => {
|
|
46
|
+
const el = mountAlert({ size: "sm" });
|
|
47
|
+
expect(el.style.getPropertyValue("--hk-pad")).toBe("");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("a named step lands the token inline", () => {
|
|
51
|
+
const el = mountAlert({ padding: "sm" });
|
|
52
|
+
expect(el.style.getPropertyValue("--hk-pad")).toBe("var(--space-8, 0.5rem)");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("none zeroes the padding explicitly", () => {
|
|
56
|
+
const el = mountAlert({ padding: "none" });
|
|
57
|
+
expect(el.style.getPropertyValue("--hk-pad")).toBe("0");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("the token rides the ROOT, so the per-size rules all consume it", () => {
|
|
61
|
+
// sm/lg carry their own padding rules; the inline token must sit
|
|
62
|
+
// above them (root element), not on a nested node.
|
|
63
|
+
const sm = mountAlert({ size: "sm", padding: "lg" });
|
|
64
|
+
expect(sm.classList.contains("hk-alert-sm")).toBe(true);
|
|
65
|
+
expect(sm.style.getPropertyValue("--hk-pad")).toBe("var(--space-16, 1rem)");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
display: flex;
|
|
3
3
|
align-items: flex-start;
|
|
4
4
|
gap: var(--space-12, 12px);
|
|
5
|
-
|
|
5
|
+
/* --hk-pad: the shared container-padding token (runtime/containerPad) —
|
|
6
|
+
unset falls back to the historical values, set wins per instance. */
|
|
7
|
+
padding: var(--hk-pad, var(--space-12, 12px) var(--space-16, 16px));
|
|
6
8
|
border-radius: var(--radius-md, 8px);
|
|
7
9
|
border: 1px solid transparent;
|
|
8
10
|
border-inline-start-width: 4px;
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
/* ── Sizes ─────────────────────────────────────── */
|
|
21
23
|
|
|
22
24
|
.hk-alert-sm {
|
|
23
|
-
padding: var(--space-8, 8px) var(--space-12, 12px);
|
|
25
|
+
padding: var(--hk-pad, var(--space-8, 8px) var(--space-12, 12px));
|
|
24
26
|
font-size: var(--text-xs, 0.75rem);
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
.hk-alert-lg {
|
|
33
|
-
padding: var(--space-16, 16px) var(--space-24, 24px);
|
|
35
|
+
padding: var(--hk-pad, var(--space-16, 16px) var(--space-24, 24px));
|
|
34
36
|
font-size: var(--text-base, 0.875rem);
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
2
|
import { useI18n } from "../i18n/context";
|
|
3
|
+
import { hkPadStyle, type HkPad } from "../runtime/containerPad";
|
|
3
4
|
import { AlertTriangle, CheckCircle, Info, X } from "lucide-vue-next";
|
|
4
5
|
|
|
5
6
|
import "./HkAlert.scss";
|
|
@@ -23,6 +24,9 @@ export default defineComponent({
|
|
|
23
24
|
closable: { type: Boolean, default: false },
|
|
24
25
|
banner: { type: Boolean, default: false },
|
|
25
26
|
size: { type: String as PropType<AlertSize>, default: "md" },
|
|
27
|
+
/** Shared container-padding override (see runtime/containerPad) —
|
|
28
|
+
* unset keeps the size's own padding. */
|
|
29
|
+
padding: { type: String as PropType<HkPad>, default: undefined },
|
|
26
30
|
},
|
|
27
31
|
emits: {
|
|
28
32
|
close: () => true,
|
|
@@ -50,7 +54,7 @@ export default defineComponent({
|
|
|
50
54
|
const IconComp = VARIANT_ICONS[props.variant];
|
|
51
55
|
|
|
52
56
|
return (
|
|
53
|
-
<div class={rootCls.value}>
|
|
57
|
+
<div class={rootCls.value} style={hkPadStyle(props.padding)}>
|
|
54
58
|
<span class="hk-alert-icon">
|
|
55
59
|
{slots.icon ? (
|
|
56
60
|
slots.icon()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkCard from "./HkCard";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Component-level guard for the shared container-padding prop
|
|
8
|
+
* (runtime/containerPad) on HkCard: the token lands on the card ROOT
|
|
9
|
+
* (custom properties inherit, so the body, the unpadded override and
|
|
10
|
+
* the scrollable card's inner pad wrapper all resolve it), and the
|
|
11
|
+
* unset prop lands nothing — `padded: false` keeps zeroing the body.
|
|
12
|
+
*
|
|
13
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
14
|
+
* @vue/test-utils dependency.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
18
|
+
|
|
19
|
+
function mountCard(props: Record<string, unknown> = {}): {
|
|
20
|
+
root: HTMLElement;
|
|
21
|
+
body: HTMLElement;
|
|
22
|
+
} {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const Wrapper = defineComponent({
|
|
26
|
+
setup() {
|
|
27
|
+
return () =>
|
|
28
|
+
h(HkCard, { title: "T", ...props }, { default: () => h("p", "body") });
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const app = createApp(Wrapper);
|
|
32
|
+
app.mount(container);
|
|
33
|
+
mounts.push({ app, container });
|
|
34
|
+
const root = container.querySelector<HTMLElement>(".hk-card");
|
|
35
|
+
expect(root, ".hk-card renders").toBeTruthy();
|
|
36
|
+
const body = root!.querySelector<HTMLElement>(".hk-card-body");
|
|
37
|
+
expect(body, ".hk-card-body renders").toBeTruthy();
|
|
38
|
+
return { root: root!, body: body! };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
for (const { app, container } of mounts) {
|
|
43
|
+
app.unmount();
|
|
44
|
+
container.remove();
|
|
45
|
+
}
|
|
46
|
+
mounts.length = 0;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("HkCard padding prop", () => {
|
|
50
|
+
it("unset prop lands no token — the body keeps its inset", () => {
|
|
51
|
+
const { root } = mountCard();
|
|
52
|
+
expect(root.style.getPropertyValue("--hk-pad")).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("a named step lands the token on the card root", () => {
|
|
56
|
+
const { root } = mountCard({ padding: "md" });
|
|
57
|
+
expect(root.style.getPropertyValue("--hk-pad")).toBe("var(--space-12, 0.75rem)");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("the token inherits into the body (one root write covers every rule)", () => {
|
|
61
|
+
const { root } = mountCard({ padding: "none" });
|
|
62
|
+
// happy-dom computes inherited custom properties off the DOM tree —
|
|
63
|
+
// the body sees the root's token, which is exactly the cascade the
|
|
64
|
+
// scss rules consume.
|
|
65
|
+
expect(root.style.getPropertyValue("--hk-pad")).toBe("0");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("composes with padded:false — an explicit padding still reaches the body", () => {
|
|
69
|
+
const { root } = mountCard({ padded: false, padding: "sm" });
|
|
70
|
+
expect(
|
|
71
|
+
root.querySelector<HTMLElement>(".hk-card-body")!.classList.contains(
|
|
72
|
+
"hk-card-body-unpadded",
|
|
73
|
+
),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
expect(root.style.getPropertyValue("--hk-pad")).toBe("var(--space-8, 0.5rem)");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -42,11 +42,13 @@
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
.hk-card-body {
|
|
45
|
-
|
|
45
|
+
/* --hk-pad: the shared container-padding token (runtime/containerPad) —
|
|
46
|
+
the card root carries it when the `padding` prop is set. */
|
|
47
|
+
padding: var(--hk-pad, var(--space-16));
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
.hk-card-body-unpadded {
|
|
49
|
-
padding: 0;
|
|
51
|
+
padding: var(--hk-pad, 0);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
.hk-card-footer {
|
|
@@ -85,6 +87,6 @@
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
.hk-card-body-pad {
|
|
88
|
-
padding: var(--space-16);
|
|
90
|
+
padding: var(--hk-pad, var(--space-16));
|
|
89
91
|
}
|
|
90
92
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { defineComponent } from "vue";
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { hkPadStyle, type HkPad } from "../runtime/containerPad";
|
|
2
4
|
|
|
3
5
|
import HkScrollContainer from "./HkScrollContainer";
|
|
4
6
|
import "./HkCard.scss";
|
|
@@ -9,6 +11,12 @@ export default defineComponent({
|
|
|
9
11
|
title: { type: String, default: undefined },
|
|
10
12
|
hoverable: { type: Boolean, default: false },
|
|
11
13
|
padded: { type: Boolean, default: true },
|
|
14
|
+
/** Shared container-padding override (see runtime/containerPad) for
|
|
15
|
+
* the body (and the scrollable card's inner pad wrapper) — unset
|
|
16
|
+
* keeps the body's historical inset; composes with `padded`
|
|
17
|
+
* (unpadded still drops the inset unless an explicit padding is
|
|
18
|
+
* given). */
|
|
19
|
+
padding: { type: String as PropType<HkPad>, default: undefined },
|
|
12
20
|
/** Turn the card body into the library's standard scroll region
|
|
13
21
|
* (HkScrollContainer: overlay scrollbar, overflow sensing) instead
|
|
14
22
|
* of a plain static block. The card becomes a column flex box —
|
|
@@ -34,6 +42,7 @@ export default defineComponent({
|
|
|
34
42
|
props.hoverable ? "hk-card-hoverable" : "",
|
|
35
43
|
props.class,
|
|
36
44
|
]}
|
|
45
|
+
style={hkPadStyle(props.padding)}
|
|
37
46
|
onClick={(e) => emit("click", e)}
|
|
38
47
|
>
|
|
39
48
|
{props.title || slots.header ? (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HDraggableList from "./HkDraggableList";
|
|
@@ -180,3 +180,165 @@ describe("HkDraggableList locked-lane contract", () => {
|
|
|
180
180
|
expect(root.hasAttribute("data-all-locked")).toBe(false);
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
describe("HkDraggableList list semantics", () => {
|
|
185
|
+
/** A reorderable list is a list for AT users whether or not the host
|
|
186
|
+
* names it: the root always carries role="list" and every row
|
|
187
|
+
* role="listitem" (the vocabulary a11y rulings #736/#737 applied to
|
|
188
|
+
* the picker surfaces — the drag list adopts the same contract). The
|
|
189
|
+
* accessible NAME stays the host's call: aria-label renders only when
|
|
190
|
+
* the `label` prop is passed, so an unnamed list is still structural
|
|
191
|
+
* and never announces an empty string. */
|
|
192
|
+
|
|
193
|
+
it("announces the root and rows as a list regardless of the label", () => {
|
|
194
|
+
const root = mountList(["a", "b"], []);
|
|
195
|
+
|
|
196
|
+
expect(root.getAttribute("role")).toBe("list");
|
|
197
|
+
expect(root.hasAttribute("aria-label")).toBe(false);
|
|
198
|
+
const rows = Array.from(root.querySelectorAll(".hk-draggable-list-item"));
|
|
199
|
+
expect(rows).toHaveLength(2);
|
|
200
|
+
expect(rows.every((row) => row.getAttribute("role") === "listitem")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("names the list through the label prop", () => {
|
|
204
|
+
const container = document.createElement("div");
|
|
205
|
+
document.body.appendChild(container);
|
|
206
|
+
const app = createApp({
|
|
207
|
+
render: () =>
|
|
208
|
+
h(
|
|
209
|
+
HDraggableList,
|
|
210
|
+
{ items: [{ key: "a" }], label: "Sortable accounts" },
|
|
211
|
+
{
|
|
212
|
+
item: ({ item }: { item: { key: string } }) =>
|
|
213
|
+
h("span", { class: "row-body" }, item.key),
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
});
|
|
217
|
+
app.mount(container);
|
|
218
|
+
mounts.push({ app, container });
|
|
219
|
+
|
|
220
|
+
const root = container.querySelector(".hk-draggable-list") as HTMLElement;
|
|
221
|
+
expect(root.getAttribute("role")).toBe("list");
|
|
222
|
+
expect(root.getAttribute("aria-label")).toBe("Sortable accounts");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("HkDraggableList press ergonomics (clickable rows)", () => {
|
|
227
|
+
/** Two guards make a clickable row safe inside the list (ported from
|
|
228
|
+
* wowsp's SortableList twin, which ran them in production):
|
|
229
|
+
*
|
|
230
|
+
* 1. ARM — a press under 5px of travel never publishes drag state
|
|
231
|
+
* (no ghost flash on a jittered tap) and keeps the tap's native
|
|
232
|
+
* semantics: no preventDefault until the arm.
|
|
233
|
+
* 2. SWALLOW — the release of an ARMED drag lands as a click on the
|
|
234
|
+
* row the press started from; a capture-phase guard on the root
|
|
235
|
+
* eats it, so dropping a row onto a clickable card never also
|
|
236
|
+
* activates that card. Time-boxed at 300ms so it can never eat
|
|
237
|
+
* a later keyboard activation, which has no pointerdown to
|
|
238
|
+
* reset it. */
|
|
239
|
+
|
|
240
|
+
const PointerEventCtor: typeof MouseEvent =
|
|
241
|
+
(globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
|
242
|
+
|
|
243
|
+
const pointerInit: MouseEventInit = { bubbles: true, cancelable: true };
|
|
244
|
+
|
|
245
|
+
function fire(el: Element, type: string, x = 0, y = 0) {
|
|
246
|
+
el.dispatchEvent(new PointerEventCtor(type, {
|
|
247
|
+
...pointerInit,
|
|
248
|
+
button: 0,
|
|
249
|
+
clientX: x,
|
|
250
|
+
clientY: y,
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function fireWin(type: string, x = 0, y = 0) {
|
|
255
|
+
window.dispatchEvent(new PointerEventCtor(type, {
|
|
256
|
+
...pointerInit,
|
|
257
|
+
clientX: x,
|
|
258
|
+
clientY: y,
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function mountClickable(): { root: HTMLElement; clicks: () => number } {
|
|
263
|
+
let clicked = 0;
|
|
264
|
+
const container = document.createElement("div");
|
|
265
|
+
document.body.appendChild(container);
|
|
266
|
+
const app = createApp({
|
|
267
|
+
render: () =>
|
|
268
|
+
h(
|
|
269
|
+
HDraggableList,
|
|
270
|
+
{ items: [{ key: "a" }, { key: "b" }] },
|
|
271
|
+
{
|
|
272
|
+
item: () =>
|
|
273
|
+
h(
|
|
274
|
+
"div",
|
|
275
|
+
{ class: "row-body", onClick: () => { clicked++; } },
|
|
276
|
+
"row",
|
|
277
|
+
),
|
|
278
|
+
},
|
|
279
|
+
),
|
|
280
|
+
});
|
|
281
|
+
app.mount(container);
|
|
282
|
+
mounts.push({ app, container });
|
|
283
|
+
const root = container.querySelector(".hk-draggable-list") as HTMLElement;
|
|
284
|
+
return { root, clicks: () => clicked };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it("keeps a plain tap a tap: no drag state, and the row's click fires", async () => {
|
|
288
|
+
const { root, clicks } = mountClickable();
|
|
289
|
+
const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
|
|
290
|
+
|
|
291
|
+
fire(row, "pointerdown", 10, 10);
|
|
292
|
+
// A 1px jitter stays under the arm threshold — no drag publishes.
|
|
293
|
+
fireWin("pointermove", 11, 10);
|
|
294
|
+
await nextTick();
|
|
295
|
+
expect(row.hasAttribute("data-dragging")).toBe(false);
|
|
296
|
+
|
|
297
|
+
fireWin("pointerup");
|
|
298
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
299
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
300
|
+
);
|
|
301
|
+
expect(clicks()).toBe(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("arms past the threshold, drags, and swallows the release click", async () => {
|
|
305
|
+
const { root, clicks } = mountClickable();
|
|
306
|
+
const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
|
|
307
|
+
|
|
308
|
+
fire(row, "pointerdown", 10, 10);
|
|
309
|
+
fireWin("pointermove", 30, 40);
|
|
310
|
+
await nextTick();
|
|
311
|
+
expect(row.getAttribute("data-dragging")).not.toBeNull();
|
|
312
|
+
|
|
313
|
+
fireWin("pointerup");
|
|
314
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
315
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
316
|
+
);
|
|
317
|
+
expect(clicks()).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("time-boxes the swallow — a click after the window passes through", async () => {
|
|
321
|
+
vi.useFakeTimers();
|
|
322
|
+
try {
|
|
323
|
+
const { root, clicks } = mountClickable();
|
|
324
|
+
const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
|
|
325
|
+
|
|
326
|
+
fire(row, "pointerdown", 10, 10);
|
|
327
|
+
fireWin("pointermove", 30, 40);
|
|
328
|
+
fireWin("pointerup");
|
|
329
|
+
|
|
330
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
331
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
332
|
+
);
|
|
333
|
+
expect(clicks()).toBe(0);
|
|
334
|
+
|
|
335
|
+
vi.advanceTimersByTime(301);
|
|
336
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
337
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
338
|
+
);
|
|
339
|
+
expect(clicks()).toBe(1);
|
|
340
|
+
} finally {
|
|
341
|
+
vi.useRealTimers();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, onBeforeUnmount, ref, shallowRef, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, onBeforeUnmount, onMounted, ref, shallowRef, type PropType } from "vue";
|
|
2
2
|
import { onceFrame } from "../runtime/animationBus";
|
|
3
3
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
4
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
@@ -14,10 +14,23 @@ interface DragState {
|
|
|
14
14
|
fromIndex: number;
|
|
15
15
|
placeholderIndex: number;
|
|
16
16
|
originX: number;
|
|
17
|
+
originY: number;
|
|
17
18
|
grabOffsetX: number;
|
|
18
19
|
grabOffsetY: number;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Press-to-drag arm distance — a plain click on a row (or its handle)
|
|
23
|
+
* never starts a drag: the ghost only rises once the pointer moves past
|
|
24
|
+
* this many px, so a clickable row keeps its tap action and a jittered
|
|
25
|
+
* tap never flashes a ghost. */
|
|
26
|
+
const ARM_PX = 5;
|
|
27
|
+
|
|
28
|
+
/** How long after a drop the release click is still recognized as the
|
|
29
|
+
* drag's own click. Time-boxed (rather than a sticky flag) so a drag
|
|
30
|
+
* released off-window can never eat a later keyboard activation,
|
|
31
|
+
* which has no pointerdown to reset it. */
|
|
32
|
+
const SWALLOW_CLICK_MS = 300;
|
|
33
|
+
|
|
21
34
|
export default defineComponent({
|
|
22
35
|
name: "HkDraggableList",
|
|
23
36
|
props: {
|
|
@@ -27,6 +40,12 @@ export default defineComponent({
|
|
|
27
40
|
* locked the root carries data-all-locked and the sheet collapses
|
|
28
41
|
* the lane entirely — the list cannot reorder at all. */
|
|
29
42
|
lockedKeys: { type: Array as PropType<string[]>, default: () => [] },
|
|
43
|
+
/** Accessible name for the list (aria-label on the role="list" root).
|
|
44
|
+
* The list semantics themselves are unconditional — a reorderable
|
|
45
|
+
* list is a list for AT users whether or not the host names it —
|
|
46
|
+
* mirroring the menu/vocabulary a11y rulings (#736/#737): chrome
|
|
47
|
+
* announces structure, the host supplies the name. */
|
|
48
|
+
label: { type: String, default: undefined },
|
|
30
49
|
},
|
|
31
50
|
emits: {
|
|
32
51
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
@@ -46,6 +65,38 @@ export default defineComponent({
|
|
|
46
65
|
|
|
47
66
|
let ghostNode: HTMLElement | null = null;
|
|
48
67
|
|
|
68
|
+
/** Press bookkeeping — captured at pointerdown, never read during
|
|
69
|
+
* render. `armed` flips only when the pointer crosses ARM_PX; until
|
|
70
|
+
* then the press is a plain click-in-progress (no ghost, no drag
|
|
71
|
+
* state, no preventDefault — the tap keeps its native semantics). */
|
|
72
|
+
let press: {
|
|
73
|
+
rect: DOMRect;
|
|
74
|
+
snapshot: string;
|
|
75
|
+
state: DragState;
|
|
76
|
+
armed: boolean;
|
|
77
|
+
} | null = null;
|
|
78
|
+
/** Timestamp of an armed drag's release — the capture-phase click
|
|
79
|
+
* guard below eats the click a completed drag leaves behind (press
|
|
80
|
+
* + release on the same element), so dropping a row onto a
|
|
81
|
+
* clickable card never also activates that card. */
|
|
82
|
+
let swallowClickAt = 0;
|
|
83
|
+
|
|
84
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
85
|
+
|
|
86
|
+
/** Capture-phase click guard — runs before any row content's own
|
|
87
|
+
* handlers (Vue's JSX typings have no onClickCapture attribute, so
|
|
88
|
+
* the listener is wired imperatively in onMounted). */
|
|
89
|
+
function onRootClickCapture(e: MouseEvent) {
|
|
90
|
+
if (Date.now() - swallowClickAt > SWALLOW_CLICK_MS) return;
|
|
91
|
+
swallowClickAt = 0;
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onMounted(() => {
|
|
97
|
+
rootEl.value?.addEventListener("click", onRootClickCapture, true);
|
|
98
|
+
});
|
|
99
|
+
|
|
49
100
|
const anim = useReportedTransition(250);
|
|
50
101
|
|
|
51
102
|
function bump() { tick.value++; }
|
|
@@ -68,28 +119,29 @@ export default defineComponent({
|
|
|
68
119
|
const row = rowEls.value[index];
|
|
69
120
|
if (!row) return;
|
|
70
121
|
|
|
71
|
-
e.preventDefault();
|
|
72
122
|
const rect = row.getBoundingClientRect();
|
|
73
123
|
|
|
74
124
|
const snapshot = row.outerHTML
|
|
75
125
|
.replace(/\sdraggable="[^"]*"/g, "")
|
|
76
126
|
.replace(/\sdata-dragging="[^"]*"/g, "");
|
|
77
127
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
128
|
+
// The press captures everything the arm needs. Nothing publishes
|
|
129
|
+
// yet — no preventDefault either: the handle lane carries its own
|
|
130
|
+
// touch-action:none and the rows are user-select:none, so a plain
|
|
131
|
+
// tap keeps its native click/scroll semantics untouched.
|
|
132
|
+
press = {
|
|
133
|
+
rect,
|
|
134
|
+
snapshot,
|
|
135
|
+
state: {
|
|
136
|
+
fromIndex: index,
|
|
137
|
+
placeholderIndex: index,
|
|
138
|
+
originX: e.clientX,
|
|
139
|
+
originY: e.clientY,
|
|
140
|
+
grabOffsetX: e.clientX - rect.left,
|
|
141
|
+
grabOffsetY: e.clientY - rect.top,
|
|
142
|
+
},
|
|
143
|
+
armed: false,
|
|
84
144
|
};
|
|
85
|
-
drag.value = state;
|
|
86
|
-
bump();
|
|
87
|
-
|
|
88
|
-
onceFrame(() => {
|
|
89
|
-
if (!drag.value) return;
|
|
90
|
-
attachGhost(rect, snapshot);
|
|
91
|
-
anim.run();
|
|
92
|
-
});
|
|
93
145
|
|
|
94
146
|
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
95
147
|
window.addEventListener("pointerup", onPointerUp, { once: true });
|
|
@@ -170,22 +222,45 @@ export default defineComponent({
|
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
function onPointerMove(e: PointerEvent) {
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
225
|
+
const p = press;
|
|
226
|
+
if (!p) return;
|
|
227
|
+
|
|
228
|
+
if (!p.armed) {
|
|
229
|
+
// Arm past the threshold: only now does the drag publish, the
|
|
230
|
+
// ghost rise, and the gesture claim its defaults.
|
|
231
|
+
if (Math.hypot(e.clientX - p.state.originX, e.clientY - p.state.originY) <= ARM_PX) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
p.armed = true;
|
|
235
|
+
drag.value = p.state;
|
|
236
|
+
bump();
|
|
237
|
+
const { rect, snapshot } = p;
|
|
238
|
+
onceFrame(() => {
|
|
239
|
+
if (!drag.value) return;
|
|
240
|
+
attachGhost(rect, snapshot);
|
|
241
|
+
anim.run();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
175
245
|
e.preventDefault();
|
|
176
246
|
moveGhost(e.clientY);
|
|
177
247
|
recomputePlaceholder(e.clientY);
|
|
178
248
|
}
|
|
179
249
|
|
|
180
250
|
function onPointerUp() {
|
|
181
|
-
const s = drag.value;
|
|
182
251
|
window.removeEventListener("pointermove", onPointerMove);
|
|
183
252
|
|
|
253
|
+
const s = drag.value;
|
|
184
254
|
if (s) {
|
|
255
|
+
// An armed drag's release lands as a click on the row the press
|
|
256
|
+
// started from — mark it for the capture guard or every drop
|
|
257
|
+
// onto a clickable card would also activate the card.
|
|
258
|
+
swallowClickAt = Date.now();
|
|
185
259
|
if (s.placeholderIndex !== s.fromIndex) {
|
|
186
260
|
emit("reorder", s.fromIndex, s.placeholderIndex);
|
|
187
261
|
}
|
|
188
262
|
}
|
|
263
|
+
press = null;
|
|
189
264
|
anim.run();
|
|
190
265
|
drag.value = null;
|
|
191
266
|
bump();
|
|
@@ -193,6 +268,7 @@ export default defineComponent({
|
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
onBeforeUnmount(() => {
|
|
271
|
+
rootEl.value?.removeEventListener("click", onRootClickCapture, true);
|
|
196
272
|
window.removeEventListener("pointermove", onPointerMove);
|
|
197
273
|
detachGhost();
|
|
198
274
|
});
|
|
@@ -211,7 +287,10 @@ export default defineComponent({
|
|
|
211
287
|
props.items.length > 0 && props.items.every((item) => isLocked(item.key));
|
|
212
288
|
return (
|
|
213
289
|
<div
|
|
290
|
+
ref={rootEl}
|
|
214
291
|
class="hk-draggable-list"
|
|
292
|
+
role="list"
|
|
293
|
+
aria-label={props.label || undefined}
|
|
215
294
|
data-dragging={s ? "" : undefined}
|
|
216
295
|
data-all-locked={allLocked || undefined}
|
|
217
296
|
>
|
|
@@ -227,6 +306,7 @@ export default defineComponent({
|
|
|
227
306
|
key={item.key}
|
|
228
307
|
ref={(el) => setRowEl(index, el as Element | null)}
|
|
229
308
|
class="hk-draggable-list-item"
|
|
309
|
+
role="listitem"
|
|
230
310
|
data-locked={locked || undefined}
|
|
231
311
|
data-dragging={isSource || undefined}
|
|
232
312
|
data-placeholder={placeholderDir || undefined}
|
|
@@ -162,7 +162,9 @@
|
|
|
162
162
|
/* Consumers nesting a self-padded menu (HkMenuPanel) zero this via
|
|
163
163
|
--hk-drawer-body-padding so nav rows and the footer menu share one
|
|
164
164
|
left edge. */
|
|
165
|
-
|
|
165
|
+
/* --hk-pad (runtime/containerPad, the `padding` prop) outranks the
|
|
166
|
+
per-component theme token, which outranks the historical default. */
|
|
167
|
+
padding: var(--hk-pad, var(--hk-drawer-body-padding, var(--space-16, 1rem)));
|
|
166
168
|
|
|
167
169
|
/* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
|
|
168
170
|
hidden, never styled. */
|
|
@@ -15,6 +15,7 @@ import "./HkDrawer.scss";
|
|
|
15
15
|
import { focusFirst, trapFocus } from "../utils/dom";
|
|
16
16
|
import { useOverlay } from "../runtime/useOverlay";
|
|
17
17
|
import { usePopupManager } from "../runtime/usePopupManager";
|
|
18
|
+
import { hkPadStyle, type HkPad } from "../runtime/containerPad";
|
|
18
19
|
import { createBackGuard } from "../runtime/backStack";
|
|
19
20
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
20
21
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
@@ -36,6 +37,10 @@ export default defineComponent({
|
|
|
36
37
|
closable: { type: Boolean, default: true },
|
|
37
38
|
overlay: { type: Boolean, default: true },
|
|
38
39
|
size: { type: String, default: "320px" },
|
|
40
|
+
/** Shared container-padding override (see runtime/containerPad) for
|
|
41
|
+
* the body — unset keeps --hk-drawer-body-padding / the 1rem
|
|
42
|
+
* default. */
|
|
43
|
+
padding: { type: String as PropType<HkPad>, default: undefined },
|
|
39
44
|
/** Extra classes for the floating panel — lets consumers scope
|
|
40
45
|
* body/footer padding overrides (attrs fallthrough cannot reach a
|
|
41
46
|
* Teleported panel). */
|
|
@@ -289,7 +294,7 @@ export default defineComponent({
|
|
|
289
294
|
<div
|
|
290
295
|
ref={panelRef}
|
|
291
296
|
class={["hk-drawer-panel", `hk-drawer-${props.side}`, props.panelClass, ...machine.classesFor(panelPrefix)]}
|
|
292
|
-
style={panelStyle.value}
|
|
297
|
+
style={{ ...panelStyle.value, ...hkPadStyle(props.padding) }}
|
|
293
298
|
role="dialog"
|
|
294
299
|
aria-label={props.title}
|
|
295
300
|
aria-modal={props.overlay}
|
|
@@ -296,10 +296,13 @@
|
|
|
296
296
|
// parent): custom properties inherit downward, so the covers, the
|
|
297
297
|
// scroller and the inner all resolve them. The mobile sheet block
|
|
298
298
|
// retunes the vars only.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
--hk-scroll-pad-
|
|
299
|
+
// The shared container-padding token (--hk-pad, runtime/containerPad;
|
|
300
|
+
// the modal root carries it via the `padding` prop) outranks the
|
|
301
|
+
// per-component theme token, which outranks the historical default.
|
|
302
|
+
--hk-scroll-pad-top: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
|
|
303
|
+
--hk-scroll-pad-right: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
|
|
304
|
+
--hk-scroll-pad-bottom: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
|
|
305
|
+
--hk-scroll-pad-left: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));
|
|
303
306
|
|
|
304
307
|
// Gutter covers (scroll-pin contract, 2026-09-14): the body's top and
|
|
305
308
|
// bottom whitespace stays paintable OUTSIDE the scroll flow, so a
|
|
@@ -7,6 +7,7 @@ import { focusFirst, trapFocus } from "../utils/dom";
|
|
|
7
7
|
import { useOverlay } from "../runtime/useOverlay";
|
|
8
8
|
import { topWindowZ, usePopupManager } from "../runtime/usePopupManager";
|
|
9
9
|
import { createBackGuard } from "../runtime/backStack";
|
|
10
|
+
import { hkPadStyle, type HkPad } from "../runtime/containerPad";
|
|
10
11
|
import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
11
12
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
12
13
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
@@ -142,6 +143,10 @@ export default defineComponent({
|
|
|
142
143
|
modelValue: { type: Boolean, required: true },
|
|
143
144
|
title: { type: String, default: undefined },
|
|
144
145
|
closable: { type: Boolean, default: true },
|
|
146
|
+
/** Shared container-padding override (see runtime/containerPad) for
|
|
147
|
+
* the body — unset keeps --hk-modal-padding-body / the 1.5rem
|
|
148
|
+
* default; the header and sub-header keep their own theme tokens. */
|
|
149
|
+
padding: { type: String as PropType<HkPad>, default: undefined },
|
|
145
150
|
/** Named preset ("xs"…"xl") or any CSS max-width value (see ModalWidth). */
|
|
146
151
|
width: { type: String as PropType<ModalWidth>, default: "32rem" },
|
|
147
152
|
/**
|
|
@@ -1033,7 +1038,7 @@ export default defineComponent({
|
|
|
1033
1038
|
<Teleport to={ensureOverlayRoot()}>
|
|
1034
1039
|
<div
|
|
1035
1040
|
class="hk-modal-root"
|
|
1036
|
-
style={{ zIndex: overlayZ.value }}
|
|
1041
|
+
style={{ zIndex: overlayZ.value, ...hkPadStyle(props.padding) }}
|
|
1037
1042
|
onKeydown={onKeydown}
|
|
1038
1043
|
>
|
|
1039
1044
|
<div
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
.hk-popover-panel {
|
|
11
11
|
min-width: 0;
|
|
12
|
+
/* --hk-pad: the shared container-padding token (runtime/containerPad) —
|
|
13
|
+
0 without glass (the bare panel never carried an inset), overridden
|
|
14
|
+
per instance via the `padding` prop. */
|
|
15
|
+
padding: var(--hk-pad, 0);
|
|
12
16
|
max-height: min(70vh, calc(100vh - 2 * var(--viewport-gutter, 16px)));
|
|
13
17
|
/* Width MUST be position-independent (max-content, viewport-capped).
|
|
14
18
|
* The anchored panel is fixed-positioned with shrink-to-fit sizing,
|
|
@@ -69,7 +73,9 @@
|
|
|
69
73
|
border: 1px solid rgb(var(--color-border) / 20%);
|
|
70
74
|
border-radius: var(--radius-sm);
|
|
71
75
|
box-shadow: var(--shadow-dropdown);
|
|
72
|
-
|
|
76
|
+
/* The glass chrome's historical 4px menu inset — the shared token
|
|
77
|
+
(runtime/containerPad) retunes it per instance (`padding` prop). */
|
|
78
|
+
padding: var(--hk-pad, var(--space-4, 0.25rem));
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/* Locale picker menu items — unified menu-item spec (./_menu-item.scss). */
|
|
@@ -159,13 +165,17 @@
|
|
|
159
165
|
/* The sheet's bottom-edge distance itself comes from the shared
|
|
160
166
|
* --hk-sheet-bottom-gap (inline in HkPopover's sheet style); here the
|
|
161
167
|
* content keeps the last row clear of the home bar: the safe-area
|
|
162
|
-
* inset stacked on
|
|
163
|
-
*
|
|
168
|
+
* inset stacked on the shared container-pad token, whose legacy
|
|
169
|
+
* fallback is the 12px family base (which also restores a bottom
|
|
170
|
+
* cushion over the glass dropdown chrome — equal specificity, earlier
|
|
164
171
|
* in source, whose own bottom padding the sheet rule would otherwise
|
|
165
|
-
* zero out).
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
|
|
172
|
+
* zero out). A host that sets the `padding` prop retunes the sheet's
|
|
173
|
+
* bottom inset with the SAME step its anchored panel uses (#808
|
|
174
|
+
* completion — hosts stop hand-padding the sheet branch); the safe-
|
|
175
|
+
* area term always rides on top. The modal sheet footer goes further
|
|
176
|
+
* (it adds --hk-sheet-footer-gap for its action buttons); this sheet
|
|
177
|
+
* has no footer, so this is the family baseline. */
|
|
178
|
+
padding-bottom: calc(var(--hk-pad, var(--space-12, 0.75rem)) + env(safe-area-inset-bottom, 0px));
|
|
169
179
|
|
|
170
180
|
/* Overlay scrollbar (useOverlayScrollbar, sheet mode only) — the
|
|
171
181
|
native chrome is hidden, never styled. */
|
|
@@ -178,9 +188,14 @@
|
|
|
178
188
|
|
|
179
189
|
.hk-popover-sheet-grabber {
|
|
180
190
|
flex-shrink: 0;
|
|
191
|
+
// Chrome, not content: cancel the panel's shared container pad inline
|
|
192
|
+
// (see the header twin above) so the grabber zone spans the full sheet
|
|
193
|
+
// width at any `padding` step — the centered bar never drifts inward.
|
|
194
|
+
// Longhand AFTER the shorthand or `margin: 0 auto` would win the cascade.
|
|
195
|
+
margin: 0 auto;
|
|
196
|
+
margin-inline: calc(-1 * var(--hk-pad, 0px));
|
|
181
197
|
// Grabber zone: tap target tall enough for a thumb, bar visually small.
|
|
182
198
|
padding: 14px 0 20px;
|
|
183
|
-
margin: 0 auto;
|
|
184
199
|
width: 100%;
|
|
185
200
|
cursor: pointer;
|
|
186
201
|
display: flex;
|
|
@@ -206,15 +221,26 @@
|
|
|
206
221
|
* The title aligns to the CONTENT edge: the leading inset defaults to
|
|
207
222
|
* --space-12, matching the padded content blocks popover sheets host
|
|
208
223
|
* (e.g. the house 0.75rem popover body). Hosts tune it via
|
|
209
|
-
* --hk-sheet-title-inset; the trailing inset hugs the close button.
|
|
224
|
+
* --hk-sheet-title-inset; the trailing inset hugs the close button.
|
|
225
|
+
*
|
|
226
|
+
* Shared container pad (#808 completion): the panel's `padding` prop
|
|
227
|
+
* insets the CONTENT band, so this chrome row cancels it inline
|
|
228
|
+
* (negative margin, the valid-CSS twin of a negative padding) and
|
|
229
|
+
* keeps its own title-inset from the panel frame — the title lands on
|
|
230
|
+
* the content edge whenever the pad step matches title-inset, at any
|
|
231
|
+
* step. The trailing block gap is the CONTENT's block-start air under
|
|
232
|
+
* the header: it reads the same pad step when the prop is set (legacy
|
|
233
|
+
* 8px otherwise), so a padded host gets one uniform inset around its
|
|
234
|
+
* sheet content too — no hand-padded wrapper branch downstream. */
|
|
210
235
|
.hk-popover-sheet-header {
|
|
211
236
|
flex-shrink: 0;
|
|
212
237
|
display: flex;
|
|
213
238
|
align-items: center;
|
|
214
239
|
gap: var(--space-8, 0.5rem);
|
|
240
|
+
margin-inline: calc(-1 * var(--hk-pad, 0px));
|
|
215
241
|
// Logical longhand (was physical `0 x y title-inset`): the title inset is
|
|
216
242
|
// the inline START edge so RTL mirrors it with the mirrored title/close.
|
|
217
|
-
padding-block: 0 var(--space-8, 0.5rem);
|
|
243
|
+
padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem));
|
|
218
244
|
padding-inline: var(--hk-sheet-title-inset, var(--space-12, 0.75rem))
|
|
219
245
|
var(--space-8, 0.5rem);
|
|
220
246
|
}
|
|
@@ -28,9 +28,12 @@ describe("HkPopover mobile sheet docking contract", () => {
|
|
|
28
28
|
|
|
29
29
|
it("keeps sheet content clear of the home bar via the safe-area inset", () => {
|
|
30
30
|
const block = scss.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)?.[0] ?? "";
|
|
31
|
-
// Stacked on the
|
|
32
|
-
//
|
|
33
|
-
|
|
31
|
+
// Stacked on the shared container-pad token whose legacy fallback is
|
|
32
|
+
// the family 12px base, so the glass dropdown chrome's bottom padding
|
|
33
|
+
// survives on non-notched phones too — and a host's `padding` prop
|
|
34
|
+
// retunes the base on the sheet face exactly like the anchored one
|
|
35
|
+
// (#808 completion; see containerPad.styles.test.ts for the pin).
|
|
36
|
+
expect(block).toContain("calc(var(--hk-pad, var(--space-12");
|
|
34
37
|
expect(block).toContain("env(safe-area-inset-bottom");
|
|
35
38
|
});
|
|
36
39
|
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "vue";
|
|
14
14
|
|
|
15
15
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
16
|
+
import { hkPadStyle, type HkPad } from "../runtime/containerPad";
|
|
16
17
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
17
18
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
18
19
|
import { popupViewportRect } from "../runtime/popupBounds";
|
|
@@ -56,6 +57,12 @@ export default defineComponent({
|
|
|
56
57
|
closeOnBackdrop: { type: Boolean, default: true },
|
|
57
58
|
closeOnEscape: { type: Boolean, default: true },
|
|
58
59
|
glass: { type: Boolean, default: true },
|
|
60
|
+
/** Shared container-padding override (see runtime/containerPad) for
|
|
61
|
+
* the panel's content inset — unset keeps the glass chrome's 4px
|
|
62
|
+
* (menu-voice) inset, or 0 without glass. Content panels (forms,
|
|
63
|
+
* editors, filter groups) set e.g. "md" instead of hand-padding
|
|
64
|
+
* their slot content. */
|
|
65
|
+
padding: { type: String as PropType<HkPad>, default: undefined },
|
|
59
66
|
anchorRef: { type: Object as PropType<HTMLElement | null>, default: null },
|
|
60
67
|
/**
|
|
61
68
|
* Dock the panel as a bottom sheet on mobile-width viewports (<768px,
|
|
@@ -699,6 +706,7 @@ export default defineComponent({
|
|
|
699
706
|
>
|
|
700
707
|
<div
|
|
701
708
|
ref={panelRef}
|
|
709
|
+
style={hkPadStyle(props.padding)}
|
|
702
710
|
class={[
|
|
703
711
|
"hk-popover-panel",
|
|
704
712
|
...(sheetBranch ? ["hk-is-sheet"] : [`hk-popover-${resolvedPlacement.value}`]),
|
|
@@ -169,6 +169,32 @@ describe("HkPopover sheetOnMobile", () => {
|
|
|
169
169
|
expect(header.querySelector(".hk-popover-sheet-close")).toBeTruthy();
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// #808 completion: the sheet branch consumes the same shared container
|
|
173
|
+
// pad its anchored twin does — the inline --hk-pad the `padding` prop
|
|
174
|
+
// writes on the panel reaches the sheet's content band (the scss
|
|
175
|
+
// contract in containerPad.styles.test.ts pins the consuming rules),
|
|
176
|
+
// so a host retunes ONE prop and both faces follow. No more hand-padded
|
|
177
|
+
// sheet-only wrapper branches downstream (chest's HkContextRing used
|
|
178
|
+
// to need one).
|
|
179
|
+
it("carries the padding prop's inline token onto the sheet panel too", async () => {
|
|
180
|
+
setViewport(375);
|
|
181
|
+
mountPopover({ sheetOnMobile: true, title: "Sheet", padding: "md" });
|
|
182
|
+
await nextTick();
|
|
183
|
+
|
|
184
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
185
|
+
expect(panel.classList.contains("hk-is-sheet")).toBe(true);
|
|
186
|
+
expect(panel.style.getPropertyValue("--hk-pad")).toBe("var(--space-12, 0.75rem)");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("keeps the sheet panel token-free when no padding prop is set", async () => {
|
|
190
|
+
setViewport(375);
|
|
191
|
+
mountPopover({ sheetOnMobile: true, title: "Sheet" });
|
|
192
|
+
await nextTick();
|
|
193
|
+
|
|
194
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
195
|
+
expect(panel.style.getPropertyValue("--hk-pad")).toBe("");
|
|
196
|
+
});
|
|
197
|
+
|
|
172
198
|
it("keeps the heading row out of anchored desktop mode", async () => {
|
|
173
199
|
setViewport(1200);
|
|
174
200
|
mountPopover({ sheetOnMobile: true, title: "Context usage" });
|
|
@@ -106,12 +106,15 @@ describe("scroll-pin whitespace contract", () => {
|
|
|
106
106
|
// Declared on the BODY — the covers are the body's own pseudo rules
|
|
107
107
|
// and custom properties inherit downward only, so the scroller (a
|
|
108
108
|
// child) could never feed them. The FULL literal is pinned (var name
|
|
109
|
-
// AND the
|
|
109
|
+
// AND the fallback chain): a silently changed default would desync
|
|
110
110
|
// the covers from the padding while every selector still matches.
|
|
111
|
+
// The chain reads the shared container-pad token (--hk-pad, the
|
|
112
|
+
// modal root's `padding` prop) over the per-component theme token
|
|
113
|
+
// over the historical 1.5rem.
|
|
111
114
|
const body = cssBlock(modalScss, ".hk-modal-body {");
|
|
112
115
|
for (const side of ["top", "right", "bottom", "left"]) {
|
|
113
116
|
expect(body, `--hk-scroll-pad-${side}`).toContain(
|
|
114
|
-
`--hk-scroll-pad-${side}: var(--hk-modal-padding-body, 1.5rem);`,
|
|
117
|
+
`--hk-scroll-pad-${side}: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem));`,
|
|
115
118
|
);
|
|
116
119
|
}
|
|
117
120
|
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
display: flex;
|
|
3
3
|
flex-direction: column;
|
|
4
4
|
gap: 0.25rem;
|
|
5
|
-
|
|
5
|
+
/* --hk-pad: the shared container-padding token (runtime/containerPad). */
|
|
6
|
+
padding: var(--hk-pad, var(--space-12, 0.75rem) var(--space-16, 1rem));
|
|
6
7
|
border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
7
8
|
border-radius: var(--radius-md, 0.5rem);
|
|
8
9
|
background: rgb(var(--color-surface));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
+
import { hkPadStyle, type HkPad } from "../runtime/containerPad";
|
|
4
|
+
|
|
3
5
|
import HkGaugeRing from "./HkGaugeRing";
|
|
4
6
|
import HkProgressBar from "./HkProgressBar";
|
|
5
7
|
import "./HkStatCard.scss";
|
|
@@ -78,6 +80,9 @@ export const HkStatCard = defineComponent({
|
|
|
78
80
|
name: "HkStatCard",
|
|
79
81
|
props: {
|
|
80
82
|
variant: { type: String as PropType<StatVariant>, default: "plain" },
|
|
83
|
+
/** Shared container-padding override (see runtime/containerPad) —
|
|
84
|
+
* unset keeps the card's historical inset. */
|
|
85
|
+
padding: { type: String as PropType<HkPad>, default: undefined },
|
|
81
86
|
label: { type: String, required: true },
|
|
82
87
|
value: { type: [String, Number], required: true },
|
|
83
88
|
tone: { type: String as PropType<StatTone>, default: "muted" },
|
|
@@ -95,6 +100,7 @@ export const HkStatCard = defineComponent({
|
|
|
95
100
|
setup(props, { emit, slots }) {
|
|
96
101
|
return () => {
|
|
97
102
|
const shared = {
|
|
103
|
+
style: hkPadStyle(props.padding),
|
|
98
104
|
class: [
|
|
99
105
|
"hk-stat-card",
|
|
100
106
|
`hk-stat-card-${props.variant}`,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet guard for the shared container-padding token
|
|
10
|
+
* (--hk-pad, runtime/containerPad) across every wired container sheet.
|
|
11
|
+
*
|
|
12
|
+
* The whole facility rests on each container's padding rule consuming
|
|
13
|
+
* `var(--hk-pad, <legacy default>)` — the token is SET per instance
|
|
14
|
+
* (inline, via the `padding` prop) and CONSUMED by these rules, so
|
|
15
|
+
* there is never a specificity contest. Vitest stubs CSS, so the
|
|
16
|
+
* component suites can never catch a rule regressed back to a literal:
|
|
17
|
+
* this file compiles the six sheets for real and pins the exact
|
|
18
|
+
* declarations.
|
|
19
|
+
*
|
|
20
|
+
* The legacy fallbacks here ARE the historical visuals: popover glass
|
|
21
|
+
* 4px (menu voice) / bare panel 0, card body 1rem (unpadded 0),
|
|
22
|
+
* stat card 12/16, modal body --hk-modal-padding-body (1.5rem), drawer
|
|
23
|
+
* body --hk-drawer-body-padding (1rem), alert base/sm/lg shorthands.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
27
|
+
|
|
28
|
+
function compileSheet(name: string): string {
|
|
29
|
+
return compile(resolve(componentDir, name), { style: "expanded" }).css;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ruleFor(css: string, selector: string): string {
|
|
33
|
+
const start = css.indexOf(selector);
|
|
34
|
+
expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
|
|
35
|
+
const open = css.indexOf("{", start);
|
|
36
|
+
const close = css.indexOf("}", open);
|
|
37
|
+
return css.slice(start, close + 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("container pad token — wired sheets", () => {
|
|
41
|
+
it("HkPopover: bare panel falls back to 0, glass chrome to the 4px menu inset", () => {
|
|
42
|
+
const css = compileSheet("HkPopover.scss");
|
|
43
|
+
expect(ruleFor(css, ".hk-popover-panel {")).toContain(
|
|
44
|
+
"padding: var(--hk-pad, 0)",
|
|
45
|
+
);
|
|
46
|
+
expect(css).toContain(
|
|
47
|
+
"padding: var(--hk-pad, var(--space-4, 0.25rem))",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("HkPopover sheet: the content band reads the token, chrome cancels it", () => {
|
|
52
|
+
// The sheet branch completes the #808 contract: a host that sets the
|
|
53
|
+
// `padding` prop gets the SAME step around its content docked as a
|
|
54
|
+
// bottom sheet (bottom = step + safe-area, block-start air under the
|
|
55
|
+
// header = step) instead of hand-padding a sheet-only wrapper — the
|
|
56
|
+
// gap chest's HkContextRing had to compensate for locally. Grabber
|
|
57
|
+
// and header are chrome: they cancel the panel's inline pad so the
|
|
58
|
+
// tap zone and title keep their own frame-relative insets.
|
|
59
|
+
const css = compileSheet("HkPopover.scss");
|
|
60
|
+
expect(css).toContain(
|
|
61
|
+
"padding-bottom: calc(var(--hk-pad, var(--space-12, 0.75rem)) + env(safe-area-inset-bottom, 0px))",
|
|
62
|
+
);
|
|
63
|
+
expect(ruleFor(css, ".hk-popover-sheet-header {")).toContain(
|
|
64
|
+
"margin-inline: calc(-1 * var(--hk-pad, 0px))",
|
|
65
|
+
);
|
|
66
|
+
expect(ruleFor(css, ".hk-popover-sheet-header {")).toContain(
|
|
67
|
+
"padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem))",
|
|
68
|
+
);
|
|
69
|
+
expect(ruleFor(css, ".hk-popover-sheet-grabber {")).toContain(
|
|
70
|
+
"margin-inline: calc(-1 * var(--hk-pad, 0px))",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("HkCard: body 1rem, unpadded 0, the scrollable pad wrapper 1rem", () => {
|
|
75
|
+
const css = compileSheet("HkCard.scss");
|
|
76
|
+
expect(ruleFor(css, ".hk-card-body {")).toContain(
|
|
77
|
+
"padding: var(--hk-pad, var(--space-16))",
|
|
78
|
+
);
|
|
79
|
+
expect(ruleFor(css, ".hk-card-body-unpadded {")).toContain(
|
|
80
|
+
"padding: var(--hk-pad, 0)",
|
|
81
|
+
);
|
|
82
|
+
expect(ruleFor(css, ".hk-card-body-pad {")).toContain(
|
|
83
|
+
"padding: var(--hk-pad, var(--space-16))",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("HkStatCard: the 12/16 surface inset", () => {
|
|
88
|
+
const css = compileSheet("HkStatCard.scss");
|
|
89
|
+
expect(ruleFor(css, ".hk-stat-card {")).toContain(
|
|
90
|
+
"padding: var(--hk-pad, var(--space-12, 0.75rem) var(--space-16, 1rem))",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("HkModal: every scroll-pad track reads the token over the theme value", () => {
|
|
95
|
+
const css = compileSheet("HkModal.scss");
|
|
96
|
+
for (const side of ["top", "right", "bottom", "left"]) {
|
|
97
|
+
expect(css).toContain(
|
|
98
|
+
`--hk-scroll-pad-${side}: var(--hk-pad, var(--hk-modal-padding-body, 1.5rem))`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("HkDrawer: the body reads the token over the theme value", () => {
|
|
104
|
+
const css = compileSheet("HkDrawer.scss");
|
|
105
|
+
expect(css).toContain(
|
|
106
|
+
"padding: var(--hk-pad, var(--hk-drawer-body-padding, var(--space-16, 1rem)))",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("HkAlert: the base and both sized shorthands read the token", () => {
|
|
111
|
+
const css = compileSheet("HkAlert.scss");
|
|
112
|
+
expect(ruleFor(css, ".hk-alert {")).toContain(
|
|
113
|
+
"padding: var(--hk-pad, var(--space-12, 12px) var(--space-16, 16px))",
|
|
114
|
+
);
|
|
115
|
+
expect(ruleFor(css, ".hk-alert-sm {")).toContain(
|
|
116
|
+
"padding: var(--hk-pad, var(--space-8, 8px) var(--space-12, 12px))",
|
|
117
|
+
);
|
|
118
|
+
expect(ruleFor(css, ".hk-alert-lg {")).toContain(
|
|
119
|
+
"padding: var(--hk-pad, var(--space-16, 16px) var(--space-24, 24px))",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -806,3 +806,4 @@ export { type HAboutLinkIcon as HkAboutLinkIcon } from "./components/HkAboutModa
|
|
|
806
806
|
export { type HAboutComponentVersion as HkAboutComponentVersion } from "./components/HkAboutModal";
|
|
807
807
|
export { type HCustomTheme as HkCustomTheme } from "./components/HkColorSchemeDialog";
|
|
808
808
|
export { OVERLAY_ROOT_SELECTOR, OVERLAY_Z, ensureOverlayRoot } from "./overlay/overlayRoot";
|
|
809
|
+
export { HK_PAD_VAR, hkPadStyle, type HkPad } from "./runtime/containerPad";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { HK_PAD_VAR, hkPadStyle } from "./containerPad";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Direct unit tests for the shared container-padding helper behind every
|
|
7
|
+
* container's `padding` prop (runtime/containerPad).
|
|
8
|
+
*
|
|
9
|
+
* The contract, in one line each:
|
|
10
|
+
* - the token is `--hk-pad`, the ONE custom property every wired
|
|
11
|
+
* container's padding rule consumes (each with its own legacy
|
|
12
|
+
* fallback, so an unset token preserves the historical look);
|
|
13
|
+
* - `hkPadStyle` maps the five named steps onto the house spacing scale
|
|
14
|
+
* (--space-4/8/12/16) verbatim, with fallbacks inlined — the values
|
|
15
|
+
* must stay pure `var()` references so themes can re-scale them;
|
|
16
|
+
* - an unset prop renders NO declaration at all (an empty style object),
|
|
17
|
+
* never a default-valued one — the absence IS the legacy default.
|
|
18
|
+
*/
|
|
19
|
+
describe("HK_PAD_VAR", () => {
|
|
20
|
+
it("is the one shared custom property", () => {
|
|
21
|
+
expect(HK_PAD_VAR).toBe("--hk-pad");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("hkPadStyle", () => {
|
|
26
|
+
it("maps each step onto the spacing scale", () => {
|
|
27
|
+
expect(hkPadStyle("none")).toEqual({ [HK_PAD_VAR]: "0" });
|
|
28
|
+
expect(hkPadStyle("xs")).toEqual({ [HK_PAD_VAR]: "var(--space-4, 0.25rem)" });
|
|
29
|
+
expect(hkPadStyle("sm")).toEqual({ [HK_PAD_VAR]: "var(--space-8, 0.5rem)" });
|
|
30
|
+
expect(hkPadStyle("md")).toEqual({ [HK_PAD_VAR]: "var(--space-12, 0.75rem)" });
|
|
31
|
+
expect(hkPadStyle("lg")).toEqual({ [HK_PAD_VAR]: "var(--space-16, 1rem)" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders no declaration when the prop is unset", () => {
|
|
35
|
+
expect(hkPadStyle(undefined)).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CSSProperties } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared container-padding scale — the ONE mechanism behind every
|
|
5
|
+
* container component's `padding` prop (HkPopover's panel, HkCard,
|
|
6
|
+
* HkStatCard, HkModal, HkDrawer, HkAlert, …).
|
|
7
|
+
*
|
|
8
|
+
* The contract has two halves:
|
|
9
|
+
*
|
|
10
|
+
* 1. TOKEN — every container's padding rule reads
|
|
11
|
+
* `padding: var(--hk-pad, <its legacy default>)`. Setting the
|
|
12
|
+
* custom property on the element (inline, a utility class, or any
|
|
13
|
+
* ancestor — custom properties inherit) retunes that container's
|
|
14
|
+
* content padding without touching its stylesheet; leaving it
|
|
15
|
+
* unset preserves the component's historical look byte-for-byte.
|
|
16
|
+
* Because the property is consumed (not a competing declaration),
|
|
17
|
+
* there is no specificity contest with the component's own rules
|
|
18
|
+
* or the theme layer.
|
|
19
|
+
*
|
|
20
|
+
* 2. PROP — `padding?: HkPad` on the container maps the named step
|
|
21
|
+
* onto the token via {@link hkPadStyle} (one inline custom
|
|
22
|
+
* property), so a call site opts in per instance:
|
|
23
|
+
* `<HkCard padding="none">…</HkCard>`.
|
|
24
|
+
*
|
|
25
|
+
* The steps ride the house spacing scale (--space-* in
|
|
26
|
+
* styles/theme/scale.scss): none=0, xs=4px, sm=8px, md=12px, lg=16px —
|
|
27
|
+
* the same four steps the popover glass chrome (xs), the settings rows
|
|
28
|
+
* (sm) and the card bodies (lg) already speak.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The named padding steps every container's `padding` prop accepts. */
|
|
32
|
+
export type HkPad = "none" | "xs" | "sm" | "md" | "lg";
|
|
33
|
+
|
|
34
|
+
/** The custom property the container padding rules consume. Hosts and
|
|
35
|
+
* themes may also set it directly (per instance or per subtree). */
|
|
36
|
+
export const HK_PAD_VAR = "--hk-pad";
|
|
37
|
+
|
|
38
|
+
/** Step → the spacing-scale value handed to the token. */
|
|
39
|
+
const HK_PAD_VALUE: Record<HkPad, string> = {
|
|
40
|
+
none: "0",
|
|
41
|
+
xs: "var(--space-4, 0.25rem)",
|
|
42
|
+
sm: "var(--space-8, 0.5rem)",
|
|
43
|
+
md: "var(--space-12, 0.75rem)",
|
|
44
|
+
lg: "var(--space-16, 1rem)",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The inline style carrying the token for one `padding` prop value —
|
|
48
|
+
* an empty object when the prop is unset (the component keeps its
|
|
49
|
+
* legacy default). Call sites spread it into their existing style
|
|
50
|
+
* object: `style={{ …panelStyle.value, …hkPadStyle(props.padding) }}`. */
|
|
51
|
+
export function hkPadStyle(pad: HkPad | undefined): CSSProperties {
|
|
52
|
+
return pad ? ({ [HK_PAD_VAR]: HK_PAD_VALUE[pad] } as CSSProperties) : {};
|
|
53
|
+
}
|