@celestia-island/hikari 0.31.0 → 0.31.1

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.31.0",
3
+ "version": "0.31.1",
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",
@@ -23,13 +23,23 @@
23
23
  background: var(--hi-color-border, #ddd);
24
24
  }
25
25
 
26
+ /* The component's own wrapper is `display: contents` (measurement anchor +
27
+ CSS-var inheritance) — layout-wise the buttons remain direct children of
28
+ HkAuthCard's `.s-auth-methods` slot container. */
29
+ .s-auth-methods-list {
30
+ display: contents;
31
+ }
32
+
26
33
  /* Fixed icon + label columns: the two spans keep the same width on every
27
34
  row, so icons and labels align across buttons while each button stays a
28
35
  centered hk-btn-block. HkButton has a fragment root, so inbound classes
29
36
  don't fall through — scope everything off the `.s-auth-methods`
30
- container instead of a button-level class. Apps with longer labels
31
- widen the label column via `--auth-methods-label-width`. */
32
- .s-auth-methods > .hk-btn .s-auth-methods-icon {
37
+ container (descendant match: the component wrapper is display:contents).
38
+ The label column width is MEASURED by the component (widest label text,
39
+ published as `--auth-methods-label-width` on its wrapper, inherited into
40
+ the rows); the 8em fallback only covers the pre-measure frame, and apps
41
+ may still override the var explicitly. */
42
+ .s-auth-methods .hk-btn .s-auth-methods-icon {
33
43
  width: 20px;
34
44
  flex: none;
35
45
  display: inline-flex;
@@ -37,7 +47,7 @@
37
47
  justify-content: center;
38
48
  }
39
49
 
40
- .s-auth-methods > .hk-btn .s-auth-methods-label {
50
+ .s-auth-methods .hk-btn .s-auth-methods-label {
41
51
  width: var(--auth-methods-label-width, 8em);
42
52
  flex: none;
43
53
  text-align: start;
@@ -29,13 +29,14 @@ const methods = [
29
29
  describe("HkAuthMethodList", () => {
30
30
  it("renders one fixed-column button per method and an optional divider", () => {
31
31
  // The card's `methods` slot owns the `.s-auth-methods` container —
32
- // reproduce that context here (the component itself is a fragment).
32
+ // reproduce that context here (the buttons are layout children of it
33
+ // through the component's display:contents wrapper).
33
34
  const c = mount(
34
35
  h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
35
36
  );
36
37
  const divider = c.querySelector(".s-auth-methods-divider");
37
38
  expect(divider?.textContent).toContain("其他方式登录");
38
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods > .hk-btn");
39
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-btn");
39
40
  expect(buttons.length).toBe(2);
40
41
  const labels = [...buttons].map(
41
42
  (b) => b.querySelector<HTMLElement>(".s-auth-methods-label")?.textContent,
@@ -57,9 +58,41 @@ describe("HkAuthMethodList", () => {
57
58
  },
58
59
  })),
59
60
  );
60
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods > .hk-btn");
61
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-btn");
61
62
  buttons[1]!.click();
62
63
  await nextTick();
63
64
  expect(picked).toBe("linuxdo");
64
65
  });
66
+
67
+ it("publishes the widest label text as the label-column custom property", async () => {
68
+ const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
69
+ await nextTick();
70
+ const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
71
+ expect(wrapper).toBeTruthy();
72
+ // Layout engines in test DOM report zero text metrics; stub scrollWidth
73
+ // on the label spans to a deterministic widest-wins pair (LinuxDo wider).
74
+ const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
75
+ expect(spans.length).toBe(2);
76
+ Object.defineProperty(spans[0]!, "scrollWidth", { configurable: true, value: 61 });
77
+ Object.defineProperty(spans[1]!, "scrollWidth", { configurable: true, value: 59 });
78
+ // Trigger the measurement through a fresh label change (watch path).
79
+ await nextTick();
80
+ const { app } = mounts[mounts.length - 1]!;
81
+ // Re-run measurement via a re-mount-less route: the component exposes
82
+ // measure() — reach it through the rendered component instance.
83
+ const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
84
+ .__vueParentComponent?.exposed;
85
+ expect(typeof instance?.measure).toBe("function");
86
+ (instance!.measure as () => void)();
87
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("61px");
88
+ void app;
89
+ });
90
+
91
+ it("leaves the column fallback intact when no text metrics are available", async () => {
92
+ const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
93
+ await nextTick();
94
+ const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
95
+ // Zero scrollWidth (no layout) must not publish a 0px column.
96
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("");
97
+ });
65
98
  });
@@ -1,4 +1,4 @@
1
- import { defineComponent, type PropType } from "vue";
1
+ import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
2
  import HkButton from "./HkButton";
3
3
  import "./HkAuthMethodList.scss";
4
4
 
@@ -7,15 +7,20 @@ import "./HkAuthMethodList.scss";
7
7
  * auth cards (the ERP login's provider-button grammar, componentized).
8
8
  *
9
9
  * Every button renders [icon | label] with a FIXED icon column and a FIXED
10
- * label column (CSS var `--auth-methods-label-width`, default 8em), so the
11
- * icons and the label text start at the same x on every row regardless of
12
- * label length; because every content block is then the same width, the
13
- * row can stay centered like any HkButton without the columns drifting.
10
+ * label column, so the icons and the label text start at the same x on
11
+ * every row regardless of label length; because every content block is
12
+ * then the same width, the row can stay centered like any HkButton
13
+ * without the columns drifting.
14
14
  *
15
- * Render it through HkAuthCard's `methods` slot: the card owns the
16
- * full-width block layout and the vertical rhythm, and the footer's
17
- * checkbox rows keep their centered-group behavior instead of being
18
- * dragged full width with the buttons.
15
+ * The label column is auto-sized: after mount (and whenever the label set
16
+ * or the document fonts change) the component measures the widest label
17
+ * text and publishes it as `--auth-methods-label-width` on its wrapper,
18
+ * so the aligned text region hugs the longest label instead of a blind
19
+ * hardcoded width. Consumers can still override the var explicitly.
20
+ *
21
+ * The wrapper is `display: contents`: the component keeps a DOM node for
22
+ * measurement and CSS-var inheritance while the buttons remain direct
23
+ * layout children of HkAuthCard's `.s-auth-methods` slot container.
19
24
  */
20
25
  export default defineComponent({
21
26
  name: "HkAuthMethodList",
@@ -45,9 +50,45 @@ export default defineComponent({
45
50
  /** A method button was clicked. */
46
51
  select: (_key: string) => true,
47
52
  },
48
- setup(props, { emit }) {
53
+ setup(props, { emit, expose }) {
54
+ const listRef = ref<HTMLDivElement | null>(null);
55
+
56
+ /** Measure the widest label text and publish it as the label-column
57
+ * width custom property. `scrollWidth` reports the text width even
58
+ * while the fixed column clips it, so this is correct both before
59
+ * the variable is first set and after any label change. */
60
+ function measure() {
61
+ const root = listRef.value;
62
+ if (!root) return;
63
+ let widest = 0;
64
+ for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
65
+ widest = Math.max(widest, label.scrollWidth);
66
+ }
67
+ if (widest > 0) root.style.setProperty("--auth-methods-label-width", `${widest}px`);
68
+ }
69
+
70
+ onMounted(() => {
71
+ void nextTick(measure);
72
+ // Webfonts change text metrics after first paint; re-measure when
73
+ // they finish loading (no-op in environments without document.fonts).
74
+ if (typeof document !== "undefined" && document.fonts?.ready) {
75
+ void document.fonts.ready.then(measure).catch(() => {});
76
+ }
77
+ });
78
+ onBeforeUnmount(() => {
79
+ listRef.value?.style.removeProperty("--auth-methods-label-width");
80
+ });
81
+ watch(
82
+ () => props.methods.map((method) => method.label).join("\u0000"),
83
+ () => {
84
+ void nextTick(measure);
85
+ },
86
+ );
87
+
88
+ expose({ measure });
89
+
49
90
  return () => (
50
- <>
91
+ <div class="s-auth-methods-list" ref={listRef}>
51
92
  {props.divider && (
52
93
  <div class="s-auth-methods-divider">
53
94
  <span>{props.divider}</span>
@@ -66,7 +107,7 @@ export default defineComponent({
66
107
  <span class="s-auth-methods-label">{method.label}</span>
67
108
  </HkButton>
68
109
  ))}
69
- </>
110
+ </div>
70
111
  );
71
112
  },
72
113
  });
@@ -340,9 +340,11 @@ export default defineComponent({
340
340
  zoomPercent={zoomPercent.value}
341
341
  canZoomIn={canZoomIn.value}
342
342
  canZoomOut={canZoomOut.value}
343
+ zoomStepPercent={5}
344
+ minZoomPercent={Math.max(1, Math.round(fit.value * 100))}
345
+ maxZoomPercent={Math.round(MAX_ZOOM * 100)}
343
346
  showReset
344
- onZoomIn={() => setZoom(zoom.value * 1.2)}
345
- onZoomOut={() => setZoom(zoom.value / 1.2)}
347
+ onZoomTo={(percent: number) => setZoom(percent / 100)}
346
348
  onReset={resetView}
347
349
  onPanDelta={onPanDelta}
348
350
  />
@@ -31,9 +31,9 @@ export interface MinimapRect {
31
31
  * `transform-origin: 0 0` — the same model HImageViewer uses.
32
32
  *
33
33
  * The zoom bar is governed by default: ±5% fixed steps clamped to the
34
- * 50–200% range, with each press emitting `zoomTo` (clamped target
35
- * percent) alongside the legacy `zoomIn`/`zoomOut`. Consumers with a
36
- * wider native camera range override `minZoomPercent`/`maxZoomPercent`.
34
+ * 50–200% range, each press emitting `zoomTo` with the clamped target
35
+ * percent. Consumers with a wider native camera range override
36
+ * `minZoomPercent`/`maxZoomPercent`.
37
37
  */
38
38
  export default defineComponent({
39
39
  name: "HkMinimap",
@@ -66,17 +66,13 @@ export default defineComponent({
66
66
  * when a reset handler was wired up). */
67
67
  showReset: { type: Boolean, default: false },
68
68
  /** Optional prop-callback surface (alternative to the emits). */
69
- onZoomIn: { type: Function as PropType<() => void>, default: undefined },
70
- onZoomOut: { type: Function as PropType<() => void>, default: undefined },
71
69
  onZoomTo: { type: Function as PropType<(percent: number) => void>, default: undefined },
72
70
  onReset: { type: Function as PropType<() => void>, default: undefined },
73
71
  onPanDelta: { type: Function as PropType<(dx: number, dy: number) => void>, default: undefined },
74
72
  },
75
73
  emits: {
76
- zoomIn: () => true,
77
- zoomOut: () => true,
78
74
  /** Fixed-step zoom request carrying the clamped target percent —
79
- * consumers that can animate their camera should prefer this. */
75
+ * consumers animate their camera toward this target. */
80
76
  zoomTo: (_percent: number) => true,
81
77
  reset: () => true,
82
78
  panDelta: (_dx: number, _dy: number) => true,
@@ -132,14 +128,12 @@ export default defineComponent({
132
128
  props.canZoomOut && props.zoomPercent > props.minZoomPercent + 1e-9);
133
129
 
134
130
  /** Step the zoom bar by the fixed increment, clamped to [min, max];
135
- * emits the legacy zoomIn/zoomOut plus a `zoomTo` carrying the exact
136
- * clamped target percent. No-op (and stays disabled) at the bounds. */
131
+ * emits `zoomTo` with the exact clamped target percent. No-op (and
132
+ * stays disabled) at the bounds. */
137
133
  function stepZoom(dir: number) {
138
134
  const raw = props.zoomPercent + dir * props.zoomStepPercent;
139
135
  const next = Math.min(props.maxZoomPercent, Math.max(props.minZoomPercent, raw));
140
136
  if (Math.abs(next - props.zoomPercent) < 1e-9) return;
141
- if (dir > 0) emit("zoomIn");
142
- else emit("zoomOut");
143
137
  emit("zoomTo", next);
144
138
  }
145
139