stimeo-ui 0.3.0 → 0.5.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +124 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/aspect_ratio_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +5 -1
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +52 -2
- data/dist/controllers/command_palette_controller.js +32 -5
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +117 -13
- data/dist/controllers/date_range_picker_controller.js +55 -1
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/empty_state_controller.js +128 -23
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +261 -27
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +13 -2
- data/dist/controllers/local_time_controller.js +102 -6
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/meter_controller.js +147 -26
- data/dist/controllers/network_status_controller.js +29 -11
- data/dist/controllers/number_input_controller.js +191 -24
- data/dist/controllers/overflow_menu_controller.js +34 -7
- data/dist/controllers/pagination_controller.js +5 -1
- data/dist/controllers/password_strength_controller.js +1 -1
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +123 -12
- data/dist/controllers/range_slider_controller.js +449 -93
- data/dist/controllers/rating_controller.js +55 -0
- data/dist/controllers/relative_time_controller.js +135 -12
- data/dist/controllers/scroll_area_controller.js +1 -1
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +143 -22
- data/dist/controllers/slider_controller.js +342 -50
- data/dist/controllers/spinner_controller.js +244 -28
- data/dist/controllers/step_indicator_controller.js +85 -6
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/stick_to_bottom_controller.js +60 -10
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +6 -3
- data/dist/controllers/tree_view_controller.js +19 -1
- data/dist/index.js +2278 -596
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/meter_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
// src/utils/coerce.ts
|
|
6
23
|
function toFiniteNumber(raw) {
|
|
7
24
|
if (raw === null || raw === void 0 || raw === "") return null;
|
|
@@ -9,10 +26,61 @@ function toFiniteNumber(raw) {
|
|
|
9
26
|
return Number.isFinite(value) ? value : null;
|
|
10
27
|
}
|
|
11
28
|
|
|
29
|
+
// src/utils/microtask_coalescer.ts
|
|
30
|
+
var MicrotaskCoalescer = class {
|
|
31
|
+
#run;
|
|
32
|
+
#queued = false;
|
|
33
|
+
#active = false;
|
|
34
|
+
#generation = 0;
|
|
35
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
36
|
+
constructor(run) {
|
|
37
|
+
this.#run = run;
|
|
38
|
+
}
|
|
39
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
40
|
+
activate() {
|
|
41
|
+
this.#active = true;
|
|
42
|
+
}
|
|
43
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
44
|
+
cancel() {
|
|
45
|
+
this.#active = false;
|
|
46
|
+
this.#queued = false;
|
|
47
|
+
this.#generation += 1;
|
|
48
|
+
}
|
|
49
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
50
|
+
schedule() {
|
|
51
|
+
if (!this.#active || this.#queued) return;
|
|
52
|
+
this.#queued = true;
|
|
53
|
+
const generation = this.#generation;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
56
|
+
this.#queued = false;
|
|
57
|
+
this.#run();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/utils/range.ts
|
|
63
|
+
function rangeFraction(value, min, max) {
|
|
64
|
+
const span = max - min;
|
|
65
|
+
if (!(span > 0)) return 0;
|
|
66
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
67
|
+
let fraction;
|
|
68
|
+
if (Number.isFinite(span)) {
|
|
69
|
+
fraction = (clamped - min) / span;
|
|
70
|
+
} else {
|
|
71
|
+
const scale = Math.max(Math.abs(min), Math.abs(max));
|
|
72
|
+
fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isFinite(fraction)) return 0;
|
|
75
|
+
return Math.min(1, Math.max(0, fraction));
|
|
76
|
+
}
|
|
77
|
+
|
|
12
78
|
// src/controllers/meter_controller.ts
|
|
79
|
+
var OWNED_VALUE_TEXT = "data-stimeo--meter-owns-valuetext";
|
|
13
80
|
var MeterController = class extends Controller {
|
|
14
81
|
static targets = ["bar"];
|
|
15
82
|
static values = {
|
|
83
|
+
announceText: { type: String, default: "" },
|
|
16
84
|
value: { type: Number, default: 0 },
|
|
17
85
|
min: { type: Number, default: 0 },
|
|
18
86
|
max: { type: Number, default: 100 },
|
|
@@ -23,9 +91,24 @@ var MeterController = class extends Controller {
|
|
|
23
91
|
};
|
|
24
92
|
static actions = ["setValue"];
|
|
25
93
|
static events = ["change"];
|
|
94
|
+
/**
|
|
95
|
+
* Collapses a morph that swaps several render inputs at once into one repaint.
|
|
96
|
+
* A single update usually rewrites the whole set, and each Value would otherwise
|
|
97
|
+
* repaint on its own.
|
|
98
|
+
*/
|
|
99
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
100
|
+
this.#render();
|
|
101
|
+
});
|
|
102
|
+
/** The segment last announced, so only a change is read out. */
|
|
103
|
+
#announcedState = null;
|
|
26
104
|
connect() {
|
|
105
|
+
this.#repaint.activate();
|
|
27
106
|
this.#render();
|
|
28
107
|
}
|
|
108
|
+
/** Closes the window in which a queued repaint may still run. */
|
|
109
|
+
disconnect() {
|
|
110
|
+
this.#repaint.cancel();
|
|
111
|
+
}
|
|
29
112
|
/**
|
|
30
113
|
* Updates the measured value from an action param (`amount`) or a
|
|
31
114
|
* `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
|
|
@@ -35,59 +118,97 @@ var MeterController = class extends Controller {
|
|
|
35
118
|
const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
|
|
36
119
|
if (next === null) return;
|
|
37
120
|
this.valueValue = this.#clamp(next);
|
|
38
|
-
this.#render();
|
|
39
|
-
this.dispatch("change", {
|
|
40
|
-
|
|
41
|
-
|
|
121
|
+
const reading = this.#render();
|
|
122
|
+
this.dispatch("change", { detail: reading });
|
|
123
|
+
if (reading.state !== this.#announcedState) {
|
|
124
|
+
this.#announcedState = reading.state;
|
|
125
|
+
announce(
|
|
126
|
+
fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value })
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
|
|
131
|
+
valueValueChanged() {
|
|
132
|
+
this.#repaint.schedule();
|
|
133
|
+
}
|
|
134
|
+
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
135
|
+
minValueChanged() {
|
|
136
|
+
this.#repaint.schedule();
|
|
137
|
+
}
|
|
138
|
+
/** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
|
|
139
|
+
maxValueChanged() {
|
|
140
|
+
this.#repaint.schedule();
|
|
141
|
+
}
|
|
142
|
+
/** Repaints when application code (or a Turbo morph) changes `low` at runtime. */
|
|
143
|
+
lowValueChanged() {
|
|
144
|
+
this.#repaint.schedule();
|
|
145
|
+
}
|
|
146
|
+
/** Repaints when application code (or a Turbo morph) changes `high` at runtime. */
|
|
147
|
+
highValueChanged() {
|
|
148
|
+
this.#repaint.schedule();
|
|
149
|
+
}
|
|
150
|
+
/** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
|
|
151
|
+
valueTextValueChanged() {
|
|
152
|
+
this.#repaint.schedule();
|
|
42
153
|
}
|
|
43
154
|
/** Clamps `raw` into the configured `[min, max]` range. */
|
|
44
155
|
#clamp(raw) {
|
|
45
156
|
return Math.min(this.maxValue, Math.max(this.minValue, raw));
|
|
46
157
|
}
|
|
47
|
-
/** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
|
|
48
|
-
get #ratio() {
|
|
49
|
-
const span = this.maxValue - this.minValue;
|
|
50
|
-
if (span <= 0) return 0;
|
|
51
|
-
return (this.#clamp(this.valueValue) - this.minValue) / span;
|
|
52
|
-
}
|
|
53
158
|
/** Whether a threshold attribute is present (absent = no threshold). */
|
|
54
159
|
#hasThreshold(name) {
|
|
55
160
|
return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
|
|
56
161
|
}
|
|
57
162
|
/**
|
|
58
|
-
* Classifies
|
|
59
|
-
*
|
|
60
|
-
*
|
|
163
|
+
* Classifies `value` into a `low`/`medium`/`high` segment. Values at or below
|
|
164
|
+
* `low` are `low`; at or above `high` are `high`; otherwise `medium`. With
|
|
165
|
+
* neither threshold present, everything is `medium`.
|
|
61
166
|
*/
|
|
62
|
-
|
|
63
|
-
const value = this.#clamp(this.valueValue);
|
|
167
|
+
#stateOf(value) {
|
|
64
168
|
if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
|
|
65
169
|
if (this.#hasThreshold("high") && value >= this.highValue) return "high";
|
|
66
170
|
return "medium";
|
|
67
171
|
}
|
|
68
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
|
|
174
|
+
* The reading is derived once and returned, so the `change` detail reports the
|
|
175
|
+
* same numbers the DOM just received.
|
|
176
|
+
*
|
|
177
|
+
* @stimeoRenderRoot
|
|
178
|
+
*/
|
|
69
179
|
#render() {
|
|
70
180
|
const value = this.#clamp(this.valueValue);
|
|
181
|
+
const reading = {
|
|
182
|
+
value,
|
|
183
|
+
ratio: rangeFraction(value, this.minValue, this.maxValue),
|
|
184
|
+
state: this.#stateOf(value)
|
|
185
|
+
};
|
|
71
186
|
this.element.setAttribute("aria-valuemin", String(this.minValue));
|
|
72
187
|
this.element.setAttribute("aria-valuemax", String(this.maxValue));
|
|
73
|
-
this.element.setAttribute("aria-valuenow", String(value));
|
|
74
|
-
this.element.style.setProperty("--stimeo
|
|
75
|
-
this.element.setAttribute("data-state",
|
|
76
|
-
this.#applyValueText(
|
|
188
|
+
this.element.setAttribute("aria-valuenow", String(reading.value));
|
|
189
|
+
this.element.style.setProperty("--stimeo--meter-ratio", String(reading.ratio));
|
|
190
|
+
this.element.setAttribute("data-state", reading.state);
|
|
191
|
+
this.#applyValueText(reading);
|
|
192
|
+
return reading;
|
|
77
193
|
}
|
|
78
194
|
/**
|
|
79
195
|
* Sets `aria-valuetext` from the consumer-provided template, substituting
|
|
80
|
-
* `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library
|
|
81
|
-
*
|
|
196
|
+
* `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.
|
|
197
|
+
* With no template the attribute belongs to the consumer, so only a text this
|
|
198
|
+
* controller wrote is taken back ({@link OWNED_VALUE_TEXT}).
|
|
82
199
|
*/
|
|
83
|
-
#applyValueText(value) {
|
|
200
|
+
#applyValueText({ value, ratio, state }) {
|
|
84
201
|
if (this.valueTextValue.length === 0) {
|
|
85
|
-
this.element.
|
|
202
|
+
if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {
|
|
203
|
+
this.element.removeAttribute("aria-valuetext");
|
|
204
|
+
this.element.removeAttribute(OWNED_VALUE_TEXT);
|
|
205
|
+
}
|
|
86
206
|
return;
|
|
87
207
|
}
|
|
88
|
-
const percent = Math.round(
|
|
89
|
-
const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}",
|
|
208
|
+
const percent = Math.round(ratio * 100);
|
|
209
|
+
const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", state);
|
|
90
210
|
this.element.setAttribute("aria-valuetext", text);
|
|
211
|
+
this.element.setAttribute(OWNED_VALUE_TEXT, "");
|
|
91
212
|
}
|
|
92
213
|
};
|
|
93
214
|
|
|
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/network_status_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
// src/utils/safe_timeout.ts
|
|
6
23
|
var TimerRegistry = class {
|
|
7
24
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,20 +76,17 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
76
|
var NetworkStatusController = class extends Controller {
|
|
60
77
|
static targets = ["offline", "online"];
|
|
61
78
|
static values = {
|
|
79
|
+
announceText: { type: String, default: "" },
|
|
80
|
+
announceOnlineText: { type: String, default: "" },
|
|
62
81
|
onlineAutoHide: { type: Number, default: 0 }
|
|
63
82
|
};
|
|
64
83
|
static events = ["change"];
|
|
65
84
|
#timers = new SafeTimeout();
|
|
66
85
|
/** Last known connectivity; guards against duplicate-state re-announcements. */
|
|
67
86
|
#online = true;
|
|
68
|
-
/** Banner text captured from the markup so transitions can re-write it. */
|
|
69
|
-
#offlineMessage = "";
|
|
70
|
-
#onlineMessage = "";
|
|
71
87
|
#handleOnline = () => this.#update(true);
|
|
72
88
|
#handleOffline = () => this.#update(false);
|
|
73
89
|
connect() {
|
|
74
|
-
this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
|
|
75
|
-
this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
|
|
76
90
|
if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
|
|
77
91
|
if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
|
|
78
92
|
this.#online = navigator.onLine;
|
|
@@ -86,7 +100,12 @@ var NetworkStatusController = class extends Controller {
|
|
|
86
100
|
window.removeEventListener("offline", this.#handleOffline);
|
|
87
101
|
this.#timers.clearAll();
|
|
88
102
|
}
|
|
89
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Applies a connectivity transition, guarded against duplicate states.
|
|
105
|
+
*
|
|
106
|
+
* The event goes out last, so a listener reading `data-state` or a banner's
|
|
107
|
+
* visibility sees the state the transition landed on rather than the previous one.
|
|
108
|
+
*/
|
|
90
109
|
#update(online) {
|
|
91
110
|
if (online === this.#online) return;
|
|
92
111
|
this.#online = online;
|
|
@@ -96,22 +115,21 @@ var NetworkStatusController = class extends Controller {
|
|
|
96
115
|
} else {
|
|
97
116
|
this.#showOffline();
|
|
98
117
|
}
|
|
118
|
+
announce(fillTemplate(online ? this.announceOnlineTextValue : this.announceTextValue, {}), {
|
|
119
|
+
assertive: !online
|
|
120
|
+
});
|
|
99
121
|
this.dispatch("change", { detail: { online } });
|
|
100
122
|
}
|
|
101
123
|
/** Shows the offline banner and hides the recovery banner. */
|
|
102
124
|
#showOffline() {
|
|
103
125
|
this.#timers.clearAll();
|
|
104
126
|
if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
|
|
105
|
-
if (this.hasOfflineTarget)
|
|
106
|
-
this.offlineTarget.textContent = this.#offlineMessage;
|
|
107
|
-
this.offlineTarget.hidden = false;
|
|
108
|
-
}
|
|
127
|
+
if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
|
|
109
128
|
}
|
|
110
129
|
/** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
|
|
111
130
|
#showOnline() {
|
|
112
131
|
if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
|
|
113
132
|
if (!this.hasOnlineTarget) return;
|
|
114
|
-
this.onlineTarget.textContent = this.#onlineMessage;
|
|
115
133
|
this.onlineTarget.hidden = false;
|
|
116
134
|
if (this.onlineAutoHideValue > 0) {
|
|
117
135
|
this.#timers.set(() => {
|
|
@@ -8,6 +8,39 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/microtask_coalescer.ts
|
|
12
|
+
var MicrotaskCoalescer = class {
|
|
13
|
+
#run;
|
|
14
|
+
#queued = false;
|
|
15
|
+
#active = false;
|
|
16
|
+
#generation = 0;
|
|
17
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
18
|
+
constructor(run) {
|
|
19
|
+
this.#run = run;
|
|
20
|
+
}
|
|
21
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
22
|
+
activate() {
|
|
23
|
+
this.#active = true;
|
|
24
|
+
}
|
|
25
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
26
|
+
cancel() {
|
|
27
|
+
this.#active = false;
|
|
28
|
+
this.#queued = false;
|
|
29
|
+
this.#generation += 1;
|
|
30
|
+
}
|
|
31
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
32
|
+
schedule() {
|
|
33
|
+
if (!this.#active || this.#queued) return;
|
|
34
|
+
this.#queued = true;
|
|
35
|
+
const generation = this.#generation;
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
38
|
+
this.#queued = false;
|
|
39
|
+
this.#run();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
11
44
|
// src/utils/safe_timeout.ts
|
|
12
45
|
var TimerRegistry = class {
|
|
13
46
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -75,6 +108,112 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
75
108
|
}
|
|
76
109
|
};
|
|
77
110
|
|
|
111
|
+
// src/utils/stepped_value.ts
|
|
112
|
+
function effectiveStep(step) {
|
|
113
|
+
return Number.isFinite(step) && step > 0 ? step : 1;
|
|
114
|
+
}
|
|
115
|
+
function snapSteppedValue(raw, range) {
|
|
116
|
+
if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
|
|
117
|
+
const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
|
|
118
|
+
const clamped = Math.min(range.max, Math.max(range.min, input));
|
|
119
|
+
const step = effectiveStep(range.step);
|
|
120
|
+
const base = stepBase(range);
|
|
121
|
+
const candidates = [];
|
|
122
|
+
if (Number.isFinite(range.min)) candidates.push(range.min);
|
|
123
|
+
if (Number.isFinite(range.max)) candidates.push(range.max);
|
|
124
|
+
const gridPosition = (clamped - base) / step;
|
|
125
|
+
if (Number.isFinite(gridPosition)) {
|
|
126
|
+
addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
|
|
127
|
+
addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
|
|
128
|
+
}
|
|
129
|
+
if (candidates.length === 0) return clamped;
|
|
130
|
+
let nearest = candidates[0];
|
|
131
|
+
let nearestDistance = Math.abs(clamped - nearest);
|
|
132
|
+
for (const candidate of candidates.slice(1)) {
|
|
133
|
+
const distance = Math.abs(clamped - candidate);
|
|
134
|
+
if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
|
|
135
|
+
nearest = candidate;
|
|
136
|
+
nearestDistance = distance;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return nearest;
|
|
140
|
+
}
|
|
141
|
+
function stepSteppedValue(current, count, range) {
|
|
142
|
+
const value = snapSteppedValue(current, range);
|
|
143
|
+
const distance = Math.abs(Math.trunc(count));
|
|
144
|
+
if (!Number.isFinite(distance) || distance === 0) return value;
|
|
145
|
+
const direction = Math.sign(count);
|
|
146
|
+
const adjacent = adjacentSteppedValue(value, direction, range);
|
|
147
|
+
const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
|
|
148
|
+
return snapSteppedValue(raw, range);
|
|
149
|
+
}
|
|
150
|
+
function adjacentSteppedValue(current, direction, range) {
|
|
151
|
+
const step = effectiveStep(range.step);
|
|
152
|
+
const base = stepBase(range);
|
|
153
|
+
const candidates = [];
|
|
154
|
+
if (direction > 0) {
|
|
155
|
+
const position2 = (current - base) / step;
|
|
156
|
+
if (Number.isFinite(position2)) {
|
|
157
|
+
let gridIndex = Math.floor(position2) + 1;
|
|
158
|
+
let candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
159
|
+
if (candidate < current || nearlyEqual(candidate, current)) {
|
|
160
|
+
gridIndex += 1;
|
|
161
|
+
candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
162
|
+
}
|
|
163
|
+
if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
|
|
164
|
+
candidates.push(clamp(candidate, range));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return clamp(Math.min(...candidates), range);
|
|
168
|
+
}
|
|
169
|
+
const position = (current - base) / step;
|
|
170
|
+
if (Number.isFinite(position)) {
|
|
171
|
+
let gridIndex = Math.ceil(position) - 1;
|
|
172
|
+
let candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
173
|
+
if (candidate > current || nearlyEqual(candidate, current)) {
|
|
174
|
+
gridIndex -= 1;
|
|
175
|
+
candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
176
|
+
}
|
|
177
|
+
if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
|
|
178
|
+
candidates.push(clamp(candidate, range));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return clamp(Math.max(...candidates), range);
|
|
182
|
+
}
|
|
183
|
+
function addGridCandidate(candidates, index, range, base, step) {
|
|
184
|
+
const candidate = cleanGridValue(base + index * step, base, step);
|
|
185
|
+
if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
|
|
186
|
+
}
|
|
187
|
+
function stepBase(range) {
|
|
188
|
+
if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
|
|
189
|
+
return Number.isFinite(range.min) ? range.min : 0;
|
|
190
|
+
}
|
|
191
|
+
function cleanGridValue(value, base, step) {
|
|
192
|
+
const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
|
|
193
|
+
return precision <= 100 ? Number(value.toFixed(precision)) : value;
|
|
194
|
+
}
|
|
195
|
+
function decimalPlaces(value) {
|
|
196
|
+
const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
|
|
197
|
+
const fractionLength = coefficient.split(".")[1]?.length ?? 0;
|
|
198
|
+
const exponent = exponentText === void 0 ? 0 : Number(exponentText);
|
|
199
|
+
return Math.max(0, fractionLength - exponent);
|
|
200
|
+
}
|
|
201
|
+
function within(value, min, max) {
|
|
202
|
+
return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
|
|
203
|
+
}
|
|
204
|
+
function clamp(value, range) {
|
|
205
|
+
return Math.min(range.max, Math.max(range.min, value));
|
|
206
|
+
}
|
|
207
|
+
function nearlyEqual(left, right) {
|
|
208
|
+
const scale = Math.max(Math.abs(left), Math.abs(right));
|
|
209
|
+
return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
|
|
210
|
+
}
|
|
211
|
+
function finiteFallback(min, max) {
|
|
212
|
+
if (Number.isFinite(min)) return min;
|
|
213
|
+
if (Number.isFinite(max)) return max;
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
78
217
|
// src/controllers/number_input_controller.ts
|
|
79
218
|
var NumberInputController = class _NumberInputController extends Controller {
|
|
80
219
|
static targets = ["input", "increment", "decrement"];
|
|
@@ -109,24 +248,24 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
109
248
|
#repeatedDuringHold = false;
|
|
110
249
|
/** True when the next `click` is the trailing one after a hold and must be ignored. */
|
|
111
250
|
#suppressNextClick = false;
|
|
251
|
+
/** Collapses runtime range/step changes into one silent input reconciliation. */
|
|
252
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
112
253
|
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
113
254
|
connect() {
|
|
255
|
+
this.#repaint.activate();
|
|
114
256
|
if (!this.hasInputTarget) return;
|
|
115
|
-
|
|
116
|
-
this.#write(this.#normalize(this.#currentValue()));
|
|
117
|
-
} else {
|
|
118
|
-
this.#updateButtons(this.#currentValue());
|
|
119
|
-
}
|
|
257
|
+
this.#reconcile();
|
|
120
258
|
this.#guards = new AbortController();
|
|
121
259
|
const { signal } = this.#guards;
|
|
122
|
-
if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget,
|
|
123
|
-
if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -
|
|
260
|
+
if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, 1, signal);
|
|
261
|
+
if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -1, signal);
|
|
124
262
|
window.addEventListener("pointerup", this.#stopHold, { signal });
|
|
125
263
|
window.addEventListener("pointercancel", this.#stopHold, { signal });
|
|
126
264
|
window.addEventListener("blur", this.#stopHold, { signal });
|
|
127
265
|
}
|
|
128
266
|
/** Releases the pointer guards and tears down every pending timer and hold state. */
|
|
129
267
|
disconnect() {
|
|
268
|
+
this.#repaint.cancel();
|
|
130
269
|
this.#guards?.abort();
|
|
131
270
|
this.#guards = null;
|
|
132
271
|
this.#holdActive = false;
|
|
@@ -135,16 +274,28 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
135
274
|
this.#holdTimeouts.clearAll();
|
|
136
275
|
this.#holdIntervals.clearAll();
|
|
137
276
|
}
|
|
277
|
+
/** Silently reconciles a minimum changed by application code or a Turbo morph. */
|
|
278
|
+
minValueChanged() {
|
|
279
|
+
this.#repaint.schedule();
|
|
280
|
+
}
|
|
281
|
+
/** Silently reconciles a maximum changed by application code or a Turbo morph. */
|
|
282
|
+
maxValueChanged() {
|
|
283
|
+
this.#repaint.schedule();
|
|
284
|
+
}
|
|
285
|
+
/** Silently reconciles a step changed by application code or a Turbo morph. */
|
|
286
|
+
stepValueChanged() {
|
|
287
|
+
this.#repaint.schedule();
|
|
288
|
+
}
|
|
138
289
|
/** Increases by one step. Bound via `data-action` (click). */
|
|
139
290
|
increment() {
|
|
140
291
|
if (this.#consumeSuppressedClick()) return;
|
|
141
|
-
this.#
|
|
292
|
+
this.#commitStep(1);
|
|
142
293
|
this.inputTarget.focus();
|
|
143
294
|
}
|
|
144
295
|
/** Decreases by one step. Bound via `data-action` (click). */
|
|
145
296
|
decrement() {
|
|
146
297
|
if (this.#consumeSuppressedClick()) return;
|
|
147
|
-
this.#
|
|
298
|
+
this.#commitStep(-1);
|
|
148
299
|
this.inputTarget.focus();
|
|
149
300
|
}
|
|
150
301
|
/** Clamps and snaps a typed value. Bound via `data-action` (change). */
|
|
@@ -155,20 +306,19 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
155
306
|
/** Keyboard stepping per the APG spinbutton model. */
|
|
156
307
|
onKeydown(event) {
|
|
157
308
|
if (isReservedArrowChord(event)) return;
|
|
158
|
-
const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
|
|
159
309
|
let next = null;
|
|
160
310
|
switch (event.key) {
|
|
161
311
|
case "ArrowUp":
|
|
162
|
-
next = this.#currentValue()
|
|
312
|
+
next = stepSteppedValue(this.#currentValue(), 1, this.#steppedRange);
|
|
163
313
|
break;
|
|
164
314
|
case "ArrowDown":
|
|
165
|
-
next = this.#currentValue() - this
|
|
315
|
+
next = stepSteppedValue(this.#currentValue(), -1, this.#steppedRange);
|
|
166
316
|
break;
|
|
167
317
|
case "PageUp":
|
|
168
|
-
next = this.#currentValue() +
|
|
318
|
+
next = this.pageStepValue > 0 ? this.#currentValue() + this.pageStepValue : stepSteppedValue(this.#currentValue(), 10, this.#steppedRange);
|
|
169
319
|
break;
|
|
170
320
|
case "PageDown":
|
|
171
|
-
next = this.#currentValue() -
|
|
321
|
+
next = this.pageStepValue > 0 ? this.#currentValue() - this.pageStepValue : stepSteppedValue(this.#currentValue(), -10, this.#steppedRange);
|
|
172
322
|
break;
|
|
173
323
|
case "Home":
|
|
174
324
|
if (!Number.isFinite(this.minValue)) return;
|
|
@@ -189,14 +339,14 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
189
339
|
* hold; leaving the button while held stops it (the global listeners cover
|
|
190
340
|
* release/cancel/blur).
|
|
191
341
|
*/
|
|
192
|
-
#wireButton(button,
|
|
193
|
-
button.addEventListener("pointerdown", (event) => this.#armHold(event, button,
|
|
342
|
+
#wireButton(button, direction, signal) {
|
|
343
|
+
button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
|
|
194
344
|
signal
|
|
195
345
|
});
|
|
196
346
|
button.addEventListener("pointerleave", this.#stopHold, { signal });
|
|
197
347
|
}
|
|
198
348
|
/** Starts a hold: focus the input, then schedule the first repeat after a delay. */
|
|
199
|
-
#armHold(event, button,
|
|
349
|
+
#armHold(event, button, direction) {
|
|
200
350
|
const pointerButton = event.button;
|
|
201
351
|
if (typeof pointerButton === "number" && pointerButton !== 0) return;
|
|
202
352
|
if (button.disabled) return;
|
|
@@ -207,13 +357,13 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
207
357
|
this.#repeatedDuringHold = false;
|
|
208
358
|
this.#suppressNextClick = false;
|
|
209
359
|
this.#holdTimeouts.set(() => {
|
|
210
|
-
if (!this.#
|
|
360
|
+
if (!this.#commitStep(direction)) {
|
|
211
361
|
this.#stopHold();
|
|
212
362
|
return;
|
|
213
363
|
}
|
|
214
364
|
this.#repeatedDuringHold = true;
|
|
215
365
|
this.#holdIntervals.set(() => {
|
|
216
|
-
if (!this.#
|
|
366
|
+
if (!this.#commitStep(direction)) this.#stopHold();
|
|
217
367
|
}, _NumberInputController.#HOLD_REPEAT_MS);
|
|
218
368
|
}, _NumberInputController.#HOLD_DELAY_MS);
|
|
219
369
|
}
|
|
@@ -254,6 +404,23 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
254
404
|
if (changed) this.dispatch("change", { detail: { value } });
|
|
255
405
|
return changed;
|
|
256
406
|
}
|
|
407
|
+
/** Commits an adjacent endpoint/grid value and reports whether it moved. */
|
|
408
|
+
#commitStep(count) {
|
|
409
|
+
return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Silently reflects the current value after a range or step morph.
|
|
413
|
+
*
|
|
414
|
+
* @stimeoRenderRoot
|
|
415
|
+
*/
|
|
416
|
+
#reconcile() {
|
|
417
|
+
if (!this.hasInputTarget) return;
|
|
418
|
+
if (this.inputTarget.value.trim() !== "") {
|
|
419
|
+
this.#write(this.#normalize(this.#currentValue()));
|
|
420
|
+
} else {
|
|
421
|
+
this.#updateButtons(this.#currentValue());
|
|
422
|
+
}
|
|
423
|
+
}
|
|
257
424
|
/** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
|
|
258
425
|
#write(value) {
|
|
259
426
|
this.inputTarget.value = String(value);
|
|
@@ -300,11 +467,11 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
300
467
|
}
|
|
301
468
|
/** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
|
|
302
469
|
#normalize(raw) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return
|
|
470
|
+
return snapSteppedValue(raw, this.#steppedRange);
|
|
471
|
+
}
|
|
472
|
+
/** Shared range configuration; finite endpoints remain allowed off the grid. */
|
|
473
|
+
get #steppedRange() {
|
|
474
|
+
return { min: this.minValue, max: this.maxValue, step: this.stepValue };
|
|
308
475
|
}
|
|
309
476
|
};
|
|
310
477
|
|