@celestia-island/hikari 0.55.82 → 0.55.83

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.82",
3
+ "version": "0.55.83",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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
- {props.hint && <div class="hk-stat-card-hint">{props.hint}</div>}
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
- {props.hint && (
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
- {props.hint && (
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
- {props.hint && <div class="hk-stat-card-hint" title={props.hint}>{props.hint}</div>}
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
+ });