@celestia-island/hikari 0.45.1 → 0.45.3

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.45.1",
3
+ "version": "0.45.3",
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",
@@ -94,13 +94,18 @@
94
94
  opacity: 0;
95
95
  }
96
96
 
97
+ /* The leave ghost keeps its in-flow geometry via the pinLeaveGeometry
98
+ * hook wired in HkListTransition.tsx (explicit width/top/left inline
99
+ * pins). Do NOT add `width: 100%` here — without a position:relative
100
+ * host it resolves against the nearest positioned ancestor's collapsed
101
+ * box and the row flashes full-width while fading (the toast #306
102
+ * defect class). */
97
103
  .hk-list-grow-leave-active {
98
104
  transition:
99
105
  transform var(--duration-fast, 0.15s) cubic-bezier(0.5, 0, 0.75, 0),
100
106
  opacity var(--duration-fast, 0.15s) ease-in;
101
107
  pointer-events: none;
102
108
  position: absolute;
103
- width: 100%;
104
109
  }
105
110
 
106
111
  .hk-list-grow-leave-to {
@@ -0,0 +1,105 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick, ref } from "vue";
3
+
4
+ import HkListTransition from "./HkListTransition";
5
+
6
+ /**
7
+ * HkListTransition leave-geometry contract:
8
+ * - a leaving row in an absolute-leave variant (pop/slide/fade/grow) is
9
+ * pinned to its measured in-flow box by the before-leave hook
10
+ * (pinLeaveGeometry), so it cannot flash to `width: 100%` of a
11
+ * collapsed containing block when the host is not position:relative
12
+ * - the `reveal` variant animates its own height and must NOT be pinned
13
+ *
14
+ * (Repo test convention: raw createApp + document queries, no
15
+ * @vue/test-utils dependency; happy-dom has no layout engine, so
16
+ * offset* metrics are stubbed.)
17
+ */
18
+
19
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
20
+
21
+ type Variant = "pop" | "slide" | "fade" | "grow" | "reveal";
22
+
23
+ function mountRows(variant: Variant, keys: () => string[]) {
24
+ const container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ const app = createApp({
27
+ render: () =>
28
+ h(
29
+ HkListTransition,
30
+ { tag: "ul", variant },
31
+ { default: () => keys().map((k) => h("li", { key: k }, k)) },
32
+ ),
33
+ });
34
+ mounts.push({ app, container });
35
+ app.mount(container);
36
+ return container;
37
+ }
38
+
39
+ function stubLayout(el: HTMLElement, over: Partial<{
40
+ offsetTop: number;
41
+ offsetLeft: number;
42
+ offsetWidth: number;
43
+ offsetHeight: number;
44
+ }> = {}): void {
45
+ const wrapper = el.parentElement as HTMLElement;
46
+ Object.defineProperty(wrapper, "clientWidth", { value: 300, configurable: true });
47
+ Object.defineProperty(el, "offsetParent", { value: wrapper, configurable: true });
48
+ Object.defineProperty(el, "offsetTop", { value: over.offsetTop ?? 0, configurable: true });
49
+ Object.defineProperty(el, "offsetLeft", { value: over.offsetLeft ?? 0, configurable: true });
50
+ Object.defineProperty(el, "offsetWidth", { value: over.offsetWidth ?? 300, configurable: true });
51
+ Object.defineProperty(el, "offsetHeight", { value: over.offsetHeight ?? 40, configurable: true });
52
+ }
53
+
54
+ function rows(): HTMLElement[] {
55
+ return Array.from(document.querySelectorAll<HTMLElement>("ul > li"));
56
+ }
57
+
58
+ afterEach(() => {
59
+ for (const { app, container } of mounts.splice(0)) {
60
+ app.unmount();
61
+ container.remove();
62
+ }
63
+ });
64
+
65
+ describe("HkListTransition leave geometry", () => {
66
+ it("pins the leaving grow row to its in-flow box instead of width:100%", async () => {
67
+ const keys = ref(["a", "b", "c"]);
68
+ mountRows("grow", () => keys.value);
69
+ await nextTick();
70
+
71
+ stubLayout(rows()[1]!, { offsetTop: 40, offsetLeft: 0, offsetWidth: 300, offsetHeight: 40 });
72
+
73
+ keys.value = ["a", "c"];
74
+ await nextTick();
75
+ await nextTick();
76
+
77
+ const leaving = document.querySelector<HTMLElement>(".hk-list-grow-leave-active");
78
+ expect(leaving).not.toBeNull();
79
+ // The pin mirrors the measured in-flow box exactly; a percentage
80
+ // width would resolve against a possibly-collapsed containing block.
81
+ expect(leaving!.style.left).toBe("0px");
82
+ expect(leaving!.style.top).toBe("40px");
83
+ expect(leaving!.style.width).toBe("300px");
84
+ expect(leaving!.style.height).toBe("40px");
85
+ expect(leaving!.style.boxSizing).toBe("border-box");
86
+ expect(leaving!.style.width).not.toBe("100%");
87
+ });
88
+
89
+ it("does not pin the reveal variant (its own height drives the squeeze)", async () => {
90
+ const keys = ref(["a", "b"]);
91
+ mountRows("reveal", () => keys.value);
92
+ await nextTick();
93
+
94
+ stubLayout(rows()[0]!);
95
+
96
+ keys.value = ["b"];
97
+ await nextTick();
98
+ await nextTick();
99
+
100
+ const leaving = document.querySelector<HTMLElement>(".hk-list-reveal-leave-active");
101
+ expect(leaving).not.toBeNull();
102
+ expect(leaving!.style.height).toBe("");
103
+ expect(leaving!.style.width).toBe("");
104
+ });
105
+ });
@@ -1,5 +1,6 @@
1
- import { defineComponent, TransitionGroup, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUpdate, ref, TransitionGroup, type PropType } from "vue";
2
2
 
