@adia-ai/web-components 0.4.5 → 0.4.7
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/README.md +63 -24
- package/USAGE.md +604 -0
- package/components/accordion/accordion.d.ts +17 -0
- package/components/accordion/accordion.js +10 -117
- package/components/accordion/class.js +132 -0
- package/components/action-list/action-list.d.ts +15 -0
- package/components/action-list/action-list.js +9 -140
- package/components/action-list/class.js +156 -0
- package/components/agent-artifact/agent-artifact.d.ts +25 -0
- package/components/agent-artifact/agent-artifact.js +8 -181
- package/components/agent-artifact/class.js +200 -0
- package/components/agent-feedback-bar/agent-feedback-bar.d.ts +21 -0
- package/components/agent-feedback-bar/agent-feedback-bar.js +8 -143
- package/components/agent-feedback-bar/class.js +162 -0
- package/components/agent-questions/agent-questions.d.ts +23 -0
- package/components/agent-questions/agent-questions.js +8 -180
- package/components/agent-questions/class.js +199 -0
- package/components/agent-reasoning/agent-reasoning.d.ts +23 -0
- package/components/agent-reasoning/agent-reasoning.js +8 -494
- package/components/agent-reasoning/class.js +513 -0
- package/components/agent-suggestions/agent-suggestions.d.ts +21 -0
- package/components/agent-suggestions/agent-suggestions.js +8 -78
- package/components/agent-suggestions/class.js +97 -0
- package/components/agent-trace/agent-trace.d.ts +19 -0
- package/components/alert/alert.d.ts +29 -0
- package/components/alert/alert.js +8 -175
- package/components/alert/class.js +194 -0
- package/components/avatar/avatar.d.ts +27 -0
- package/components/avatar/avatar.js +9 -159
- package/components/avatar/class.js +173 -0
- package/components/badge/badge.d.ts +27 -0
- package/components/badge/badge.js +9 -75
- package/components/badge/class.js +93 -0
- package/components/block/block.d.ts +19 -0
- package/components/block/block.js +9 -15
- package/components/block/class.js +33 -0
- package/components/breadcrumb/breadcrumb.d.ts +23 -0
- package/components/breadcrumb/breadcrumb.js +8 -113
- package/components/breadcrumb/class.js +132 -0
- package/components/button/button.d.ts +34 -0
- package/components/button/button.js +15 -66
- package/components/button/class.js +80 -0
- package/components/calendar-picker/calendar-picker.a2ui.json +6 -1
- package/components/calendar-picker/calendar-picker.d.ts +27 -0
- package/components/calendar-picker/calendar-picker.js +8 -332
- package/components/calendar-picker/calendar-picker.yaml +51 -177
- package/components/calendar-picker/class.js +351 -0
- package/components/canvas/canvas.a2ui.json +6 -1
- package/components/canvas/canvas.d.ts +17 -0
- package/components/canvas/canvas.yaml +19 -36
- package/components/card/card.a2ui.json +3 -0
- package/components/card/card.d.ts +27 -0
- package/components/card/card.js +9 -50
- package/components/card/card.yaml +171 -433
- package/components/card/class.js +68 -0
- package/components/chart/chart.d.ts +41 -0
- package/components/chart/chart.js +8 -2131
- package/components/chart/class.js +2150 -0
- package/components/chart-legend/chart-legend.d.ts +27 -0
- package/components/chart-legend/chart-legend.js +8 -197
- package/components/chart-legend/class.js +215 -0
- package/components/chat-thread/chat-thread.d.ts +17 -0
- package/components/chat-thread/chat-thread.js +8 -157
- package/components/chat-thread/class.js +176 -0
- package/components/check/check.d.ts +30 -0
- package/components/check/check.js +11 -52
- package/components/check/class.js +68 -0
- package/components/code/class.js +501 -0
- package/components/code/code.d.ts +39 -0
- package/components/code/code.js +8 -482
- package/components/col/class.js +30 -0
- package/components/col/col.d.ts +23 -0
- package/components/col/col.js +10 -13
- package/components/color-picker/class.js +550 -0
- package/components/color-picker/color-picker.d.ts +37 -0
- package/components/color-picker/color-picker.js +8 -531
- package/components/command/class.js +364 -0
- package/components/command/command.a2ui.json +3 -0
- package/components/command/command.d.ts +19 -0
- package/components/command/command.js +8 -345
- package/components/command/command.yaml +105 -124
- package/components/demo-toggle/class.js +153 -0
- package/components/demo-toggle/demo-toggle.d.ts +23 -0
- package/components/demo-toggle/demo-toggle.js +8 -135
- package/components/description-list/class.js +86 -0
- package/components/description-list/description-list.d.ts +21 -0
- package/components/description-list/description-list.js +8 -67
- package/components/divider/class.js +57 -0
- package/components/divider/divider.d.ts +19 -0
- package/components/divider/divider.js +10 -40
- package/components/drawer/class.js +306 -0
- package/components/drawer/drawer.d.ts +25 -0
- package/components/drawer/drawer.js +8 -287
- package/components/embed/class.js +73 -0
- package/components/embed/embed.d.ts +23 -0
- package/components/embed/embed.js +9 -55
- package/components/empty-state/class.js +108 -0
- package/components/empty-state/empty-state.d.ts +21 -0
- package/components/empty-state/empty-state.js +9 -90
- package/components/feed/class.js +381 -0
- package/components/feed/feed.d.ts +19 -0
- package/components/feed/feed.js +9 -367
- package/components/field/class.js +266 -0
- package/components/field/field.d.ts +23 -0
- package/components/field/field.js +8 -247
- package/components/fields/class.js +106 -0
- package/components/fields/fields.d.ts +19 -0
- package/components/fields/fields.js +8 -87
- package/components/grid/class.js +31 -0
- package/components/grid/grid.d.ts +23 -0
- package/components/grid/grid.js +10 -14
- package/components/heatmap/class.js +305 -0
- package/components/heatmap/heatmap.d.ts +31 -0
- package/components/heatmap/heatmap.js +8 -286
- package/components/icon/class.js +54 -0
- package/components/icon/icon.d.ts +23 -0
- package/components/icon/icon.js +13 -40
- package/components/image/class.js +112 -0
- package/components/image/image.d.ts +33 -0
- package/components/image/image.js +9 -94
- package/components/index.js +1 -0
- package/components/input/class.js +773 -0
- package/components/input/input.a2ui.json +3 -0
- package/components/input/input.d.ts +61 -0
- package/components/input/input.js +8 -755
- package/components/input/input.yaml +171 -442
- package/components/inspector/class.js +142 -0
- package/components/inspector/inspector.a2ui.json +8 -1
- package/components/inspector/inspector.d.ts +17 -0
- package/components/inspector/inspector.js +8 -124
- package/components/inspector/inspector.yaml +15 -30
- package/components/kbd/class.js +34 -0
- package/components/kbd/kbd.a2ui.json +3 -0
- package/components/kbd/kbd.d.ts +17 -0
- package/components/kbd/kbd.js +10 -17
- package/components/kbd/kbd.yaml +54 -185
- package/components/link/class.js +187 -0
- package/components/link/link.d.ts +55 -0
- package/components/link/link.js +8 -168
- package/components/list/class.js +249 -0
- package/components/list/list.d.ts +23 -0
- package/components/list/list.js +9 -231
- package/components/menu/class.js +332 -0
- package/components/menu/menu.d.ts +21 -0
- package/components/menu/menu.js +11 -316
- package/components/modal/class.js +231 -0
- package/components/modal/modal.a2ui.json +5 -1
- package/components/modal/modal.d.ts +23 -0
- package/components/modal/modal.js +8 -212
- package/components/modal/modal.yaml +19 -39
- package/components/nav/class.js +150 -0
- package/components/nav/nav.d.ts +31 -0
- package/components/nav/nav.js +8 -131
- package/components/nav-group/class.js +152 -0
- package/components/nav-group/nav-group.d.ts +35 -0
- package/components/nav-group/nav-group.js +9 -134
- package/components/nav-item/class.js +86 -0
- package/components/nav-item/nav-item.d.ts +37 -0
- package/components/nav-item/nav-item.js +10 -69
- package/components/noodles/class.js +510 -0
- package/components/noodles/noodles.d.ts +33 -0
- package/components/noodles/noodles.js +9 -493
- package/components/option-card/class.js +167 -0
- package/components/option-card/option-card.d.ts +30 -0
- package/components/option-card/option-card.js +8 -149
- package/components/otp-input/class.js +180 -0
- package/components/otp-input/otp-input.a2ui.json +5 -1
- package/components/otp-input/otp-input.d.ts +25 -0
- package/components/otp-input/otp-input.js +9 -162
- package/components/otp-input/otp-input.yaml +45 -174
- package/components/page/class.js +97 -0
- package/components/page/page.d.ts +46 -0
- package/components/page/page.js +8 -79
- package/components/pagination/class.js +195 -0
- package/components/pagination/pagination.d.ts +23 -0
- package/components/pagination/pagination.js +9 -177
- package/components/pane/class.js +186 -0
- package/components/pane/pane.a2ui.json +12 -1
- package/components/pane/pane.css +10 -0
- package/components/pane/pane.d.ts +31 -0
- package/components/pane/pane.js +8 -143
- package/components/pane/pane.yaml +57 -157
- package/components/pipeline-status/class.js +189 -0
- package/components/pipeline-status/pipeline-status.a2ui.json +7 -1
- package/components/pipeline-status/pipeline-status.d.ts +21 -0
- package/components/pipeline-status/pipeline-status.js +9 -172
- package/components/pipeline-status/pipeline-status.yaml +34 -72
- package/components/popover/class.js +194 -0
- package/components/popover/popover.d.ts +23 -0
- package/components/popover/popover.js +9 -176
- package/components/progress/class.js +74 -0
- package/components/progress/progress.a2ui.json +3 -0
- package/components/progress/progress.d.ts +19 -0
- package/components/progress/progress.js +10 -57
- package/components/progress/progress.yaml +124 -287
- package/components/progress-row/class.js +110 -0
- package/components/progress-row/progress-row.d.ts +23 -0
- package/components/progress-row/progress-row.js +8 -92
- package/components/radio/class.js +83 -0
- package/components/radio/radio.d.ts +28 -0
- package/components/radio/radio.js +11 -67
- package/components/range/class.js +194 -0
- package/components/range/range.d.ts +31 -0
- package/components/range/range.js +9 -176
- package/components/rating/class.js +148 -0
- package/components/rating/rating.d.ts +33 -0
- package/components/rating/rating.js +9 -130
- package/components/richtext/class.js +87 -0
- package/components/richtext/richtext.a2ui.json +7 -1
- package/components/richtext/richtext.d.ts +19 -0
- package/components/richtext/richtext.js +8 -68
- package/components/richtext/richtext.yaml +30 -65
- package/components/row/class.js +50 -0
- package/components/row/row.d.ts +27 -0
- package/components/row/row.js +10 -33
- package/components/search/class.js +134 -0
- package/components/search/search.d.ts +35 -0
- package/components/search/search.js +10 -117
- package/components/segment/class.js +62 -0
- package/components/segment/segment.d.ts +25 -0
- package/components/segment/segment.js +10 -45
- package/components/segmented/class.js +165 -0
- package/components/segmented/segmented.a2ui.json +4 -0
- package/components/segmented/segmented.d.ts +24 -0
- package/components/segmented/segmented.js +10 -148
- package/components/segmented/segmented.yaml +41 -59
- package/components/select/class.js +408 -0
- package/components/select/select.d.ts +57 -0
- package/components/select/select.js +15 -396
- package/components/skeleton/class.js +52 -0
- package/components/skeleton/skeleton.d.ts +23 -0
- package/components/skeleton/skeleton.js +8 -34
- package/components/slider/class.js +184 -0
- package/components/slider/slider.d.ts +31 -0
- package/components/slider/slider.js +9 -166
- package/components/stack/class.js +28 -0
- package/components/stack/stack.d.ts +17 -0
- package/components/stack/stack.js +10 -11
- package/components/step-progress/class.js +98 -0
- package/components/step-progress/step-progress.d.ts +27 -0
- package/components/step-progress/step-progress.js +8 -79
- package/components/stepper/class.js +126 -0
- package/components/stepper/stepper.d.ts +19 -0
- package/components/stepper/stepper.js +9 -112
- package/components/stream/class.js +109 -0
- package/components/stream/stream.d.ts +19 -0
- package/components/stream/stream.js +8 -90
- package/components/swatch/class.js +131 -0
- package/components/swatch/swatch.d.ts +28 -0
- package/components/swatch/swatch.js +8 -112
- package/components/swiper/class.js +373 -0
- package/components/swiper/swiper.a2ui.json +4 -0
- package/components/swiper/swiper.d.ts +31 -0
- package/components/swiper/swiper.js +8 -354
- package/components/swiper/swiper.yaml +68 -212
- package/components/switch/class.js +63 -0
- package/components/switch/switch.a2ui.json +6 -1
- package/components/switch/switch.d.ts +30 -0
- package/components/switch/switch.js +11 -47
- package/components/switch/switch.yaml +70 -265
- package/components/table/class.js +1453 -0
- package/components/table/table.d.ts +37 -0
- package/components/table/table.js +8 -1435
- package/components/table-toolbar/class.js +680 -0
- package/components/table-toolbar/table-toolbar.d.ts +33 -0
- package/components/table-toolbar/table-toolbar.js +8 -689
- package/components/tabs/class.js +242 -0
- package/components/tabs/tabs.d.ts +21 -0
- package/components/tabs/tabs.js +8 -223
- package/components/tag/class.js +99 -0
- package/components/tag/tag.d.ts +27 -0
- package/components/tag/tag.js +8 -80
- package/components/text/class.js +46 -0
- package/components/text/text.d.ts +25 -0
- package/components/text/text.js +9 -28
- package/components/textarea/class.js +134 -0
- package/components/textarea/textarea.d.ts +31 -0
- package/components/textarea/textarea.js +11 -118
- package/components/timeline/class.js +176 -0
- package/components/timeline/timeline.d.ts +19 -0
- package/components/timeline/timeline.js +9 -162
- package/components/toast/class.js +92 -0
- package/components/toast/toast.d.ts +23 -0
- package/components/toast/toast.js +9 -76
- package/components/toggle-group/class.js +154 -0
- package/components/toggle-group/toggle-group.d.ts +19 -0
- package/components/toggle-group/toggle-group.js +11 -140
- package/components/toggle-scheme/class.js +286 -0
- package/components/toggle-scheme/toggle-scheme.a2ui.json +197 -0
- package/components/toggle-scheme/toggle-scheme.css +20 -0
- package/components/toggle-scheme/toggle-scheme.d.ts +41 -0
- package/components/toggle-scheme/toggle-scheme.js +17 -0
- package/components/toggle-scheme/toggle-scheme.yaml +173 -0
- package/components/toolbar/class.js +388 -0
- package/components/toolbar/toolbar.d.ts +23 -0
- package/components/toolbar/toolbar.js +10 -376
- package/components/tooltip/class.js +299 -0
- package/components/tooltip/tooltip.d.ts +27 -0
- package/components/tooltip/tooltip.js +8 -280
- package/components/tree/class.js +245 -0
- package/components/tree/tree.d.ts +15 -0
- package/components/tree/tree.js +9 -244
- package/components/upload/class.js +199 -0
- package/components/upload/upload.d.ts +27 -0
- package/components/upload/upload.js +11 -183
- package/core/element.d.ts +174 -0
- package/core/form.d.ts +108 -0
- package/core/index.d.ts +11 -0
- package/core/index.js +1 -0
- package/core/register.d.ts +25 -0
- package/core/register.js +58 -0
- package/core/signals.d.ts +94 -0
- package/core/template.d.ts +70 -0
- package/index.d.ts +315 -0
- package/package.json +25 -6
- package/traits/CATEGORIES.md +1 -1
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-side-effect class export for `<input-ui>`.
|
|
3
|
+
*
|
|
4
|
+
* Importing this file gives you the class(es) without auto-registering the tag.
|
|
5
|
+
* Useful for test isolation, subclassing with tag-name override, or selective
|
|
6
|
+
* composition.
|
|
7
|
+
*
|
|
8
|
+
* The auto-register path stays at `@adia-ai/web-components/components/input`
|
|
9
|
+
* (which imports this file + calls `defineIfFree()`).
|
|
10
|
+
*
|
|
11
|
+
* @see ../../USAGE.md#registration--auto-vs-explicit
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* <input-ui> — Text input. The host IS the interactive surface.
|
|
16
|
+
* Uses contenteditable for text entry, ElementInternals for form participation.
|
|
17
|
+
*
|
|
18
|
+
* Slots inside [slot="field"]:
|
|
19
|
+
* prefix → label → text → suffix → controls (number mode)
|
|
20
|
+
*
|
|
21
|
+
* <input-ui label="Email" placeholder="you@acme.com"></input-ui>
|
|
22
|
+
* <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
|
|
23
|
+
* <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
|
|
24
|
+
* <input-ui prefix="@" value="kim"></input-ui>
|
|
25
|
+
*
|
|
26
|
+
* <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
|
|
27
|
+
* <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
|
|
28
|
+
*
|
|
29
|
+
* type="number" renders a contenteditable surface + [+]/[-] stepper buttons,
|
|
30
|
+
* filters input to digits / minus / decimal, snaps to step, clamps to min/max,
|
|
31
|
+
* and exposes ARIA spinbutton semantics. No native <input type=number>.
|
|
32
|
+
*
|
|
33
|
+
* type="password" still wraps a native <input> — only path that needs
|
|
34
|
+
* `-webkit-text-security` disc masking, which only works on native inputs.
|
|
35
|
+
*
|
|
36
|
+
* label renders as a dim leading caption inside the chrome (next to the
|
|
37
|
+
* value, sharing the input's border) — for stacked label / hint / error
|
|
38
|
+
* compositions, wrap with field-ui.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { UIFormElement } from '../../core/form.js';
|
|
42
|
+
import { isIconName, whenIconRegistryReady } from '../../core/icons.js';
|
|
43
|
+
|
|
44
|
+
const renderAffix = (v) => isIconName(v)
|
|
45
|
+
? `<icon-ui name="${v}"></icon-ui>`
|
|
46
|
+
: v;
|
|
47
|
+
|
|
48
|
+
export class UIInput extends UIFormElement {
|
|
49
|
+
// Opt out of UIFormElement's per-control `label` deprecation warning.
|
|
50
|
+
// input-ui's `label` is a first-class API rendering an inline-leading
|
|
51
|
+
// caption inside the chrome with `aria-labelledby` wiring on the
|
|
52
|
+
// editable surface — not the inert above-the-field rendering that
|
|
53
|
+
// motivated the deprecation.
|
|
54
|
+
static labelDeprecated = false;
|
|
55
|
+
|
|
56
|
+
static properties = {
|
|
57
|
+
...UIFormElement.properties,
|
|
58
|
+
placeholder: { type: String, default: '', reflect: true },
|
|
59
|
+
type: { type: String, default: 'text', reflect: true },
|
|
60
|
+
label: { type: String, default: '', reflect: true },
|
|
61
|
+
prefix: { type: String, default: '', reflect: true },
|
|
62
|
+
suffix: { type: String, default: '', reflect: true },
|
|
63
|
+
raw: { type: Boolean, default: false, reflect: true },
|
|
64
|
+
// ── Number mode ──
|
|
65
|
+
min: { type: Number, default: null, reflect: true },
|
|
66
|
+
max: { type: Number, default: null, reflect: true },
|
|
67
|
+
step: { type: Number, default: 1, reflect: true },
|
|
68
|
+
precision: { type: Number, default: null, reflect: true },
|
|
69
|
+
// BCP-47 locale tag, e.g. "de-DE" / "fr-FR" / "en-IN". Default empty =
|
|
70
|
+
// en-US (`.` decimal separator, no thousands grouping). When set, the
|
|
71
|
+
// input accepts both `.` AND the locale's decimal separator (so en-US-
|
|
72
|
+
// formatted programmatic values still parse), and `#format` uses
|
|
73
|
+
// `Intl.NumberFormat` for display. Internal storage stays in JS-Number
|
|
74
|
+
// canonical form so `.value` round-trips through `Number(v)` unchanged.
|
|
75
|
+
locale: { type: String, default: '', reflect: true },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
static template = () => null;
|
|
79
|
+
|
|
80
|
+
#textEl = null;
|
|
81
|
+
#labelEl = null;
|
|
82
|
+
#upBtn = null;
|
|
83
|
+
#downBtn = null;
|
|
84
|
+
#valueAtFocus = '';
|
|
85
|
+
#repeatTimer = null;
|
|
86
|
+
#repeatDelayTimer = null;
|
|
87
|
+
#cachedSep = '.';
|
|
88
|
+
#cachedGroup = '';
|
|
89
|
+
#cachedSepFor = null;
|
|
90
|
+
static #labelSeq = 0;
|
|
91
|
+
|
|
92
|
+
// Hold-to-repeat tuning. Initial delay before autorepeat begins, and the
|
|
93
|
+
// interval between repeats. Values match the cadence of the native
|
|
94
|
+
// <input type="number"> spinner behavior in Chromium/Safari.
|
|
95
|
+
static #REPEAT_INITIAL_MS = 400;
|
|
96
|
+
static #REPEAT_INTERVAL_MS = 60;
|
|
97
|
+
|
|
98
|
+
get #isNativePassword() { return this.type === 'password'; }
|
|
99
|
+
get #isNumberMode() { return this.type === 'number'; }
|
|
100
|
+
|
|
101
|
+
/** Parsed numeric value. NaN when empty or unparseable. When `locale` is
|
|
102
|
+
* set, the value may carry the locale's decimal separator (e.g. "1,5" in
|
|
103
|
+
* de-DE); we canonicalize to JS form before `Number(…)`. */
|
|
104
|
+
get valueAsNumber() {
|
|
105
|
+
const raw = String(this.value ?? '').trim();
|
|
106
|
+
if (!raw) return NaN;
|
|
107
|
+
const s = this.#toCanonical(raw);
|
|
108
|
+
if (s === '-' || s === '.' || s === '-.') return NaN;
|
|
109
|
+
const n = Number(s);
|
|
110
|
+
return Number.isFinite(n) ? n : NaN;
|
|
111
|
+
}
|
|
112
|
+
set valueAsNumber(n) {
|
|
113
|
+
if (!Number.isFinite(n)) { this.value = ''; return; }
|
|
114
|
+
this.value = this.#format(n);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
connected() {
|
|
118
|
+
super.connected();
|
|
119
|
+
this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
|
|
120
|
+
|
|
121
|
+
if (!this.querySelector('[slot="text"]')) {
|
|
122
|
+
const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
|
|
123
|
+
this.innerHTML = this.#shellHTML(labelId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.#textEl = this.querySelector('[slot="text"]');
|
|
127
|
+
this.#labelEl = this.querySelector('[slot="label"]');
|
|
128
|
+
this.#upBtn = this.querySelector('[data-step="up"]');
|
|
129
|
+
this.#downBtn = this.querySelector('[data-step="down"]');
|
|
130
|
+
|
|
131
|
+
if (!this.#isNativePassword && this.value) {
|
|
132
|
+
this.#textEl.textContent = this.#isNumberMode
|
|
133
|
+
? this.#formatStored(this.value)
|
|
134
|
+
: this.value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (this.#textEl) {
|
|
138
|
+
this.#textEl.addEventListener('input', this.#onInput);
|
|
139
|
+
this.#textEl.addEventListener('keydown', this.#onKeydown);
|
|
140
|
+
this.#textEl.addEventListener('blur', this.#onBlur);
|
|
141
|
+
this.#textEl.addEventListener('focus', this.#onFocus);
|
|
142
|
+
this.#textEl.addEventListener('paste', this.#onPaste);
|
|
143
|
+
if (this.#isNumberMode) {
|
|
144
|
+
this.#textEl.addEventListener('beforeinput', this.#onBeforeInput);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// pointerdown.preventDefault keeps focus on the contenteditable surface
|
|
149
|
+
// when the user pokes a stepper button with a pointing device. Same
|
|
150
|
+
// handler fires the initial step + arms hold-to-repeat; pointerup/leave/
|
|
151
|
+
// cancel on document stops it (the user can drag off the button to
|
|
152
|
+
// abort the repeat without lifting their finger first).
|
|
153
|
+
this.#upBtn?.addEventListener('pointerdown', this.#onStepperUpDown);
|
|
154
|
+
this.#downBtn?.addEventListener('pointerdown', this.#onStepperDownDown);
|
|
155
|
+
// Stop autorepeat on any pointer release, anywhere — captures the
|
|
156
|
+
// "drag-off-then-lift" abort path without per-button leave/cancel
|
|
157
|
+
// bookkeeping. Cheap; runs only while a stepper is held.
|
|
158
|
+
|
|
159
|
+
// In non-Vite static deploys, the icon registry loads asynchronously
|
|
160
|
+
// after the manifest fetch resolves. If our prefix/suffix were checked
|
|
161
|
+
// by isIconName() during that window, kebab-case icon names like
|
|
162
|
+
// "magnifying-glass" got baked into the DOM as literal text. Re-evaluate
|
|
163
|
+
// once the registry is ready and promote text-rendered affixes to
|
|
164
|
+
// <icon-ui>. (No-op on Vite dev where the promise resolves synchronously.)
|
|
165
|
+
if (this.prefix || this.suffix) {
|
|
166
|
+
whenIconRegistryReady.then(() => this.#promoteAffixes());
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#shellHTML(labelId) {
|
|
171
|
+
const prefix = this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : '';
|
|
172
|
+
const label = this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : '';
|
|
173
|
+
const suffix = this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : '';
|
|
174
|
+
const labelby = labelId ? `aria-labelledby="${labelId}"` : '';
|
|
175
|
+
|
|
176
|
+
if (this.#isNativePassword) {
|
|
177
|
+
return `
|
|
178
|
+
<div slot="field">
|
|
179
|
+
${prefix}${label}
|
|
180
|
+
<input slot="text" type="password" tabindex="0"
|
|
181
|
+
placeholder="${this.placeholder}" value="${this.value || ''}"
|
|
182
|
+
autocomplete="current-password" ${labelby}
|
|
183
|
+
${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />
|
|
184
|
+
${suffix}
|
|
185
|
+
</div>
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const editable = `
|
|
190
|
+
<span slot="text" contenteditable="plaintext-only" tabindex="0"
|
|
191
|
+
${this.value ? '' : 'data-empty=""'}
|
|
192
|
+
${labelby}
|
|
193
|
+
data-placeholder="${this.placeholder}"
|
|
194
|
+
${this.#isNumberMode ? 'inputmode="decimal"' : ''}></span>`;
|
|
195
|
+
|
|
196
|
+
const controls = this.#isNumberMode ? `
|
|
197
|
+
<span slot="controls" data-controls aria-hidden="true">
|
|
198
|
+
<button-ui type="button" tabindex="-1" variant="ghost" size="xs"
|
|
199
|
+
icon="caret-up" data-step="up" aria-label="Increase"></button-ui>
|
|
200
|
+
<button-ui type="button" tabindex="-1" variant="ghost" size="xs"
|
|
201
|
+
icon="caret-down" data-step="down" aria-label="Decrease"></button-ui>
|
|
202
|
+
</span>` : '';
|
|
203
|
+
|
|
204
|
+
return `
|
|
205
|
+
<div slot="field"${this.#isNumberMode ? ' data-number' : ''}>
|
|
206
|
+
${prefix}${label}${editable}${suffix}${controls}
|
|
207
|
+
</div>
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#promoteAffixes() {
|
|
212
|
+
if (!this.isConnected) return;
|
|
213
|
+
for (const which of ['prefix', 'suffix']) {
|
|
214
|
+
const value = this[which];
|
|
215
|
+
if (!value) continue;
|
|
216
|
+
const slot = this.querySelector(`:scope [slot="${which}"]`);
|
|
217
|
+
if (!slot) continue;
|
|
218
|
+
// Already an <icon-ui> — nothing to do.
|
|
219
|
+
if (slot.querySelector(':scope > icon-ui')) continue;
|
|
220
|
+
// Was rendered as text and the value is now a known icon — replace.
|
|
221
|
+
if (isIconName(value)) {
|
|
222
|
+
slot.replaceChildren();
|
|
223
|
+
const icon = document.createElement('icon-ui');
|
|
224
|
+
icon.setAttribute('name', value);
|
|
225
|
+
slot.appendChild(icon);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
render() {
|
|
231
|
+
if (!this.#textEl) return;
|
|
232
|
+
|
|
233
|
+
const text = this.value ?? '';
|
|
234
|
+
|
|
235
|
+
if (this.#isNativePassword) {
|
|
236
|
+
this.#textEl.placeholder = this.placeholder;
|
|
237
|
+
this.#textEl.disabled = this.disabled;
|
|
238
|
+
this.#textEl.readOnly = this.readonly;
|
|
239
|
+
if (this.#textEl.value !== text) this.#textEl.value = text;
|
|
240
|
+
} else {
|
|
241
|
+
this.#textEl.setAttribute('data-placeholder', this.placeholder);
|
|
242
|
+
if (this.disabled || this.readonly) {
|
|
243
|
+
this.#textEl.contentEditable = 'false';
|
|
244
|
+
} else {
|
|
245
|
+
this.#textEl.contentEditable = 'plaintext-only';
|
|
246
|
+
}
|
|
247
|
+
// Sync programmatic value writes into the contenteditable surface.
|
|
248
|
+
// Skip when already in sync to avoid clobbering an in-flight edit's
|
|
249
|
+
// caret position. For number mode, render the formatted display, but
|
|
250
|
+
// only when the surface DOESN'T have focus (mid-edit reformat would
|
|
251
|
+
// wipe caret + lose the user's transient state like "9." → "9").
|
|
252
|
+
const display = this.#isNumberMode && document.activeElement !== this.#textEl
|
|
253
|
+
? this.#formatStored(text)
|
|
254
|
+
: String(text);
|
|
255
|
+
if (this.#textEl.textContent !== display) {
|
|
256
|
+
this.#textEl.textContent = display;
|
|
257
|
+
this.#textEl.toggleAttribute('data-empty', !display);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (this.#labelEl) this.#labelEl.textContent = this.label || '';
|
|
262
|
+
|
|
263
|
+
if (this.label) {
|
|
264
|
+
this.removeAttribute('aria-label');
|
|
265
|
+
} else if (this.placeholder) {
|
|
266
|
+
this.setAttribute('aria-label', this.placeholder);
|
|
267
|
+
} else {
|
|
268
|
+
this.removeAttribute('aria-label');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (this.#isNumberMode) {
|
|
272
|
+
const n = this.valueAsNumber;
|
|
273
|
+
if (Number.isFinite(n)) {
|
|
274
|
+
this.setAttribute('aria-valuenow', String(n));
|
|
275
|
+
this.setAttribute('aria-valuetext', `${this.#format(n)}${this.suffix ? ' ' + this.suffix : ''}`);
|
|
276
|
+
} else {
|
|
277
|
+
this.removeAttribute('aria-valuenow');
|
|
278
|
+
this.removeAttribute('aria-valuetext');
|
|
279
|
+
}
|
|
280
|
+
if (this.min != null) this.setAttribute('aria-valuemin', String(this.min));
|
|
281
|
+
else this.removeAttribute('aria-valuemin');
|
|
282
|
+
if (this.max != null) this.setAttribute('aria-valuemax', String(this.max));
|
|
283
|
+
else this.removeAttribute('aria-valuemax');
|
|
284
|
+
|
|
285
|
+
const disableUp = this.disabled || this.readonly || (this.max != null && Number.isFinite(n) && n >= this.max);
|
|
286
|
+
const disableDown = this.disabled || this.readonly || (this.min != null && Number.isFinite(n) && n <= this.min);
|
|
287
|
+
this.#upBtn?.toggleAttribute('disabled', !!disableUp);
|
|
288
|
+
this.#downBtn?.toggleAttribute('disabled', !!disableDown);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Value sync + validation override ──
|
|
293
|
+
|
|
294
|
+
syncValue(val) {
|
|
295
|
+
val = val ?? this.value ?? '';
|
|
296
|
+
super.syncValue(String(val));
|
|
297
|
+
if (this.#isNumberMode) this.#runNumberConstraints(String(val));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
validate() {
|
|
301
|
+
const baseValid = super.validate();
|
|
302
|
+
if (!this.#isNumberMode) return baseValid;
|
|
303
|
+
// super.validate cleared validity if all base constraints passed; layer
|
|
304
|
+
// number-specific checks on top.
|
|
305
|
+
if (!baseValid) return false;
|
|
306
|
+
const numValid = this.#runNumberConstraints(this.value ?? '');
|
|
307
|
+
if (!numValid) {
|
|
308
|
+
this.setAttribute('aria-invalid', 'true');
|
|
309
|
+
this.error = this.validationMessage;
|
|
310
|
+
}
|
|
311
|
+
return numValid;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#runNumberConstraints(val) {
|
|
315
|
+
const raw = String(val ?? '').trim();
|
|
316
|
+
// Empty is handled by `required` in the base class; nothing to check here.
|
|
317
|
+
if (!raw) return true;
|
|
318
|
+
// Canonicalize for `Number(…)` parse — when `locale` is set the raw
|
|
319
|
+
// value may carry the locale's decimal separator.
|
|
320
|
+
const s = this.#toCanonical(raw);
|
|
321
|
+
const n = Number(s);
|
|
322
|
+
if (!Number.isFinite(n)) {
|
|
323
|
+
this.internals.setValidity(
|
|
324
|
+
{ badInput: true },
|
|
325
|
+
this.getAttribute('data-msg-bad-input') || 'Please enter a valid number.',
|
|
326
|
+
this,
|
|
327
|
+
);
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
if (this.min != null && n < this.min) {
|
|
331
|
+
this.internals.setValidity(
|
|
332
|
+
{ rangeUnderflow: true },
|
|
333
|
+
this.getAttribute('data-msg-min') || `Value must be ${this.min} or greater.`,
|
|
334
|
+
this,
|
|
335
|
+
);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
if (this.max != null && n > this.max) {
|
|
339
|
+
this.internals.setValidity(
|
|
340
|
+
{ rangeOverflow: true },
|
|
341
|
+
this.getAttribute('data-msg-max') || `Value must be ${this.max} or less.`,
|
|
342
|
+
this,
|
|
343
|
+
);
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Number helpers ──
|
|
350
|
+
|
|
351
|
+
#decimals() {
|
|
352
|
+
if (this.precision != null) return Math.max(0, this.precision | 0);
|
|
353
|
+
const stepStr = String(this.step ?? 1);
|
|
354
|
+
return (stepStr.split('.')[1] || '').length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Locale's decimal separator, or '.' for the default en-US-equivalent path.
|
|
358
|
+
* Result cached per-locale on the host so `Intl.NumberFormat.formatToParts`
|
|
359
|
+
* isn't called per keystroke. */
|
|
360
|
+
#decimalSep() {
|
|
361
|
+
if (!this.locale) return '.';
|
|
362
|
+
if (this.#cachedSepFor === this.locale) return this.#cachedSep;
|
|
363
|
+
this.#refreshSepCache();
|
|
364
|
+
return this.#cachedSep;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Locale's thousands/grouping separator (e.g. `,` in en-US, `.` in de-DE).
|
|
368
|
+
* Returns '' for the default path (no locale → no grouping). Cached
|
|
369
|
+
* alongside the decimal separator. */
|
|
370
|
+
#groupSep() {
|
|
371
|
+
if (!this.locale) return '';
|
|
372
|
+
if (this.#cachedSepFor === this.locale) return this.#cachedGroup;
|
|
373
|
+
this.#refreshSepCache();
|
|
374
|
+
return this.#cachedGroup;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#refreshSepCache() {
|
|
378
|
+
try {
|
|
379
|
+
const parts = new Intl.NumberFormat(this.locale).formatToParts(1234567.89);
|
|
380
|
+
this.#cachedSep = parts.find((p) => p.type === 'decimal')?.value || '.';
|
|
381
|
+
this.#cachedGroup = parts.find((p) => p.type === 'group')?.value || '';
|
|
382
|
+
} catch {
|
|
383
|
+
this.#cachedSep = '.';
|
|
384
|
+
this.#cachedGroup = '';
|
|
385
|
+
}
|
|
386
|
+
this.#cachedSepFor = this.locale;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Convert a locale-formatted numeric string to the JS-canonical form
|
|
390
|
+
* (decimal `.`, no thousands grouping). Strips group separators first so
|
|
391
|
+
* "1.234,5" (de-DE) → "1234.5", "1,234.5" (en-US) → "1234.5". Pure string
|
|
392
|
+
* transform; no validation. */
|
|
393
|
+
#toCanonical(s) {
|
|
394
|
+
const sep = this.#decimalSep();
|
|
395
|
+
const group = this.#groupSep();
|
|
396
|
+
let out = String(s);
|
|
397
|
+
if (group) out = out.split(group).join('');
|
|
398
|
+
if (sep !== '.') out = out.replace(new RegExp(`\\${sep}`, 'g'), '.');
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Internal/edit-mode format: locale decimal separator, NO thousands
|
|
403
|
+
* grouping. Used for `this.value` storage and for the textContent
|
|
404
|
+
* rendering while the input is focused (so the user can edit without
|
|
405
|
+
* the group separator jumping around as they type). */
|
|
406
|
+
#format(n) {
|
|
407
|
+
if (!Number.isFinite(n)) return '';
|
|
408
|
+
const d = this.#decimals();
|
|
409
|
+
if (this.locale) {
|
|
410
|
+
try {
|
|
411
|
+
return new Intl.NumberFormat(this.locale, {
|
|
412
|
+
minimumFractionDigits: d,
|
|
413
|
+
maximumFractionDigits: d,
|
|
414
|
+
useGrouping: false,
|
|
415
|
+
}).format(n);
|
|
416
|
+
} catch { /* fall through to JS toFixed */ }
|
|
417
|
+
}
|
|
418
|
+
return d > 0 ? n.toFixed(d) : String(Math.round(n));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Display-mode format: locale decimal separator + thousands grouping when
|
|
422
|
+
* the locale supports it. Used for the textContent rendering when the
|
|
423
|
+
* input is NOT focused (initial render + post-blur). Returns the same as
|
|
424
|
+
* `#format` when no `locale` is set. */
|
|
425
|
+
#formatDisplay(n) {
|
|
426
|
+
if (!Number.isFinite(n)) return '';
|
|
427
|
+
if (!this.locale) return this.#format(n);
|
|
428
|
+
const d = this.#decimals();
|
|
429
|
+
try {
|
|
430
|
+
return new Intl.NumberFormat(this.locale, {
|
|
431
|
+
minimumFractionDigits: d,
|
|
432
|
+
maximumFractionDigits: d,
|
|
433
|
+
useGrouping: true,
|
|
434
|
+
}).format(n);
|
|
435
|
+
} catch { return this.#format(n); }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Display value derived from the stored string. During focus we leave
|
|
439
|
+
* the user's raw text alone; otherwise reformat (e.g. "9.9" → "9.90"
|
|
440
|
+
* for precision=2). Non-numeric stored strings pass through unchanged
|
|
441
|
+
* so error-state visuals can echo what the user typed. */
|
|
442
|
+
#formatStored(stored) {
|
|
443
|
+
const s = String(stored ?? '');
|
|
444
|
+
if (!s) return '';
|
|
445
|
+
// Canonicalize before Number() — `.value` may carry the locale's
|
|
446
|
+
// decimal separator if the host has `locale` set.
|
|
447
|
+
const n = Number(this.#toCanonical(s));
|
|
448
|
+
if (!Number.isFinite(n)) return s;
|
|
449
|
+
// If the input is currently focused, render without grouping so the
|
|
450
|
+
// user can edit naturally; otherwise group when locale is set. Falls
|
|
451
|
+
// back to #format (ungrouped) when there's no locale.
|
|
452
|
+
return document.activeElement === this.#textEl
|
|
453
|
+
? this.#format(n)
|
|
454
|
+
: this.#formatDisplay(n);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#snap(raw) {
|
|
458
|
+
const step = this.step || 1;
|
|
459
|
+
const base = this.min != null ? this.min : 0;
|
|
460
|
+
const stepped = Math.round((raw - base) / step) * step + base;
|
|
461
|
+
const clamped = Math.max(
|
|
462
|
+
this.min != null ? this.min : -Infinity,
|
|
463
|
+
Math.min(this.max != null ? this.max : Infinity, stepped),
|
|
464
|
+
);
|
|
465
|
+
return parseFloat(clamped.toFixed(10));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#stepBy(multiplier) {
|
|
469
|
+
if (this.disabled || this.readonly) return;
|
|
470
|
+
const step = (this.step || 1) * multiplier;
|
|
471
|
+
const current = Number.isFinite(this.valueAsNumber)
|
|
472
|
+
? this.valueAsNumber
|
|
473
|
+
: (this.min != null ? this.min : 0);
|
|
474
|
+
const next = this.#snap(current + step);
|
|
475
|
+
if (next === this.valueAsNumber) return;
|
|
476
|
+
this.value = this.#format(next);
|
|
477
|
+
this.syncValue(this.value);
|
|
478
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
479
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Event handlers ──
|
|
483
|
+
|
|
484
|
+
#onInput = () => {
|
|
485
|
+
let text;
|
|
486
|
+
if (this.#isNativePassword) {
|
|
487
|
+
text = this.#textEl.value || '';
|
|
488
|
+
} else if (this.#isNumberMode) {
|
|
489
|
+
// beforeinput filtered the keystroke; some browsers still let through
|
|
490
|
+
// composition or paste events that bypass beforeinput. Re-sanitize.
|
|
491
|
+
const raw = this.#textEl.textContent || '';
|
|
492
|
+
text = this.#sanitizeNumeric(raw);
|
|
493
|
+
if (text !== raw) {
|
|
494
|
+
// Soft-revert: restore filtered text + put caret at end. Rare path.
|
|
495
|
+
this.#textEl.textContent = text;
|
|
496
|
+
this.#placeCaretAtEnd();
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
text = this.#textEl.textContent || '';
|
|
500
|
+
}
|
|
501
|
+
this.value = text;
|
|
502
|
+
if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
|
|
503
|
+
this.syncValue(text);
|
|
504
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
#onBeforeInput = (e) => {
|
|
508
|
+
// Allow deletions, formatting, composition — only gate text insertions.
|
|
509
|
+
const t = e.inputType;
|
|
510
|
+
if (!t || !t.startsWith('insert')) return;
|
|
511
|
+
if (t === 'insertCompositionText') return; // IME — let through, #onInput cleans up
|
|
512
|
+
const incoming = (e.data ?? '');
|
|
513
|
+
if (!incoming) return;
|
|
514
|
+
const current = this.#textEl.textContent || '';
|
|
515
|
+
const sel = window.getSelection();
|
|
516
|
+
// Build prospective string: replace selection (or insert at caret).
|
|
517
|
+
let start = current.length, end = current.length;
|
|
518
|
+
if (sel && sel.rangeCount && this.#textEl.contains(sel.anchorNode)) {
|
|
519
|
+
const r = sel.getRangeAt(0);
|
|
520
|
+
start = this.#offsetFromTextStart(r.startContainer, r.startOffset);
|
|
521
|
+
end = this.#offsetFromTextStart(r.endContainer, r.endOffset);
|
|
522
|
+
if (start > end) [start, end] = [end, start];
|
|
523
|
+
}
|
|
524
|
+
const prospective = current.slice(0, start) + incoming + current.slice(end);
|
|
525
|
+
if (!this.#isNumericProspect(prospective)) e.preventDefault();
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
#isNumericProspect(s) {
|
|
529
|
+
// Permissive while typing: allow lone '-', lone '.', and trailing '.'.
|
|
530
|
+
// Reject scientific notation, multiple decimals, multiple signs.
|
|
531
|
+
// When `locale` is set, accept both '.' AND the locale's decimal
|
|
532
|
+
// separator, and silently strip thousands-group separators (paste of
|
|
533
|
+
// "1,234.5" or "1.234,5" both validate).
|
|
534
|
+
const c = this.#toCanonical(s);
|
|
535
|
+
if (c === '' || c === '-' || c === '.' || c === '-.') {
|
|
536
|
+
return c === '' || c === '-' || (this.min == null || this.min < 0) ? true : false;
|
|
537
|
+
}
|
|
538
|
+
if (!/^-?\d*\.?\d*$/.test(c)) return false;
|
|
539
|
+
if (c.startsWith('-') && this.min != null && this.min >= 0) return false;
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#sanitizeNumeric(s) {
|
|
544
|
+
// Strip everything but digits / one leading minus / one decimal point.
|
|
545
|
+
// The decimal mark is the locale's separator; characters that match the
|
|
546
|
+
// locale's group separator (e.g. `.` in de-DE, `,` in en-US) are silently
|
|
547
|
+
// dropped — never preserved in `this.value`. The blur handler re-renders
|
|
548
|
+
// with grouping for display via `#formatDisplay`.
|
|
549
|
+
//
|
|
550
|
+
// Note on programmatic `.value = "1.5"` in de-DE: that path doesn't run
|
|
551
|
+
// through sanitization (UIFormElement.value setter is string-only), so
|
|
552
|
+
// canonical-form programmatic values still parse correctly via
|
|
553
|
+
// `valueAsNumber` (which canonicalizes through `#toCanonical`). Only
|
|
554
|
+
// user-typed/-pasted input flows through this sanitizer, and there the
|
|
555
|
+
// locale interpretation (`.` = group when sep=`,`) is the correct read.
|
|
556
|
+
const sep = this.#decimalSep();
|
|
557
|
+
let out = '';
|
|
558
|
+
let sawDecimal = false;
|
|
559
|
+
for (let i = 0; i < s.length; i++) {
|
|
560
|
+
const c = s[i];
|
|
561
|
+
if (c >= '0' && c <= '9') out += c;
|
|
562
|
+
else if (c === '-' && out === '' && (this.min == null || this.min < 0)) out += c;
|
|
563
|
+
else if (c === sep && !sawDecimal) { out += sep; sawDecimal = true; }
|
|
564
|
+
// group separator and other punctuation silently dropped
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#offsetFromTextStart(node, offset) {
|
|
570
|
+
// Walk the text descendants until we reach `node`, accumulating chars.
|
|
571
|
+
if (!this.#textEl.contains(node)) return 0;
|
|
572
|
+
let acc = 0;
|
|
573
|
+
const walker = document.createTreeWalker(this.#textEl, NodeFilter.SHOW_TEXT);
|
|
574
|
+
let n;
|
|
575
|
+
while ((n = walker.nextNode())) {
|
|
576
|
+
if (n === node) return acc + offset;
|
|
577
|
+
acc += n.textContent.length;
|
|
578
|
+
}
|
|
579
|
+
return node === this.#textEl ? offset : acc;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
#placeCaretAtEnd() {
|
|
583
|
+
const sel = window.getSelection();
|
|
584
|
+
const range = document.createRange();
|
|
585
|
+
range.selectNodeContents(this.#textEl);
|
|
586
|
+
range.collapse(false);
|
|
587
|
+
sel.removeAllRanges();
|
|
588
|
+
sel.addRange(range);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
#onKeydown = (e) => {
|
|
592
|
+
if (this.#isNumberMode) {
|
|
593
|
+
switch (e.key) {
|
|
594
|
+
case 'ArrowUp': e.preventDefault(); this.#stepBy( 1); return;
|
|
595
|
+
case 'ArrowDown': e.preventDefault(); this.#stepBy(-1); return;
|
|
596
|
+
case 'PageUp': e.preventDefault(); this.#stepBy( 10); return;
|
|
597
|
+
case 'PageDown': e.preventDefault(); this.#stepBy(-10); return;
|
|
598
|
+
case 'Home':
|
|
599
|
+
if (this.min != null) { e.preventDefault(); this.#commitNumeric(this.min); }
|
|
600
|
+
return;
|
|
601
|
+
case 'End':
|
|
602
|
+
if (this.max != null) { e.preventDefault(); this.#commitNumeric(this.max); }
|
|
603
|
+
return;
|
|
604
|
+
case 'Escape':
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
this.value = this.#valueAtFocus;
|
|
607
|
+
this.#textEl.textContent = this.#formatStored(this.value);
|
|
608
|
+
this.#textEl.toggleAttribute('data-empty', !this.value);
|
|
609
|
+
this.syncValue(this.value);
|
|
610
|
+
this.#textEl.blur();
|
|
611
|
+
return;
|
|
612
|
+
case 'Enter':
|
|
613
|
+
e.preventDefault();
|
|
614
|
+
// Commit normalized value before firing form events.
|
|
615
|
+
this.#commitOnBlur();
|
|
616
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
617
|
+
this.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (e.key === 'Enter') {
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
625
|
+
this.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
#onFocus = () => {
|
|
630
|
+
this.#valueAtFocus = this.value ?? '';
|
|
631
|
+
// When focused: re-render textContent without thousands grouping so the
|
|
632
|
+
// user can edit naturally — group separators jumping mid-keystroke is
|
|
633
|
+
// disorienting. Only matters when `locale` is set AND the post-blur
|
|
634
|
+
// render added grouping; no-op for the default `.` path.
|
|
635
|
+
if (this.#isNumberMode && this.locale) {
|
|
636
|
+
const raw = String(this.value ?? '').trim();
|
|
637
|
+
if (!raw) return;
|
|
638
|
+
const n = Number(this.#toCanonical(raw));
|
|
639
|
+
if (!Number.isFinite(n)) return;
|
|
640
|
+
const ungrouped = this.#format(n);
|
|
641
|
+
if (this.#textEl.textContent !== ungrouped) this.#textEl.textContent = ungrouped;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
#onBlur = () => {
|
|
646
|
+
if (this.#isNumberMode) this.#commitOnBlur();
|
|
647
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
#commitOnBlur() {
|
|
651
|
+
const raw = String(this.value ?? '').trim();
|
|
652
|
+
if (!raw) return;
|
|
653
|
+
// Canonicalize before Number() — `this.value` may carry the locale's
|
|
654
|
+
// decimal separator (e.g. "1,5" in de-DE).
|
|
655
|
+
const n = Number(this.#toCanonical(raw));
|
|
656
|
+
if (!Number.isFinite(n)) return; // leave the bad input visible for the error UX
|
|
657
|
+
const snapped = this.#snap(n);
|
|
658
|
+
// `this.value` stores the ungrouped, locale-decimal form (round-trippable
|
|
659
|
+
// through #toCanonical → Number → #format). textContent shows the
|
|
660
|
+
// grouped display form when `locale` is set.
|
|
661
|
+
const stored = this.#format(snapped);
|
|
662
|
+
const displayed = this.#formatDisplay(snapped);
|
|
663
|
+
if (this.value !== stored) {
|
|
664
|
+
this.value = stored;
|
|
665
|
+
this.syncValue(stored);
|
|
666
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
667
|
+
}
|
|
668
|
+
if (this.#textEl.textContent !== displayed) {
|
|
669
|
+
this.#textEl.textContent = displayed;
|
|
670
|
+
this.#textEl.toggleAttribute('data-empty', !displayed);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#commitNumeric(n) {
|
|
675
|
+
const snapped = this.#snap(n);
|
|
676
|
+
if (snapped === this.valueAsNumber) return;
|
|
677
|
+
this.value = this.#format(snapped);
|
|
678
|
+
this.syncValue(this.value);
|
|
679
|
+
this.#textEl.textContent = this.value;
|
|
680
|
+
this.#textEl.toggleAttribute('data-empty', !this.value);
|
|
681
|
+
this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
|
|
682
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
#onPaste = (e) => {
|
|
686
|
+
e.preventDefault();
|
|
687
|
+
const raw = e.clipboardData?.getData('text/plain') || '';
|
|
688
|
+
const text = this.#isNumberMode ? this.#sanitizeNumeric(raw) : raw;
|
|
689
|
+
document.execCommand('insertText', false, text);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
// Hold-to-repeat: pointerdown fires the initial step + arms an autorepeat
|
|
693
|
+
// timer. The first repeat fires after REPEAT_INITIAL_MS; subsequent ones
|
|
694
|
+
// every REPEAT_INTERVAL_MS. pointerup on document stops everything. We
|
|
695
|
+
// also stop on a stale value (disabled at min/max boundary) so the
|
|
696
|
+
// browser doesn't keep firing input events for no-op increments.
|
|
697
|
+
#onStepperUpDown = (e) => this.#startStepperHold(e, 1);
|
|
698
|
+
#onStepperDownDown = (e) => this.#startStepperHold(e, -1);
|
|
699
|
+
|
|
700
|
+
#startStepperHold(e, multiplier) {
|
|
701
|
+
// Keep focus on the editable surface when the button is pressed.
|
|
702
|
+
e.preventDefault();
|
|
703
|
+
if (this.disabled || this.readonly) return;
|
|
704
|
+
// Initial step fires immediately on press.
|
|
705
|
+
this.#stepBy(multiplier);
|
|
706
|
+
this.#stopStepperHold();
|
|
707
|
+
// Listen for release on document (cheap; only while held).
|
|
708
|
+
document.addEventListener('pointerup', this.#onStepperRelease, { once: true });
|
|
709
|
+
document.addEventListener('pointercancel', this.#onStepperRelease, { once: true });
|
|
710
|
+
// Initial delay → then continuous repeat.
|
|
711
|
+
this.#repeatDelayTimer = window.setTimeout(() => {
|
|
712
|
+
this.#repeatDelayTimer = null;
|
|
713
|
+
this.#repeatTimer = window.setInterval(() => {
|
|
714
|
+
const before = this.valueAsNumber;
|
|
715
|
+
this.#stepBy(multiplier);
|
|
716
|
+
// Boundary hit → no-op; cancel to avoid wasted intervals + event spam.
|
|
717
|
+
if (this.valueAsNumber === before) this.#stopStepperHold();
|
|
718
|
+
}, UIInput.#REPEAT_INTERVAL_MS);
|
|
719
|
+
}, UIInput.#REPEAT_INITIAL_MS);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
#onStepperRelease = () => this.#stopStepperHold();
|
|
723
|
+
|
|
724
|
+
#stopStepperHold() {
|
|
725
|
+
if (this.#repeatDelayTimer != null) {
|
|
726
|
+
window.clearTimeout(this.#repeatDelayTimer);
|
|
727
|
+
this.#repeatDelayTimer = null;
|
|
728
|
+
}
|
|
729
|
+
if (this.#repeatTimer != null) {
|
|
730
|
+
window.clearInterval(this.#repeatTimer);
|
|
731
|
+
this.#repeatTimer = null;
|
|
732
|
+
}
|
|
733
|
+
document.removeEventListener('pointerup', this.#onStepperRelease);
|
|
734
|
+
document.removeEventListener('pointercancel', this.#onStepperRelease);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
focus() { this.#textEl?.focus(); }
|
|
738
|
+
|
|
739
|
+
clear() {
|
|
740
|
+
this.value = '';
|
|
741
|
+
if (this.#textEl) {
|
|
742
|
+
if (this.#isNativePassword) {
|
|
743
|
+
this.#textEl.value = '';
|
|
744
|
+
} else {
|
|
745
|
+
this.#textEl.textContent = '';
|
|
746
|
+
this.#textEl.setAttribute('data-empty', '');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
this.syncValue('');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
disconnected() {
|
|
753
|
+
super.disconnected();
|
|
754
|
+
if (this.#textEl) {
|
|
755
|
+
this.#textEl.removeEventListener('input', this.#onInput);
|
|
756
|
+
this.#textEl.removeEventListener('keydown', this.#onKeydown);
|
|
757
|
+
this.#textEl.removeEventListener('blur', this.#onBlur);
|
|
758
|
+
this.#textEl.removeEventListener('focus', this.#onFocus);
|
|
759
|
+
this.#textEl.removeEventListener('paste', this.#onPaste);
|
|
760
|
+
this.#textEl.removeEventListener('beforeinput', this.#onBeforeInput);
|
|
761
|
+
}
|
|
762
|
+
this.#upBtn?.removeEventListener('pointerdown', this.#onStepperUpDown);
|
|
763
|
+
this.#downBtn?.removeEventListener('pointerdown', this.#onStepperDownDown);
|
|
764
|
+
// Cancel any in-flight hold (the document-level pointerup listener
|
|
765
|
+
// is `{once: true}` so it self-cleans on fire; this also clears the
|
|
766
|
+
// timers if the host disconnects mid-hold).
|
|
767
|
+
this.#stopStepperHold();
|
|
768
|
+
this.#textEl = null;
|
|
769
|
+
this.#labelEl = null;
|
|
770
|
+
this.#upBtn = null;
|
|
771
|
+
this.#downBtn = null;
|
|
772
|
+
}
|
|
773
|
+
}
|