@celestia-island/hikari 0.40.31 → 0.40.32

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.40.31",
3
+ "version": "0.40.32",
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,151 @@
1
+ /**
2
+ * Source + behavior contract for HkNumberInput affixes (2026-09-08
3
+ * user report via shittim-chest: a "seconds" unit placed as an EXTERNAL
4
+ * sibling span next to a labeled HkNumberInput centered against the
5
+ * root (label row + input), so it sat ABOVE the input box's center
6
+ * axis). The affixes must ride INSIDE the field, and the component now
7
+ * also accepts plain `prefix`/`suffix` string props so consumers never
8
+ * need the external-span pattern again.
9
+ *
10
+ * Pinned guarantees:
11
+ * 1. The affix spans are flex items of the inner row with
12
+ * align-items: center — the unit text shares the input box's
13
+ * vertical axis, regardless of the label row above.
14
+ * 2. The inner row order is prefix → input → suffix → steppers, so an
15
+ * affix always sits between the typed value and the stepper
16
+ * column (never outside the bordered field).
17
+ * 3. Slots take precedence over the string props; absent both, no
18
+ * affix span renders (no stray padding).
19
+ */
20
+ import { afterEach, describe, expect, it } from "vitest";
21
+ import { readFileSync } from "node:fs";
22
+ import { createApp, h, type Component } from "vue";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import HkNumberInput from "./HkNumberInput";
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const scss = readFileSync(join(here, "HkNumberInput.scss"), "utf-8");
30
+
31
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
32
+
33
+ function mountNumberInput(
34
+ opts: { slots?: Record<string, Component>; props?: Record<string, unknown> } = {},
35
+ ) {
36
+ const container = document.createElement("div");
37
+ document.body.appendChild(container);
38
+ const app = createApp({
39
+ render: () =>
40
+ h(HkNumberInput, { modelValue: 3, ...opts.props }, opts.slots ?? {}),
41
+ });
42
+ app.mount(container);
43
+ mounts.push({ app, container });
44
+ return { container };
45
+ }
46
+
47
+ function query(container: HTMLElement, selector: string): Element | null {
48
+ return container.querySelector(selector);
49
+ }
50
+
51
+ afterEach(() => {
52
+ for (const { app, container } of mounts.splice(0)) {
53
+ app.unmount();
54
+ container.remove();
55
+ }
56
+ document.body.innerHTML = "";
57
+ });
58
+
59
+ describe("HkNumberInput affix contract", () => {
60
+ it("centers the affix spans on the input box axis (SCSS contract)", () => {
61
+ const rule =
62
+ scss.match(/\.hk-number-input-prefix,\s*\.hk-number-input-suffix\s*{[^}]*}/)?.[0] ?? "";
63
+ expect(rule, "affix rule must exist").toContain("align-items: center");
64
+ expect(rule).toContain("flex-shrink: 0");
65
+ });
66
+
67
+ it("stretches the stepper column so height:50% steps and the divider span the box", () => {
68
+ const rule = scss.match(/\.hk-number-input-steppers\s*{[^}]*}/)?.[0] ?? "";
69
+ expect(rule, "steppers rule must exist").toContain("align-self: stretch");
70
+ const steps = scss.match(/\.hk-number-input-step\s*{[^}]*}/)?.[0] ?? "";
71
+ expect(steps).toContain("height: 50%");
72
+ });
73
+
74
+ it("self-declares border-box so per-size heights survive hosts without a reset", () => {
75
+ const field = scss.match(/\.hk-number-input-field\s*{[^}]*}/)?.[0] ?? "";
76
+ expect(field, "field rule must exist").toContain("box-sizing: border-box");
77
+ });
78
+
79
+ it("keeps the affix font size equal to the field font size in every size variant", () => {
80
+ for (const size of ["sm", "md", "lg"] as const) {
81
+ const field = scss.match(
82
+ new RegExp(`\\.hk-number-input-${size} \\.hk-number-input-field\\s*{[^}]*}`),
83
+ )?.[0];
84
+ expect(field, `${size} field rule must exist`).toBeTruthy();
85
+ const fieldSize = field?.match(/font-size:\s*([^;]+);/)?.[1]?.trim();
86
+ const affix = scss.match(
87
+ new RegExp(
88
+ `\\.hk-number-input-${size} \\.hk-number-input-(?:prefix|suffix),[\\s\\S]*?{[^}]*}`,
89
+ ),
90
+ )?.[0];
91
+ expect(affix, `${size} affix rule must exist`).toBeTruthy();
92
+ expect(affix?.match(/font-size:\s*([^;]+);/)?.[1]?.trim()).toBe(fieldSize);
93
+ }
94
+ });
95
+
96
+ it("renders the suffix string prop inside the field, before the steppers", () => {
97
+ const { container } = mountNumberInput({ props: { suffix: "秒" } });
98
+ const suffix = query(container, ".hk-number-input-suffix");
99
+ expect(suffix, "suffix span must render").not.toBeNull();
100
+ expect(suffix?.textContent).toBe("秒");
101
+
102
+ const inner = query(container, ".hk-number-input-inner");
103
+ const children = Array.from(inner?.children ?? []).map((el) => el.className);
104
+ expect(children).toEqual([
105
+ "hk-number-input-field",
106
+ "hk-number-input-suffix",
107
+ "hk-number-input-steppers",
108
+ ]);
109
+ });
110
+
111
+ it("renders the prefix string prop inside the field, before the input", () => {
112
+ const { container } = mountNumberInput({ props: { prefix: "PT" } });
113
+ const prefix = query(container, ".hk-number-input-prefix");
114
+ expect(prefix, "prefix span must render").not.toBeNull();
115
+ expect(prefix?.textContent).toBe("PT");
116
+ const children = Array.from(
117
+ query(container, ".hk-number-input-inner")?.children ?? [],
118
+ ).map((el) => el.className);
119
+ expect(children[0]).toBe("hk-number-input-prefix");
120
+ });
121
+
122
+ it("prefers the named slot over the string prop", () => {
123
+ const { container } = mountNumberInput({
124
+ props: { suffix: "prop-text" },
125
+ slots: { suffix: () => h("span", { class: "slot-marker" }, "slot-text") },
126
+ });
127
+ const suffix = query(container, ".hk-number-input-suffix");
128
+ expect(suffix?.textContent).toBe("slot-text");
129
+ expect(suffix?.querySelector(".slot-marker")).not.toBeNull();
130
+ expect(suffix?.textContent).not.toContain("prop-text");
131
+ });
132
+
133
+ it("treats an empty-string affix prop as no affix", () => {
134
+ const { container } = mountNumberInput({ props: { prefix: "", suffix: "" } });
135
+ expect(query(container, ".hk-number-input-prefix")).toBeNull();
136
+ expect(query(container, ".hk-number-input-suffix")).toBeNull();
137
+ });
138
+
139
+ it("renders no affix spans without props or slots", () => {
140
+ const { container } = mountNumberInput({});
141
+ expect(query(container, ".hk-number-input-prefix")).toBeNull();
142
+ expect(query(container, ".hk-number-input-suffix")).toBeNull();
143
+ });
144
+
145
+ it("still renders the field caption label above the box", () => {
146
+ const { container } = mountNumberInput({ props: { label: "轮播间隔" } });
147
+ const label = query(container, ".hk-input-label");
148
+ expect(label, "label must render").not.toBeNull();
149
+ expect(label?.textContent).toBe("轮播间隔");
150
+ });
151
+ });
@@ -45,6 +45,9 @@
45
45
  .hk-number-input-field {
46
46
  flex: 1;
47
47
  min-width: 0;
48
+ /* Self-declared so the per-size height guarantee never depends on a
49
+ host app's global reset (HkInput.scss precedent). */
50
+ box-sizing: border-box;
48
51
  background: transparent;
49
52
  border: none;
50
53
  outline: none;
@@ -74,7 +77,9 @@
74
77
  align-items: center;
75
78
  flex-shrink: 0;
76
79
  color: var(--hi-color-muted);
77
- font-size: 0.875rem;
80
+ /* Font size tracks the field per size variant (see the size blocks
81
+ below) so the unit text never dwarfs or shrinks next to the typed
82
+ value. */
78
83
  }
