@celestia-island/hikari 0.55.237 → 0.55.239

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Behavior + source contract for HkInput's number variant (2026-10-30
3
+ * user request via the wowsp overlay settings): a number field used to
4
+ * carry the browser's NATIVE spin buttons — platform-drawn, unthemeable,
5
+ * the tiny up/down stack WebKit paints inside the field's right edge. The
6
+ * box now hides them (SCSS) and hosts the shared lucide stepper column
7
+ * (HkNumberSteppers) instead: pointer stepping runs through the component,
8
+ * keyboard stepping stays native (the field keeps type="number").
9
+ *
10
+ * Pinned guarantees:
11
+ * 1. type="number" AND variant="number" render the steppers + the box
12
+ * markers (.hk-input-box-number, data-number-steppers); text and
13
+ * textarea fields render none.
14
+ * 2. The native spin buttons are killed in SCSS under .hk-input-box-number
15
+ * (-moz-appearance: textfield + the webkit pseudo-elements).
16
+ * 3. Pointer steps parse the STRING model (Number.parseFloat), honor the
17
+ * min/max/step ATTRS (the same numbers the DOM input receives through
18
+ * the spread), and emit the string form without float dust.
19
+ * 4. An unparsable draft restarts from the nearest meaningful bound.
20
+ * 5. The steppers' width folds into --hk-input-affix-end-w (suffix +
21
+ * steppers SUM) so centered text and the marquee clear them.
22
+ * 6. disabled / readonly grey the column out.
23
+ */
24
+ import { afterEach, describe, expect, it } from "vitest";
25
+ import { readFileSync } from "node:fs";
26
+ import { createApp, h, nextTick, ref, type Component } from "vue";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ import HkInput from "./HkInput";
31
+
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const scss = readFileSync(join(here, "HkInput.scss"), "utf-8");
34
+
35
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
36
+
37
+ interface NumberHarness {
38
+ container: HTMLElement;
39
+ emitted: string[];
40
+ setModel: (v: string) => void;
41
+ }
42
+
43
+ function mountNumber(
44
+ opts: { props?: Record<string, unknown>; slots?: Record<string, Component>; initial?: string } = {},
45
+ ): NumberHarness {
46
+ const model = ref(opts.initial ?? "60");
47
+ const emitted: string[] = [];
48
+ const container = document.createElement("div");
49
+ document.body.appendChild(container);
50
+ const app = createApp({
51
+ render: () =>
52
+ h(
53
+ HkInput,
54
+ {
55
+ type: "number",
56
+ placeholder: "Hang",
57
+ modelValue: model.value,
58
+ "onUpdate:modelValue": (v: string) => {
59
+ model.value = v;
60
+ emitted.push(v);
61
+ },
62
+ ...opts.props,
63
+ },
64
+ opts.slots ?? {},
65
+ ),
66
+ });
67
+ app.mount(container);
68
+ mounts.push({ app, container });
69
+ return { container, emitted, setModel: (v: string) => { model.value = v; } };
70
+ }
71
+
72
+ function query<T extends HTMLElement = HTMLElement>(container: HTMLElement, selector: string): T | null {
73
+ return container.querySelector<T>(selector);
74
+ }
75
+
76
+ function click(container: HTMLElement, dir: "up" | "down"): void {
77
+ const el = query(container, `.hk-number-input-step-${dir}`);
78
+ expect(el, `${dir} stepper must render`).not.toBeNull();
79
+ el!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
80
+ }
81
+
82
+ afterEach(() => {
83
+ for (const { app, container } of mounts.splice(0)) {
84
+ app.unmount();
85
+ container.remove();
86
+ }
87
+ document.body.innerHTML = "";
88
+ });
89
+
90
+ describe("HkInput number variant — stepper column", () => {
91
+ it("renders the steppers for type=number and variant=number, none for other types", async () => {
92
+ const typed = mountNumber();
93
+ await nextTick();
94
+ expect(query(typed.container, ".hk-number-input-steppers")).not.toBeNull();
95
+ const box = query(typed.container, ".hk-input-box")!;
96
+ expect(box.classList.contains("hk-input-box-number")).toBe(true);
97
+ expect(box.getAttribute("data-number-steppers")).toBe("true");
98
+ // The field stays native — keyboard stepping and validation untouched.
99
+ expect(query<HTMLInputElement>(typed.container, ".hk-input-element")!.type).toBe("number");
100
+
101
+ const variant = mountNumber({ props: { type: "text", variant: "number" }, initial: "3" });
102
+ await nextTick();
103
+ expect(query(variant.container, ".hk-number-input-steppers")).not.toBeNull();
104
+
105
+ const text = mountNumber({ props: { type: "text" }, initial: "abc" });
106
+ await nextTick();
107
+ expect(query(text.container, ".hk-number-input-steppers")).toBeNull();
108
+ expect(query(text.container, ".hk-input-box-number")).toBeNull();
109
+
110
+ const ta = mountNumber({ props: { type: "textarea" } });
111
+ await nextTick();
112
+ expect(query(ta.container, ".hk-number-input-steppers")).toBeNull();
113
+ });
114
+
115
+ it("steps the parsed string model and clears float dust", async () => {
116
+ const harness = mountNumber({ initial: "60" });
117
+ await nextTick();
118
+ click(harness.container, "up");
119
+ await nextTick(); // props (the model) settle before the next parse
120
+ click(harness.container, "down");
121
+ expect(harness.emitted).toEqual(["61", "60"]);
122
+
123
+ const dust = mountNumber({ initial: "0.2", props: { step: "0.1" } });
124
+ await nextTick();
125
+ click(dust.container, "up");
126
+ expect(dust.emitted).toEqual(["0.3"]);
127
+ });
128
+
129
+ it("honors the min/max/step attrs the DOM input also receives", async () => {
130
+ const harness = mountNumber({ initial: "60", props: { min: "60", max: "240", step: "10" } });
131
+ await nextTick();
132
+ const up = query<HTMLButtonElement>(harness.container, ".hk-number-input-step-up")!;
133
+ const down = query<HTMLButtonElement>(harness.container, ".hk-number-input-step-down")!;
134
+ expect(down.disabled, "at the min bound the down stepper greys out").toBe(true);
135
+ expect(up.disabled).toBe(false);
136
+
137
+ click(harness.container, "down");
138
+ expect(harness.emitted, "a dead stepper must not emit").toEqual([]);
139
+
140
+ click(harness.container, "up");
141
+ expect(harness.emitted).toEqual(["70"]);
142
+
143
+ harness.setModel("240");
144
+ await nextTick();
145
+ expect(up.disabled, "at the max bound the up stepper greys out").toBe(true);
146
+ expect(down.disabled).toBe(false);
147
+ });
148
+
149
+ it("restarts an unparsable draft from the nearest meaningful bound", async () => {
150
+ const harness = mountNumber({ initial: "", props: { min: "2", max: "5" } });
151
+ await nextTick();
152
+ click(harness.container, "up");
153
+ expect(harness.emitted).toEqual(["2"]);
154
+ harness.setModel("garbage");
155
+ await nextTick();
156
+ click(harness.container, "down");
157
+ expect(harness.emitted).toEqual(["2", "5"]);
158
+ });
159
+
160
+ it("greys the column out while disabled or readonly", async () => {
161
+ const disabled = mountNumber({ props: { disabled: true } });
162
+ await nextTick();
163
+ expect(query<HTMLButtonElement>(disabled.container, ".hk-number-input-step-up")!.disabled).toBe(true);
164
+ expect(query<HTMLButtonElement>(disabled.container, ".hk-number-input-step-down")!.disabled).toBe(true);
165
+
166
+ const readonly = mountNumber({ props: { readonly: true } });
167
+ await nextTick();
168
+ expect(query<HTMLButtonElement>(readonly.container, ".hk-number-input-step-up")!.disabled).toBe(true);
169
+ });
170
+
171
+ it("folds the stepper width into the end-side affix reservation", async () => {
172
+ const receiver = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth");
173
+ expect(receiver, "offsetWidth accessor exists on HTMLElement").toBeTruthy();
174
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
175
+ configurable: true,
176
+ get: () => 96,
177
+ });
178
+ try {
179
+ // suffix slot (96) + steppers column (96) — the end var is the SUM.
180
+ const both = mountNumber({ slots: { suffix: () => h("span", "px") } });
181
+ await nextTick();
182
+ const box = query(both.container, ".hk-input-box")!;
183
+ expect(box.style.getPropertyValue("--hk-input-affix-end-w")).toBe("192px");
184
+
185
+ const steppersOnly = mountNumber();
186
+ await nextTick();
187
+ expect(
188
+ query(steppersOnly.container, ".hk-input-box")!.style.getPropertyValue("--hk-input-affix-end-w"),
189
+ ).toBe("96px");
190
+ } finally {
191
+ if (receiver) {
192
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", receiver);
193
+ }
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("HkInput number variant — SCSS contract", () => {
199
+ it("kills the native spin buttons under the number box", () => {
200
+ const rule = scss.match(/\.hk-input-box-number \.hk-input-element\s*{[^}]*}/)?.[0] ?? "";
201
+ expect(rule, "number element rule must exist").toContain("-moz-appearance: textfield");
202
+ expect(rule).toContain("::-webkit-inner-spin-button");
203
+ expect(rule).toContain("::-webkit-outer-spin-button");
204
+ expect(rule).toContain("-webkit-appearance: none");
205
+ expect(rule).toContain("margin: 0");
206
+ });
207
+
208
+ it("parks the suffix next to the steppers via the box marker", () => {
209
+ const rule =
210
+ scss.match(
211
+ /\.hk-input-box\[data-number-steppers\] \.hk-input-affix\.hk-input-suffix\s*{[^}]*}/,
212
+ )?.[0] ?? "";
213
+ expect(rule, "suffix park rule must exist").toContain("margin-inline-start: 0");
214
+
215
+ const column = scss.match(/\.hk-input-box \.hk-number-input-steppers\s*{[^}]*}/)?.[0] ?? "";
216
+ expect(column, "stepper column context rule must exist").toContain("margin-inline-start: auto");
217
+ expect(column).toContain("z-index: 1");
218
+ });
219
+ });
@@ -186,6 +186,38 @@
186
186
  margin-inline-start: auto;
