@lumiose/ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// Number Field's client script (decision-62, decision-63): it defines `<lu-number-field>`, the
|
|
2
|
+
// light-DOM element NumberField.Root renders. Its input is native, so typing, `required`, the
|
|
3
|
+
// form's value, reset and a disabled fieldset are the platform's:
|
|
4
|
+
//
|
|
5
|
+
// - Without `format`, a native `<input type="number">`: the browser steps it with the arrow keys,
|
|
6
|
+
// validates `min`, `max` and `step`, and submits the number, in server HTML and without
|
|
7
|
+
// JavaScript.
|
|
8
|
+
// - With `format` (`Intl.NumberFormat` options, JSON), an `<input type="text" role="spinbutton">`
|
|
9
|
+
// showing the formatted value, and an `<input type="hidden">` with the number: the element moves
|
|
10
|
+
// the `name` from the text input to the hidden one when it connects (without JavaScript, the
|
|
11
|
+
// text input submits its text).
|
|
12
|
+
//
|
|
13
|
+
// The element adds, after Base UI's NumberField and the APG spinbutton:
|
|
14
|
+
//
|
|
15
|
+
// - The keys: Arrow Up and Arrow Down step by `step`, Shift with them and Page Up and Page Down by
|
|
16
|
+
// `large-step` (default 10), Alt with them by `small-step` (default 0.1), Home and End go to
|
|
17
|
+
// `min` and `max`. With `snap-on-step`, a step lands on a multiple of its size (from `min`, or
|
|
18
|
+
// 0) instead of adding it to a value between them.
|
|
19
|
+
// - The `[data-action=increment]` and `[data-action=decrement]` buttons: a press steps once (Shift
|
|
20
|
+
// by `large-step`, Alt by `small-step`), and holding it steps again and again; a click from the
|
|
21
|
+
// keyboard or assistive technology steps once. They are disabled at the ends of the range.
|
|
22
|
+
// - A `[data-scrub]` area: dragging across it steps the value (`data-direction`, horizontal or
|
|
23
|
+
// vertical, and `data-pixel-sensitivity`, pixels per step); right to left, leftward goes up.
|
|
24
|
+
// - The wheel steps the value only while the input has focus and the pointer is over it, with
|
|
25
|
+
// `allow-wheel-scrub`, and never otherwise (Chromium's native stepping of a focused number input
|
|
26
|
+
// is cancelled).
|
|
27
|
+
// - On commit (`change`: leaving the input, Enter), the typed value is read in the locale, kept in
|
|
28
|
+
// `min` and `max` (unless `allow-out-of-range`) and shown formatted. Every change the element
|
|
29
|
+
// makes fires `input` on the input, and `change` when it is done, like typing does.
|
|
30
|
+
//
|
|
31
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
32
|
+
import { formatNumber, formatter, parseNumber } from "./format.ts";
|
|
33
|
+
|
|
34
|
+
/** How long a held button waits before it repeats, and how often it repeats then. */
|
|
35
|
+
const HOLD = 400;
|
|
36
|
+
const REPEAT = 60;
|
|
37
|
+
|
|
38
|
+
const finite = (value: string | null): number | undefined => {
|
|
39
|
+
const parsed = value === null || value === "" ? Number.NaN : Number(value);
|
|
40
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The digits after the decimal point of `value`, for sums without floating-point noise. */
|
|
44
|
+
const decimals = (value: number) => (String(value).split(".")[1] ?? "").length;
|
|
45
|
+
|
|
46
|
+
export class NumberFieldElement extends HTMLElement {
|
|
47
|
+
/** The number before the current edit, which an unreadable edit goes back to. */
|
|
48
|
+
#committed: number | null = null;
|
|
49
|
+
/** The held button or scrub drag in progress. */
|
|
50
|
+
#holding: { stop: () => void } | undefined;
|
|
51
|
+
/** A pointer press on a button just stepped: its click must not step again. */
|
|
52
|
+
#pressed = false;
|
|
53
|
+
#numbers: Intl.NumberFormat | undefined;
|
|
54
|
+
|
|
55
|
+
/** The input people type in: the native number input, or the formatted text input. */
|
|
56
|
+
get input(): HTMLInputElement | null {
|
|
57
|
+
return this.querySelector<HTMLInputElement>("input:not([type=hidden])");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** With `format`, the hidden input that holds and submits the number. */
|
|
61
|
+
get hiddenInput(): HTMLInputElement | null {
|
|
62
|
+
return this.querySelector<HTMLInputElement>("input[type=hidden]");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether the input shows a formatted value (`format`), not a native number. */
|
|
66
|
+
get formatted(): boolean {
|
|
67
|
+
return this.input?.type !== "number";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The range from the attributes. Not properties: Lumiose would set them instead of the attributes.
|
|
71
|
+
get #min(): number | undefined {
|
|
72
|
+
return finite(this.getAttribute("min"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get #max(): number | undefined {
|
|
76
|
+
return finite(this.getAttribute("max"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get #step(): number {
|
|
80
|
+
return finite(this.getAttribute("step")) ?? 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get #largeStep(): number {
|
|
84
|
+
return finite(this.getAttribute("large-step")) ?? 10;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get #smallStep(): number {
|
|
88
|
+
return finite(this.getAttribute("small-step")) ?? 0.1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The step a key or a press with these modifiers takes: Shift large, Alt small. */
|
|
92
|
+
#stepFor(event: { shiftKey: boolean; altKey: boolean }): number {
|
|
93
|
+
return event.shiftKey ? this.#largeStep : event.altKey ? this.#smallStep : this.#step;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The number, `null` when the field is empty. Setting it shows it, without events. */
|
|
97
|
+
get value(): number | null {
|
|
98
|
+
const input = this.input;
|
|
99
|
+
if (!input) return null;
|
|
100
|
+
if (!this.formatted) return input.value === "" ? null : input.valueAsNumber;
|
|
101
|
+
const parsed = parseNumber(input.value, this.#format());
|
|
102
|
+
return parsed === null || Number.isNaN(parsed) ? null : parsed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
set value(next: number | null | undefined) {
|
|
106
|
+
this.#write(next ?? null);
|
|
107
|
+
this.#committed = next ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
connectedCallback(): void {
|
|
111
|
+
this.addEventListener("keydown", this.#onKeyDown);
|
|
112
|
+
this.addEventListener("input", this.#onInput);
|
|
113
|
+
this.addEventListener("change", this.#onChange, true);
|
|
114
|
+
this.addEventListener("pointerdown", this.#onPress);
|
|
115
|
+
this.addEventListener("click", this.#onClick);
|
|
116
|
+
this.addEventListener("wheel", this.#onWheel, { passive: false });
|
|
117
|
+
this.addEventListener("focusin", this.#onFocus);
|
|
118
|
+
document.addEventListener("reset", this.#onReset, true);
|
|
119
|
+
const settle = () => {
|
|
120
|
+
const hidden = this.hiddenInput;
|
|
121
|
+
const input = this.input;
|
|
122
|
+
// The keys (Alt's small step), Home, End and clamping leave the step grid on purpose: the
|
|
123
|
+
// native input must not refuse to submit those values (`stepMismatch`). Without the script
|
|
124
|
+
// it keeps its step (TASK-440, M32).
|
|
125
|
+
if (input && !this.formatted) input.step = "any";
|
|
126
|
+
// The number submits from the hidden input, once the script can keep it current.
|
|
127
|
+
if (hidden && input?.name && this.formatted) {
|
|
128
|
+
hidden.name = input.name;
|
|
129
|
+
input.removeAttribute("name");
|
|
130
|
+
}
|
|
131
|
+
this.#committed = this.value;
|
|
132
|
+
this.#sync();
|
|
133
|
+
};
|
|
134
|
+
if (document.readyState === "loading") {
|
|
135
|
+
document.addEventListener("DOMContentLoaded", settle, { once: true });
|
|
136
|
+
} else queueMicrotask(settle);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
disconnectedCallback(): void {
|
|
140
|
+
this.removeEventListener("keydown", this.#onKeyDown);
|
|
141
|
+
this.removeEventListener("input", this.#onInput);
|
|
142
|
+
this.removeEventListener("change", this.#onChange, true);
|
|
143
|
+
this.removeEventListener("pointerdown", this.#onPress);
|
|
144
|
+
this.removeEventListener("click", this.#onClick);
|
|
145
|
+
this.removeEventListener("wheel", this.#onWheel);
|
|
146
|
+
this.removeEventListener("focusin", this.#onFocus);
|
|
147
|
+
document.removeEventListener("reset", this.#onReset, true);
|
|
148
|
+
this.#holding?.stop();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#format(): Intl.NumberFormat {
|
|
152
|
+
if (!this.#numbers) {
|
|
153
|
+
let options: Intl.NumberFormatOptions | undefined;
|
|
154
|
+
try {
|
|
155
|
+
options = JSON.parse(this.getAttribute("format") ?? "null") ?? undefined;
|
|
156
|
+
} catch {
|
|
157
|
+
options = undefined;
|
|
158
|
+
}
|
|
159
|
+
this.#numbers = formatter(options, this.getAttribute("locale"));
|
|
160
|
+
}
|
|
161
|
+
return this.#numbers;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#usable(): boolean {
|
|
165
|
+
const input = this.input;
|
|
166
|
+
// `:disabled`: a disabled fieldset around the field disables it too (TASK-439, M30).
|
|
167
|
+
return !!input && !input.matches(":disabled") && !input.readOnly;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `value` within `min` and `max`. */
|
|
171
|
+
#clamp(value: number): number {
|
|
172
|
+
const [min, max] = [this.#min, this.#max];
|
|
173
|
+
return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Shows `value` in the input (formatted or native) and the hidden input. */
|
|
177
|
+
#write(value: number | null): void {
|
|
178
|
+
const input = this.input;
|
|
179
|
+
if (!input) return;
|
|
180
|
+
const text = this.formatted
|
|
181
|
+
? formatNumber(value, this.#format())
|
|
182
|
+
: value === null
|
|
183
|
+
? ""
|
|
184
|
+
: String(value);
|
|
185
|
+
if (input.value !== text) input.value = text;
|
|
186
|
+
this.#sync();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The hidden input, the spinbutton's ARIA values and the buttons, from the current value. */
|
|
190
|
+
#sync(): void {
|
|
191
|
+
const input = this.input;
|
|
192
|
+
if (!input) return;
|
|
193
|
+
const value = this.value;
|
|
194
|
+
const hidden = this.hiddenInput;
|
|
195
|
+
if (hidden) hidden.value = value === null ? "" : String(value);
|
|
196
|
+
if (this.formatted) {
|
|
197
|
+
const set = (name: string, number: number | null | undefined) => {
|
|
198
|
+
if (number === null || number === undefined) input.removeAttribute(name);
|
|
199
|
+
else input.setAttribute(name, String(number));
|
|
200
|
+
};
|
|
201
|
+
set("aria-valuenow", value);
|
|
202
|
+
set("aria-valuemin", this.#min);
|
|
203
|
+
set("aria-valuemax", this.#max);
|
|
204
|
+
if (value === null) input.removeAttribute("aria-valuetext");
|
|
205
|
+
else input.setAttribute("aria-valuetext", formatNumber(value, this.#format()));
|
|
206
|
+
}
|
|
207
|
+
const usable = this.#usable();
|
|
208
|
+
for (const button of this.querySelectorAll<HTMLButtonElement>("button[data-action]")) {
|
|
209
|
+
const up = button.dataset.action === "increment";
|
|
210
|
+
const limit = up ? this.#max : this.#min;
|
|
211
|
+
const atLimit =
|
|
212
|
+
value !== null && limit !== undefined && (up ? value >= limit : value <= limit);
|
|
213
|
+
button.disabled = !usable || atLimit;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Sets the value as the user would: fires `input`, and `change` when `commit`. */
|
|
218
|
+
#set(value: number | null, commit: boolean): void {
|
|
219
|
+
const input = this.input;
|
|
220
|
+
if (!input) return;
|
|
221
|
+
const before = input.value;
|
|
222
|
+
this.#write(value);
|
|
223
|
+
if (input.value !== before) input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
224
|
+
if (commit) {
|
|
225
|
+
if (this.#committed !== value) input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
226
|
+
this.#committed = value;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Steps the value by `amount` (up when positive), from `min` (or 0) when it is empty. */
|
|
231
|
+
#stepBy(amount: number, commit: boolean): void {
|
|
232
|
+
if (!this.#usable()) return;
|
|
233
|
+
const current = this.value;
|
|
234
|
+
const start = current ?? (amount > 0 ? (this.#min ?? 0) - amount : (this.#max ?? 0) - amount);
|
|
235
|
+
const places = Math.max(decimals(start), decimals(amount));
|
|
236
|
+
let next = start + amount;
|
|
237
|
+
if (current !== null && this.hasAttribute("snap-on-step")) {
|
|
238
|
+
// The next multiple of the step from the base, up or down (1.3 + 1 is 2).
|
|
239
|
+
const base = this.#min ?? 0;
|
|
240
|
+
const size = Math.abs(amount);
|
|
241
|
+
const at = Number(((current - base) / size).toFixed(9));
|
|
242
|
+
next = base + (amount > 0 ? Math.floor(at) + 1 : Math.ceil(at) - 1) * size;
|
|
243
|
+
}
|
|
244
|
+
this.#set(this.#clamp(Number(next.toFixed(places))), commit);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#onKeyDown = (event: KeyboardEvent) => {
|
|
248
|
+
if (event.target !== this.input || event.isComposing || !this.#usable()) return;
|
|
249
|
+
const [largeStep, min, max] = [this.#largeStep, this.#min, this.#max];
|
|
250
|
+
let handled = true;
|
|
251
|
+
switch (event.key) {
|
|
252
|
+
case "ArrowUp":
|
|
253
|
+
this.#stepBy(this.#stepFor(event), true);
|
|
254
|
+
break;
|
|
255
|
+
case "ArrowDown":
|
|
256
|
+
this.#stepBy(-this.#stepFor(event), true);
|
|
257
|
+
break;
|
|
258
|
+
case "PageUp":
|
|
259
|
+
this.#stepBy(largeStep, true);
|
|
260
|
+
break;
|
|
261
|
+
case "PageDown":
|
|
262
|
+
this.#stepBy(-largeStep, true);
|
|
263
|
+
break;
|
|
264
|
+
case "Home":
|
|
265
|
+
if (min === undefined) handled = false;
|
|
266
|
+
else this.#set(min, true);
|
|
267
|
+
break;
|
|
268
|
+
case "End":
|
|
269
|
+
if (max === undefined) handled = false;
|
|
270
|
+
else this.#set(max, true);
|
|
271
|
+
break;
|
|
272
|
+
case "Enter":
|
|
273
|
+
// Commits, then lets the form submit with the number.
|
|
274
|
+
this.#commit();
|
|
275
|
+
handled = false;
|
|
276
|
+
break;
|
|
277
|
+
default:
|
|
278
|
+
handled = false;
|
|
279
|
+
}
|
|
280
|
+
if (handled) event.preventDefault();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
#onInput = (event: Event) => {
|
|
284
|
+
if (event.target !== this.input) return;
|
|
285
|
+
// Typing: the hidden input and the ARIA values follow what the text reads as.
|
|
286
|
+
this.#sync();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Reads the typed value, keeps it in range and shows it formatted. */
|
|
290
|
+
#commit(): void {
|
|
291
|
+
const input = this.input;
|
|
292
|
+
if (!input) return;
|
|
293
|
+
let value: number | null;
|
|
294
|
+
if (this.formatted) {
|
|
295
|
+
const parsed = parseNumber(input.value, this.#format());
|
|
296
|
+
value = parsed !== null && Number.isNaN(parsed) ? this.#committed : parsed;
|
|
297
|
+
} else if (input.validity.badInput) {
|
|
298
|
+
// Firefox keeps text like "12abc" in a number input, read as "": the last number comes
|
|
299
|
+
// back, and the text goes (TASK-447, L19).
|
|
300
|
+
value = this.#committed;
|
|
301
|
+
input.value = "";
|
|
302
|
+
} else value = input.value === "" ? null : input.valueAsNumber;
|
|
303
|
+
if (value !== null && !this.hasAttribute("allow-out-of-range")) value = this.#clamp(value);
|
|
304
|
+
// As shown: the formatted value is what the field holds.
|
|
305
|
+
if (value !== null && this.formatted) {
|
|
306
|
+
value = parseNumber(formatNumber(value, this.#format()), this.#format()) ?? value;
|
|
307
|
+
}
|
|
308
|
+
const before = input.value;
|
|
309
|
+
this.#write(value);
|
|
310
|
+
if (input.value !== before) input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
311
|
+
this.#committed = value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The input's own `change` (leaving it after typing): commit first, so it reports the number.
|
|
315
|
+
#onChange = (event: Event) => {
|
|
316
|
+
if (event.target === this.input && event.isTrusted) this.#commit();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
#onFocus = (event: FocusEvent) => {
|
|
320
|
+
if (event.target === this.input) this.#committed = this.value;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** A form reset puts the input back without an `input` event: the rest follows it. */
|
|
324
|
+
#onReset = (event: Event) => {
|
|
325
|
+
if (this.input?.form !== event.target) return;
|
|
326
|
+
setTimeout(() => {
|
|
327
|
+
this.#committed = this.value;
|
|
328
|
+
this.#sync();
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
#onWheel = (event: WheelEvent) => {
|
|
333
|
+
const input = this.input;
|
|
334
|
+
if (event.target !== input || document.activeElement !== input || event.deltaY === 0) return;
|
|
335
|
+
// Ctrl (or a pinch) zooms the page, and a mostly horizontal swipe is no step (TASK-447, L17).
|
|
336
|
+
if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
|
337
|
+
if (!this.hasAttribute("allow-wheel-scrub")) {
|
|
338
|
+
// Chromium steps a focused number input on the wheel: here only `allow-wheel-scrub` does.
|
|
339
|
+
// Cancelling that also stops the page's scroll, so the wheel scrolls it here (L17).
|
|
340
|
+
if (!this.formatted) {
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
scrollFor(input!, event);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!this.#usable()) return;
|
|
347
|
+
event.preventDefault();
|
|
348
|
+
this.#stepBy(event.deltaY < 0 ? this.#step : -this.#step, true);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Repeats `action` while the pointer stays down: once now, then after a pause, faster. It stops
|
|
353
|
+
* when the press ends, wherever it ends, or when `action` returns `false` (a button that got
|
|
354
|
+
* disabled at the end of the range no longer gets pointer events).
|
|
355
|
+
*/
|
|
356
|
+
#hold(
|
|
357
|
+
target: HTMLElement,
|
|
358
|
+
event: PointerEvent,
|
|
359
|
+
action: () => boolean | void,
|
|
360
|
+
done: () => void,
|
|
361
|
+
): void {
|
|
362
|
+
this.#holding?.stop();
|
|
363
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
364
|
+
const stop = () => {
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
for (const type of ["pointerup", "pointercancel"]) removeEventListener(type, end, true);
|
|
367
|
+
removeEventListener("blur", stop);
|
|
368
|
+
target.removeEventListener("lostpointercapture", stop);
|
|
369
|
+
if (this.#holding?.stop === stop) this.#holding = undefined;
|
|
370
|
+
done();
|
|
371
|
+
};
|
|
372
|
+
const end = (next: Event) => {
|
|
373
|
+
if ((next as PointerEvent).pointerId === event.pointerId) stop();
|
|
374
|
+
};
|
|
375
|
+
const repeat = () => {
|
|
376
|
+
if (action() === false) stop();
|
|
377
|
+
else timer = setTimeout(repeat, REPEAT);
|
|
378
|
+
};
|
|
379
|
+
target.setPointerCapture?.(event.pointerId);
|
|
380
|
+
for (const type of ["pointerup", "pointercancel"]) addEventListener(type, end, true);
|
|
381
|
+
addEventListener("blur", stop);
|
|
382
|
+
target.addEventListener("lostpointercapture", stop);
|
|
383
|
+
this.#holding = { stop };
|
|
384
|
+
if (action() === false) stop();
|
|
385
|
+
else timer = setTimeout(repeat, HOLD);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#onPress = (event: PointerEvent) => {
|
|
389
|
+
if (event.button !== 0 || !event.isPrimary || !this.#usable()) return;
|
|
390
|
+
const target = event.target as Element;
|
|
391
|
+
const button = target.closest?.<HTMLButtonElement>("button[data-action]");
|
|
392
|
+
if (button && this.contains(button) && !button.disabled) {
|
|
393
|
+
// No focus on the button. A mouse puts it in the input, so the arrow keys go on stepping
|
|
394
|
+
// (TASK-447, L18); a touch leaves it where it is, so no virtual keyboard opens.
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
if (event.pointerType === "mouse") this.input?.focus({ preventScroll: true });
|
|
397
|
+
this.#pressed = true;
|
|
398
|
+
const size = this.#stepFor(event);
|
|
399
|
+
const amount = button.dataset.action === "increment" ? size : -size;
|
|
400
|
+
this.#hold(
|
|
401
|
+
button,
|
|
402
|
+
event,
|
|
403
|
+
() => {
|
|
404
|
+
if (button.disabled) return false;
|
|
405
|
+
this.#stepBy(amount, false);
|
|
406
|
+
},
|
|
407
|
+
() => {
|
|
408
|
+
this.#settle();
|
|
409
|
+
// After the click this press may fire (none when it ends off the button).
|
|
410
|
+
setTimeout(() => (this.#pressed = false));
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const area = target.closest?.<HTMLElement>("[data-scrub]");
|
|
416
|
+
if (area && this.contains(area)) this.#scrub(area, event);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/** Ends a press or a drag: commits the value it reached. */
|
|
420
|
+
#settle(): void {
|
|
421
|
+
const input = this.input;
|
|
422
|
+
if (!input) return;
|
|
423
|
+
const value = this.value;
|
|
424
|
+
if (this.#committed !== value) input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
425
|
+
this.#committed = value;
|
|
426
|
+
this.removeAttribute("data-scrubbing");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#onClick = (event: MouseEvent) => {
|
|
430
|
+
const button = (event.target as Element).closest?.<HTMLButtonElement>("button[data-action]");
|
|
431
|
+
if (!button || !this.contains(button)) return;
|
|
432
|
+
// The click that ends a pointer press already stepped; one from a key or a screen reader steps.
|
|
433
|
+
if (this.#pressed) {
|
|
434
|
+
this.#pressed = false;
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const size = this.#stepFor(event);
|
|
438
|
+
this.#stepBy(button.dataset.action === "increment" ? size : -size, true);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/** Dragging across a scrub area steps the value, one step per `data-pixel-sensitivity` px. */
|
|
442
|
+
#scrub(area: HTMLElement, event: PointerEvent): void {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
const vertical = area.dataset.direction === "vertical";
|
|
445
|
+
// Right to left, a horizontal drag goes up toward the left, as a slider does (TASK-385).
|
|
446
|
+
const sign = !vertical && getComputedStyle(area).direction === "rtl" ? -1 : 1;
|
|
447
|
+
const sensitivity = finite(area.dataset.pixelSensitivity ?? null) ?? 2;
|
|
448
|
+
let travelled = 0;
|
|
449
|
+
let last = vertical ? event.clientY : event.clientX;
|
|
450
|
+
const move = (next: PointerEvent) => {
|
|
451
|
+
if (next.pointerId !== event.pointerId) return;
|
|
452
|
+
const position = vertical ? next.clientY : next.clientX;
|
|
453
|
+
// Right and up go up.
|
|
454
|
+
travelled += vertical ? last - position : (position - last) * sign;
|
|
455
|
+
last = position;
|
|
456
|
+
const steps = Math.trunc(travelled / sensitivity);
|
|
457
|
+
if (steps === 0) return;
|
|
458
|
+
travelled -= steps * sensitivity;
|
|
459
|
+
this.setAttribute("data-scrubbing", "");
|
|
460
|
+
this.#stepBy(steps * this.#step, false);
|
|
461
|
+
};
|
|
462
|
+
area.addEventListener("pointermove", move);
|
|
463
|
+
this.#hold(
|
|
464
|
+
area,
|
|
465
|
+
event,
|
|
466
|
+
() => {},
|
|
467
|
+
() => {
|
|
468
|
+
area.removeEventListener("pointermove", move);
|
|
469
|
+
this.#settle();
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Scrolls the input's nearest scrollable ancestor (or the page) as the wheel would have. */
|
|
476
|
+
function scrollFor(input: HTMLElement, event: WheelEvent): void {
|
|
477
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
|
|
478
|
+
let box = input.parentElement;
|
|
479
|
+
while (box && box !== document.body) {
|
|
480
|
+
const { overflowY } = getComputedStyle(box);
|
|
481
|
+
if (/auto|scroll/.test(overflowY) && box.scrollHeight > box.clientHeight) break;
|
|
482
|
+
box = box.parentElement;
|
|
483
|
+
}
|
|
484
|
+
const target = box && box !== document.body ? box : window;
|
|
485
|
+
target.scrollBy({ left: event.deltaX * unit, top: event.deltaY * unit });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!customElements.get("lu-number-field")) {
|
|
489
|
+
customElements.define("lu-number-field", NumberFieldElement);
|
|
490
|
+
}
|