@celestia-island/hikari 0.45.1 → 0.45.2

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.2",
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>
@@ -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;