@celestia-island/hikari 0.35.1 → 0.36.0

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.35.1",
3
+ "version": "0.36.0",
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",
@@ -0,0 +1,259 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import { HkAuthCard } from "./HkAuthCard";
5
+
6
+ /** Injectable ResizeObserver: captures the callback so tests can fire
7
+ * content changes deterministically (happy-dom's own RO never fires —
8
+ * there is no layout engine). Same harness as useSizeMorph.test.ts. */
9
+ class FakeResizeObserver {
10
+ static instances: FakeResizeObserver[] = [];
11
+ callback: () => void;
12
+ observed: Element[] = [];
13
+ disconnected = false;
14
+
15
+ constructor(callback: () => void) {
16
+ this.callback = callback;
17
+ FakeResizeObserver.instances.push(this);
18
+ }
19
+ observe(el: Element): void {
20
+ this.observed.push(el);
21
+ }
22
+ disconnect(): void {
23
+ this.disconnected = true;
24
+ }
25
+ }
26
+
27
+ const originalRO = globalThis.ResizeObserver;
28
+
29
+ /** The prototype that actually defines `offsetHeight` in this DOM
30
+ * implementation, so the stub below is portable across environments. */
31
+ function offsetHeightOwner(): object {
32
+ let proto = Object.getPrototypeOf(document.createElement("div")) as object | null;
33
+ while (proto) {
34
+ if (Object.getOwnPropertyDescriptor(proto, "offsetHeight")) return proto;
35
+ proto = Object.getPrototypeOf(proto);
36
+ }
37
+ return HTMLElement.prototype;
38
+ }
39
+
40
+ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
41
+ offsetHeightOwner(),
42
+ "offsetHeight",
43
+ );
44
+
45
+ /** Natural height every element reports for `offsetHeight` in tests. */
46
+ let naturalHeight = 0;
47
+
48
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
49
+
50
+ function mount(node: ReturnType<typeof h>) {
51
+ const container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ const app = createApp({ render: () => node });
54
+ app.mount(container);
55
+ mounts.push({ app, container });
56
+ return container;
57
+ }
58
+
59
+ function cardOf(c: HTMLElement) {
60
+ return c.querySelector<HTMLElement>(".s-auth-card")!;
61
+ }
62
+
63
+ function wrapperOf(c: HTMLElement) {
64
+ return c.querySelector<HTMLElement>(".s-auth-card-height")!;
65
+ }
66
+
67
+ function fireTransitionEnd(el: HTMLElement, propertyName: string) {
68
+ const event = new Event("transitionend", { bubbles: true });
69
+ Object.defineProperty(event, "propertyName", { value: propertyName });
70
+ el.dispatchEvent(event);
71
+ }
72
+
73
+ beforeEach(() => {
74
+ FakeResizeObserver.instances = [];
75
+ naturalHeight = 140;
76
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
77
+ Object.defineProperty(offsetHeightOwner(), "offsetHeight", {
78
+ configurable: true,
79
+ get: () => naturalHeight,
80
+ });
81
+ });
82
+
83
+ afterEach(() => {
84
+ globalThis.ResizeObserver = originalRO;
85
+ if (originalOffsetHeight) {
86
+ Object.defineProperty(offsetHeightOwner(), "offsetHeight", originalOffsetHeight);
87
+ } else {
88
+ delete (offsetHeightOwner() as { offsetHeight?: number }).offsetHeight;
89
+ }
90
+ vi.restoreAllMocks();
91
+ vi.useRealTimers();
92
+ for (const { app, container } of mounts.splice(0)) {
93
+ app.unmount();
94
+ container.remove();
95
+ }
96
+ });
97
+
98
+ describe("HkAuthCard", () => {
99
+ it("renders title, subtitle and every slot inside the unchanged card shell", () => {
100
+ const c = mount(
101
+ h(HkAuthCard, { title: "Sign in", subtitle: "Welcome back" }, {
102
+ default: () => h("input", { name: "probe-field" }),
103
+ methods: () => h("div", { class: "probe-methods" }, "methods"),
104
+ footer: () => h("div", { class: "probe-footer" }, "footer"),
105
+ }),
106
+ );
107
+ // The measuring wrapper is present and the card is still `.s-auth-card`.
108
+ const wrapper = wrapperOf(c);
109
+ const card = cardOf(c);
110
+ expect(wrapper).toBeTruthy();
111
+ expect(card).toBeTruthy();
112
+ expect(c.querySelector(".s-auth-title")?.textContent).toBe("Sign in");
113
+ expect(c.querySelector(".s-auth-subtitle")?.textContent).toBe("Welcome back");
114
+ expect(card.querySelector(".s-auth-form input[name='probe-field']")).toBeTruthy();
115
+ expect(card.querySelector(".s-auth-methods .probe-methods")).toBeTruthy();
116
+ expect(card.querySelector(".s-auth-footer .probe-footer")).toBeTruthy();
117
+ // The wrapper is transparent to descendant styling: the header, form,
118
+ // methods and footer blocks all remain children of `.s-auth-card`.
119
+ expect(wrapper.querySelector(".s-auth-card")).toBe(card);
120
+ });
121
+
122
+ it("seeds the wrapper height from the card on mount without animating", () => {
123
+ naturalHeight = 140;
124
+ const c = mount(h(HkAuthCard, { title: "T" }));
125
+ const wrapper = wrapperOf(c);
126
+ expect(wrapper.style.height).toBe("140px");
127
+ // Seeding must not animate: the measuring class is absent on first paint.
128
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
129
+ // The observer watches the card element itself, not the wrapper.
130
+ expect(FakeResizeObserver.instances).toHaveLength(1);
131
+ expect(FakeResizeObserver.instances[0]!.observed).toEqual([cardOf(c)]);
132
+ });
133
+
134
+ it("animates growth through the measuring class and releases it on the backstop timer", () => {
135
+ vi.useFakeTimers();
136
+ naturalHeight = 140;
137
+ const c = mount(h(HkAuthCard, { title: "T" }));
138
+ const wrapper = wrapperOf(c);
139
+ expect(wrapper.style.height).toBe("140px");
140
+
141
+ naturalHeight = 240;
142
+ FakeResizeObserver.instances[0]!.callback();
143
+ expect(wrapper.style.height).toBe("240px");
144
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
145
+
146
+ vi.advanceTimersByTime(449);
147
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
148
+ vi.advanceTimersByTime(1);
149
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
150
+ });
151
+
152
+ it("releases the measuring class when the height transition finishes", () => {
153
+ naturalHeight = 100;
154
+ const c = mount(h(HkAuthCard, { title: "T" }));
155
+ const wrapper = wrapperOf(c);
156
+
157
+ naturalHeight = 180;
158
+ FakeResizeObserver.instances[0]!.callback();
159
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
160
+
161
+ // An unrelated property finishing its transition must not release.
162
+ fireTransitionEnd(wrapper, "color");
163
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
164
+
165
+ fireTransitionEnd(wrapper, "height");
166
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
167
+ });
168
+
169
+ it("ignores height transitionsend events bubbling up from descendant content", () => {
170
+ naturalHeight = 100;
171
+ const c = mount(
172
+ h(HkAuthCard, { title: "T" }, { default: () => h("div", { class: "probe-anim" }) }),
173
+ );
174
+ const wrapper = wrapperOf(c);
175
+ const child = c.querySelector<HTMLElement>(".probe-anim")!;
176
+
177
+ naturalHeight = 180;
178
+ FakeResizeObserver.instances[0]!.callback();
179
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
180
+
181
+ // A descendant slot element's own height transition bubbling up must
182
+ // NOT release the clip — it did not open this measuring window.
183
+ fireTransitionEnd(child, "height");
184
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
185
+
186
+ // Only the wrapper's own height transition may end it.
187
+ fireTransitionEnd(wrapper, "height");
188
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
189
+ });
190
+
191
+ it("snaps unclipped under prefers-reduced-motion without arming the backstop", () => {
192
+ vi.useFakeTimers();
193
+ vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as unknown as MediaQueryList);
194
+ naturalHeight = 140;
195
+ const c = mount(h(HkAuthCard, { title: "T" }));
196
+ const wrapper = wrapperOf(c);
197
+
198
+ naturalHeight = 240;
199
+ FakeResizeObserver.instances[0]!.callback();
200
+ // Height still tracks the content…
201
+ expect(wrapper.style.height).toBe("240px");
202
+ // …but with the transition disabled there is nothing to animate, so
203
+ // no measuring window opens: no clip, no pending backstop timer.
204
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
205
+ expect(vi.getTimerCount()).toBe(0);
206
+ });
207
+
208
+ it("ignores observer firings that move the height by less than a pixel", () => {
209
+ naturalHeight = 140;
210
+ const c = mount(h(HkAuthCard, { title: "T" }));
211
+ const wrapper = wrapperOf(c);
212
+
213
+ naturalHeight = 140.6;
214
+ FakeResizeObserver.instances[0]!.callback();
215
+ expect(wrapper.style.height).toBe("140px");
216
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
217
+ });
218
+
219
+ it("with smoothHeight=false renders the wrapper but installs no measuring", () => {
220
+ naturalHeight = 140;
221
+ const c = mount(h(HkAuthCard, { title: "T", smoothHeight: false }));
222
+ const wrapper = wrapperOf(c);
223
+ // The wrapper still exists (stable DOM shape for consumers) but is
224
+ // completely unmanaged: no explicit height and no observer.
225
+ expect(wrapper).toBeTruthy();
226
+ expect(wrapper.style.height).toBe("");
227
+ expect(FakeResizeObserver.instances).toHaveLength(0);
228
+ });
229
+
230
+ it("degrades to an unmanaged wrapper when ResizeObserver is unavailable", () => {
231
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = undefined;
232
+ naturalHeight = 140;
233
+ const c = mount(h(HkAuthCard, { title: "T" }));
234
+ const wrapper = wrapperOf(c);
235
+ expect(wrapper).toBeTruthy();
236
+ expect(wrapper.style.height).toBe("");
237
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
238
+ });
239
+
240
+ it("disconnects the observer and clears the backstop on unmount", () => {
241
+ vi.useFakeTimers();
242
+ naturalHeight = 140;
243
+ const c = mount(h(HkAuthCard, { title: "T" }));
244
+ const ro = FakeResizeObserver.instances[0]!;
245
+
246
+ naturalHeight = 200;
247
+ ro.callback();
248
+ expect(wrapperOf(c).classList.contains("s-auth-card--measuring")).toBe(true);
249
+
250
+ // Pop the entry so the shared afterEach does not unmount it twice.
251
+ const entry = mounts.pop()!;
252
+ entry.app.unmount();
253
+ expect(ro.disconnected).toBe(true);
254
+ // The pending backstop timer was cleared on unmount: nothing is left
255
+ // scheduled (a leaked timer here would also fire releaseClip on a
256
+ // dead element).
257
+ expect(vi.getTimerCount()).toBe(0);
258
+ });
259
+ });
@@ -1,4 +1,10 @@
1
- import { defineComponent, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
+
3
+ /** Grace window that force-releases the `--measuring` clip if the height
4
+ * `transitionend` never arrives (interrupted transition, backgrounded
5
+ * tab, reduced-motion). Slightly longer than the 0.3s CSS height
6
+ * transition so a normal completion is always released by the event. */
7
+ const MEASURE_BACKSTOP_MS = 450;
2
8
 
3
9
  /**
4
10
  * HkAuthCard — the shared shell of every Celestia auth screen (login,
@@ -19,42 +25,162 @@ import { defineComponent, type PropType } from "vue";
19
25
  * per-row centering; content that must sit on one row belongs inside
20
26
  * one child element.
21
27
  * - `logo` swaps the header's logo slot; `default` is the form body.
28
+ *
29
+ * Smooth height contract:
30
+ * The card's content is consumer-driven and changes at runtime — channel
31
+ * tabs swap the OAuth `methods` list (3 buttons ↔ 1), validation banners
32
+ * appear, locale switches reflow labels — and the card's height would
33
+ * otherwise snap between the two layouts. The root card is therefore
34
+ * wrapped in a `.s-auth-card-height` measuring wrapper that owns an
35
+ * explicit `height` and animates toward whatever natural height the card
36
+ * grows or shrinks to:
37
+ *
38
+ * - On mount the wrapper seeds its `height` from the card's current
39
+ * `offsetHeight` while the animating state is impossible, so the first
40
+ * paint never animates.
41
+ * - A `ResizeObserver` on the card element (not the wrapper) fires when
42
+ * the content-driven natural height changes. Changes smaller than 1px
43
+ * are ignored to keep rounding noise from starting a cycle. A real
44
+ * change adds the `s-auth-card--measuring` class — the ONLY place
45
+ * `overflow: hidden` is applied — then rewrites the height, and the CSS
46
+ * `height` transition animates the wrapper between the two sizes.
47
+ * - The clipping class is released by the wrapper's `transitionend`
48
+ * (propertyName === "height") and, if that event never fires, by a
49
+ * 450ms backstop timer. Between animations the card carries no clip,
50
+ * so its shadow, focus rings and popovers are never cut off at rest.
51
+ * - `smoothHeight: false` opts out entirely: the wrapper still renders
52
+ * (the DOM shape stays stable for consumers) but no height style, no
53
+ * observer and no listeners are installed, and height changes snap as
54
+ * before. The prop is read ONCE at mount time — runtime toggles are
55
+ * ignored, making it a static opt-out rather than a reactive switch
56
+ * (remount with a changed `:key` to apply a new value).
57
+ * - Environments without `ResizeObserver` degrade the same way: instant
58
+ * snap, no measurement, no clipping.
59
+ * - `prefers-reduced-motion: reduce` disables the transition in CSS, so
60
+ * the height still tracks the content, just without animation. While
61
+ * it matches, the observer updates the height directly WITHOUT opening
62
+ * a measuring window — no clip and no backstop timer — because a clip
63
+ * held for the backstop would cut off the card's shadow with nothing
64
+ * animating to mask it.
65
+ *
66
+ * The wrapper is transparent to descendant styling: nothing inside
67
+ * `.s-auth-card` changes, and no hikari stylesheet targets the card as a
68
+ * direct child. Only consumer styles that select `.s-auth-card` as a
69
+ * DIRECT child of a specific parent must account for the extra wrapper
70
+ * (e.g. `.us-main > .s-auth-card` becomes
71
+ * `.us-main > .s-auth-card-height > .s-auth-card`).
22
72
  */
23
73
  export const HkAuthCard = defineComponent({
24
74
  name: "HkAuthCard",
25
75
  props: {
26
76
  title: { type: String, required: true },
27
77
  subtitle: { type: String, default: "" },
78
+ smoothHeight: { type: Boolean, default: true },
28
79
  },
29
80
  setup(props, { slots }) {
81
+ const heightEl = ref<HTMLElement | null>(null);
82
+ const cardEl = ref<HTMLElement | null>(null);
83
+
84
+ let observer: ResizeObserver | null = null;
85
+ let backstopTimer: ReturnType<typeof setTimeout> | null = null;
86
+
87
+ /** End the current measuring window: drop the clip and the backstop. */
88
+ const releaseClip = () => {
89
+ if (backstopTimer !== null) {
90
+ clearTimeout(backstopTimer);
91
+ backstopTimer = null;
92
+ }
93
+ heightEl.value?.classList.remove("s-auth-card--measuring");
94
+ };
95
+
96
+ const onTransitionEnd = (event: TransitionEvent) => {
97
+ // Only the wrapper's OWN height transition may release the clip —
98
+ // a descendant slot element's height transition bubbling up must
99
+ // not end a measuring window it did not open.
100
+ if (event.target !== heightEl.value) return;
101
+ if (event.propertyName !== "height") return;
102
+ releaseClip();
103
+ };
104
+
105
+ onMounted(() => {
106
+ if (!props.smoothHeight) return;
107
+ const wrapper = heightEl.value;
108
+ const card = cardEl.value;
109
+ if (!wrapper || !card || typeof ResizeObserver === "undefined") return;
110
+
111
+ // Seed the explicit height BEFORE any animating class exists, so
112
+ // the first paint (wrapper already at the natural size) never
113
+ // animates.
114
+ wrapper.style.height = `${card.offsetHeight}px`;
115
+ wrapper.addEventListener("transitionend", onTransitionEnd);
116
+
117
+ observer = new ResizeObserver(() => {
118
+ const next = card.offsetHeight;
119
+ const current = parseFloat(wrapper.style.height) || 0;
120
+ // Ignore sub-pixel churn — rounding noise must not start a
121
+ // clip-and-animate cycle.
122
+ if (Math.abs(next - current) < 1) return;
123
+
124
+ // Reduced motion: the CSS transition is `none`, so transitionend
125
+ // will never fire and the clip would just sit there for the
126
+ // backstop window, cutting off the shadow with nothing to mask.
127
+ // Snap unclipped instead — no class, no timer.
128
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
129
+ wrapper.style.height = `${next}px`;
130
+ return;
131
+ }
132
+
133
+ // Clip ONLY while a height transition is actually running; the
134
+ // class is released by transitionend or the backstop timer.
135
+ wrapper.classList.add("s-auth-card--measuring");
136
+ wrapper.style.height = `${next}px`;
137
+
138
+ if (backstopTimer !== null) clearTimeout(backstopTimer);
139
+ backstopTimer = setTimeout(releaseClip, MEASURE_BACKSTOP_MS);
140
+ });
141
+ observer.observe(card);
142
+ });
143
+
144
+ onBeforeUnmount(() => {
145
+ observer?.disconnect();
146
+ observer = null;
147
+ heightEl.value?.removeEventListener("transitionend", onTransitionEnd);
148
+ if (backstopTimer !== null) {
149
+ clearTimeout(backstopTimer);
150
+ backstopTimer = null;
151
+ }
152
+ });
153
+
30
154
  return () => (
31
- <div class="s-auth-card">
32
- {slots.logo && (
33
- <div class="s-auth-header">
34
- {slots.logo()}
35
- <h1 class="s-auth-title">{props.title}</h1>
36
- {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
37
- </div>
38
- )}
39
- {!slots.logo && (
40
- <div class="s-auth-header">
41
- <h1 class="s-auth-title">{props.title}</h1>
42
- {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
155
+ <div class="s-auth-card-height" ref={heightEl}>
156
+ <div class="s-auth-card" ref={cardEl}>
157
+ {slots.logo && (
158
+ <div class="s-auth-header">
159
+ {slots.logo()}
160
+ <h1 class="s-auth-title">{props.title}</h1>
161
+ {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
162
+ </div>
163
+ )}
164
+ {!slots.logo && (
165
+ <div class="s-auth-header">
166
+ <h1 class="s-auth-title">{props.title}</h1>
167
+ {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
168
+ </div>
169
+ )}
170
+ <div class="s-auth-form">
171
+ {slots.default?.()}
43
172
  </div>
44
- )}
45
- <div class="s-auth-form">
46
- {slots.default?.()}
173
+ {slots.methods && (
174
+ <div class="s-auth-methods">
175
+ {slots.methods()}
176
+ </div>
177
+ )}
178
+ {slots.footer && (
179
+ <div class="s-auth-footer">
180
+ {slots.footer()}
181
+ </div>
182
+ )}
47
183
  </div>
48
- {slots.methods && (
49
- <div class="s-auth-methods">
50
- {slots.methods()}
51
- </div>
52
- )}
53
- {slots.footer && (
54
- <div class="s-auth-footer">
55
- {slots.footer()}
56
- </div>
57
- )}
58
184
  </div>
59
185
  );
60
186
  },
@@ -527,6 +527,30 @@
527
527
  to { opacity: 1; transform: translateY(0) scale(1); }
528
528
  }
529
529
 
530
+ /* Measuring wrapper (HkAuthCard): owns an explicit height so content-
531
+ driven height changes (channel tabs swapping the methods list, error
532
+ banners, …) animate instead of snapping. The transition lives here,
533
+ always on; the `--measuring` clip below is applied by HkAuthCard only
534
+ for the duration of a height transition, so an idle card never clips
535
+ its shadow, focus rings or popovers. */
536
+ .s-auth-card-height {
537
+ /* The wrapper is sized from the card's offsetHeight (border-box); an
538
+ explicit box-sizing keeps that mapping honest on hosts without a
539
+ global border-box reset. */
540
+ box-sizing: border-box;
541
+ transition: height 0.3s var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
542
+
543
+ &.s-auth-card--measuring {
544
+ overflow: hidden;
545
+ }
546
+ }
547
+
548
+ @media (prefers-reduced-motion: reduce) {
549
+ .s-auth-card-height {
550
+ transition: none;
551
+ }
552
+ }
553
+
530
554
  .s-auth-card input {
531
555
  transition: color calc(infinity * 1s) step-end, background-color calc(infinity * 1s) step-end;
532
556
  appearance: none;