79
84
 
80
85
  .hk-number-input-prefix {
@@ -89,6 +94,10 @@
89
94
  display: flex;
90
95
  flex-direction: column;
91
96
  flex-shrink: 0;
97
+ /* Stretch to the inner box so the step buttons' height:50% resolves
98
+ against the real box height and the divider spans it fully —
99
+ content-sized centering left a floating ~28px stack in a 36px box. */
100
+ align-self: stretch;
92
101
  border-left: 1px solid var(--hi-color-border);
93
102
  }
94
103
 
@@ -131,6 +140,11 @@
131
140
  height: 1.75rem;
132
141
  }
133
142
 
143
+ .hk-number-input-sm .hk-number-input-prefix,
144
+ .hk-number-input-sm .hk-number-input-suffix {
145
+ font-size: 0.75rem;
146
+ }
147
+
134
148
  .hk-number-input-sm .hk-number-input-step {
135
149
  width: 1.25rem;
136
150
  }
@@ -146,6 +160,11 @@
146
160
  height: 2.25rem;
147
161
  }
148
162
 
163
+ .hk-number-input-md .hk-number-input-prefix,
164
+ .hk-number-input-md .hk-number-input-suffix {
165
+ font-size: 0.875rem;
166
+ }
167
+
149
168
  .hk-number-input-md .hk-number-input-step {
150
169
  width: 1.5rem;
151
170
  }
