@celestia-island/hikari 0.55.239 → 0.55.240
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/HkMenuPanel.scss +4 -0
- package/src/components/HkMenuSection.test.tsx +191 -0
- package/src/components/HkMenuSection.tsx +95 -0
- package/src/components/HkTemplateField.scss +20 -6
- package/src/components/HkTemplateField.styles.test.ts +50 -2
- package/src/components/_menu-item.contract.test.ts +448 -0
- package/src/components/_menu-item.scss +95 -2
- package/src/index.ts +1 -0
- package/src/styles/admin-tokens.scss +17 -0
package/package.json
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
display: flex;
|
|
14
14
|
flex-direction: column;
|
|
15
15
|
gap: var(--space-4, 4px);
|
|
16
|
+
/* This list's own row rhythm is 4px, so a captioned run moved into a
|
|
17
|
+
group box must match it (the section grammar defaults to the level's
|
|
18
|
+
2px). */
|
|
19
|
+
--hk-menu-section-gap: var(--space-4, 4px);
|
|
16
20
|
/* Inset matches .hk-sidebar-body's inline padding so items share the
|
|
17
21
|
nav text edge; override --hk-menu-panel-inset to re-anchor. */
|
|
18
22
|
padding: var(--space-8, 8px) var(--hk-menu-panel-inset, var(--space-12, 12px));
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
Fragment,
|
|
4
|
+
createApp,
|
|
5
|
+
createCommentVNode,
|
|
6
|
+
createTextVNode,
|
|
7
|
+
h,
|
|
8
|
+
nextTick,
|
|
9
|
+
ref,
|
|
10
|
+
} from "vue";
|
|
11
|
+
|
|
12
|
+
import HkMenuSection from "./HkMenuSection";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* HkMenuSection contract tests:
|
|
16
|
+
*
|
|
17
|
+
* - GROUP: the wrapper is `role="group"` named by the `label` prop (the
|
|
18
|
+
* HkTemplateField a11y precedent), so a captioned row run is announced
|
|
19
|
+
* as one named group inside a menu surface.
|
|
20
|
+
* - CAPTION: the visible band carries the label text, is `aria-hidden`
|
|
21
|
+
* (the wrapper already announced the name) and uses the grammar's
|
|
22
|
+
* class names — the global adopter rules in styles/admin-tokens style
|
|
23
|
+
* them, the component imports no scss.
|
|
24
|
+
* - SLOT OVERRIDE: the `label` slot replaces the visible text while the
|
|
25
|
+
* accessible name stays the `label` prop; an EMPTY slot result (an empty
|
|
26
|
+
* array, a comment vnode from a compiled `v-if`, blank strings / TEXT
|
|
27
|
+
* vnodes) falls back to the prop text (never an empty padded band).
|
|
28
|
+
* - ROWS: the default slot renders inside the group, after the caption.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
32
|
+
|
|
33
|
+
function mount(render: () => unknown): HTMLElement {
|
|
34
|
+
const container = document.createElement("div");
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
const app = createApp({ render });
|
|
37
|
+
app.mount(container);
|
|
38
|
+
mounts.push({ app, container });
|
|
39
|
+
return container;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const { app, container } of mounts) {
|
|
44
|
+
app.unmount();
|
|
45
|
+
container.remove();
|
|
46
|
+
}
|
|
47
|
+
mounts.length = 0;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("HkMenuSection", () => {
|
|
51
|
+
it("renders a role=group named by label, with a visible aria-hidden caption", () => {
|
|
52
|
+
const c = mount(() => h(HkMenuSection, { label: "Temp spaces" }));
|
|
53
|
+
|
|
54
|
+
const group = c.querySelector<HTMLElement>(".hk-menu-section");
|
|
55
|
+
expect(group, ".hk-menu-section renders").toBeTruthy();
|
|
56
|
+
expect(group!.getAttribute("role")).toBe("group");
|
|
57
|
+
expect(group!.getAttribute("aria-label")).toBe("Temp spaces");
|
|
58
|
+
|
|
59
|
+
const caption = group!.querySelector<HTMLElement>(".hk-menu-section-label");
|
|
60
|
+
expect(caption, "the visible caption band renders").toBeTruthy();
|
|
61
|
+
expect(caption!.textContent).toBe("Temp spaces");
|
|
62
|
+
// The band repeats the wrapper's accessible name visually only.
|
|
63
|
+
expect(caption!.getAttribute("aria-hidden")).toBe("true");
|
|
64
|
+
// It is the group's first child: caption above the rows.
|
|
65
|
+
expect(group!.firstElementChild).toBe(caption);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps the accessible name on the label prop when the label slot overrides the band", () => {
|
|
69
|
+
const c = mount(() =>
|
|
70
|
+
h(
|
|
71
|
+
HkMenuSection,
|
|
72
|
+
{ label: "Temp spaces" },
|
|
73
|
+
{
|
|
74
|
+
label: () => [h("span", { class: "section-count" }, "3")],
|
|
75
|
+
default: () => [
|
|
76
|
+
h("button", { type: "button", class: "hk-menu-action-item" }, "Scratch"),
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const group = c.querySelector<HTMLElement>(".hk-menu-section")!;
|
|
83
|
+
// Rich visible content never changes the announced group name.
|
|
84
|
+
expect(group.getAttribute("aria-label")).toBe("Temp spaces");
|
|
85
|
+
const caption = group.querySelector<HTMLElement>(".hk-menu-section-label")!;
|
|
86
|
+
expect(caption.textContent).toBe("3");
|
|
87
|
+
expect(caption.querySelector(".section-count")).toBeTruthy();
|
|
88
|
+
expect(caption.getAttribute("aria-hidden")).toBe("true");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back to the label prop when the label slot renders nothing", () => {
|
|
92
|
+
// `[]` is not nullish: a naive `slots.label?.() ?? props.label` keeps
|
|
93
|
+
// the empty band (padded dead space above the rows).
|
|
94
|
+
const c = mount(() =>
|
|
95
|
+
h(HkMenuSection, { label: "Temp spaces" }, { label: () => [] }),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
|
|
99
|
+
expect(caption.textContent).toBe("Temp spaces");
|
|
100
|
+
expect(caption.getAttribute("aria-hidden")).toBe("true");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to the label prop when the label slot renders only a comment vnode", () => {
|
|
104
|
+
// A compiled `v-if` slot root is a COMMENT vnode (length 1, not nullish):
|
|
105
|
+
// it must count as no caption or the aria-hidden band stays padded and
|
|
106
|
+
// empty.
|
|
107
|
+
const c = mount(() =>
|
|
108
|
+
h(HkMenuSection, { label: "Temp spaces" }, {
|
|
109
|
+
label: () => [createCommentVNode("v-if", true)],
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
|
|
114
|
+
expect(caption.textContent).toBe("Temp spaces");
|
|
115
|
+
expect(caption.getAttribute("aria-hidden")).toBe("true");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("looks through a fragment that wraps only comments and blank strings", () => {
|
|
119
|
+
const c = mount(() =>
|
|
120
|
+
h(HkMenuSection, { label: "Temp spaces" }, {
|
|
121
|
+
label: () => [
|
|
122
|
+
h(Fragment, null, [createCommentVNode("v-if", true), " "]),
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
|
|
128
|
+
expect(caption.textContent).toBe("Temp spaces");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("falls back for top-level blank and empty slot strings", () => {
|
|
132
|
+
for (const blank of [" ", ""]) {
|
|
133
|
+
const c = mount(() =>
|
|
134
|
+
h(HkMenuSection, { label: "Temp spaces" }, { label: () => [blank] }),
|
|
135
|
+
);
|
|
136
|
+
const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
|
|
137
|
+
expect(caption.textContent, `slot ${JSON.stringify(blank)}`).toBe("Temp spaces");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("falls back for a TEXT vnode inside a fragment", () => {
|
|
142
|
+
// Vue normalizes text children into TEXT vnodes (and a compiled slot's
|
|
143
|
+
// whitespace root becomes one directly) — blank text must be absent.
|
|
144
|
+
const c = mount(() =>
|
|
145
|
+
h(HkMenuSection, { label: "Temp spaces" }, {
|
|
146
|
+
label: () => [h(Fragment, null, [createTextVNode(" "), createTextVNode("")])],
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const caption = c.querySelector<HTMLElement>(".hk-menu-section-label")!;
|
|
151
|
+
expect(caption.textContent).toBe("Temp spaces");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("updates the visible caption when the label prop changes", async () => {
|
|
155
|
+
// Fences the render-function caption resolution: a cached computed
|
|
156
|
+
// would keep the first nodes.
|
|
157
|
+
const label = ref("Temp spaces");
|
|
158
|
+
const c = mount(() => h(HkMenuSection, { label: label.value }));
|
|
159
|
+
expect(c.querySelector(".hk-menu-section-label")!.textContent).toBe("Temp spaces");
|
|
160
|
+
|
|
161
|
+
label.value = "Permanent spaces";
|
|
162
|
+
await nextTick();
|
|
163
|
+
expect(c.querySelector(".hk-menu-section-label")!.textContent).toBe("Permanent spaces");
|
|
164
|
+
expect(c.querySelector(".hk-menu-section")!.getAttribute("aria-label")).toBe(
|
|
165
|
+
"Permanent spaces",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("renders the default slot's rows inside the group, after the caption", () => {
|
|
170
|
+
const c = mount(() =>
|
|
171
|
+
h(
|
|
172
|
+
HkMenuSection,
|
|
173
|
+
{ label: "Temp spaces" },
|
|
174
|
+
{
|
|
175
|
+
default: () => [
|
|
176
|
+
h("button", { type: "button", class: "hk-menu-action-item" }, "Scratch"),
|
|
177
|
+
h("button", { type: "button", class: "hk-menu-action-item" }, "Long-term"),
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const group = c.querySelector<HTMLElement>(".hk-menu-section")!;
|
|
184
|
+
const rows = group.querySelectorAll(".hk-menu-action-item");
|
|
185
|
+
expect(Array.from(rows).map((row) => row.textContent)).toEqual(["Scratch", "Long-term"]);
|
|
186
|
+
// caption + the two rows, in that ORDER (rows must never precede the
|
|
187
|
+
// band that names them).
|
|
188
|
+
expect(group.children).toHaveLength(3);
|
|
189
|
+
expect(group.querySelector(".hk-menu-section-label")!.nextElementSibling).toBe(rows[0]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Comment, Fragment, Text, defineComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
/** True when slot content carries something visible. A compiled `v-if`
|
|
4
|
+
* root is a COMMENT vnode (not an empty array), a text slot child may be
|
|
5
|
+
* normalized into a TEXT vnode, a fragment may wrap only comments/blank
|
|
6
|
+
* text, and whitespace-only strings are not a caption — none of them may
|
|
7
|
+
* leave a padded, empty aria-hidden band. */
|
|
8
|
+
function hasVisibleContent(nodes: readonly unknown[]): boolean {
|
|
9
|
+
for (const node of nodes) {
|
|
10
|
+
if (typeof node === "string") {
|
|
11
|
+
if (node.trim() !== "") return true;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (typeof node === "number") return true;
|
|
15
|
+
if (node && typeof node === "object") {
|
|
16
|
+
const vnode = node as { type?: unknown; children?: unknown };
|
|
17
|
+
if (vnode.type === Comment) continue;
|
|
18
|
+
if (vnode.type === Text) {
|
|
19
|
+
if (typeof vnode.children === "string" && vnode.children.trim() !== "") return true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (vnode.type === Fragment) {
|
|
23
|
+
if (typeof vnode.children === "string") {
|
|
24
|
+
if (vnode.children.trim() !== "") return true;
|
|
25
|
+
} else if (Array.isArray(vnode.children)) {
|
|
26
|
+
if (hasVisibleContent(vnode.children)) return true;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* HkMenuSection — a captioned run of menu rows.
|
|
38
|
+
*
|
|
39
|
+
* The small caption ABOVE a run of rows is part of the menu-item grammar
|
|
40
|
+
* (see _menu-item.scss, @mixin section / @mixin section-label), but it is
|
|
41
|
+
* hand-rolled at call sites across this library and its consumer's popup
|
|
42
|
+
* menus, with vertical insets from 0 to 6px and no shared token. This
|
|
43
|
+
* component is the ONE markup for it:
|
|
44
|
+
*
|
|
45
|
+
* <HkMenuSection label="Temp spaces">
|
|
46
|
+
* <HkMenuActionItem … />
|
|
47
|
+
* </HkMenuSection>
|
|
48
|
+
*
|
|
49
|
+
* Accessibility follows the HkTemplateField precedent: grouping is a
|
|
50
|
+
* `role="group"` wrapper carrying the group's name in `aria-label`, and
|
|
51
|
+
* the visible caption band inside is `aria-hidden="true"`. The wrapper
|
|
52
|
+
* already announces the name, so leaving the visible band in the a11y
|
|
53
|
+
* tree would double-announce it (and a bare heading band was invisible to
|
|
54
|
+
* screen readers before that wave).
|
|
55
|
+
*
|
|
56
|
+
* `label` is both the accessible group name and the default visible
|
|
57
|
+
* caption text; the optional `label` slot supplies richer visible content
|
|
58
|
+
* (a badge, a count) while `aria-label` keeps the prop's plain string.
|
|
59
|
+
* The slot must be NON-INTERACTIVE content: it renders inside the
|
|
60
|
+
* aria-hidden band, so a focusable node there would be an axe
|
|
61
|
+
* `aria-hidden-focus` violation, and its visible text must not contradict
|
|
62
|
+
* the accessible name (keep the prop as the plain group name). The
|
|
63
|
+
* default slot is the rows.
|
|
64
|
+
*
|
|
65
|
+
* Metrics come from the global adopter rules in styles/admin-tokens
|
|
66
|
+
* (`@include mi.section` / `@include mi.section-label`), loaded by the
|
|
67
|
+
* canonical styles entry — deliberately NO local scss import, so hosts
|
|
68
|
+
* that render the classes by hand and this component share one
|
|
69
|
+
* declaration (the HkAvatarButton precedent).
|
|
70
|
+
*/
|
|
71
|
+
export default defineComponent({
|
|
72
|
+
name: "HkMenuSection",
|
|
73
|
+
props: {
|
|
74
|
+
/** Accessible group name AND the default visible caption text. */
|
|
75
|
+
label: { type: String, required: true },
|
|
76
|
+
},
|
|
77
|
+
setup(props, { slots }) {
|
|
78
|
+
// Resolve the caption INSIDE the render function: a computed caches
|
|
79
|
+
// the slot's vnodes and a pre-built-node slot goes stale when the
|
|
80
|
+
// parent re-renders. An empty result ([] / comment vnodes / blank
|
|
81
|
+
// strings) falls back to the prop text instead of leaving a padded,
|
|
82
|
+
// empty aria-hidden band.
|
|
83
|
+
return () => {
|
|
84
|
+
const caption = slots.label?.();
|
|
85
|
+
return (
|
|
86
|
+
<div class="hk-menu-section" role="group" aria-label={props.label}>
|
|
87
|
+
<div class="hk-menu-section-label" aria-hidden="true">
|
|
88
|
+
{caption && hasVisibleContent(caption) ? caption : props.label}
|
|
89
|
+
</div>
|
|
90
|
+
{slots.default?.()}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "./menu-item" as mi;
|
|
2
|
+
|
|
1
3
|
// HkTemplateField / HkTemplateText — the fill-template surfaces.
|
|
2
4
|
// Self-sufficient styling (house rule): the field box, label, hint and
|
|
3
5
|
// error rows live here rather than borrowing HkInput's sheet, and the
|
|
@@ -185,14 +187,26 @@
|
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
.hk-tpl-group-label {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// The menu grammar's shared section-caption band (_menu-item.scss
|
|
191
|
+
// @mixin section-label), same as HkMenuSection's
|
|
192
|
+
// .hk-menu-section-label — the hand-rolled bands across this library
|
|
193
|
+
// and its consumer's popup menus (0–6px vertical insets) converge here.
|
|
194
|
+
// hikari's theme-menu caption (.s-theme-menu-label) stays hand-rolled
|
|
195
|
+
// on purpose: its 4/6/4 band sits ~8px clear of its rows, so it never
|
|
196
|
+
// exhibited the glued-to-the-first-row defect this grammar fixes.
|
|
197
|
+
// This adopter inherits the grammar's shift too, it is not a band-only
|
|
198
|
+
// change: vertical rhythm 6/8/2 → 4/6/8 (up, bottom-heavy) AND the
|
|
199
|
+
// inline inset 8 → 6px, i.e. the caption steps 2px LEFT toward
|
|
200
|
+
// .hk-tpl-row's content edge — the requested 偏左 direction.
|
|
201
|
+
@include mi.section-label;
|
|
202
|
+
// The local alias is this adopter's default: hosts scope
|
|
203
|
+
// --hi-color-muted for this panel and the rest of this sheet sources
|
|
204
|
+
// muted through the same alias. The grammar's
|
|
205
|
+
// --hk-menu-section-label-color still overrides it for a host that
|
|
206
|
+
// themes captions as a family.
|
|
207
|
+
color: var(--hk-menu-section-label-color, var(--hi-color-muted));
|
|
192
208
|
// No text-transform: labels arrive from the host (usually i18n
|
|
193
209
|
// strings) and the translator owns the casing.
|
|
194
|
-
color: var(--hi-color-muted);
|
|
195
|
-
user-select: none;
|
|
196
210
|
}
|
|
197
211
|
|
|
198
212
|
.hk-tpl-row {
|
|
@@ -41,6 +41,7 @@ describe("HkTemplateField stylesheet contract", () => {
|
|
|
41
41
|
const tsx = readFileSync(resolve(componentDir, "HkTemplateField.tsx"), "utf8");
|
|
42
42
|
const rendererTsx = readFileSync(resolve(componentDir, "HkTemplateText.tsx"), "utf8");
|
|
43
43
|
const grammars = readFileSync(resolve(componentDir, "templateGrammar.ts"), "utf8");
|
|
44
|
+
const scss = readFileSync(resolve(componentDir, "HkTemplateField.scss"), "utf8");
|
|
44
45
|
const css = compile(resolve(componentDir, "HkTemplateField.scss"), {
|
|
45
46
|
style: "expanded",
|
|
46
47
|
}).css;
|
|
@@ -99,12 +100,59 @@ describe("HkTemplateField stylesheet contract", () => {
|
|
|
99
100
|
|
|
100
101
|
it("keeps the heading band on the label class, not the group wrapper", () => {
|
|
101
102
|
// The wrapper is a bare layout box; carrying the band's chrome
|
|
102
|
-
// (padding/font-weight) there would pad every row run.
|
|
103
|
-
|
|
103
|
+
// (padding/font-weight) there would pad every row run. The band is an
|
|
104
|
+
// ADOPTER of the menu grammar's section-caption mixin, and the pins
|
|
105
|
+
// below are satisfied ONLY by the grammar's token form: a restored
|
|
106
|
+
// hand-rolled band (literal padding / a bare `font-weight: 600` /
|
|
107
|
+
// `@include` removed) must fail. R2 proved an optional-group regex
|
|
108
|
+
// matched both spellings, so the previous pin was vacuous.
|
|
109
|
+
expect(
|
|
110
|
+
css,
|
|
111
|
+
".hk-tpl-group-label must take its bottom band from the grammar's token",
|
|
112
|
+
).toMatch(
|
|
113
|
+
/\.hk-tpl-group-label\s*\{[^}]*padding-block-end:\s*var\(--hk-menu-section-label-pad-bottom,/s,
|
|
114
|
+
);
|
|
115
|
+
expect(
|
|
116
|
+
css,
|
|
117
|
+
".hk-tpl-group-label must take its weight from the grammar's token with 600 as the fallback",
|
|
118
|
+
).toMatch(
|
|
119
|
+
/\.hk-tpl-group-label\s*\{[^}]*font-weight:\s*var\(--hk-menu-section-label-weight,\s*600\)/s,
|
|
120
|
+
);
|
|
121
|
+
expect(
|
|
122
|
+
scss,
|
|
123
|
+
"the source must ADOPT the grammar (@include mi.section-label), not restate its values",
|
|
124
|
+
).toMatch(/\.hk-tpl-group-label\s*\{[^}]*@include mi\.section-label;/s);
|
|
104
125
|
expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*font-weight/s);
|
|
105
126
|
expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*padding/s);
|
|
106
127
|
});
|
|
107
128
|
|
|
129
|
+
it("keeps the label band's declarations to the grammar binding plus the colour hook", () => {
|
|
130
|
+
// R4 appended `padding: 2px 16px;` after the @include and all five
|
|
131
|
+
// shipped pins stayed green. EVERY rule whose selector mentions the band
|
|
132
|
+
// must be EXACTLY the grammar binding plus the one colour line — any
|
|
133
|
+
// later padding / font-size / font-weight / letter-spacing /
|
|
134
|
+
// text-transform, and any SECOND .hk-tpl-group-label rule (R5: the old
|
|
135
|
+
// pin read only the first match), fails.
|
|
136
|
+
const code = scss
|
|
137
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
138
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
139
|
+
const rules = [...code.matchAll(/([^{}]*\.hk-tpl-group-label[^{}]*)\{([^{}]*)\}/g)];
|
|
140
|
+
expect(rules.length, ".hk-tpl-group-label rule(s) found").toBeGreaterThan(0);
|
|
141
|
+
for (const [, selector, body] of rules) {
|
|
142
|
+
const declarations = (body ?? "")
|
|
143
|
+
.split(";")
|
|
144
|
+
.map((statement) => statement.replace(/\s+/g, " ").trim())
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
expect(
|
|
147
|
+
declarations,
|
|
148
|
+
`every .hk-tpl-group-label rule must stay the grammar binding + colour hook (${(selector ?? "").trim()})`,
|
|
149
|
+
).toEqual([
|
|
150
|
+
"@include mi.section-label",
|
|
151
|
+
"color: var(--hk-menu-section-label-color, var(--hi-color-muted))",
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
108
156
|
it("keeps group headings free of text-transform (the host owns casing)", () => {
|
|
109
157
|
// R2 M13: re-adding `text-transform: uppercase` used to stay green.
|
|
110
158
|
expect(css).not.toMatch(/\.hk-tpl-group[^{]*\{[^}]*text-transform/s);
|
|
@@ -26,6 +26,127 @@ const grammar = readFileSync(join(here, "_menu-item.scss"), "utf-8");
|
|
|
26
26
|
const menuTsx = readFileSync(join(here, "HkMenu.tsx"), "utf-8");
|
|
27
27
|
const themeTsx = readFileSync(join(here, "HkThemePanelContent.tsx"), "utf8");
|
|
28
28
|
const adminTokens = readFileSync(join(here, "../styles/admin-tokens.scss"), "utf-8");
|
|
29
|
+
const panelScss = readFileSync(join(here, "HkMenuPanel.scss"), "utf-8");
|
|
30
|
+
|
|
31
|
+
/** One node of a parsed SCSS tree: a rule/at-rule block, the
|
|
32
|
+
* statements directly in its body, and its nested blocks. */
|
|
33
|
+
interface ScssBlock {
|
|
34
|
+
prelude: string;
|
|
35
|
+
statements: string[];
|
|
36
|
+
children: ScssBlock[];
|
|
37
|
+
parent: ScssBlock | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remove comments so the block parser only sees code. */
|
|
41
|
+
function stripScssComments(source: string): string {
|
|
42
|
+
return source
|
|
43
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
44
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse an SCSS sheet into its block tree. At-rules are blocks like any
|
|
48
|
+
* other (their prelude starts with "@" and is not a selector). */
|
|
49
|
+
function parseScss(source: string): ScssBlock[] {
|
|
50
|
+
const root: ScssBlock = { prelude: "", statements: [], children: [], parent: null };
|
|
51
|
+
const stack: ScssBlock[] = [root];
|
|
52
|
+
let buf = "";
|
|
53
|
+
for (const ch of source) {
|
|
54
|
+
if (ch === "{") {
|
|
55
|
+
const parent = stack[stack.length - 1]!;
|
|
56
|
+
const block: ScssBlock = { prelude: buf.trim(), statements: [], children: [], parent };
|
|
57
|
+
parent.children.push(block);
|
|
58
|
+
stack.push(block);
|
|
59
|
+
buf = "";
|
|
60
|
+
} else if (ch === "}") {
|
|
61
|
+
const block = stack.pop()!;
|
|
62
|
+
if (buf.trim()) block.statements.push(buf.trim());
|
|
63
|
+
buf = "";
|
|
64
|
+
} else if (ch === ";") {
|
|
65
|
+
if (buf.trim()) stack[stack.length - 1]!.statements.push(buf.trim());
|
|
66
|
+
buf = "";
|
|
67
|
+
} else {
|
|
68
|
+
buf += ch;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return root.children;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Split a selector list on the commas that are NOT inside parentheses. */
|
|
75
|
+
function splitSelectors(prelude: string): string[] {
|
|
76
|
+
const out: string[] = [];
|
|
77
|
+
let depth = 0;
|
|
78
|
+
let current = "";
|
|
79
|
+
for (const ch of prelude) {
|
|
80
|
+
if (ch === "(") depth += 1;
|
|
81
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
82
|
+
if (ch === "," && depth === 0) {
|
|
83
|
+
out.push(current.trim());
|
|
84
|
+
current = "";
|
|
85
|
+
} else {
|
|
86
|
+
current += ch;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
out.push(current.trim());
|
|
90
|
+
return out.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolved selector list of a block: its ancestor chain joined with
|
|
94
|
+
* spaces, comma lists expanded (at-rules are not selectors). */
|
|
95
|
+
function resolvedSelectors(block: ScssBlock): string[] {
|
|
96
|
+
const groups: string[][] = [];
|
|
97
|
+
for (let cur: ScssBlock | null = block; cur; cur = cur.parent) {
|
|
98
|
+
if (!cur.prelude || cur.prelude.startsWith("@")) continue;
|
|
99
|
+
groups.unshift(splitSelectors(cur.prelude));
|
|
100
|
+
}
|
|
101
|
+
let resolved = [""];
|
|
102
|
+
for (const group of groups) {
|
|
103
|
+
resolved = resolved.flatMap((prefix) =>
|
|
104
|
+
group.map((sel) => (prefix ? `${prefix} ${sel}` : sel)),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolved PARENT-selector prefixes of a block (ancestors only). */
|
|
111
|
+
function parentPrefixes(block: ScssBlock): string[] {
|
|
112
|
+
const groups: string[][] = [];
|
|
113
|
+
for (let cur = block.parent; cur; cur = cur.parent) {
|
|
114
|
+
if (!cur.prelude || cur.prelude.startsWith("@")) continue;
|
|
115
|
+
groups.unshift(splitSelectors(cur.prelude));
|
|
116
|
+
}
|
|
117
|
+
let resolved = [""];
|
|
118
|
+
for (const group of groups) {
|
|
119
|
+
resolved = resolved.flatMap((prefix) =>
|
|
120
|
+
group.map((sel) => (prefix ? `${prefix} ${sel}` : sel)),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return resolved;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Depth-first flatten of a block tree. */
|
|
127
|
+
function flattenBlocks(blocks: ScssBlock[]): ScssBlock[] {
|
|
128
|
+
return blocks.flatMap((block) => [block, ...flattenBlocks(block.children)]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The px fallback a grammar custom property carries through the shared
|
|
132
|
+
* scale: the `4px` in `var(--token, var(--space-4, 4px))`. */
|
|
133
|
+
function pxFallback(body: string, token: string): number {
|
|
134
|
+
const match = body.match(
|
|
135
|
+
new RegExp(`var\\(${token},\\s*var\\(--space-\\d+,\\s*(\\d+)px\\)\\)`),
|
|
136
|
+
);
|
|
137
|
+
expect(
|
|
138
|
+
match,
|
|
139
|
+
`${token} must fall back through a shared --space-* token to px`,
|
|
140
|
+
).toBeTruthy();
|
|
141
|
+
return Number(match![1]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The body of `@mixin <name> { ... }` (single level, mixes are flat). */
|
|
145
|
+
function mixinBody(name: string): string {
|
|
146
|
+
const match = grammar.match(new RegExp(`@mixin ${name} \\{([^}]*)\\}`));
|
|
147
|
+
expect(match, `@mixin ${name} must exist in _menu-item.scss`).toBeTruthy();
|
|
148
|
+
return match![1]!;
|
|
149
|
+
}
|
|
29
150
|
|
|
30
151
|
describe("menu-item grammar contract", () => {
|
|
31
152
|
it("declares the divider row as a mixin reading the grammar tokens", () => {
|
|
@@ -126,4 +247,331 @@ describe("menu-item grammar contract", () => {
|
|
|
126
247
|
);
|
|
127
248
|
expect(grammar.match(/var\(--hk-menu-item-label-cushion\)/g)?.length).toBe(1);
|
|
128
249
|
});
|
|
250
|
+
|
|
251
|
+
it("gives the section caption a bottom-heavy band (never glued to the first row)", () => {
|
|
252
|
+
// Field report: the caption above a run of rows sat almost flush
|
|
253
|
+
// against the first row (four hand-rolled bands, 2/4/6/8px, no shared
|
|
254
|
+
// token). The rhythm is pinned EXACTLY, not merely ordered: a bare
|
|
255
|
+
// `bottom > top` check lets a top revert (4px → 6px) pass — and the
|
|
256
|
+
// top band moving back down is the "move UP" half of the user
|
|
257
|
+
// direction. Pin both numbers, then pin the order.
|
|
258
|
+
const body = mixinBody("section-label");
|
|
259
|
+
const top = pxFallback(body, "--hk-menu-section-label-pad-top");
|
|
260
|
+
const bottom = pxFallback(body, "--hk-menu-section-label-pad-bottom");
|
|
261
|
+
expect(
|
|
262
|
+
top,
|
|
263
|
+
`caption top band is ${top}px; the shipped value is 4px (the band must sit tight/UP under the panel inset)`,
|
|
264
|
+
).toBe(4);
|
|
265
|
+
expect(
|
|
266
|
+
bottom,
|
|
267
|
+
`caption bottom band is ${bottom}px; the shipped value is 8px (the clear gap under the caption)`,
|
|
268
|
+
).toBe(8);
|
|
269
|
+
expect(
|
|
270
|
+
bottom,
|
|
271
|
+
`caption bottom band (${bottom}px) must be strictly larger than its top band (${top}px) — bottom-heavy rhythm`,
|
|
272
|
+
).toBeGreaterThan(top);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("declares the caption's bands as exactly three token declarations", () => {
|
|
276
|
+
// Presence-based pins survive a LATER override (R3 P7: appending
|
|
277
|
+
// `padding-inline: 16px; padding-block-end: 2px;` after the tokens
|
|
278
|
+
// re-created the field defect 4/16/2 while every value pin stayed
|
|
279
|
+
// green). The padding set must be EXACTLY the three grammar
|
|
280
|
+
// declarations — a fourth declaration, a bare shorthand or a literal
|
|
281
|
+
// added later fails here.
|
|
282
|
+
const body = mixinBody("section-label");
|
|
283
|
+
const paddingDecls = [...body.matchAll(/\bpadding[\w-]*\s*:[^;]+;/g)].map((match) =>
|
|
284
|
+
match[0].replace(/\s+/g, " ").trim(),
|
|
285
|
+
);
|
|
286
|
+
expect(paddingDecls, "exactly the three grammar padding declarations").toEqual([
|
|
287
|
+
"padding-block-start: var(--hk-menu-section-label-pad-top, var(--space-4, 4px));",
|
|
288
|
+
"padding-inline: var(--hk-menu-section-label-pad-x, var(--space-6, 6px));",
|
|
289
|
+
"padding-block-end: var(--hk-menu-section-label-pad-bottom, var(--space-8, 8px));",
|
|
290
|
+
]);
|
|
291
|
+
expect(
|
|
292
|
+
body,
|
|
293
|
+
"no bare padding shorthand (it would override the token bands)",
|
|
294
|
+
).not.toMatch(/(^|[;{\s])padding\s*:/);
|
|
295
|
+
expect(body, "capitalization stays the translator's").not.toContain("text-transform");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("keeps the caption's desktop typography on the grammar tokens", () => {
|
|
299
|
+
const body = mixinBody("section-label");
|
|
300
|
+
expect(body).toMatch(
|
|
301
|
+
/font-size:\s*var\(--hk-menu-section-label-font,\s*var\(--text-2xs,\s*0\.625rem\)\)/,
|
|
302
|
+
);
|
|
303
|
+
expect(body).toMatch(
|
|
304
|
+
/letter-spacing:\s*var\(--hk-menu-section-label-tracking,\s*0\.02em\)/,
|
|
305
|
+
);
|
|
306
|
+
expect(body).toMatch(/user-select:\s*none/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("hangs the caption's inline inset just left of the row content edge", () => {
|
|
310
|
+
// The caption must NOT read as an indented row: its inline inset is a
|
|
311
|
+
// hair smaller than the row grammar's --hk-menu-item-pad-x, so it
|
|
312
|
+
// hugs the section edge slightly left of the leading icon column.
|
|
313
|
+
// SCOPE of that guarantee: the menu row grammar and HkMenuSection —
|
|
314
|
+
// a one-off adopter with its own smaller padding (HkTemplateField's
|
|
315
|
+
// .hk-tpl-row is 4px 8px) lands only a couple of px left, so the
|
|
316
|
+
// shared token keeps the two bands related, not pixel-identical
|
|
317
|
+
// everywhere.
|
|
318
|
+
const body = mixinBody("section-label");
|
|
319
|
+
expect(
|
|
320
|
+
body,
|
|
321
|
+
"caption inline inset must follow the shared --space-6 scale token",
|
|
322
|
+
).toMatch(/var\(--hk-menu-section-label-pad-x,\s*var\(--space-6,\s*6px\)\)/);
|
|
323
|
+
const inline = pxFallback(body, "--hk-menu-section-label-pad-x");
|
|
324
|
+
const rowPadX = Number(
|
|
325
|
+
grammar.match(/@mixin item-tokens[\s\S]*?--hk-menu-item-pad-x:\s*(\d+)px/)?.[1],
|
|
326
|
+
);
|
|
327
|
+
expect(rowPadX, "row grammar pad-x parsed from item-tokens").toBeGreaterThan(0);
|
|
328
|
+
expect(
|
|
329
|
+
inline,
|
|
330
|
+
`caption inline inset (${inline}px) must stay smaller than the row pad-x (${rowPadX}px)`,
|
|
331
|
+
).toBeLessThan(rowPadX);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("registers the caption in every sheet-context list that registers the row band", () => {
|
|
335
|
+
// Resolved-selector scan, not a top-level block count: for every block
|
|
336
|
+
// that swaps the ROW sheet tokens we resolve its parent selector
|
|
337
|
+
// prefix and require an exactly matching caption block that swaps the
|
|
338
|
+
// CAPTION sheet tokens. A sheet-context list nested inside an existing
|
|
339
|
+
// block (a third context added under the panel list) therefore fails
|
|
340
|
+
// even though a depth-0 scan would never see it.
|
|
341
|
+
const blocks = flattenBlocks(parseScss(stripScssComments(grammar)));
|
|
342
|
+
const ruleIndex = blocks.flatMap((block) =>
|
|
343
|
+
resolvedSelectors(block).map((selector) => ({ selector, block })),
|
|
344
|
+
);
|
|
345
|
+
const rowSwaps = blocks.filter((block) =>
|
|
346
|
+
block.statements.some((stmt) => stmt.includes("@include item-sheet-tokens")),
|
|
347
|
+
);
|
|
348
|
+
// Extraction sanity (never trust a 0-hit scan): the shipped sheet
|
|
349
|
+
// lists do swap row tokens somewhere.
|
|
350
|
+
expect(rowSwaps.length, "row sheet-token swaps found").toBeGreaterThan(0);
|
|
351
|
+
|
|
352
|
+
const contexts = new Set<string>();
|
|
353
|
+
for (const rowSwap of rowSwaps) {
|
|
354
|
+
const prefixes = parentPrefixes(rowSwap);
|
|
355
|
+
expect(prefixes.length, "a row token swap has a selector ancestor").toBeGreaterThan(0);
|
|
356
|
+
for (const prefix of prefixes) {
|
|
357
|
+
contexts.add(prefix);
|
|
358
|
+
const captionSelector = `${prefix} .hk-menu-section-label`;
|
|
359
|
+
const captionBlock = ruleIndex.find(
|
|
360
|
+
(entry) => entry.selector === captionSelector,
|
|
361
|
+
)?.block;
|
|
362
|
+
expect(
|
|
363
|
+
captionBlock,
|
|
364
|
+
`sheet context "${prefix}" widens the rows but has no "${captionSelector}" caption block`,
|
|
365
|
+
).toBeTruthy();
|
|
366
|
+
expect(
|
|
367
|
+
captionBlock!.statements.some((stmt) =>
|
|
368
|
+
stmt.includes("@include section-label-sheet-tokens"),
|
|
369
|
+
),
|
|
370
|
+
`sheet context "${prefix}" must swap the caption's sheet tokens`,
|
|
371
|
+
).toBe(true);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The shipped sheet surfaces are all covered (dropping one from a
|
|
376
|
+
// context list fails here, not just for the panel-shaped ones).
|
|
377
|
+
for (const context of [
|
|
378
|
+
".hk-select-sheet-panel",
|
|
379
|
+
".hk-popover-panel.hk-is-sheet",
|
|
380
|
+
".hk-drawer-panel",
|
|
381
|
+
".hk-modal-content",
|
|
382
|
+
]) {
|
|
383
|
+
expect(
|
|
384
|
+
[...contexts],
|
|
385
|
+
`sheet surface ${context} must widen the rows (and therefore be checked for a caption)`,
|
|
386
|
+
).toContain(context);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("widens the caption to the touch geometry inside sheets", () => {
|
|
391
|
+
// Sheet values are the touch band: 6px top / 8px inline / 12px bottom
|
|
392
|
+
// and the 12px caption font. A drifted sheet entry would leave the
|
|
393
|
+
// caption at desktop rhythm inside a bottom sheet.
|
|
394
|
+
const body = mixinBody("section-label-sheet-tokens");
|
|
395
|
+
expect(body).toMatch(/--hk-menu-section-label-pad-top:\s*var\(--space-6,\s*6px\)/);
|
|
396
|
+
expect(body).toMatch(/--hk-menu-section-label-pad-x:\s*var\(--space-8,\s*8px\)/);
|
|
397
|
+
expect(body).toMatch(/--hk-menu-section-label-pad-bottom:\s*var\(--space-12,\s*12px\)/);
|
|
398
|
+
expect(body).toMatch(/--hk-menu-section-label-font:\s*var\(--text-xs,\s*0\.75rem\)/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("never uppercases the caption (translators own casing)", () => {
|
|
402
|
+
// The HkTemplateField rule: caption strings are host i18n strings;
|
|
403
|
+
// text-transform would fight locales whose casing rules differ.
|
|
404
|
+
expect(mixinBody("section-label")).not.toContain("text-transform");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("publishes the section caption globally and only binds it to the grammar", () => {
|
|
408
|
+
// Hosts render .hk-menu-section / .hk-menu-section-label by hand (the
|
|
409
|
+
// workspace picker does) and cannot import the scss partial, so the
|
|
410
|
+
// global sheet binds the classes to the grammar mixins. EVERY rule
|
|
411
|
+
// whose selector mentions the section classes may contain NOTHING but
|
|
412
|
+
// that binding: an adopter that adds a later padding declaration
|
|
413
|
+
// (R3 P5) or a scoped duplicate that restates the band (R3 P8) must
|
|
414
|
+
// fail — a "one global rule" count missed both.
|
|
415
|
+
const blocks = flattenBlocks(parseScss(stripScssComments(adminTokens)));
|
|
416
|
+
const sectionRules = blocks.flatMap((block) =>
|
|
417
|
+
resolvedSelectors(block)
|
|
418
|
+
.filter((selector) => selector.includes("hk-menu-section"))
|
|
419
|
+
.map((selector) => ({ selector, block })),
|
|
420
|
+
);
|
|
421
|
+
// Extraction sanity (never trust a 0-hit scan): the two global
|
|
422
|
+
// adopters are found.
|
|
423
|
+
expect(
|
|
424
|
+
sectionRules.map((entry) => entry.selector),
|
|
425
|
+
"the two global section adopters",
|
|
426
|
+
).toEqual(expect.arrayContaining([".hk-menu-section", ".hk-menu-section-label"]));
|
|
427
|
+
|
|
428
|
+
for (const { selector, block } of sectionRules) {
|
|
429
|
+
const statements = block.statements.map((stmt) => stmt.replace(/\s+/g, " ").trim());
|
|
430
|
+
expect(
|
|
431
|
+
statements,
|
|
432
|
+
`section rule "${selector}" must bind the grammar only, got: ${statements.join("; ")}`,
|
|
433
|
+
).toHaveLength(1);
|
|
434
|
+
expect(
|
|
435
|
+
statements[0],
|
|
436
|
+
`section rule "${selector}" must be an @include of the grammar mixin`,
|
|
437
|
+
).toMatch(/^@include mi\.(?:section|section-label)$/);
|
|
438
|
+
expect(
|
|
439
|
+
statements.join("; "),
|
|
440
|
+
`section rule "${selector}" must not declare padding`,
|
|
441
|
+
).not.toMatch(/\bpadding/);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// The exact binding per class (nothing else, no later override).
|
|
445
|
+
const statementsFor = (selector: string) => {
|
|
446
|
+
const block = sectionRules.find((entry) => entry.selector === selector)?.block;
|
|
447
|
+
expect(block, `the global ${selector} adopter rule`).toBeTruthy();
|
|
448
|
+
return block!.statements.map((stmt) => stmt.replace(/\s+/g, " ").trim());
|
|
449
|
+
};
|
|
450
|
+
expect(statementsFor(".hk-menu-section")).toEqual(["@include mi.section"]);
|
|
451
|
+
expect(statementsFor(".hk-menu-section-label")).toEqual(["@include mi.section-label"]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("keeps the grammar's own section rules to the sheet token swap only", () => {
|
|
455
|
+
// The grammar must not style the band directly: @mixin section-label
|
|
456
|
+
// owns the metrics (pinned separately), and a direct
|
|
457
|
+
// .hk-menu-section-label rule is ONLY the sheet-context token swap.
|
|
458
|
+
// R4 appended (a) an unconditional
|
|
459
|
+
// .hk-menu-section-label { @include section-label-sheet-tokens; }
|
|
460
|
+
// — touch geometry on desktop — and (b) a later
|
|
461
|
+
// .hk-menu-section-label { padding-block-end: 2px; }
|
|
462
|
+
// and every pin stayed green.
|
|
463
|
+
const blocks = flattenBlocks(parseScss(stripScssComments(grammar)));
|
|
464
|
+
// Derive the allowlist from the registration lists themselves: only a
|
|
465
|
+
// context that swaps the ROW sheet tokens may scope a caption rule
|
|
466
|
+
// (.hk-select-sheet-panel / .hk-popover-panel.hk-is-sheet /
|
|
467
|
+
// .hk-drawer-panel, plus the narrow mobile .hk-modal-content band).
|
|
468
|
+
const rowSwaps = blocks.filter((block) =>
|
|
469
|
+
block.statements.some((stmt) => stmt.includes("@include item-sheet-tokens")),
|
|
470
|
+
);
|
|
471
|
+
expect(rowSwaps.length, "row sheet-token swaps found").toBeGreaterThan(0);
|
|
472
|
+
const sheetContexts = new Set(rowSwaps.flatMap((rowSwap) => parentPrefixes(rowSwap)));
|
|
473
|
+
|
|
474
|
+
const sectionBlocks = blocks.flatMap((block) =>
|
|
475
|
+
resolvedSelectors(block)
|
|
476
|
+
.filter((selector) => selector.includes(".hk-menu-section"))
|
|
477
|
+
.map((selector) => ({ selector, block })),
|
|
478
|
+
);
|
|
479
|
+
// Extraction sanity (never trust a 0-hit scan): the shipped sheet
|
|
480
|
+
// entries are found.
|
|
481
|
+
expect(sectionBlocks.length, "grammar caption sheet-token entries found").toBeGreaterThan(0);
|
|
482
|
+
for (const { selector, block } of sectionBlocks) {
|
|
483
|
+
// A grammar rule for the band may only be scoped by a REGISTERED
|
|
484
|
+
// sheet context: an unconditional rule puts the sheet geometry on
|
|
485
|
+
// desktop, and any other ancestor (.hk-menu-level, .hk-menu-list)
|
|
486
|
+
// would be a stray desktop restyle.
|
|
487
|
+
const scoped = selector.match(/^(.+) \.hk-menu-section-label$/);
|
|
488
|
+
expect(
|
|
489
|
+
scoped,
|
|
490
|
+
`grammar section rule "${selector}" must be sheet-scoped, not unconditional`,
|
|
491
|
+
).toBeTruthy();
|
|
492
|
+
const context = scoped![1]!;
|
|
493
|
+
expect(
|
|
494
|
+
[...sheetContexts],
|
|
495
|
+
`grammar section rule "${selector}" is scoped by "${context}", which is not a registered sheet context`,
|
|
496
|
+
).toContain(context);
|
|
497
|
+
if (context === ".hk-modal-content") {
|
|
498
|
+
// .hk-modal-content exists at EVERY viewport (HkModal docks via a
|
|
499
|
+
// media query — the element itself is always there): a sheet swap
|
|
500
|
+
// scoped by it OUTSIDE the narrow band puts touch geometry on
|
|
501
|
+
// DESKTOP modals. The legitimate rule is nested in the band, and
|
|
502
|
+
// resolvedSelectors drops at-rule preludes, so walk the parent
|
|
503
|
+
// chain for the @media (max-width: 767px) ancestor explicitly.
|
|
504
|
+
let mobileBand = false;
|
|
505
|
+
for (let ancestor = block.parent; ancestor; ancestor = ancestor.parent) {
|
|
506
|
+
if (/^@media\s*\(max-width:\s*767px\)$/.test(ancestor.prelude.trim())) {
|
|
507
|
+
mobileBand = true;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
expect(
|
|
512
|
+
mobileBand,
|
|
513
|
+
`grammar section rule "${selector}" scopes .hk-modal-content OUTSIDE the @media (max-width: 767px) band — its sheet geometry would land on desktop modals`,
|
|
514
|
+
).toBe(true);
|
|
515
|
+
}
|
|
516
|
+
for (const statement of block.statements) {
|
|
517
|
+
const normalized = statement.replace(/\s+/g, " ").trim();
|
|
518
|
+
expect(
|
|
519
|
+
normalized,
|
|
520
|
+
`grammar section rule "${selector}" may only swap the caption's sheet tokens, got: ${normalized}`,
|
|
521
|
+
).toMatch(
|
|
522
|
+
/^(?:@include section-label-sheet-tokens|--hk-menu-section-label-[\w-]+\s*:\s*[^;]+)$/,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("pins the wrapper to the level rhythm and publishes the list's own rhythm", () => {
|
|
529
|
+
// Rows declare no margins: the inter-row rhythm IS the parent's flex
|
|
530
|
+
// gap (.hk-menu-level 2px, .hk-menu-list 4px). A group box therefore
|
|
531
|
+
// takes over that rhythm, its default must mirror the level's 2px,
|
|
532
|
+
// and .hk-menu-list must publish its own 4px. The declarations are
|
|
533
|
+
// collected through the block parser (including the nested `& + &`
|
|
534
|
+
// block): a regex over the raw mixin text stopped at the first `}`,
|
|
535
|
+
// so a later `gap: 0;` after the stack rule re-introduced the S1
|
|
536
|
+
// defect with every pin green (R5 mutation A).
|
|
537
|
+
const sectionMixin = flattenBlocks(parseScss(stripScssComments(grammar))).find(
|
|
538
|
+
(block) => block.prelude === "@mixin section",
|
|
539
|
+
);
|
|
540
|
+
expect(sectionMixin, "@mixin section must exist").toBeTruthy();
|
|
541
|
+
const declarations = flattenBlocks([sectionMixin!]).flatMap((block) =>
|
|
542
|
+
block.statements.map((statement) => statement.replace(/\s+/g, " ").trim()),
|
|
543
|
+
);
|
|
544
|
+
expect(
|
|
545
|
+
declarations,
|
|
546
|
+
"the wrapper must be a flex box (the gap is inert otherwise)",
|
|
547
|
+
).toContain("display: flex");
|
|
548
|
+
expect(
|
|
549
|
+
declarations,
|
|
550
|
+
"the wrapper's children must stack vertically, never in a row",
|
|
551
|
+
).toContain("flex-direction: column");
|
|
552
|
+
// The 6px stack margin belongs to the SIBLING rule (`& + &`), so only
|
|
553
|
+
// the SECOND and later sections are separated. Moving it into the mixin
|
|
554
|
+
// body would push 6px above the very first section too.
|
|
555
|
+
const stackOwners = flattenBlocks([sectionMixin!]).filter((block) =>
|
|
556
|
+
block.statements.some((statement) =>
|
|
557
|
+
statement.replace(/\s+/g, " ").trim().startsWith("margin-block-start:"),
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
expect(
|
|
561
|
+
stackOwners.map((block) => block.prelude),
|
|
562
|
+
"the stack margin must live in the `& + &` block only",
|
|
563
|
+
).toEqual(["& + &"]);
|
|
564
|
+
const spacingDecls = declarations.filter((declaration) =>
|
|
565
|
+
/^(?:gap|margin[\w-]*)\s*:/.test(declaration),
|
|
566
|
+
);
|
|
567
|
+
expect(spacingDecls, "exactly the wrapper's two spacing declarations").toEqual([
|
|
568
|
+
"gap: var(--hk-menu-section-gap, var(--space-2, 2px))",
|
|
569
|
+
"margin-block-start: var(--hk-menu-section-stack, var(--space-6, 6px))",
|
|
570
|
+
]);
|
|
571
|
+
// The one shipped surface with a different row rhythm publishes it.
|
|
572
|
+
expect(
|
|
573
|
+
panelScss,
|
|
574
|
+
".hk-menu-list must publish its 4px row rhythm for a captioned run",
|
|
575
|
+
).toMatch(/\.hk-menu-list\s*\{[^}]*--hk-menu-section-gap:\s*var\(--space-4,\s*4px\)/s);
|
|
576
|
+
});
|
|
129
577
|
});
|
|
@@ -28,10 +28,28 @@
|
|
|
28
28
|
* glyph ink high against box-centered icons; a half-pixel down
|
|
29
29
|
* nudge re-centers the perceived mass (tunable per host).
|
|
30
30
|
*
|
|
31
|
+
* Section caption (a captioned run of rows):
|
|
32
|
+
* The caption band ABOVE a run of rows is its own band, never a row.
|
|
33
|
+
* Its block rhythm is deliberately BOTTOM-HEAVY (desktop 4px above /
|
|
34
|
+
* 8px below, sheets 6/12): the panel already insets the surface above
|
|
35
|
+
* the caption, so the caption's own top band stays tight, while the
|
|
36
|
+
* bottom band is the separation that must read as "this caption heads
|
|
37
|
+
* the rows below, it is not one of them". The section wrapper is where
|
|
38
|
+
* the parent's row rhythm moves when rows go behind a group box, so its
|
|
39
|
+
* default gap MIRRORS the level rhythm (2px, --hk-menu-section-gap);
|
|
40
|
+
* a surface whose rhythm differs publishes that token (HkMenuPanel's
|
|
41
|
+
* 4px list; a margin-separated consumer list sets 0). Its inline inset
|
|
42
|
+
* is a hair SMALLER than the row's --hk-menu-item-pad-x, so the caption
|
|
43
|
+
* hangs just left of the rows' leading icon column and reads as a
|
|
44
|
+
* section title hugging the edge rather than as an indented row. No
|
|
45
|
+
* text-transform: translators own casing (the HkTemplateField rule).
|
|
46
|
+
*
|
|
31
47
|
* Adoption: `@use "./menu-item" as mi;` then
|
|
32
48
|
* .your-row { @include mi.item; }
|
|
33
49
|
* .your-row .your-icon { @include mi.icon; } // wrap TSX side!
|
|
34
50
|
* .your-row .your-label { @include mi.label; }
|
|
51
|
+
* .your-section { @include mi.section; }
|
|
52
|
+
* .your-section-label { @include mi.section-label; }
|
|
35
53
|
* Keep the component's own class names — this file owns metrics and
|
|
36
54
|
* states only, never selectors of the adopting rows.
|
|
37
55
|
*/
|
|
@@ -65,6 +83,18 @@
|
|
|
65
83
|
--hk-menu-item-icon-box: 20px;
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
/* Mobile bottom-sheet geometry for the section caption. The caption is a
|
|
87
|
+
* sibling BAND, not a row, so it carries its own token names: the
|
|
88
|
+
* sheet-context lists below register .hk-menu-section-label separately
|
|
89
|
+
* (rows pick up item-sheet-tokens; a caption inside the same panel must
|
|
90
|
+
* not be widened by the row mixin's token names). */
|
|
91
|
+
@mixin section-label-sheet-tokens {
|
|
92
|
+
--hk-menu-section-label-pad-top: var(--space-6, 6px);
|
|
93
|
+
--hk-menu-section-label-pad-x: var(--space-8, 8px);
|
|
94
|
+
--hk-menu-section-label-pad-bottom: var(--space-12, 12px);
|
|
95
|
+
--hk-menu-section-label-font: var(--text-xs, 0.75rem);
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
/* Sheet context. The tokens are re-declared ON each adopting row, not
|
|
69
99
|
* merely on the panel: a custom property declared on an element always
|
|
70
100
|
* beats an inherited value, and @mixin item declares the desktop set at
|
|
@@ -73,8 +103,9 @@
|
|
|
73
103
|
* Therefore every adopter MUST @include mi.item at a TOP-LEVEL (0,1,0)
|
|
74
104
|
* rule — a nested include (e.g. inside its parent menu selector)
|
|
75
105
|
* compiles to a higher specificity and silently defeats this binding
|
|
76
|
-
* (HkBreadcrumb was caught exactly this way). New row families
|
|
77
|
-
*
|
|
106
|
+
* (HkBreadcrumb was caught exactly this way). New row families and band
|
|
107
|
+
* elements (the divider, the section caption) must also register their
|
|
108
|
+
* class in this list. */
|
|
78
109
|
.hk-select-sheet-panel,
|
|
79
110
|
.hk-popover-panel.hk-is-sheet,
|
|
80
111
|
.hk-drawer-panel {
|
|
@@ -94,6 +125,12 @@
|
|
|
94
125
|
.hk-tag-dot {
|
|
95
126
|
@include item-sheet-tokens;
|
|
96
127
|
}
|
|
128
|
+
|
|
129
|
+
/* The caption band's own sheet entry — same parent list, so every sheet
|
|
130
|
+
* surface that widens its rows widens their caption too. */
|
|
131
|
+
.hk-menu-section-label {
|
|
132
|
+
@include section-label-sheet-tokens;
|
|
133
|
+
}
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
/* Modal-hosted rows ride the sheet geometry too: unlike the three
|
|
@@ -115,6 +152,12 @@
|
|
|
115
152
|
.hk-tag-dot {
|
|
116
153
|
@include item-sheet-tokens;
|
|
117
154
|
}
|
|
155
|
+
|
|
156
|
+
/* Same registration rule as the panel list above: the caption rides
|
|
157
|
+
* the modal's sheet geometry without its own media query. */
|
|
158
|
+
.hk-menu-section-label {
|
|
159
|
+
@include section-label-sheet-tokens;
|
|
160
|
+
}
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
|
|
@@ -242,3 +285,53 @@
|
|
|
242
285
|
background: rgb(var(--color-border, 100 100 100) / 18%);
|
|
243
286
|
border: none;
|
|
244
287
|
}
|
|
288
|
+
|
|
289
|
+
/* ── Section caption band ────────────────────────────────────────────
|
|
290
|
+
* The small caption ABOVE a run of rows (the workspace picker's
|
|
291
|
+
* 「临时空间」 band is the field case). Every value is a custom property
|
|
292
|
+
* with a grammar fallback, exactly like the row tokens, so a host can
|
|
293
|
+
* restyle one surface without forking the band. The bottom band is the
|
|
294
|
+
* separation from the first row (bottom-heavy on purpose — see the
|
|
295
|
+
* header comment); the inline inset hangs just left of the rows' icon
|
|
296
|
+
* column. The 8px band is NOT the whole caption→row gap: the wrapper's
|
|
297
|
+
* own gap adds 2px on the level default (4px in .hk-menu-list), so the
|
|
298
|
+
* rendered separation is 10/12px on desktop and 14px in sheets. Sheet
|
|
299
|
+
* geometry arrives through the sheet-context lists above, never through
|
|
300
|
+
* a per-component media query. NO text-transform: translators own
|
|
301
|
+
* casing (the HkTemplateField rule). */
|
|
302
|
+
@mixin section-label {
|
|
303
|
+
padding-block-start: var(--hk-menu-section-label-pad-top, var(--space-4, 4px));
|
|
304
|
+
padding-inline: var(--hk-menu-section-label-pad-x, var(--space-6, 6px));
|
|
305
|
+
padding-block-end: var(--hk-menu-section-label-pad-bottom, var(--space-8, 8px));
|
|
306
|
+
font-size: var(--hk-menu-section-label-font, var(--text-2xs, 0.625rem));
|
|
307
|
+
font-weight: var(--hk-menu-section-label-weight, 600);
|
|
308
|
+
letter-spacing: var(--hk-menu-section-label-tracking, 0.02em);
|
|
309
|
+
color: var(--hk-menu-section-label-color, rgb(var(--color-muted)));
|
|
310
|
+
user-select: none;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ── Section wrapper ─────────────────────────────────────────────────
|
|
314
|
+
* The role="group" box HkMenuSection renders: one caption plus its rows.
|
|
315
|
+
* Rows declare no margins — the inter-row rhythm is the parent's flex gap
|
|
316
|
+
* (.hk-menu-level 2px, .hk-menu-list 4px) — so a group box is where that
|
|
317
|
+
* rhythm must move. The default --hk-menu-section-gap therefore MIRRORS
|
|
318
|
+
* the level rhythm (2px), which keeps an unconfigured surface correct;
|
|
319
|
+
* every surface whose rhythm differs publishes the token: .hk-menu-list
|
|
320
|
+
* sets 4px, and a consumer list that separates rows with margins instead
|
|
321
|
+
* sets 0 and keeps its margins (those still apply inside the wrapper).
|
|
322
|
+
* Stacked sections take a 6px stack margin so two captioned runs read as
|
|
323
|
+
* two groups, not one long list. The wrapper stays chrome-free — the
|
|
324
|
+
* caption owns the band.
|
|
325
|
+
*
|
|
326
|
+
* MUST be included at a TOP-LEVEL (0,1,0) rule, like @mixin item: the
|
|
327
|
+
* `& + &` stack rule compiles to a broken descendant selector when the
|
|
328
|
+
* mixin is nested inside a parent menu selector. */
|
|
329
|
+
@mixin section {
|
|
330
|
+
display: flex;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
gap: var(--hk-menu-section-gap, var(--space-2, 2px));
|
|
333
|
+
|
|
334
|
+
& + & {
|
|
335
|
+
margin-block-start: var(--hk-menu-section-stack, var(--space-6, 6px));
|
|
336
|
+
}
|
|
337
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -718,6 +718,7 @@ export { default as HkContextMenuProvider } from "./components/HkContextMenuProv
|
|
|
718
718
|
export { default as HkMenuPanel } from "./components/HkMenuPanel";
|
|
719
719
|
export { default as HkMenuActionItem } from "./components/HkMenuActionItem";
|
|
720
720
|
export { default as HkMenuIdentityItem } from "./components/HkMenuIdentityItem";
|
|
721
|
+
export { default as HkMenuSection } from "./components/HkMenuSection";
|
|
721
722
|
export { default as HkTagDotRow } from "./components/HkTagDotRow";
|
|
722
723
|
export { default as HkRowActions } from "./components/HkRowActions";
|
|
723
724
|
export { default as HkPopupSelect } from "./components/HkPopupSelect";
|
|
@@ -790,6 +790,23 @@
|
|
|
790
790
|
}
|
|
791
791
|
}
|
|
792
792
|
|
|
793
|
+
/* The section caption and its group wrapper (hikari _menu-item.scss
|
|
794
|
+
* @mixin section / @mixin section-label): the small band above a run of
|
|
795
|
+
* rows, e.g. 「临时空间」 in the workspace picker, plus the role="group"
|
|
796
|
+
* wrapper HkMenuSection renders. Declared globally here because host apps
|
|
797
|
+
* render these classes by HAND (the workspace picker's own markup) and
|
|
798
|
+
* cannot import the scss partial — the grammar (and every metric
|
|
799
|
+
* fallback) lives in _menu-item.scss. The caption's SHEET geometry rides
|
|
800
|
+
* the grammar's sheet-context lists, so it must NOT be re-declared here;
|
|
801
|
+
* this file only binds the class names to the grammar. */
|
|
802
|
+
.hk-menu-section {
|
|
803
|
+
@include mi.section;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.hk-menu-section-label {
|
|
807
|
+
@include mi.section-label;
|
|
808
|
+
}
|
|
809
|
+
|
|
793
810
|
/* ── User identity header (shared) ─ */
|
|
794
811
|
/* One identity block for every account surface — the admin user
|
|
795
812
|
dropdown's header, a mobile drawer's user panel footer — so all
|