@celestia-island/hikari 0.55.82 → 0.55.84
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/HkAffixPicker.test.tsx +20 -0
- package/src/components/HkNumberInput.affix.test.ts +33 -0
- package/src/components/HkStatCard.scss +47 -0
- package/src/components/HkStatCard.tsx +43 -14
- package/src/components/HkStatCard.variants.test.tsx +128 -0
- package/src/components/HkThemeToggle.contract.test.ts +23 -0
- package/src/components/HkThemeToggle.scss +30 -0
- package/src/components/HkThemeToggle.test.ts +56 -0
- package/src/components/HkThemeToggle.tsx +15 -2
package/package.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
|
|
4
7
|
import HkAffixPicker, { type HkAffixOption } from "./HkAffixPicker";
|
|
5
8
|
|
|
@@ -474,3 +477,20 @@ describe("HkAffixPicker", () => {
|
|
|
474
477
|
expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
|
|
475
478
|
});
|
|
476
479
|
});
|
|
480
|
+
|
|
481
|
+
describe("affix side → popup placement contract", () => {
|
|
482
|
+
it("anchors side=suffix popups to the bottom-end edge", () => {
|
|
483
|
+
// The suffix side hosts unit pickers (e.g. KiB/MiB/GiB chips on a
|
|
484
|
+
// number field — shittim-chest quota editor): the popup must anchor
|
|
485
|
+
// to the END edge so it opens toward the viewport instead of off
|
|
486
|
+
// the field's right edge. The mapping is one expression in the
|
|
487
|
+
// component; this source contract pins it against silent edits.
|
|
488
|
+
const src = readFileSync(
|
|
489
|
+
join(dirname(fileURLToPath(import.meta.url)), "HkAffixPicker.tsx"),
|
|
490
|
+
"utf-8",
|
|
491
|
+
);
|
|
492
|
+
expect(src).toContain(
|
|
493
|
+
'props.side === "suffix" ? "bottom-end" : "bottom-start"',
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -149,3 +149,36 @@ describe("HkNumberInput affix contract", () => {
|
|
|
149
149
|
expect(label?.textContent).toBe("轮播间隔");
|
|
150
150
|
});
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
describe("HkNumberInput suffix slot interactivity — the affix-picker host contract", () => {
|
|
154
|
+
it("delivers clicks on interactive suffix content (the unit-picker chip)", () => {
|
|
155
|
+
// The "unit picker" composition (an affix chip riding the suffix
|
|
156
|
+
// slot — e.g. HkAffixPicker side="suffix" for KiB/MiB/GiB in the
|
|
157
|
+
// quota editor) mounts a BUTTON inside the suffix span. The span is
|
|
158
|
+
// a plain flex item. This pins the event WIRING (a listener inside
|
|
159
|
+
// the suffix slot fires) — synthetic dispatch bypasses CSS
|
|
160
|
+
// hit-testing, so the pointer-events half of that guarantee lives
|
|
161
|
+
// in the SCSS source contract below (R1 observation).
|
|
162
|
+
let clicks = 0;
|
|
163
|
+
const { container } = mountNumberInput({
|
|
164
|
+
slots: {
|
|
165
|
+
suffix: () =>
|
|
166
|
+
h("button", { class: "suffix-chip-probe", onClick: () => clicks++ }, "MiB"),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const chip = query(container, ".hk-number-input-suffix .suffix-chip-probe") as HTMLElement;
|
|
170
|
+
expect(chip, "chip button inside the suffix span must render").not.toBeNull();
|
|
171
|
+
chip.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
172
|
+
expect(clicks).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("keeps pointer-events off the affix spans themselves (SCSS contract)", () => {
|
|
176
|
+
// Only the wrapper's `-disabled` state may set pointer-events:none —
|
|
177
|
+
// the same rule the wrapper uses to disable the whole field. A none
|
|
178
|
+
// on the affix spans would deaden interactive suffix content while
|
|
179
|
+
// the field looked enabled.
|
|
180
|
+
const affixRule =
|
|
181
|
+
scss.match(/\.hk-number-input-prefix,\s*\.hk-number-input-suffix\s*{[^}]*}/)?.[0] ?? "";
|
|
182
|
+
expect(affixRule, "affix rule must exist").not.toContain("pointer-events: none");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -197,3 +197,50 @@
|
|
|
197
197
|
gap: var(--space-8, 0.5rem);
|
|
198
198
|
max-width: 100%;
|
|
199
199
|
}
|
|
200
|
+
|
|
201
|
+
/* ── foot row — the `action` slot's corner anchor ──────────────────────
|
|
202
|
+
When a card hosts the `action` slot, the hint line is wrapped in a
|
|
203
|
+
relative foot row and the action content is pinned to the row's
|
|
204
|
+
trailing edge — the card's bottom-right corner (the per-card "⋯"
|
|
205
|
+
affordance). Clicks stop at the wrapper (the TSX stops propagation),
|
|
206
|
+
so an action button never double-fires a clickable card's click. */
|
|
207
|
+
.hk-stat-card-foot {
|
|
208
|
+
position: relative;
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
min-width: 0;
|
|
212
|
+
max-width: 100%;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* The ring variant centers its detail line — keep the hint visually
|
|
216
|
+
centered while the action hugs the trailing edge. The card centers
|
|
217
|
+
its children (align-items:center), which would leave the foot row
|
|
218
|
+
fit-content-wide and BOTH of those goals dead: the centering would
|
|
219
|
+
have nothing to center and the absolutely-positioned action would
|
|
220
|
+
anchor to the hint's edge instead of the card's corner — so the foot
|
|
221
|
+
explicitly stretches back to the full card width (R2). */
|
|
222
|
+
.hk-stat-card-ring .hk-stat-card-foot {
|
|
223
|
+
align-self: stretch;
|
|
224
|
+
justify-content: center;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* Reserve the corner: a min-height keeps an action-only foot (no hint)
|
|
228
|
+
from collapsing onto the content above, and the hint truncates before
|
|
229
|
+
it can slide under the affordance. */
|
|
230
|
+
.hk-stat-card-foot[data-has-action] {
|
|
231
|
+
min-height: 1.5rem;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.hk-stat-card-foot[data-has-action] .hk-stat-card-hint {
|
|
235
|
+
padding-inline-end: 1.75rem;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.hk-stat-card-action {
|
|
239
|
+
position: absolute;
|
|
240
|
+
inset-inline-end: calc(-1 * var(--space-4, 0.25rem));
|
|
241
|
+
top: 50%;
|
|
242
|
+
transform: translateY(-50%);
|
|
243
|
+
display: inline-flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
flex-shrink: 0;
|
|
246
|
+
}
|
|
@@ -15,12 +15,20 @@ export type StatTone = "success" | "warning" | "error" | "info" | "primary" | "m
|
|
|
15
15
|
* hint (the original anatomy, still the default).
|
|
16
16
|
* - `chip` — horizontal: a tone-tinted icon chip beside value + label,
|
|
17
17
|
* with an optional trailing `aside` slot (tier badge, delta, …).
|
|
18
|
-
* Every variant also takes a `footer` slot — a free-form content row
|
|
19
|
-
* under the value/hint for badge rows and deltas.
|
|
20
18
|
* - `ring` — a single utilization ring with the value inside, the label
|
|
21
19
|
* (with the icon) underneath, and the hint as the detail line.
|
|
22
20
|
* - `bar` — label + value on one row over a tone-colored progress bar,
|
|
23
21
|
* with the hint as the detail line.
|
|
22
|
+
*
|
|
23
|
+
* Every variant also takes a `footer` slot — a free-form content row
|
|
24
|
+
* under the value/hint for badge rows and deltas — and an `action`
|
|
25
|
+
* slot: a corner affordance
|
|
26
|
+
* (typically an icon button) pinned to the trailing edge of the hint
|
|
27
|
+
* line — the card's bottom-right corner. Hosts use it for per-card
|
|
28
|
+
* controls ("⋯" customization buttons) that must live on the KPI cell
|
|
29
|
+
* itself. Clicks inside the action wrapper stop at the wrapper, so an
|
|
30
|
+
* action button never double-fires a `clickable` card's click. Without
|
|
31
|
+
* the slot the hint renders exactly as before (no wrapper node).
|
|
24
32
|
*/
|
|
25
33
|
export type StatVariant = "plain" | "chip" | "ring" | "bar";
|
|
26
34
|
|
|
@@ -109,6 +117,35 @@ export const HkStatCard = defineComponent({
|
|
|
109
117
|
: undefined,
|
|
110
118
|
};
|
|
111
119
|
|
|
120
|
+
// The hint line, optionally hosting the `action` slot at the foot
|
|
121
|
+
// row's trailing edge (the card's bottom-right corner). WITHOUT an
|
|
122
|
+
// action slot the hint renders exactly as before — the foot wrapper
|
|
123
|
+
// exists only when a corner affordance needs the anchor, and each
|
|
124
|
+
// variant keeps its pre-slot attributes (the chip variant's hint
|
|
125
|
+
// never carried a title — R1), so hosts not using the slot see a
|
|
126
|
+
// byte-identical DOM.
|
|
127
|
+
const renderHint = (detailClass?: string, titled = true) => {
|
|
128
|
+
const hintEl = props.hint && (
|
|
129
|
+
<div
|
|
130
|
+
class={detailClass ? ["hk-stat-card-hint", detailClass] : "hk-stat-card-hint"}
|
|
131
|
+
title={titled ? props.hint : undefined}
|
|
132
|
+
>
|
|
133
|
+
{props.hint}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
if (!slots.action) return hintEl;
|
|
137
|
+
return (
|
|
138
|
+
<div class="hk-stat-card-foot" data-has-action="">
|
|
139
|
+
{hintEl}
|
|
140
|
+
{/* The corner affordance stops clicks here: an action button
|
|
141
|
+
must never double-fire a clickable card's click. */}
|
|
142
|
+
<div class="hk-stat-card-action" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
|
143
|
+
{slots.action()}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
112
149
|
if (props.variant === "chip") {
|
|
113
150
|
return (
|
|
114
151
|
<div {...shared}>
|
|
@@ -127,7 +164,7 @@ export const HkStatCard = defineComponent({
|
|
|
127
164
|
</div>
|
|
128
165
|
{slots.aside && <div class="hk-stat-card-aside">{slots.aside()}</div>}
|
|
129
166
|
</div>
|
|
130
|
-
{
|
|
167
|
+
{renderHint(undefined, false)}
|
|
131
168
|
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
132
169
|
</div>
|
|
133
170
|
);
|
|
@@ -155,11 +192,7 @@ export const HkStatCard = defineComponent({
|
|
|
155
192
|
{props.icon && h(props.icon, { size: 13, "aria-hidden": true })}
|
|
156
193
|
<span class="hk-stat-card-ring-label-text">{props.label}</span>
|
|
157
194
|
</div>
|
|
158
|
-
{
|
|
159
|
-
<div class="hk-stat-card-hint hk-stat-card-ring-detail" title={props.hint}>
|
|
160
|
-
{props.hint}
|
|
161
|
-
</div>
|
|
162
|
-
)}
|
|
195
|
+
{renderHint("hk-stat-card-ring-detail")}
|
|
163
196
|
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
164
197
|
</div>
|
|
165
198
|
);
|
|
@@ -177,11 +210,7 @@ export const HkStatCard = defineComponent({
|
|
|
177
210
|
size="xs"
|
|
178
211
|
segments={[{ value: clampPct(props.pct), color: statToneColor(props.tone) }]}
|
|
179
212
|
/>
|
|
180
|
-
{
|
|
181
|
-
<div class="hk-stat-card-hint hk-stat-card-bar-detail" title={props.hint}>
|
|
182
|
-
{props.hint}
|
|
183
|
-
</div>
|
|
184
|
-
)}
|
|
213
|
+
{renderHint("hk-stat-card-bar-detail")}
|
|
185
214
|
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
186
215
|
</div>
|
|
187
216
|
);
|
|
@@ -195,7 +224,7 @@ export const HkStatCard = defineComponent({
|
|
|
195
224
|
<span class={`hk-stat-card-dot hk-stat-card-dot-${props.tone}`} aria-hidden="true" />
|
|
196
225
|
</div>
|
|
197
226
|
<div class="hk-stat-card-value">{props.value}</div>
|
|
198
|
-
{
|
|
227
|
+
{renderHint()}
|
|
199
228
|
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
200
229
|
</div>
|
|
201
230
|
);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, h, defineComponent, nextTick } from "vue";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
|
|
4
7
|
import { HkStatCard, statToneColor } from "./HkStatCard";
|
|
5
8
|
|
|
@@ -240,3 +243,128 @@ describe("statToneColor", () => {
|
|
|
240
243
|
expect(statToneColor("info")).toBe("rgb(var(--color-info, rgb(var(--color-primary))))");
|
|
241
244
|
});
|
|
242
245
|
});
|
|
246
|
+
|
|
247
|
+
describe("HkStatCard action slot — the corner affordance", () => {
|
|
248
|
+
it("hosts the action at the foot row's trailing edge on ring, hint stays centered-class", () => {
|
|
249
|
+
const c = mount(
|
|
250
|
+
h(
|
|
251
|
+
HkStatCard,
|
|
252
|
+
{
|
|
253
|
+
variant: "ring", tone: "success", pct: 37.5, value: "38%", unit: "cores",
|
|
254
|
+
icon: FakeIcon, label: "Cores", hint: "12 / 32 · monthly",
|
|
255
|
+
},
|
|
256
|
+
{ action: () => h("button", { class: "action-probe" }, "⋯") },
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
// The action content rides the dedicated wrapper inside the foot row.
|
|
260
|
+
const foot = c.querySelector(".hk-stat-card-foot") as HTMLElement;
|
|
261
|
+
expect(foot).toBeTruthy();
|
|
262
|
+
expect(foot.hasAttribute("data-has-action")).toBe(true);
|
|
263
|
+
expect(c.querySelector(".hk-stat-card-action .action-probe")?.textContent).toBe("⋯");
|
|
264
|
+
// The hint keeps its anatomy classes inside the foot — it is wrapped,
|
|
265
|
+
// not replaced.
|
|
266
|
+
expect(c.querySelector(".hk-stat-card-foot .hk-stat-card-hint")?.textContent).toBe(
|
|
267
|
+
"12 / 32 · monthly",
|
|
268
|
+
);
|
|
269
|
+
expect(c.querySelector(".hk-stat-card-foot .hk-stat-card-ring-detail")).toBeTruthy();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("wraps the hint on plain, chip and bar the same way", () => {
|
|
273
|
+
for (const variant of ["plain", "chip", "bar"] as const) {
|
|
274
|
+
const c = mount(
|
|
275
|
+
h(HkStatCard, { variant, value: "v", label: "L", hint: "detail" }, {
|
|
276
|
+
action: () => h("span", { class: "action-probe-generic" }, "⋯"),
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
expect(c.querySelector(".hk-stat-card-foot[data-has-action]")).toBeTruthy();
|
|
280
|
+
expect(c.querySelector(".hk-stat-card-action .action-probe-generic")).toBeTruthy();
|
|
281
|
+
expect(c.querySelector(".hk-stat-card-foot .hk-stat-card-hint")?.textContent).toBe("detail");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("keeps the hint DOM bare (no foot wrapper) when the slot is absent", () => {
|
|
286
|
+
// The wrapper exists ONLY for the action anchor: a host not using the
|
|
287
|
+
// slot must see the exact pre-slot DOM. This kills an "always wrap"
|
|
288
|
+
// mutation, which would silently re-nest every existing hint.
|
|
289
|
+
for (const variant of ["plain", "chip", "ring", "bar"] as const) {
|
|
290
|
+
const c = mount(h(HkStatCard, { variant, value: "v", label: "L", hint: "detail" }));
|
|
291
|
+
expect(c.querySelector(".hk-stat-card-foot")).toBeNull();
|
|
292
|
+
expect(c.querySelector(".hk-stat-card > .hk-stat-card-hint")?.textContent).toBe("detail");
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("keeps each variant's pre-slot hint attributes when the slot is absent", () => {
|
|
297
|
+
// R1 finding: the chip variant's hint never carried a title before
|
|
298
|
+
// the action slot existed — "byte-identical without the slot" must
|
|
299
|
+
// hold attribute-for-attribute, not just structure-for-structure.
|
|
300
|
+
// plain/ring/bar always titled their hint; chip never did.
|
|
301
|
+
const titled = (variant: "plain" | "chip" | "ring" | "bar") => {
|
|
302
|
+
const c = mount(h(HkStatCard, { variant, value: "v", label: "L", hint: "detail" }));
|
|
303
|
+
const hint = c.querySelector(".hk-stat-card > .hk-stat-card-hint") as HTMLElement;
|
|
304
|
+
return hint.hasAttribute("title");
|
|
305
|
+
};
|
|
306
|
+
expect(titled("chip")).toBe(false);
|
|
307
|
+
expect(titled("plain")).toBe(true);
|
|
308
|
+
expect(titled("ring")).toBe(true);
|
|
309
|
+
expect(titled("bar")).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps the action-corner SCSS contract (source pin)", () => {
|
|
313
|
+
// happy-dom never applies the stylesheet, so the data-has-action
|
|
314
|
+
// layout rules (corner clearance + action-only min-height) are
|
|
315
|
+
// otherwise removable with zero red (R1 mutation c). The house
|
|
316
|
+
// source-contract style pins them.
|
|
317
|
+
const scss = readFileSync(
|
|
318
|
+
join(dirname(fileURLToPath(import.meta.url)), "HkStatCard.scss"),
|
|
319
|
+
"utf-8",
|
|
320
|
+
);
|
|
321
|
+
const foot = scss.match(/\.hk-stat-card-foot\[data-has-action\]\s*{[^}]*}/)?.[0] ?? "";
|
|
322
|
+
expect(foot, "action-only foot min-height rule must exist").toContain("min-height");
|
|
323
|
+
const clearance = scss.match(
|
|
324
|
+
/\.hk-stat-card-foot\[data-has-action\] \.hk-stat-card-hint\s*{[^}]*}/,
|
|
325
|
+
)?.[0];
|
|
326
|
+
expect(clearance, "hint corner-clearance rule must exist").toBeTruthy();
|
|
327
|
+
expect(clearance).toContain("padding-inline-end");
|
|
328
|
+
// The ring card centers its children, which would leave the foot
|
|
329
|
+
// fit-content-wide — dead centering AND a hint-edge-anchored action
|
|
330
|
+
// (R2 Major). The stretch is what puts the corner back.
|
|
331
|
+
const ringFoot = scss.match(
|
|
332
|
+
/\.hk-stat-card-ring \.hk-stat-card-foot\s*{[^}]*}/,
|
|
333
|
+
)?.[0];
|
|
334
|
+
expect(ringFoot, "ring foot stretch rule must exist").toBeTruthy();
|
|
335
|
+
expect(ringFoot).toContain("align-self: stretch");
|
|
336
|
+
expect(ringFoot).toContain("justify-content: center");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("stops action clicks from double-firing a clickable card's click", async () => {
|
|
340
|
+
const clicks: number[] = [];
|
|
341
|
+
const c = mount(
|
|
342
|
+
h(
|
|
343
|
+
HkStatCard,
|
|
344
|
+
{ variant: "ring", value: "38%", label: "L", pct: 38, clickable: true, onClick: () => clicks.push(1) },
|
|
345
|
+
{ action: () => h("button", { class: "action-btn-probe" }, "⋯") },
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
const btn = c.querySelector(".action-btn-probe") as HTMLElement;
|
|
349
|
+
btn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
350
|
+
await nextTick();
|
|
351
|
+
expect(clicks).toEqual([]);
|
|
352
|
+
// …while a click on the card surface itself still fires.
|
|
353
|
+
const card = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
354
|
+
card.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
355
|
+
await nextTick();
|
|
356
|
+
expect(clicks).toEqual([1]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("renders an action-only foot (no hint) without collapsing onto the card body", () => {
|
|
360
|
+
const c = mount(
|
|
361
|
+
h(HkStatCard, { variant: "ring", value: "38%", label: "L", pct: 38 }, {
|
|
362
|
+
action: () => h("span", { class: "action-only-probe" }, "⋯"),
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
const foot = c.querySelector(".hk-stat-card-foot") as HTMLElement;
|
|
366
|
+
expect(foot).toBeTruthy();
|
|
367
|
+
expect(foot.querySelector(".hk-stat-card-hint")).toBeNull();
|
|
368
|
+
expect(c.querySelector(".hk-stat-card-action .action-only-probe")).toBeTruthy();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -82,4 +82,27 @@ describe("HkThemeToggle row lead-cell contract", () => {
|
|
|
82
82
|
expect(svgBlock).not.toBeNull();
|
|
83
83
|
expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
it("pins the name-suffix mark against the name instead of the trailing edge", () => {
|
|
87
|
+
const css = read("HkThemeToggle.scss");
|
|
88
|
+
// happy-dom has no layout engine, so the "reads as part of the name"
|
|
89
|
+
// claim is pinned as the two declarations that produce it: the name
|
|
90
|
+
// stops stretching (the shared mixin's `flex: 1` would push the mark
|
|
91
|
+
// to the row's far end, next to the trailing affordances) and the
|
|
92
|
+
// suffix eats the leftover width on its leading side. Losing either
|
|
93
|
+
// one silently turns the marker into a trailing icon.
|
|
94
|
+
const nameBlock = css.match(
|
|
95
|
+
/\.s-theme-item-row\[data-name-suffix="slot"\] \.s-theme-item-name\s*\{[^}]*\}/,
|
|
96
|
+
);
|
|
97
|
+
expect(nameBlock).not.toBeNull();
|
|
98
|
+
expect(nameBlock![0]).toContain("flex: 0 1 auto");
|
|
99
|
+
// Long names must WRAP, not push the mark out of the row: the mixin's
|
|
100
|
+
// nowrap is exactly what would clip the marker away at narrow widths.
|
|
101
|
+
expect(nameBlock![0]).toContain("white-space: normal");
|
|
102
|
+
|
|
103
|
+
const suffixBlock = css.match(/\.s-theme-item-name-suffix\s*\{[^}]*\}/);
|
|
104
|
+
expect(suffixBlock).not.toBeNull();
|
|
105
|
+
expect(suffixBlock![0]).toContain("margin-inline-end: auto");
|
|
106
|
+
expect(suffixBlock![0]).toContain("flex-shrink: 0");
|
|
107
|
+
});
|
|
85
108
|
});
|
|
@@ -201,6 +201,36 @@
|
|
|
201
201
|
@include mi.label;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/* `item-name-suffix` slot — a host mark that must read as PART OF the
|
|
205
|
+
* name, not as a trailing affordance. Layout is what makes that true:
|
|
206
|
+
* the shared label mixin is `flex: 1`, which would stretch the name to
|
|
207
|
+
* the full row and leave the mark floating at the far right, next to the
|
|
208
|
+
* trailing column (and, in the host's cloud/local marker case, next to
|
|
209
|
+
* the edit button — reading as a per-row action). On suffix rows the
|
|
210
|
+
* name therefore shrinks to its content (`flex: 0 1 auto`, keeping the
|
|
211
|
+
* mixin's `min-width: 0` so long names still wrap inside the available
|
|
212
|
+
* box) and the suffix claims the leftover space on its leading side
|
|
213
|
+
* (`margin-inline-end: auto`), pinning the mark directly against the
|
|
214
|
+
* name's trailing edge. `white-space: normal` overrides the mixin's
|
|
215
|
+
* nowrap so a long name WRAPS instead of pushing the mark out of the
|
|
216
|
+
* row — a name that clips its own provenance mark defeats the point.
|
|
217
|
+
* Rows without the slot are untouched (the mixin's behaviour stands). */
|
|
218
|
+
.s-theme-item-row[data-name-suffix="slot"] .s-theme-item-name {
|
|
219
|
+
flex: 0 1 auto;
|
|
220
|
+
white-space: normal;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.s-theme-item-name-suffix {
|
|
224
|
+
display: inline-flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
flex-shrink: 0;
|
|
227
|
+
margin-inline-start: var(--space-4, 0.25rem);
|
|
228
|
+
/* Trailing auto-margin eats the row's leftover width, so the mark sits
|
|
229
|
+
* with the name at the leading edge and the trailing column stays
|
|
230
|
+
* flush right. */
|
|
231
|
+
margin-inline-end: auto;
|
|
232
|
+
}
|
|
233
|
+
|
|
204
234
|
/* Leading slot glyphs share the spec's fixed icon cell — sized for the
|
|
205
235
|
* WIDEST lead mark hosts render there, not the generic 16px box: chest's
|
|
206
236
|
* three-dot palette swatch is 28px (3×8px dots + 2px gaps), and a mark
|
|
@@ -269,6 +269,7 @@ describe("HkThemeToggle item slots", () => {
|
|
|
269
269
|
function mountSlotted(
|
|
270
270
|
lead: (scope: { id: string }) => unknown,
|
|
271
271
|
trail: (scope: { id: string }) => unknown,
|
|
272
|
+
nameSuffix?: (scope: { id: string }) => unknown,
|
|
272
273
|
): HTMLElement {
|
|
273
274
|
const container = document.createElement("div");
|
|
274
275
|
document.body.appendChild(container);
|
|
@@ -279,6 +280,9 @@ describe("HkThemeToggle item slots", () => {
|
|
|
279
280
|
}, {
|
|
280
281
|
"item-leading": lead,
|
|
281
282
|
"item-trailing": trail,
|
|
283
|
+
// Only provided when the case exercises it: the slot's ABSENCE is
|
|
284
|
+
// itself a contract (rows must stay exactly as before).
|
|
285
|
+
...(nameSuffix ? { "item-name-suffix": nameSuffix } : {}),
|
|
282
286
|
}),
|
|
283
287
|
});
|
|
284
288
|
app.mount(container);
|
|
@@ -342,6 +346,58 @@ describe("HkThemeToggle item slots", () => {
|
|
|
342
346
|
expect(customRows).toBe(2);
|
|
343
347
|
});
|
|
344
348
|
|
|
349
|
+
it("renders the name-suffix cell INSIDE the row button, right after the name", async () => {
|
|
350
|
+
// The whole point of the slot (vs. reusing item-trailing) is that the
|
|
351
|
+
// mark reads as part of the NAME. That is a DOM-position claim, so it
|
|
352
|
+
// is asserted structurally: inside `.s-theme-item-btn`, in the same
|
|
353
|
+
// row, and immediately after `.s-theme-item-name` — not merely
|
|
354
|
+
// "somewhere in the row".
|
|
355
|
+
useTheme().addCustomTheme(anyPresetTokens());
|
|
356
|
+
const container = mountSlotted(
|
|
357
|
+
() => null,
|
|
358
|
+
() => null,
|
|
359
|
+
(scope) => h("i", { class: "suffix-mark", "data-id": scope.id }),
|
|
360
|
+
);
|
|
361
|
+
await settle();
|
|
362
|
+
openMenu(container);
|
|
363
|
+
await settle();
|
|
364
|
+
|
|
365
|
+
const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
|
|
366
|
+
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
expect(row.getAttribute("data-name-suffix")).toBe("slot");
|
|
369
|
+
const cell = row.querySelector<HTMLElement>(".s-theme-item-name-suffix .suffix-mark");
|
|
370
|
+
expect(cell).toBeTruthy();
|
|
371
|
+
expect(row.querySelectorAll(".suffix-mark")).toHaveLength(1);
|
|
372
|
+
const name = row.querySelector<HTMLElement>(".s-theme-item-name");
|
|
373
|
+
expect(name).toBeTruthy();
|
|
374
|
+
// Inside the row BUTTON (so a click on the row still selects the
|
|
375
|
+
// theme) and the name's immediate next sibling.
|
|
376
|
+
expect(name!.closest(".s-theme-item-btn")).toBeTruthy();
|
|
377
|
+
expect(cell!.closest(".s-theme-item-name-suffix")!.parentElement).toBe(name!.parentElement);
|
|
378
|
+
expect(name!.nextElementSibling).toBe(cell!.closest(".s-theme-item-name-suffix"));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("leaves suffix rows untouched when the host provides no name-suffix slot", async () => {
|
|
383
|
+
// Absence is a contract: without the slot the row must keep the
|
|
384
|
+
// unmodified DOM (no empty cell, no data attribute), or every
|
|
385
|
+
// existing host gains a phantom cell.
|
|
386
|
+
useTheme().addCustomTheme(anyPresetTokens());
|
|
387
|
+
const container = mountSlotted(() => null, () => null);
|
|
388
|
+
await settle();
|
|
389
|
+
openMenu(container);
|
|
390
|
+
await settle();
|
|
391
|
+
|
|
392
|
+
const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
|
|
393
|
+
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
394
|
+
for (const row of rows) {
|
|
395
|
+
expect(row.hasAttribute("data-name-suffix")).toBe(false);
|
|
396
|
+
expect(row.querySelector(".s-theme-item-name-suffix")).toBeNull();
|
|
397
|
+
expect(row.querySelector(".s-theme-item-name")).toBeTruthy();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
345
401
|
it("scopes each row with its resolved preset definition", async () => {
|
|
346
402
|
const presets: Array<unknown> = [];
|
|
347
403
|
// Two rows are the point: with a single built-in row the >= 2 bound
|
|
@@ -45,11 +45,16 @@ export interface ThemeItemScope {
|
|
|
45
45
|
* color-mode group and preset/custom theme selection (custom themes are
|
|
46
46
|
* removable), and opens HColorSchemeDialog to create a new custom scheme.
|
|
47
47
|
*
|
|
48
|
-
* Theme rows are host-customizable through
|
|
48
|
+
* Theme rows are host-customizable through three scoped slots (all render
|
|
49
49
|
* in the popover AND the mobile bottom sheet — same DOM):
|
|
50
50
|
* · `item-leading` — replaces the selected-check cell; renders for
|
|
51
51
|
* every row in the fixed icon cell so names align regardless of the
|
|
52
52
|
* active row. Scope: `ThemeItemScope` (id/name/isCustom/preset).
|
|
53
|
+
* · `item-name-suffix` — an inline cell rendered INSIDE the row button,
|
|
54
|
+
* immediately after the (catalog-resolved) name, so a host mark reads
|
|
55
|
+
* as part of the name rather than as an affordance. Renders only when
|
|
56
|
+
* the host provides the slot; the cell is `aria-hidden`-free and
|
|
57
|
+
* layout-neutral (does not grow, never pushes the trailing column).
|
|
53
58
|
* · `item-trailing` — reserves a trailing column on every row and owns
|
|
54
59
|
* it entirely; the built-in custom-delete overlay is suppressed, so
|
|
55
60
|
* the host renders delete/edit itself where it wants them.
|
|
@@ -301,7 +306,10 @@ export const HkThemeToggle = defineComponent({
|
|
|
301
306
|
{allThemeList.value.map((th) => {
|
|
302
307
|
// Host-customizable row anatomy. `item-leading` replaces the
|
|
303
308
|
// selected-check cell and renders for EVERY row (fixed icon
|
|
304
|
-
// cell, so names stay aligned); `item-
|
|
309
|
+
// cell, so names stay aligned); `item-name-suffix` renders
|
|
310
|
+
// INSIDE the row button right after the name, so a host mark
|
|
311
|
+
// (e.g. a provenance/sync glyph) reads as part of the name
|
|
312
|
+
// instead of as an affordance; `item-trailing` reserves a
|
|
305
313
|
// real trailing column for every row and takes full
|
|
306
314
|
// ownership of the affordances there — the built-in custom
|
|
307
315
|
// delete overlay is suppressed in that mode, so the host
|
|
@@ -315,6 +323,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
315
323
|
preset: presetOf(th.id),
|
|
316
324
|
};
|
|
317
325
|
const leadingSlot = slots["item-leading"];
|
|
326
|
+
const nameSuffixSlot = slots["item-name-suffix"];
|
|
318
327
|
const trailingSlot = slots["item-trailing"];
|
|
319
328
|
// A preset/custom name may be a message key into hikari's own
|
|
320
329
|
// catalog (the documented ThemePreset.name convention — hosts
|
|
@@ -329,6 +338,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
329
338
|
class="s-theme-item-row"
|
|
330
339
|
data-custom={th.isCustom || undefined}
|
|
331
340
|
data-trailing={trailingSlot ? "slot" : undefined}
|
|
341
|
+
data-name-suffix={nameSuffixSlot ? "slot" : undefined}
|
|
332
342
|
>
|
|
333
343
|
<button
|
|
334
344
|
type="button"
|
|
@@ -344,6 +354,9 @@ export const HkThemeToggle = defineComponent({
|
|
|
344
354
|
<span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
|
|
345
355
|
) : null}
|
|
346
356
|
<span class="s-theme-item-name">{displayName}</span>
|
|
357
|
+
{nameSuffixSlot ? (
|
|
358
|
+
<span class="s-theme-item-name-suffix">{nameSuffixSlot(scope)}</span>
|
|
359
|
+
) : null}
|
|
347
360
|
</button>
|
|
348
361
|
{trailingSlot ? (
|
|
349
362
|
<span class="s-theme-item-trailing">{trailingSlot(scope)}</span>
|