@celestia-island/hikari 0.55.237 → 0.55.238
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 +1 -1
- package/src/DemoApp.tsx +4 -1
- package/src/components/HkInput.number.test.tsx +219 -0
- package/src/components/HkInput.scss +32 -0
- package/src/components/HkInput.tsx +93 -11
- package/src/components/HkNumberInput.affix.test.ts +11 -3
- package/src/components/HkNumberInput.scss +3 -66
- package/src/components/HkNumberInput.tsx +12 -81
- package/src/components/HkNumberSteppers.scss +85 -0
- package/src/components/HkNumberSteppers.test.tsx +267 -0
- package/src/components/HkNumberSteppers.tsx +158 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -360,12 +360,15 @@ export default defineComponent({
|
|
|
360
360
|
</section>
|
|
361
361
|
|
|
362
362
|
<section>
|
|
363
|
-
<h2>HInput (text / password) / HSearchInput / HNumberInput / HTextarea</h2>
|
|
363
|
+
<h2>HInput (text / password / number) / HSearchInput / HNumberInput / HTextarea</h2>
|
|
364
364
|
<div class="form-grid">
|
|
365
365
|
<HInput modelValue={form.value.text} onUpdate:modelValue={(v: string) => (form.value.text = v)} placeholder="Text input" />
|
|
366
366
|
<HInput modelValue={form.value.text} onUpdate:modelValue={(v: string) => (form.value.text = v)} placeholder="Disabled" disabled />
|
|
367
367
|
<HSearchInput modelValue={form.value.search} onUpdate:modelValue={(v: string | number) => (form.value.search = String(v))} placeholder="Search..." />
|
|
368
368
|
<HNumberInput modelValue={form.value.number} onUpdate:modelValue={(v: number) => (form.value.number = v)} placeholder="Number" />
|
|
369
|
+
{/* The plain input's number variant — same shared lucide stepper
|
|
370
|
+
column, no browser-native spin buttons. */}
|
|
371
|
+
<HInput type="number" modelValue={String(form.value.number)} onUpdate:modelValue={(v: string) => (form.value.number = v === "" ? 0 : Number(v))} placeholder="Number (steppers)" />
|
|
369
372
|
<HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (filter reveal — default)" />
|
|
370
373
|
<HInput variant="password" revealStrategy="sweep" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (sweep reveal — readable band)" />
|
|
371
374
|
<HInput variant="password" revealStrategy="noise" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (noise reveal — screenshot-safe)" />
|
|
@@ -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 =
|
|
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
|
|
255
|
-
* so width changes (a longer chip label, a swapped
|
|
256
|
-
*
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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 =
|
|
292
|
-
const suffixAffixRef =
|
|
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 =
|
|
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 =
|
|
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
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
2
|
-
import
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* Shared stepper column for hikari's number fields — HkNumberInput and
|
|
2
|
+
HkInput's number variant (type="number" / variant="number"), rendered by
|
|
3
|
+
HkNumberSteppers.tsx. The rules moved here verbatim from HkNumberInput.scss
|
|
4
|
+
(2026-10-30) so both hosts load the affordance from one stylesheet; the
|
|
5
|
+
size class now lives on the STACK ROOT (hk-number-input-steppers-{sm,md,lg})
|
|
6
|
+
instead of the host wrapper, so the geometry follows each host's `size`
|
|
7
|
+
prop without needing the host's own size class anywhere up the tree. */
|
|
8
|
+
|
|
9
|
+
.hk-number-input-steppers {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
/* Stretch to the host box so the step buttons' height:50% resolves
|
|
14
|
+
against the real box height and the divider spans it fully —
|
|
15
|
+
content-sized centering left a floating ~28px stack in a 36px box. */
|
|
16
|
+
align-self: stretch;
|
|
17
|
+
/* Inline-start so the divider faces the field/suffix side in both
|
|
18
|
+
LTR and RTL (the flex row flips under direction: rtl). */
|
|
19
|
+
border-inline-start: 1px solid var(--hi-color-border);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.hk-number-input-step {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: center;
|
|
26
|
+
/* Width comes from the per-size blocks below; height stays here as the
|
|
27
|
+
size-independent half. */
|
|
28
|
+
height: 50%;
|
|
29
|
+
padding: 0;
|
|
30
|
+
background: transparent;
|
|
31
|
+
border: none;
|
|
32
|
+
color: var(--hi-color-muted);
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
transition:
|
|
35
|
+
background-color var(--duration-fast, 0.15s) ease,
|
|
36
|
+
color var(--duration-fast, 0.15s) ease;
|
|
37
|
+
|
|
38
|
+
&:hover:not(:disabled) {
|
|
39
|
+
background: color-mix(in srgb, var(--hi-color-surface) 60%, transparent);
|
|
40
|
+
color: var(--hi-color-text-primary);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&:active:not(:disabled) {
|
|
44
|
+
background: color-mix(in srgb, var(--hi-color-primary) 15%, transparent);
|
|
45
|
+
color: var(--hi-color-primary);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&:disabled {
|
|
49
|
+
opacity: 0.3;
|
|
50
|
+
cursor: not-allowed;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* --- Size variants (size class on the stack root) --- */
|
|
55
|
+
|
|
56
|
+
.hk-number-input-steppers-sm .hk-number-input-step {
|
|
57
|
+
width: var(--space-20, 1.25rem);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.hk-number-input-steppers-sm .hk-number-input-step svg {
|
|
61
|
+
width: 12px;
|
|
62
|
+
height: 12px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.hk-number-input-steppers-md .hk-number-input-step {
|
|
66
|
+
width: var(--space-24, 1.5rem);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* The md svg size is declared EXPLICITLY: the retired inline markup carried
|
|
70
|
+
width/height=14 attributes that md silently leaned on — the lucide
|
|
71
|
+
components render their own 24px attribute default, so CSS owns every
|
|
72
|
+
size now. */
|
|
73
|
+
.hk-number-input-steppers-md .hk-number-input-step svg {
|
|
74
|
+
width: 14px;
|
|
75
|
+
height: 14px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hk-number-input-steppers-lg .hk-number-input-step {
|
|
79
|
+
width: var(--space-28, 1.75rem);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.hk-number-input-steppers-lg .hk-number-input-step svg {
|
|
83
|
+
width: 16px;
|
|
84
|
+
height: 16px;
|
|
85
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior contract for the shared number steppers (2026-10-30): the
|
|
3
|
+
* lucide-drawn up/down column (ChevronUp / ChevronDown) that replaced both
|
|
4
|
+
* the hand-inlined polyline SVGs of HkNumberInput and the browser-native
|
|
5
|
+
* spin buttons of HkInput's number variant.
|
|
6
|
+
*
|
|
7
|
+
* Pinned behavior:
|
|
8
|
+
* 1. Click = one step per direction; a dead bound disables its button.
|
|
9
|
+
* 2. Press-and-hold arms on the shared animation bus (300ms) and repeats
|
|
10
|
+
* every 50ms; the release click after a held run is SWALLOWED (the
|
|
11
|
+
* original HkNumberInput cadence delivered it as one extra step).
|
|
12
|
+
* 3. A hold dies the moment its direction's bound goes dead mid-hold —
|
|
13
|
+
* no repeat keeps running behind a button no mouseup can reach.
|
|
14
|
+
* 4. Non-primary buttons never arm a hold; leaving the button cancels it.
|
|
15
|
+
* 5. The stack root carries the size class and reports itself through the
|
|
16
|
+
* rootRef function ref (the host's trailing-affix measurement), then
|
|
17
|
+
* clears it on unmount.
|
|
18
|
+
*/
|
|
19
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
import HkNumberSteppers from "./HkNumberSteppers";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const scss = readFileSync(join(here, "HkNumberSteppers.scss"), "utf-8");
|
|
29
|
+
|
|
30
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
31
|
+
|
|
32
|
+
/* ── rAF pump: drive the shared animation bus deterministically ── */
|
|
33
|
+
const rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
|
|
34
|
+
let rafSeq = 0;
|
|
35
|
+
let rafStubbed = false;
|
|
36
|
+
/* Virtual clock: strictly increasing fired timestamps on the SAME axis as
|
|
37
|
+
performance.now() (the bus arms entries against the real clock). Per-pump
|
|
38
|
+
offsets accumulate, so a small offset never underflows the previous tick. */
|
|
39
|
+
let vnow = 0;
|
|
40
|
+
|
|
41
|
+
function stubRaf(): void {
|
|
42
|
+
if (rafStubbed) return;
|
|
43
|
+
rafStubbed = true;
|
|
44
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
45
|
+
const id = ++rafSeq;
|
|
46
|
+
rafQueue.push({ id, cb });
|
|
47
|
+
return id;
|
|
48
|
+
});
|
|
49
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
|
50
|
+
const i = rafQueue.findIndex((e) => e.id === id);
|
|
51
|
+
if (i !== -1) rafQueue.splice(i, 1);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fire every queued frame `offsetMs` past the running virtual clock (which
|
|
56
|
+
* never trails the real clock the bus armed its entries against). */
|
|
57
|
+
function pumpRaf(offsetMs: number): void {
|
|
58
|
+
vnow = Math.max(vnow, performance.now()) + offsetMs;
|
|
59
|
+
for (const { cb } of rafQueue.splice(0)) cb(vnow);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ── mount harness ── */
|
|
63
|
+
function mountSteppers(props: Record<string, unknown> = {}): {
|
|
64
|
+
container: HTMLElement;
|
|
65
|
+
steps: string[];
|
|
66
|
+
} {
|
|
67
|
+
const steps: string[] = [];
|
|
68
|
+
const container = document.createElement("div");
|
|
69
|
+
document.body.appendChild(container);
|
|
70
|
+
const app = createApp({
|
|
71
|
+
render: () =>
|
|
72
|
+
h(HkNumberSteppers, {
|
|
73
|
+
onStep: (dir: "up" | "down") => steps.push(dir),
|
|
74
|
+
...props,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
app.mount(container);
|
|
78
|
+
mounts.push({ app, container });
|
|
79
|
+
return { container, steps };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function button(container: HTMLElement, dir: "up" | "down"): HTMLButtonElement {
|
|
83
|
+
const el = container.querySelector(`.hk-number-input-step-${dir}`);
|
|
84
|
+
expect(el, `${dir} button must render`).not.toBeNull();
|
|
85
|
+
return el as HTMLButtonElement;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function press(target: HTMLButtonElement, buttonArg = 0): void {
|
|
89
|
+
target.dispatchEvent(
|
|
90
|
+
new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: buttonArg }),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function release(target: HTMLButtonElement): void {
|
|
95
|
+
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function click(target: HTMLButtonElement): void {
|
|
99
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
104
|
+
app.unmount();
|
|
105
|
+
container.remove();
|
|
106
|
+
}
|
|
107
|
+
document.body.innerHTML = "";
|
|
108
|
+
rafQueue.length = 0;
|
|
109
|
+
vnow = 0;
|
|
110
|
+
if (rafStubbed) vi.unstubAllGlobals();
|
|
111
|
+
rafStubbed = false;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("HkNumberSteppers behavior", () => {
|
|
115
|
+
it("steps once per click and greys out dead bounds", () => {
|
|
116
|
+
const { container, steps } = mountSteppers();
|
|
117
|
+
click(button(container, "up"));
|
|
118
|
+
click(button(container, "down"));
|
|
119
|
+
expect(steps).toEqual(["up", "down"]);
|
|
120
|
+
|
|
121
|
+
const bounded = mountSteppers({ canUp: false });
|
|
122
|
+
expect(button(bounded.container, "up").disabled).toBe(true);
|
|
123
|
+
expect(button(bounded.container, "down").disabled).toBe(false);
|
|
124
|
+
click(button(bounded.container, "up"));
|
|
125
|
+
expect(bounded.steps).toEqual([]);
|
|
126
|
+
click(button(bounded.container, "down"));
|
|
127
|
+
expect(bounded.steps).toEqual(["down"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("respects the disabled prop on both buttons", () => {
|
|
131
|
+
const { container, steps } = mountSteppers({ disabled: true });
|
|
132
|
+
expect(button(container, "up").disabled).toBe(true);
|
|
133
|
+
expect(button(container, "down").disabled).toBe(true);
|
|
134
|
+
click(button(container, "up"));
|
|
135
|
+
click(button(container, "down"));
|
|
136
|
+
expect(steps).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("arms the hold after 300ms, repeats per 50ms tick, and swallows the release click", () => {
|
|
140
|
+
stubRaf();
|
|
141
|
+
const { container, steps } = mountSteppers();
|
|
142
|
+
const up = button(container, "up");
|
|
143
|
+
|
|
144
|
+
press(up);
|
|
145
|
+
pumpRaf(10);
|
|
146
|
+
expect(steps, "arming delay — nothing yet").toEqual([]);
|
|
147
|
+
|
|
148
|
+
pumpRaf(320);
|
|
149
|
+
expect(steps, "threshold crossed — the first repeat fires in the same tick (the interval arms with lastRun=0, and the Map iteration visits entries added mid-tick)").toEqual(["up"]);
|
|
150
|
+
|
|
151
|
+
pumpRaf(1);
|
|
152
|
+
expect(steps, "inside the 50ms repeat window — nothing").toEqual(["up"]);
|
|
153
|
+
|
|
154
|
+
pumpRaf(60);
|
|
155
|
+
expect(steps).toEqual(["up", "up"]);
|
|
156
|
+
|
|
157
|
+
pumpRaf(120);
|
|
158
|
+
expect(steps, "one fire per frame, not two").toEqual(["up", "up", "up"]);
|
|
159
|
+
|
|
160
|
+
release(up);
|
|
161
|
+
pumpRaf(10);
|
|
162
|
+
expect(steps, "hold stopped — no further repeats").toEqual(["up", "up", "up"]);
|
|
163
|
+
|
|
164
|
+
click(up);
|
|
165
|
+
expect(steps, "the release click after a held run is swallowed").toEqual([
|
|
166
|
+
"up",
|
|
167
|
+
"up",
|
|
168
|
+
"up",
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
click(up);
|
|
172
|
+
expect(steps, "a fresh plain click steps again").toEqual([
|
|
173
|
+
"up",
|
|
174
|
+
"up",
|
|
175
|
+
"up",
|
|
176
|
+
"up",
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("kills the hold when its bound goes dead mid-hold", async () => {
|
|
181
|
+
stubRaf();
|
|
182
|
+
const steps: string[] = [];
|
|
183
|
+
const bounds = ref({ canUp: true });
|
|
184
|
+
const container = document.createElement("div");
|
|
185
|
+
document.body.appendChild(container);
|
|
186
|
+
const Wrapper = defineComponent({
|
|
187
|
+
render: () =>
|
|
188
|
+
h(HkNumberSteppers, {
|
|
189
|
+
canUp: bounds.value.canUp,
|
|
190
|
+
onStep: (dir: "up" | "down") => {
|
|
191
|
+
steps.push(dir);
|
|
192
|
+
// The host clamps at its max and reports the dead bound.
|
|
193
|
+
bounds.value = { canUp: false };
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
const app = createApp(Wrapper);
|
|
198
|
+
app.mount(container);
|
|
199
|
+
mounts.push({ app, container });
|
|
200
|
+
|
|
201
|
+
const up = button(container, "up");
|
|
202
|
+
press(up);
|
|
203
|
+
pumpRaf(320);
|
|
204
|
+
expect(steps, "arm + first repeat in the same tick").toEqual(["up"]);
|
|
205
|
+
await nextTick();
|
|
206
|
+
|
|
207
|
+
pumpRaf(60);
|
|
208
|
+
expect(steps, "the dead bound released the hold — no further steps").toEqual(["up"]);
|
|
209
|
+
pumpRaf(60);
|
|
210
|
+
expect(steps).toEqual(["up"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("ignores non-primary buttons", () => {
|
|
214
|
+
stubRaf();
|
|
215
|
+
const { container, steps } = mountSteppers();
|
|
216
|
+
press(button(container, "up"), 2);
|
|
217
|
+
pumpRaf(400);
|
|
218
|
+
expect(steps).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("cancels a pending hold when the pointer leaves the button", () => {
|
|
222
|
+
stubRaf();
|
|
223
|
+
const { container, steps } = mountSteppers();
|
|
224
|
+
const up = button(container, "up");
|
|
225
|
+
press(up);
|
|
226
|
+
up.dispatchEvent(new MouseEvent("mouseleave"));
|
|
227
|
+
pumpRaf(400);
|
|
228
|
+
expect(steps).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("HkNumberSteppers DOM + stylesheet contract", () => {
|
|
233
|
+
it("renders lucide chevrons, carries the size class, and reports its root", () => {
|
|
234
|
+
const roots: Array<HTMLElement | null> = [];
|
|
235
|
+
const { container } = mountSteppers({ size: "lg", rootRef: (el: HTMLElement | null) => roots.push(el) });
|
|
236
|
+
|
|
237
|
+
const stack = container.querySelector<HTMLElement>(".hk-number-input-steppers");
|
|
238
|
+
expect(stack, "stepper column must render").not.toBeNull();
|
|
239
|
+
expect(stack!.className).toBe("hk-number-input-steppers hk-number-input-steppers-lg");
|
|
240
|
+
expect(stack!.querySelector("svg.lucide-chevron-up")).not.toBeNull();
|
|
241
|
+
expect(stack!.querySelector("svg.lucide-chevron-down")).not.toBeNull();
|
|
242
|
+
expect(roots).toEqual([stack]);
|
|
243
|
+
expect(button(container, "up").getAttribute("tabindex")).toBe("-1");
|
|
244
|
+
|
|
245
|
+
mounts[0]!.app.unmount();
|
|
246
|
+
mounts.splice(0);
|
|
247
|
+
expect(roots.at(-1), "the rootRef clears on unmount").toBeNull();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("owns every size's geometry in the shared stylesheet", () => {
|
|
251
|
+
for (const [size, px] of [["sm", "12px"], ["md", "14px"], ["lg", "16px"]] as const) {
|
|
252
|
+
const svg = scss.match(
|
|
253
|
+
new RegExp(`\\.hk-number-input-steppers-${size} \\.hk-number-input-step svg\\s*{[^}]*}`),
|
|
254
|
+
)?.[0];
|
|
255
|
+
expect(svg, `${size} svg rule must exist`).toBeTruthy();
|
|
256
|
+
expect(svg).toContain(`width: ${px}`);
|
|
257
|
+
expect(svg).toContain(`height: ${px}`);
|
|
258
|
+
const column = scss.match(
|
|
259
|
+
new RegExp(`\\.hk-number-input-steppers-${size} \\.hk-number-input-step\\s*{[^}]*}`),
|
|
260
|
+
)?.[0];
|
|
261
|
+
expect(column, `${size} column rule must exist`).toBeTruthy();
|
|
262
|
+
}
|
|
263
|
+
const stack = scss.match(/\.hk-number-input-steppers\s*{[^}]*}/)?.[0] ?? "";
|
|
264
|
+
expect(stack).toContain("align-self: stretch");
|
|
265
|
+
expect(stack).toContain("border-inline-start: 1px solid var(--hi-color-border)");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { defineComponent, onUnmounted, type PropType } from "vue";
|
|
2
|
+
import { ChevronDown, ChevronUp } from "lucide-vue-next";
|
|
3
|
+
|
|
4
|
+
import { scheduleAfter, scheduleEvery, type AnimationHandle } from "../runtime/animationBus";
|
|
5
|
+
import "./HkNumberSteppers.scss";
|
|
6
|
+
|
|
7
|
+
/** Shared stepper column for hikari's number fields (HkNumberInput and
|
|
8
|
+
* HkInput's number variant — type="number" / variant="number").
|
|
9
|
+
*
|
|
10
|
+
* Extracted from HkNumberInput (2026-10-30) so the plain input can retire
|
|
11
|
+
* the browser-native spin buttons too: one lucide-drawn affordance
|
|
12
|
+
* (ChevronUp / ChevronDown) for every number field, replacing the
|
|
13
|
+
* hand-inlined polyline SVGs the dedicated component used to carry.
|
|
14
|
+
*
|
|
15
|
+
* Interaction contract — the original HkNumberInput cadence, plus two
|
|
16
|
+
* hardening fixes:
|
|
17
|
+
* - click = one step; press-and-hold = 300ms arming delay, then a 50ms
|
|
18
|
+
* repeat, both scheduled on the shared animation bus so the hold
|
|
19
|
+
* cadence parks with the reduced-motion switch like every other
|
|
20
|
+
* JS-driven animation;
|
|
21
|
+
* - a hold that reached the repeat phase swallows its release click (the
|
|
22
|
+
* original delivered it as one extra step after a held run of repeats);
|
|
23
|
+
* - a hold dies the moment the host reports its direction bound dead
|
|
24
|
+
* (canUp/canDown flipping off mid-hold — the original kept the repeat
|
|
25
|
+
* entry alive on the bus after a clamped field hit its max);
|
|
26
|
+
* - non-primary buttons never arm the hold (the original intercepted
|
|
27
|
+
* right-clicks too), and leaving the button cancels a pending hold.
|
|
28
|
+
*
|
|
29
|
+
* The buttons are tabindex=-1: keyboard users step through the field's
|
|
30
|
+
* native arrow keys (every host keeps real type="number" semantics) —
|
|
31
|
+
* the column is the pointer affordance on top. */
|
|
32
|
+
export default defineComponent({
|
|
33
|
+
name: "HkNumberSteppers",
|
|
34
|
+
emits: {
|
|
35
|
+
/** Step intent — once per click, once per repeat tick while held. */
|
|
36
|
+
step: (_dir: "up" | "down") => true,
|
|
37
|
+
},
|
|
38
|
+
props: {
|
|
39
|
+
/** Bounds mirrored from the host field's min/max so a dead end renders
|
|
40
|
+
* greyed instead of silently ignoring clicks. */
|
|
41
|
+
canUp: { type: Boolean, default: true },
|
|
42
|
+
canDown: { type: Boolean, default: true },
|
|
43
|
+
disabled: { type: Boolean, default: false },
|
|
44
|
+
size: {
|
|
45
|
+
type: String as PropType<"sm" | "md" | "lg">,
|
|
46
|
+
default: "md",
|
|
47
|
+
},
|
|
48
|
+
/** Function ref for the host's width measurement — HkInput folds the
|
|
49
|
+
* column into its trailing affix reservation so centered text and the
|
|
50
|
+
* placeholder marquee never run underneath the buttons. */
|
|
51
|
+
rootRef: {
|
|
52
|
+
type: Function as PropType<(el: HTMLElement | null) => void>,
|
|
53
|
+
default: undefined,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
setup(props, { emit }) {
|
|
57
|
+
let holdDelay: AnimationHandle | null = null;
|
|
58
|
+
let holdRepeat: AnimationHandle | null = null;
|
|
59
|
+
/* Set by the FIRST repeat tick — the release click that follows a held
|
|
60
|
+
run of steps must not add one more. */
|
|
61
|
+
let holdRepeated = false;
|
|
62
|
+
|
|
63
|
+
function step(dir: "up" | "down") {
|
|
64
|
+
if (props.disabled) return;
|
|
65
|
+
const blocked = dir === "up" ? !props.canUp : !props.canDown;
|
|
66
|
+
if (blocked) {
|
|
67
|
+
// The bound went dead under a live hold (a repeat stepped the field
|
|
68
|
+
// onto its max) — release the hold instead of leaving the bus entry
|
|
69
|
+
// running behind a disabled button no mouseup can reach.
|
|
70
|
+
stopHold();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
emit("step", dir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function startHold(dir: "up" | "down") {
|
|
77
|
+
stopHold();
|
|
78
|
+
holdRepeated = false;
|
|
79
|
+
// 300ms press threshold, then a 50ms repeat — both on the shared
|
|
80
|
+
// animation bus (HkNumberInput's original cadence, kept verbatim).
|
|
81
|
+
holdDelay = scheduleAfter(() => {
|
|
82
|
+
holdDelay = null;
|
|
83
|
+
holdRepeat = scheduleEvery(() => {
|
|
84
|
+
holdRepeated = true;
|
|
85
|
+
step(dir);
|
|
86
|
+
}, 50);
|
|
87
|
+
}, 300);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stopHold() {
|
|
91
|
+
holdDelay?.disconnect();
|
|
92
|
+
holdDelay = null;
|
|
93
|
+
holdRepeat?.disconnect();
|
|
94
|
+
holdRepeat = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onUnmounted(stopHold);
|
|
98
|
+
|
|
99
|
+
/* Created once per direction (not per render) so the div's ref handlers
|
|
100
|
+
stay referentially stable — a per-render closure would make Vue detach
|
|
101
|
+
and re-attach the ref on every patch. */
|
|
102
|
+
function pressHandlers(dir: "up" | "down") {
|
|
103
|
+
return {
|
|
104
|
+
onMousedown: (e: MouseEvent) => {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
if (e.button !== 0) return;
|
|
107
|
+
startHold(dir);
|
|
108
|
+
},
|
|
109
|
+
onMouseup: (e: MouseEvent) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
stopHold();
|
|
112
|
+
},
|
|
113
|
+
onMouseleave: () => stopHold(),
|
|
114
|
+
onClick: (e: MouseEvent) => {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
if (holdRepeated) {
|
|
117
|
+
holdRepeated = false;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
step(dir);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const upHandlers = pressHandlers("up");
|
|
126
|
+
const downHandlers = pressHandlers("down");
|
|
127
|
+
|
|
128
|
+
function bindRoot(el: unknown) {
|
|
129
|
+
props.rootRef?.((el as HTMLElement | null) ?? null);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return () => (
|
|
133
|
+
<div
|
|
134
|
+
class={["hk-number-input-steppers", `hk-number-input-steppers-${props.size}`]}
|
|
135
|
+
ref={bindRoot}
|
|
136
|
+
>
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
class="hk-number-input-step hk-number-input-step-up"
|
|
140
|
+
disabled={props.disabled || !props.canUp}
|
|
141
|
+
tabindex={-1}
|
|
142
|
+
{...upHandlers}
|
|
143
|
+
>
|
|
144
|
+
<ChevronUp size={14} aria-hidden="true" />
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
class="hk-number-input-step hk-number-input-step-down"
|
|
149
|
+
disabled={props.disabled || !props.canDown}
|
|
150
|
+
tabindex={-1}
|
|
151
|
+
{...downHandlers}
|
|
152
|
+
>
|
|
153
|
+
<ChevronDown size={14} aria-hidden="true" />
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
});
|