187
187
  }
188
188
 
189
+ /* ── Number fields (type="number" / variant="number") ───────────────
190
+ * The browser's native spin buttons are retired: the shared lucide
191
+ * stepper column (HkNumberSteppers) takes the trailing edge instead, so
192
+ * every hikari number field draws the same up/down affordance regardless
193
+ * of host platform. The field itself keeps type="number" — keyboard
194
+ * arrows and min/max validation stay native. */
195
+ .hk-input-box-number .hk-input-element {
196
+ -moz-appearance: textfield;
197
+
198
+ &::-webkit-inner-spin-button,
199
+ &::-webkit-outer-spin-button {
200
+ -webkit-appearance: none;
201
+ margin: 0;
202
+ }
203
+ }
204
+
205
+ /* The stepper column is an in-flow sibling riding ABOVE the absolutely
206
+ * positioned element (z-index 1, exactly like the affixes) and it owns the
207
+ * trailing auto margin — the slot the suffix's auto margin occupies when no
208
+ * steppers render. */
209
+ .hk-input-box .hk-number-input-steppers {
210
+ position: relative;
211
+ z-index: 1;
212
+ margin-inline-start: auto;
213
+ }
214
+
215
+ /* With the steppers present the suffix parks NEXT to them: two auto margins
216
+ * would split the free space and float the suffix mid-gap. */
217
+ .hk-input-box[data-number-steppers] .hk-input-affix.hk-input-suffix {
218
+ margin-inline-start: 0;
219
+ }
220
+
189
221
  /* The placeholder-marquee window shares the element's content box: inset
190
222
  * by the same base padding plus the affix clearance, so the scrolling
191
223
  * strip never slides underneath the affixes either. Like the element,
@@ -1,9 +1,10 @@
1
- import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch, type PropType } from "vue";
1
+ import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch, type PropType, type Ref } from "vue";
2
2
 
3
3
  import type { PasswordStrengthEvaluator } from "../utils/password";
4
4
 
5
5
  import { HkPlaceholderMarquee } from "./HkPlaceholderMarquee";
6
6
  import HkPasswordSurface from "./HkPasswordSurface";
7
+ import HkNumberSteppers from "./HkNumberSteppers";
7
8
  import "./HkInput.scss";
8
9
 
9
10
  export default defineComponent({
@@ -210,6 +211,13 @@ export default defineComponent({
210
211
  return props.type;
211
212
  });
212
213
 
214
+ /* A number field (type="number" or variant="number") carries the shared
215
+ lucide stepper column (HkNumberSteppers) INSTEAD of the browser's
216
+ native spin buttons — the field keeps its native semantics (arrow
217
+ keys step, min/max validation), the column is the pointer
218
+ affordance riding the trailing edge. */
219
+ const isNumberField = computed(() => resolvedType.value === "number");
220
+
213
221
  const filteredAttrs = computed(() => {
214
222
  const { class: _, style: __, ...rest } = attrs as Record<string, unknown>;
215
223
  return rest;
@@ -237,12 +245,17 @@ export default defineComponent({
237
245
  // / "end" pads each side only by its own affix.
238
246
  const prefixEl = ref<HTMLElement | null>(null);
239
247
  const suffixEl = ref<HTMLElement | null>(null);
248
+ /* The number steppers' column — measured INTO the end-side clearance
249
+ (summed with the suffix span) so centered text and the marquee
250
+ window always end short of the buttons. */
251
+ const steppersEl = ref<HTMLElement | null>(null);
240
252
  const affixStartW = ref(0);
241
253
  const affixEndW = ref(0);
242
254
 
243
255
  function measureAffixes() {
244
256
  affixStartW.value = prefixEl.value?.offsetWidth ?? 0;
245
- affixEndW.value = suffixEl.value?.offsetWidth ?? 0;
257
+ affixEndW.value =
258
+ (suffixEl.value?.offsetWidth ?? 0) + (steppersEl.value?.offsetWidth ?? 0);
246
259
  }
247
260
 
248
261
  const affixRO: ResizeObserver | null =
@@ -251,14 +264,13 @@ export default defineComponent({
251
264
  affixRO?.disconnect();
252
265
  });
253
266
 
254
- /** Stable ref callback for one affix slot — observes the live span
255
- * so width changes (a longer chip label, a swapped icon) re-publish
256
- * the custom properties without any consumer involvement. */
257
- function bindAffix(which: "start" | "end") {
258
- const slot = () => (which === "start" ? prefixEl : suffixEl);
267
+ /** Stable ref callback factory for one measured slot — observes the
268
+ * live element so width changes (a longer chip label, a swapped
269
+ * icon, a steppers size flip) re-publish the custom properties
270
+ * without any consumer involvement. */
271
+ function bindMeasured(slot: () => Ref<HTMLElement | null>) {
259
272
  const set = (node: HTMLElement | null) => {
260
- if (which === "start") prefixEl.value = node;
261
- else suffixEl.value = node;
273
+ slot().value = node;
262
274
  };
263
275
  return (el: unknown) => {
264
276
  const node = (el as HTMLElement | null) ?? null;
@@ -288,8 +300,9 @@ export default defineComponent({
288
300
  measureAffixes();
289
301
  };
290
302
  }
291
- const prefixAffixRef = bindAffix("start");
292
- const suffixAffixRef = bindAffix("end");
303
+ const prefixAffixRef = bindMeasured(() => prefixEl);
304
+ const suffixAffixRef = bindMeasured(() => suffixEl);
305
+ const steppersRootRef = bindMeasured(() => steppersEl);
293
306
 
294
307
  const boxVars = computed(
295
308
  () =>
@@ -299,6 +312,60 @@ export default defineComponent({
299
312
  }) as Record<string, string>,
300
313
  );
301
314
 
315
+ /* ── number steppers (type="number" / variant="number") ──
316
+ min/max/step reach the DOM input as undeclared attrs through the
317
+ spread; the steppers read the SAME numbers so pointer stepping
318
+ agrees with the native keyboard stepping and validation. */
319
+ function attrNumber(key: string): number | undefined {
320
+ const raw = attrs[key];
321
+ if (raw == null || raw === "") return undefined;
322
+ const n = Number(raw);
323
+ return Number.isFinite(n) ? n : undefined;
324
+ }
325
+
326
+ const numberBounds = computed(() => ({
327
+ min: attrNumber("min"),
328
+ max: attrNumber("max"),
329
+ step: attrNumber("step") ?? 1,
330
+ }));
331
+
332
+ const currentNumber = computed(() =>
333
+ Number.parseFloat(String(props.modelValue ?? "")),
334
+ );
335
+
336
+ const canStepUp = computed(() => {
337
+ const { max } = numberBounds.value;
338
+ if (max == null) return true;
339
+ const current = currentNumber.value;
340
+ // An unparsable draft leaves the end open — the value itself picks
341
+ // the restart bound in stepNumberValue.
342
+ return !Number.isFinite(current) || current < max;
343
+ });
344
+
345
+ const canStepDown = computed(() => {
346
+ const { min } = numberBounds.value;
347
+ if (min == null) return true;
348
+ const current = currentNumber.value;
349
+ return !Number.isFinite(current) || current > min;
350
+ });
351
+
352
+ function stepNumberValue(dir: "up" | "down") {
353
+ if (props.disabled || props.readonly) return;
354
+ const { min, max, step } = numberBounds.value;
355
+ const current = currentNumber.value;
356
+ // An unparsable draft lands ON the bound (native number-input
357
+ // behavior): up restarts at min (else 0), down at max (else 0).
358
+ if (!Number.isFinite(current)) {
359
+ emit("update:modelValue", String(dir === "up" ? (min ?? 0) : (max ?? 0)));
360
+ return;
361
+ }
362
+ let next = dir === "up" ? current + step : current - step;
363
+ if (min != null && next < min) next = min;
364
+ if (max != null && next > max) next = max;
365
+ // Kill float dust (0.1 + 0.2 must not surface as 0.30000000000000004).
366
+ emit("update:modelValue", String(Number.parseFloat(next.toFixed(10))));
367
+ }
368
+
302
369
  function onInput(e: Event) {
303
370
  const target = e.target as HTMLInputElement | HTMLTextAreaElement;
304
371
  emit("update:modelValue", target.value);
@@ -348,6 +415,7 @@ export default defineComponent({
348
415
  `hk-input-box-${props.size}`,
349
416
  props.error ? "hk-input-box-error" : "",
350
417
  props.disabled ? "hk-input-box-disabled" : "",
418
+ isNumberField.value ? "hk-input-box-number" : "",
351
419
  ]);
352
420
 
353
421
  const isText = !isTextarea.value;
@@ -419,6 +487,10 @@ export default defineComponent({
419
487
  class={boxClass.value}
420
488
  style={boxVars.value}
421
489
  data-autogrow={isAutoGrow.value || undefined}
490
+ // Number boxes carry the marker so the SCSS can hide the native
491
+ // spin buttons and park the suffix next to the steppers (the
492
+ // column owns the trailing auto margin).
493
+ data-number-steppers={isNumberField.value || undefined}
422
494
  // Textarea boxes never carry the marker: their element is
423
495
  // excluded by :not() anyway, and keeping it off also spares
424
496
  // a textarea-scoped marquee from the per-side window insets.
@@ -545,6 +617,16 @@ export default defineComponent({
545
617
  {slots.suffixIcon()}
546
618
  </span>
547
619
  )}
620
+ {isNumberField.value && (
621
+ <HkNumberSteppers
622
+ rootRef={steppersRootRef}
623
+ size={props.size}
624
+ canUp={canStepUp.value}
625
+ canDown={canStepDown.value}
626
+ disabled={props.disabled || props.readonly}
627
+ onStep={stepNumberValue}
628
+ />
629
+ )}
548
630
  </div>
549
631
  )}
550
632
  {props.error ? (
@@ -27,6 +27,11 @@ import HkNumberInput from "./HkNumberInput";
27
27
 
28
28
  const here = dirname(fileURLToPath(import.meta.url));
29
29
  const scss = readFileSync(join(here, "HkNumberInput.scss"), "utf-8");
30
+ /* The stepper column's rules moved to the shared HkNumberSteppers
31
+ stylesheet (2026-10-30) — the same affordance now renders inside
32
+ HkInput's number variant, so its geometry contract ships with the
33
+ component that draws it. */
34
+ const steppersScss = readFileSync(join(here, "HkNumberSteppers.scss"), "utf-8");
30
35
 
31
36
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
32
37
 
@@ -65,9 +70,9 @@ describe("HkNumberInput affix contract", () => {
65
70
  });
66
71
 
67
72
  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] ?? "";
73
+ const rule = steppersScss.match(/\.hk-number-input-steppers\s*{[^}]*}/)?.[0] ?? "";
69
74
  expect(rule, "steppers rule must exist").toContain("align-self: stretch");
70
- const steps = scss.match(/\.hk-number-input-step\s*{[^}]*}/)?.[0] ?? "";
75
+ const steps = steppersScss.match(/\.hk-number-input-step\s*{[^}]*}/)?.[0] ?? "";
71
76
  expect(steps).toContain("height: 50%");
72
77
  });
73
78
 
@@ -101,10 +106,13 @@ describe("HkNumberInput affix contract", () => {
101
106
 
102
107
  const inner = query(container, ".hk-number-input-inner");
103
108
  const children = Array.from(inner?.children ?? []).map((el) => el.className);
109
+ // The stepper column carries its size class on the stack root (the
110
+ // shared HkNumberSteppers sizes from it — HkNumberInput.scss no longer
111
+ // holds per-size step rules).
104
112
  expect(children).toEqual([
105
113
  "hk-number-input-field",
106
114
  "hk-number-input-suffix",
107
- "hk-number-input-steppers",
115
+ "hk-number-input-steppers hk-number-input-steppers-md",
108
116
  ]);
109
117
  });
110
118
 
@@ -91,50 +91,9 @@
91
91
  padding-inline-end: var(--space-8, 0.5rem);
92
92
  }
93
93
 
94
- .hk-number-input-steppers {
95
- display: flex;
96
- flex-direction: column;
97
- flex-shrink: 0;
98
- /* Stretch to the inner box so the step buttons' height:50% resolves
99
- against the real box height and the divider spans it fully —
100
- content-sized centering left a floating ~28px stack in a 36px box. */
101
- align-self: stretch;
102
- /* Inline-start so the divider faces the field/suffix side in both
103
- LTR and RTL (the flex row flips under direction: rtl). */
104
- border-inline-start: 1px solid var(--hi-color-border);
105
- }
106
-
107
- .hk-number-input-step {
108
- display: flex;
109
- align-items: center;
110
- justify-content: center;
111
- /* Width comes from the per-size blocks below (the size class is always
112
- on the wrapper); height stays here as the size-independent half. */
113
- height: 50%;
114
- padding: 0;
115
- background: transparent;
116
- border: none;
117
- color: var(--hi-color-muted);
118
- cursor: pointer;
119
- transition:
120
- background-color var(--duration-fast, 0.15s) ease,
121
- color var(--duration-fast, 0.15s) ease;
122
-
123
- &:hover:not(:disabled) {
124
- background: color-mix(in srgb, var(--hi-color-surface) 60%, transparent);
125
- color: var(--hi-color-text-primary);
126
- }
127
-
128
- &:active:not(:disabled) {
129
- background: color-mix(in srgb, var(--hi-color-primary) 15%, transparent);
130
- color: var(--hi-color-primary);
131
- }
132
-
133
- &:disabled {
134
- opacity: 0.3;
135
- cursor: not-allowed;
136
- }
137
- }
94
+ /* The stepper column's rules live in HkNumberSteppers.scss (the shared
95
+ affordance also rendered by HkInput's number variant); the size class
96
+ rides the stack root itself, so no per-size step rules remain here. */
138
97
 
139
98
  /* --- Size variants --- */
140
99
 
@@ -149,15 +108,6 @@
149
108
  font-size: var(--text-xs, 0.75rem);
150
109
  }
151
110
 
152
- .hk-number-input-sm .hk-number-input-step {
153
- width: var(--space-20, 1.25rem);
154
- }
155
-
156
- .hk-number-input-sm .hk-number-input-step svg {
157
- width: 12px;
158
- height: 12px;
159
- }
160
-
161
111
  .hk-number-input-md .hk-number-input-field {
162
112
  font-size: var(--text-base, 0.875rem);
163
113
  padding: 0 var(--space-12, 0.75rem);
@@ -169,10 +119,6 @@
169
119
  font-size: var(--text-base, 0.875rem);
170
120
  }
171
121
 
172
- .hk-number-input-md .hk-number-input-step {
173
- width: var(--space-24, 1.5rem);
174
- }
175
-
176
122
  .hk-number-input-lg .hk-number-input-field {
177
123
  font-size: var(--text-md, 1rem);
178
124
  padding: 0 var(--space-16, 1rem);
@@ -183,12 +129,3 @@
183
129
  .hk-number-input-lg .hk-number-input-suffix {
184
130
  font-size: var(--text-md, 1rem);
185
131
  }
186
-
187
- .hk-number-input-lg .hk-number-input-step {
188
- width: var(--space-28, 1.75rem);
189
- }
190
-
191
- .hk-number-input-lg .hk-number-input-step svg {
192
- width: 16px;
193
- height: 16px;
194
- }
@@ -1,5 +1,5 @@
1
- import { computed, defineComponent, onUnmounted, ref, type PropType } from "vue";
2
- import { scheduleAfter, scheduleEvery, type AnimationHandle } from "../runtime/animationBus";
1
+ import { computed, defineComponent, ref, type PropType } from "vue";
2
+ import HkNumberSteppers from "./HkNumberSteppers";
3
3
  import "./HkNumberInput.scss";
4
4
 
5
5
  export default defineComponent({
@@ -29,8 +29,6 @@ export default defineComponent({
29
29
  "update:modelValue": (_value: number) => true,
30
30
  },
31
31
  setup(props, { emit, slots }) {
32
- let holdDelay: AnimationHandle | null = null;
33
- let holdRepeat: AnimationHandle | null = null;
34
32
  const inputRef = ref<HTMLInputElement | null>(null);
35
33
 
36
34
  const canIncrement = computed(() =>
@@ -62,29 +60,6 @@ export default defineComponent({
62
60
  emitValue(props.modelValue - props.step);
63
61
  }
64
62
 
65
- function startHold(dir: "up" | "down") {
66
- stopHold();
67
- // 300ms press threshold, then a 50ms repeat — both on the shared
68
- // animation bus so the hold cadence parks with the reduced-motion
69
- // switch like every other JS-driven animation.
70
- holdDelay = scheduleAfter(() => {
71
- holdDelay = null;
72
- holdRepeat = scheduleEvery(() => {
73
- if (dir === "up") increment();
74
- else decrement();
75
- }, 50);
76
- }, 300);
77
- }
78
-
79
- function stopHold() {
80
- holdDelay?.disconnect();
81
- holdDelay = null;
82
- holdRepeat?.disconnect();
83
- holdRepeat = null;
84
- }
85
-
86
- onUnmounted(stopHold);
87
-
88
63
  const wrapperClass = computed(() => [
89
64
  "hk-number-input",
90
65
  `hk-number-input-${props.size}`,
@@ -119,60 +94,16 @@ export default defineComponent({
119
94
  {slots.suffix ? slots.suffix() : props.suffix}
120
95
  </span>
121
96
  ) : null}
122
- <div class="hk-number-input-steppers">
123
- <button
124
- type="button"
125
- class="hk-number-input-step hk-number-input-step-up"
126
- disabled={!canIncrement.value || props.disabled}
127
- onMousedown={(e: MouseEvent) => {
128
- e.preventDefault();
129
- startHold("up");
130
- }}
131
- onMouseup={(e: MouseEvent) => {
132
- e.preventDefault();
133
- stopHold();
134
- }}
135
- onMouseleave={(e: MouseEvent) => {
136
- e.preventDefault();
137
- stopHold();
138
- }}
139
- onClick={(e: MouseEvent) => {
140
- e.preventDefault();
141
- increment();
142
- }}
143
- tabindex={-1}
144
- >
145
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
146
- <polyline points="18 15 12 9 6 15" />
147
- </svg>
148
- </button>
149
- <button
150
- type="button"
151
- class="hk-number-input-step hk-number-input-step-down"
152
- disabled={!canDecrement.value || props.disabled}
153
- onMousedown={(e: MouseEvent) => {
154
- e.preventDefault();
155
- startHold("down");
156
- }}
157
- onMouseup={(e: MouseEvent) => {
158
- e.preventDefault();
159
- stopHold();
160
- }}
161
- onMouseleave={(e: MouseEvent) => {
162
- e.preventDefault();
163
- stopHold();
164
- }}
165
- onClick={(e: MouseEvent) => {
166
- e.preventDefault();
167
- decrement();
168
- }}
169
- tabindex={-1}
170
- >
171
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
172
- <polyline points="6 9 12 15 18 9" />
173
- </svg>
174
- </button>
175
- </div>
97
+ {/* The shared stepper column (lucide ChevronUp/Down) — it owns
98
+ the hold-to-repeat cadence on the animation bus; the bounds
99
+ mirror this field's min/max so dead ends render greyed. */}
100
+ <HkNumberSteppers
101
+ canUp={canIncrement.value}
102
+ canDown={canDecrement.value}
103
+ disabled={props.disabled}
104
+ size={props.size}
105
+ onStep={(dir: "up" | "down") => (dir === "up" ? increment() : decrement())}
106
+ />
176
107
  </div>
177
108
  </div>
178
109
  );