@@ -156,6 +175,11 @@
156
175
  height: 2.75rem;
157
176
  }
158
177
 
178
+ .hk-number-input-lg .hk-number-input-prefix,
179
+ .hk-number-input-lg .hk-number-input-suffix {
180
+ font-size: 1rem;
181
+ }
182
+
159
183
  .hk-number-input-lg .hk-number-input-step {
160
184
  width: 1.75rem;
161
185
  }
@@ -12,6 +12,14 @@ export default defineComponent({
12
12
  disabled: { type: Boolean, default: false },
13
13
  placeholder: { type: String, default: undefined },
14
14
  label: { type: String, default: undefined },
15
+ /* Affix text rendered INSIDE the field (before/after the input,
16
+ flex-centered by the inner row). Slots of the same name take
17
+ precedence; empty/undefined means no affix. Consumers should use
18
+ these instead of placing unit text in a sibling element next to
19
+ the component — the root stacks label over input, so a sibling
20
+ centered against the root targets the wrong axis midpoint. */
21
+ prefix: { type: String, default: undefined },
22
+ suffix: { type: String, default: undefined },
15
23
  size: {
16
24
  type: String as PropType<"sm" | "md" | "lg">,
17
25
  default: "md",
@@ -87,8 +95,10 @@ export default defineComponent({
87
95
  <div class={wrapperClass.value}>
88
96
  {props.label && <label class="hk-input-label">{props.label}</label>}
89
97
  <div class="hk-number-input-inner">
90
- {slots.prefix ? (
91
- <span class="hk-number-input-prefix">{slots.prefix()}</span>
98
+ {(slots.prefix || props.prefix) ? (
99
+ <span class="hk-number-input-prefix">
100
+ {slots.prefix ? slots.prefix() : props.prefix}
101
+ </span>
92
102
  ) : null}
93
103
  <input
94
104
  ref={inputRef}
@@ -104,8 +114,10 @@ export default defineComponent({
104
114
  emitValue(Number((e.target as HTMLInputElement).value))
105
115
  }
106
116
  />
107
- {slots.suffix ? (
108
- <span class="hk-number-input-suffix">{slots.suffix()}</span>
117
+ {(slots.suffix || props.suffix) ? (
118
+ <span class="hk-number-input-suffix">
119
+ {slots.suffix ? slots.suffix() : props.suffix}
120
+ </span>
109
121
  ) : null}
110
122
  <div class="hk-number-input-steppers">
111
123
  <button