3
+ import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
3
4
  import { useReportedTransition } from "../composables/useReportedTransition";
4
5
 
5
6
  import "./HkListTransition.scss";
@@ -63,10 +64,58 @@ export default defineComponent({
63
64
  if (pending === 0) report.cancel();
64
65
  };
65
66
 
67
+ // Variants whose leave-active rule lifts the row out of flow with
68
+ // `position: absolute` (see HkListTransition.scss). Only these need
69
+ // the geometry pin; `reveal` animates the row's own height in place
70
+ // and `none` never leaves the flow, so a pin would freeze the very
71
+ // property they animate.
72
+ const ABSOLUTE_VARIANTS: ReadonlySet<string> = new Set(["pop", "slide", "fade", "grow"]);
73
+
74
+ // Pre-patch geometry of every row, refreshed on each update (the DOM
75
+ // is still the pre-patch tree at onBeforeUpdate — same pattern as
76
+ // HkTabs). During a multi-row removal the first leaving sibling gets
77
+ // its leave-active class (position:absolute) synchronously inside the
78
+ // patch pass, so a live offset read in a later sibling's beforeLeave
79
+ // hook would freeze an already-reflowed position. The pin also
80
+ // replaces `grow`'s old `width: 100%`, which resolved against a
81
+ // possibly-collapsed containing block when the host is not
82
+ // position:relative (the full-width leave flash).
83
+ const hostRef = ref<{ $el?: Element } | null>(null);
84
+ const prePatchBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
85
+ onBeforeUpdate(() => {
86
+ // Fragment hosts (tag="") have a comment anchor as $el — no
87
+ // element children to measure there.
88
+ const host = hostRef.value?.$el;
89
+ if (host == null || host.nodeType !== 1) return;
90
+ for (const child of Array.from(host.children)) {
91
+ const e = child as HTMLElement;
92
+ prePatchBoxes.set(e, {
93
+ top: e.offsetTop,
94
+ left: e.offsetLeft,
95
+ width: e.offsetWidth,
96
+ height: e.offsetHeight,
97
+ });
98
+ }
99
+ });
100
+
101
+ const usesAbsoluteLeave = () =>
102
+ !props.disabled && ABSOLUTE_VARIANTS.has(props.variant);
103
+
104
+ function pinLeaving(el: Element) {
105
+ if (!usesAbsoluteLeave()) return;
106
+ pinLeaveGeometry(el, { anchorX: "left", box: prePatchBoxes.get(el) });
107
+ }
108
+
109
+ function unpinLeaving(el: Element) {
110
+ if (!usesAbsoluteLeave()) return;
111
+ clearLeaveGeometry(el);
112
+ }
113
+
66
114
  return () => {
67
115
  const name = props.disabled ? "hk-list-none" : `hk-list-${props.variant}`;
68
116
  return (
69
117
  <TransitionGroup
118
+ ref={hostRef}
70
119
  tag={props.tag}
71
120
  name={name}
72
121
  appear={props.appear}
@@ -74,9 +123,15 @@ export default defineComponent({
74
123
  onBeforeEnter={arm}
75
124
  onAfterEnter={settle}
76
125
  onEnterCancelled={settle}
77
- onBeforeLeave={arm}
126
+ onBeforeLeave={(el: Element) => {
127
+ arm();
128
+ pinLeaving(el);
129
+ }}
78
130
  onAfterLeave={settle}
79
- onLeaveCancelled={settle}
131
+ onLeaveCancelled={(el: Element) => {
132
+ settle();
133
+ unpinLeaving(el);
134
+ }}
80
135
  >
81
136
  {slots.default?.()}
82
137
  </TransitionGroup>
@@ -0,0 +1,123 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ /**
7
+ * HkModal footerLead slot contract tests:
8
+ * - the named slot renders inside .hk-modal-footer BEFORE the action buttons,
9
+ * wrapped in .hk-modal-footer-lead, with the band marked .hk-modal-footer--lead
10
+ * - the band still renders with ONLY a footerLead (no footerActions) — a lead
11
+ * alone must not vanish with the actions
12
+ * - absent the slot, no new DOM appears (backward compat)
13
+ * - the explicit `footer` slot keeps precedence over footerLead + footerActions
14
+ * (existing full-custom contract unchanged)
15
+ */
16
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
17
+
18
+ afterEach(() => {
19
+ for (const { app, container } of mounts.splice(0)) {
20
+ app.unmount();
21
+ container.remove();
22
+ }
23
+ });
24
+
25
+ interface Harness {
26
+ container: HTMLElement;
27
+ footer: HTMLElement | null;
28
+ }
29
+
30
+ async function mountModal(
31
+ props: Record<string, unknown>,
32
+ slots: Record<string, unknown> = {},
33
+ ): Promise<Harness> {
34
+ const container = document.createElement("div");
35
+ document.body.appendChild(container);
36
+ const open = ref(true);
37
+ const Wrapper = defineComponent({
38
+ setup() {
39
+ // A FRESH slots object per Wrapper render: updateSlots mutates the
40
+ // slots object it is handed (deleting keys absent from a later
41
+ // sibling mount's shape), and a shared literal would lose footerLead
42
+ // on re-render.
43
+ return () =>
44
+ h(HkModal, {
45
+ ...props,
46
+ modelValue: open.value,
47
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
48
+ }, { ...slots });
49
+ },
50
+ });
51
+ const app = createApp(Wrapper);
52
+ app.mount(container);
53
+ mounts.push({ app, container });
54
+ await nextTick();
55
+ await new Promise((resolve) => setTimeout(resolve, 0));
56
+ await nextTick();
57
+ // HkModal teleports to body and sibling test files in this worker can
58
+ // leave their own modals behind — scope to the NEWEST mounted footer
59
+ // instead of the document-first match.
60
+ const footers = document.querySelectorAll(".hk-modal-footer");
61
+ return {
62
+ container,
63
+ footer: footers.length ? (footers[footers.length - 1] as HTMLElement) : null,
64
+ };
65
+ }
66
+
67
+ describe("HkModal footerLead slot", () => {
68
+ it("renders the lead before the action buttons inside the band", async () => {
69
+ const { footer } = await mountModal(
70
+ {
71
+ title: "Config",
72
+ footerActions: [{ label: "Add", variant: "primary" }],
73
+ },
74
+ { footerLead: () => h("button", { class: "lead-jump", "data-test": "jump" }, "JUMP") },
75
+ );
76
+ expect(footer).not.toBeNull();
77
+ expect(footer?.classList.contains("hk-modal-footer--lead")).toBe(true);
78
+ const lead = footer?.querySelector(".hk-modal-footer-lead");
79
+ expect(lead).not.toBeNull();
80
+ expect(lead?.querySelector(".lead-jump")?.textContent).toBe("JUMP");
81
+
82
+ const children = footer ? Array.from(footer.children) : [];
83
+ expect(children[0]?.classList.contains("hk-modal-footer-lead")).toBe(true);
84
+ expect(children.length).toBe(2);
85
+ expect(children[1]?.textContent).toBe("Add");
86
+ });
87
+
88
+ it("keeps the band alive with ONLY a footerLead and no actions", async () => {
89
+ const { footer } = await mountModal(
90
+ { title: "Config" },
91
+ { footerLead: () => h("button", { class: "lead-jump" }, "JUMP") },
92
+ );
93
+ expect(footer).not.toBeNull();
94
+ expect(footer?.classList.contains("hk-modal-footer--lead")).toBe(true);
95
+ expect(footer?.querySelector(".hk-modal-footer-lead")).not.toBeNull();
96
+ // No action buttons — the only children are the lead wrapper.
97
+ const children = footer ? Array.from(footer.children) : [];
98
+ expect(children).toHaveLength(1);
99
+ });
100
+
101
+ it("adds no lead DOM and no band when both lead and actions are absent", async () => {
102
+ const { footer } = await mountModal({ title: "Config" });
103
+ expect(footer).toBeNull();
104
+ });
105
+
106
+ it("the explicit footer slot keeps precedence over footerLead and actions", async () => {
107
+ const { footer } = await mountModal(
108
+ {
109
+ title: "Config",
110
+ footerActions: [{ label: "Add" }],
111
+ },
112
+ {
113
+ footer: () => h("div", { class: "custom-footer" }, "CUSTOM"),
114
+ footerLead: () => h("button", { class: "lead-jump" }, "JUMP"),
115
+ },
116
+ );
117
+ expect(footer).not.toBeNull();
118
+ expect(footer?.classList.contains("hk-modal-footer--lead")).toBe(false);
119
+ expect(footer?.querySelector(".custom-footer")?.textContent).toBe("CUSTOM");
120
+ expect(footer?.querySelector(".hk-modal-footer-lead")).toBeNull();
121
+ expect(footer?.textContent).not.toContain("Add");
122
+ });
123
+ });
@@ -298,6 +298,21 @@
298
298
  }
299
299
  }
300
300
 
301
+ // Lead content (e.g. a ghost jump link into the admin console) rendered at
302
+ // the footer's inline-start with the action buttons still pinned at
303
+ // inline-end — the footer counterpart of .hk-modal-header-lead. The lead's
304
+ // auto inline-end margin absorbs the slack (same distribution trick the
305
+ // header uses), so the layout mirrors correctly in RTL without a
306
+ // space-between override.
307
+ .hk-modal-footer--lead {
308
+ .hk-modal-footer-lead {
309
+ display: flex;
310
+ align-items: center;
311
+ flex-shrink: 0;
312
+ margin-inline-end: auto;
313
+ }
314
+ }
315
+
301
316
  // ------
302
317
  // Responsive — full-screen on narrow viewports
303
318
  // ------
@@ -695,10 +695,13 @@ export default defineComponent({
695
695
  if (slots.footer) {
696
696
  return <div class="hk-modal-footer">{slots.footer()}</div>;
697
697
  }
698
- if (props.footerActions && props.footerActions.length > 0) {
698
+ const hasLead = Boolean(slots.footerLead);
699
+ const hasActions = Boolean(props.footerActions && props.footerActions.length > 0);
700
+ if (hasLead || hasActions) {
699
701
  return (
700
- <div class="hk-modal-footer">
701
- {props.footerActions.map((action, i) => (
702
+ <div class={["hk-modal-footer", hasLead ? "hk-modal-footer--lead" : ""]}>
703
+ {hasLead && <div class="hk-modal-footer-lead">{slots.footerLead?.()}</div>}
704
+ {props.footerActions?.map((action, i) => (
702
705
  <HButton
703
706
  key={i}
704
707
  variant={action.variant ?? "secondary"}
@@ -7,12 +7,15 @@
7
7
  * assertion: the theme-row lead cell must carry an explicit size that
8
8
  * fits the widest host lead mark, not just the mixin's generic box.
9
9
  *
10
- * The widened cell must stay scoped to the lead slot (2026-09-12 field
11
- * report): shipped first as a bare `.s-theme-item-btn .hk-menu-item-icon`
12
- * rule, it also matched the 自定义 row and every host row reusing
13
- * s-theme-item-btn (chest's mode-extra DPI entry) — and mi.icon
14
- * normalizes those cells' svg to the cell, so their 14px glyphs rendered
15
- * 28px wide.
10
+ * The widened cell must stay scoped (2026-09-12 field report): shipped
11
+ * first as a bare `.s-theme-item-btn .hk-menu-item-icon` rule, it also
12
+ * matched the 自定义 row and every host row reusing s-theme-item-btn —
13
+ * and mi.icon normalizes those cells' svg to the cell, so their 14px
14
+ * glyphs rendered 28px wide. The widening therefore targets named scopes
15
+ * (lead slot, customize row) plus the toggle-owned mode-extra strip, the
16
+ * last one widening the CELL while pinning the glyph back to the
17
+ * standard box so host strip rows (chest's DPI entry) align their labels
18
+ * with the theme rows' names without rescaling their icons.
16
19
  */
17
20
  import { describe, expect, it } from "vitest";
18
21
  import { readFileSync } from "node:fs";
@@ -33,12 +36,12 @@ describe("HkThemeToggle row lead-cell contract", () => {
33
36
  expect(block![0]).toContain("height: 28px");
34
37
  });
35
38
 
36
- it("does not widen icon cells outside the leading slot", () => {
39
+ it("does not widen icon cells with a bare unscoped theme-row rule", () => {
37
40
  const css = read("HkThemeToggle.scss");
38
41
  // A bare `.s-theme-item-btn .hk-menu-item-icon {` rule also hits the
39
42
  // customize row and host mode-extra rows; the widened cell may only
40
- // target the lead slot class.
41
- expect(css).not.toMatch(/\.s-theme-item-btn \.hk-menu-item-icon\s*\{/);
43
+ // target named scopes (lead slot, customize row, mode-extra strip).
44
+ expect(css).not.toMatch(/^\.s-theme-item-btn \.hk-menu-item-icon\s*\{/m);
42
45
  });
43
46
 
44
47
  it("aligns the customize row to the lead column without rescaling its glyph", () => {
@@ -60,4 +63,23 @@ describe("HkThemeToggle row lead-cell contract", () => {
60
63
  expect(svgBlock).not.toBeNull();
61
64
  expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
62
65
  });
66
+
67
+ it("aligns mode-extra host rows to the lead column without rescaling their glyph", () => {
68
+ const css = read("HkThemeToggle.scss");
69
+ // Host strip rows (chest's DPI entry) join the 28px lead column so
70
+ // their label starts at the same x as the theme rows' names
71
+ // (2026-09-12 field report), but the glyph keeps the standard box.
72
+ const block = css.match(
73
+ /\.s-theme-mode-extra \.s-theme-item-btn \.hk-menu-item-icon\s*\{[^}]*\}/,
74
+ );
75
+ expect(block).not.toBeNull();
76
+ expect(block![0]).toContain("width: 28px");
77
+ expect(block![0]).toContain("height: 28px");
78
+
79
+ const svgBlock = css.match(
80
+ /\.s-theme-mode-extra \.s-theme-item-btn \.hk-menu-item-icon > svg\s*\{[^}]*\}/,
81
+ );
82
+ expect(svgBlock).not.toBeNull();
83
+ expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
84
+ });
63
85
  });
@@ -243,6 +243,27 @@
243
243
  height: var(--hk-menu-item-icon-box);
244
244
  }
245
245
 
246
+ /* Host rows in the mode-extra strip (chest's DPI entry row reuses
247
+ * s-theme-item-btn) join the same 28px lead column, so every row's name
248
+ * in the toggle starts at one x — desktop popover and mobile sheet both
249
+ * (2026-09-12 field report: with the strip row at the generic 16px cell
250
+ * its label sat a full cell-width left of the theme rows' names). The
251
+ * strip container is the toggle's own, so the scope needs no host
252
+ * class-name contract. As with the customize row, the cell widens but
253
+ * the GLYPH keeps the standard menu icon size — widening a cell must
254
+ * never re-scale its glyph (the 0.42.2 regression class). */
255
+ .s-theme-mode-extra .s-theme-item-btn .hk-menu-item-icon {
256
+ @include mi.icon;
257
+
258
+ width: 28px;
259
+ height: 28px;
260
+ }
261
+
262
+ .s-theme-mode-extra .s-theme-item-btn .hk-menu-item-icon > svg {
263
+ width: var(--hk-menu-item-icon-box);
264
+ height: var(--hk-menu-item-icon-box);
265
+ }
266
+
246
267
  /* Custom rows keep the name clear of the overlaid delete button. */
247
268
  .s-theme-item-row[data-custom] .s-theme-item-name {
248
269
  margin-inline-end: 2rem;