stimeo-ui 0.5.0 → 0.6.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 +116 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/carousel_controller.js +85 -9
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/color_picker_controller.js +35 -9
- data/dist/controllers/date_range_picker_controller.js +157 -30
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/idle_controller.js +27 -5
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/number_input_controller.js +275 -51
- data/dist/controllers/overflow_menu_controller.js +4 -0
- data/dist/controllers/pagination_controller.js +33 -0
- data/dist/controllers/password_strength_controller.js +20 -2
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/rating_controller.js +272 -89
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +154 -22
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/time_picker_controller.js +296 -107
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +5 -3
- data/dist/index.js +2637 -781
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,17 +1,89 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/checkbox_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/microtask_coalescer.ts
|
|
6
|
+
var MicrotaskCoalescer = class {
|
|
7
|
+
#run;
|
|
8
|
+
#queued = false;
|
|
9
|
+
#active = false;
|
|
10
|
+
#generation = 0;
|
|
11
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
12
|
+
constructor(run) {
|
|
13
|
+
this.#run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
16
|
+
activate() {
|
|
17
|
+
this.#active = true;
|
|
18
|
+
}
|
|
19
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.#active = false;
|
|
22
|
+
this.#queued = false;
|
|
23
|
+
this.#generation += 1;
|
|
24
|
+
}
|
|
25
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
26
|
+
schedule() {
|
|
27
|
+
if (!this.#active || this.#queued) return;
|
|
28
|
+
this.#queued = true;
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
32
|
+
this.#queued = false;
|
|
33
|
+
this.#run();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/checkbox_controller.ts
|
|
4
39
|
var CheckboxController = class extends Controller {
|
|
5
40
|
static targets = ["parent", "child"];
|
|
6
41
|
static actions = ["onChildChange", "onParentChange"];
|
|
7
|
-
static events = ["change"];
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
42
|
+
static events = ["change", "reconcile"];
|
|
43
|
+
/** Collapses every lifecycle signal from one DOM update into one derived pass. */
|
|
44
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
|
|
45
|
+
/** Aggregate this root last settled on, so a derived repair is reported once. */
|
|
46
|
+
#committedState = null;
|
|
47
|
+
/** Watches authored checked-attribute changes on retained target elements. */
|
|
48
|
+
#checkedObserver = new MutationObserver((records) => {
|
|
49
|
+
if (records.some((record) => this.#isManagedCheckbox(record.target))) {
|
|
50
|
+
this.#reconcile.schedule();
|
|
14
51
|
}
|
|
52
|
+
});
|
|
53
|
+
/** Reflects the initial aggregate and starts retained-DOM reconciliation. */
|
|
54
|
+
connect() {
|
|
55
|
+
this.#reconcile.activate();
|
|
56
|
+
this.#syncFromChildren();
|
|
57
|
+
this.#checkedObserver.observe(this.element, {
|
|
58
|
+
attributes: true,
|
|
59
|
+
attributeFilter: ["checked"],
|
|
60
|
+
subtree: true
|
|
61
|
+
});
|
|
62
|
+
this.element.addEventListener("turbo:morph-element", this.#onMorph);
|
|
63
|
+
document.addEventListener("reset", this.#onReset, true);
|
|
64
|
+
}
|
|
65
|
+
/** Releases the observer, global reset listener, and every pending reconciliation. */
|
|
66
|
+
disconnect() {
|
|
67
|
+
this.#reconcile.cancel();
|
|
68
|
+
this.#checkedObserver.disconnect();
|
|
69
|
+
this.element.removeEventListener("turbo:morph-element", this.#onMorph);
|
|
70
|
+
document.removeEventListener("reset", this.#onReset, true);
|
|
71
|
+
}
|
|
72
|
+
/** Reconciles the aggregate for a parent added or replaced at runtime. */
|
|
73
|
+
parentTargetConnected() {
|
|
74
|
+
this.#reconcile.schedule();
|
|
75
|
+
}
|
|
76
|
+
/** Reconciles the aggregate after a parent target leaves the group. */
|
|
77
|
+
parentTargetDisconnected() {
|
|
78
|
+
this.#reconcile.schedule();
|
|
79
|
+
}
|
|
80
|
+
/** Reconciles the aggregate for a child added at runtime. */
|
|
81
|
+
childTargetConnected() {
|
|
82
|
+
this.#reconcile.schedule();
|
|
83
|
+
}
|
|
84
|
+
/** Reconciles the aggregate after a child leaves the group. */
|
|
85
|
+
childTargetDisconnected() {
|
|
86
|
+
this.#reconcile.schedule();
|
|
15
87
|
}
|
|
16
88
|
/** Cascades the parent's state to every child. Bound via `data-action` (change). */
|
|
17
89
|
onParentChange() {
|
|
@@ -20,35 +92,74 @@ var CheckboxController = class extends Controller {
|
|
|
20
92
|
for (const child of this.childTargets) {
|
|
21
93
|
child.checked = checked;
|
|
22
94
|
}
|
|
23
|
-
this
|
|
24
|
-
const
|
|
25
|
-
this.
|
|
26
|
-
this.dispatch("change", { detail: { checked, indeterminate: false, state } });
|
|
95
|
+
this.#reflect(checked ? "all" : "none", true);
|
|
96
|
+
const detail = this.#settledDetail();
|
|
97
|
+
if (detail) this.dispatch("change", { detail });
|
|
27
98
|
}
|
|
28
99
|
/** Recomputes the parent from its children. Bound via `data-action` (change). */
|
|
29
100
|
onChildChange() {
|
|
30
|
-
this.#syncFromChildren(
|
|
101
|
+
this.#syncFromChildren();
|
|
102
|
+
const detail = this.#settledDetail();
|
|
103
|
+
if (detail) this.dispatch("change", { detail });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Announces an aggregate this pass derived rather than the user. `change` stays
|
|
107
|
+
* reserved for the two public actions, so automation never reads a repair as an edit.
|
|
108
|
+
*/
|
|
109
|
+
#reconcileFromChildren() {
|
|
110
|
+
const previous = this.#committedState;
|
|
111
|
+
this.#syncFromChildren();
|
|
112
|
+
if (this.#committedState === previous) return;
|
|
113
|
+
const detail = this.#settledDetail();
|
|
114
|
+
if (detail) this.dispatch("reconcile", { detail });
|
|
31
115
|
}
|
|
32
116
|
/**
|
|
33
117
|
* Derives the parent's `checked`/`indeterminate` and the root `data-state` from
|
|
34
|
-
* the children
|
|
118
|
+
* the children. Writing state and reporting it are separate so the caller — not
|
|
119
|
+
* a flag threaded through the write — decides which event describes the cause.
|
|
35
120
|
*/
|
|
36
|
-
#syncFromChildren(
|
|
37
|
-
|
|
38
|
-
|
|
121
|
+
#syncFromChildren() {
|
|
122
|
+
this.#reflect(this.#aggregate(), this.childTargets.length > 0);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Reflects one aggregate state.
|
|
126
|
+
*
|
|
127
|
+
* @param writeParent - whether the state is authoritative over the `parent`
|
|
128
|
+
* target's own `checked` / `indeterminate`.
|
|
129
|
+
*/
|
|
130
|
+
#reflect(state, writeParent) {
|
|
131
|
+
if (writeParent && this.hasParentTarget) {
|
|
39
132
|
this.parentTarget.checked = state === "all";
|
|
40
133
|
this.parentTarget.indeterminate = state === "partial";
|
|
41
134
|
}
|
|
42
135
|
this.element.setAttribute("data-state", state);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
136
|
+
this.#committedState = state;
|
|
137
|
+
}
|
|
138
|
+
/** The settled aggregate as event detail, or `null` before anything has settled. */
|
|
139
|
+
#settledDetail() {
|
|
140
|
+
const state = this.#committedState;
|
|
141
|
+
if (state === null) return null;
|
|
142
|
+
return { checked: state === "all", indeterminate: state === "partial", state };
|
|
143
|
+
}
|
|
144
|
+
/** Reconciles retained targets after Turbo has finished morphing their live state. */
|
|
145
|
+
#onMorph = () => {
|
|
146
|
+
this.#reconcile.schedule();
|
|
147
|
+
};
|
|
148
|
+
/** Reconciles after a non-cancelled reset restores any managed checkbox. */
|
|
149
|
+
#onReset = (event) => {
|
|
150
|
+
const form = event.target;
|
|
151
|
+
if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
|
|
152
|
+
queueMicrotask(() => {
|
|
153
|
+
if (!event.defaultPrevented) this.#reconcile.schedule();
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
/** Whether a form owns at least one current parent or child target. */
|
|
157
|
+
#hasCheckboxOwnedBy(form) {
|
|
158
|
+
return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
|
|
159
|
+
}
|
|
160
|
+
/** Whether an observed attribute mutation belongs to this controller's target set. */
|
|
161
|
+
#isManagedCheckbox(node) {
|
|
162
|
+
return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
|
|
52
163
|
}
|
|
53
164
|
/**
|
|
54
165
|
* Computes the aggregate state. With children it counts them; with none it
|
|
@@ -74,7 +74,7 @@ var ColorPickerController = class extends Controller {
|
|
|
74
74
|
logicalTrack: { type: Boolean, default: false }
|
|
75
75
|
};
|
|
76
76
|
static actions = ["onHexInput", "onKeydown", "onPointerDown"];
|
|
77
|
-
static events = ["change"];
|
|
77
|
+
static events = ["change", "reconcile"];
|
|
78
78
|
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
79
79
|
get #mirrored() {
|
|
80
80
|
return this.logicalTrackValue && isRtl(this.element);
|
|
@@ -83,14 +83,14 @@ var ColorPickerController = class extends Controller {
|
|
|
83
83
|
#color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
|
|
84
84
|
/** Aborts in-progress pointer-drag listeners on drag end / teardown. */
|
|
85
85
|
#dragAbort = null;
|
|
86
|
-
/**
|
|
86
|
+
/** Color the last repaint settled on, so a configuration-driven move is reported once. */
|
|
87
|
+
#committedHex = null;
|
|
87
88
|
/**
|
|
88
89
|
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
89
90
|
* pass Stimulus delivers before `connect()`.
|
|
90
91
|
*/
|
|
91
|
-
#repaint = new MicrotaskCoalescer(() =>
|
|
92
|
-
|
|
93
|
-
});
|
|
92
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcileColor());
|
|
93
|
+
/** Seeds the model from the initial hex value and renders every surface. */
|
|
94
94
|
connect() {
|
|
95
95
|
this.#repaint.activate();
|
|
96
96
|
const parsed = hexToHsla(this.valueValue);
|
|
@@ -181,12 +181,24 @@ var ColorPickerController = class extends Controller {
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
184
|
-
this.#
|
|
184
|
+
this.#commitColor();
|
|
185
185
|
}
|
|
186
186
|
/** Clamps and snaps one channel to an integer, then re-renders + emits change. */
|
|
187
187
|
#setChannel(channel, raw, min, max) {
|
|
188
188
|
this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
|
|
189
|
+
this.#commitColor();
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Renders the model and reports a color the user actually moved. A key pressed
|
|
193
|
+
* at a bound, a pointer that lands on the step already showing, and a re-confirmed
|
|
194
|
+
* hex all leave the committed color where it was, so no `change` describes them.
|
|
195
|
+
*/
|
|
196
|
+
#commitColor() {
|
|
197
|
+
const previous = this.#committedHex;
|
|
189
198
|
this.#render();
|
|
199
|
+
if (this.#committedHex !== previous) {
|
|
200
|
+
this.dispatch("change", { detail: this.#settledDetail() });
|
|
201
|
+
}
|
|
190
202
|
}
|
|
191
203
|
/**
|
|
192
204
|
* Reflects the model onto sliders, the hex input, preview, and form field.
|
|
@@ -202,14 +214,28 @@ var ColorPickerController = class extends Controller {
|
|
|
202
214
|
slider.setAttribute("aria-valuetext", valueText(channel, value));
|
|
203
215
|
}
|
|
204
216
|
const hex = this.#hexString();
|
|
217
|
+
this.#committedHex = hex;
|
|
205
218
|
if (this.hasHexTarget) this.hexTarget.value = hex;
|
|
206
219
|
for (const field of this.fieldTargets) field.value = hex;
|
|
207
220
|
for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
|
|
208
221
|
this.element.style.setProperty(COLOR_PROPERTY, hex);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Repaints after `alpha` changed at runtime and reports a color this controller
|
|
225
|
+
* settled on. Disabling alpha drops it from the model, so the committed color can
|
|
226
|
+
* move without a user edit; `change` stays reserved for the picker's own actions.
|
|
227
|
+
*/
|
|
228
|
+
#reconcileColor() {
|
|
229
|
+
const previous = this.#committedHex;
|
|
230
|
+
this.#render();
|
|
231
|
+
if (previous !== null && this.#committedHex !== previous) {
|
|
232
|
+
this.dispatch("reconcile", { detail: this.#settledDetail() });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** The settled color as event detail, shared by both report paths. */
|
|
236
|
+
#settledDetail() {
|
|
209
237
|
const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
|
|
210
|
-
this
|
|
211
|
-
detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
|
|
212
|
-
});
|
|
238
|
+
return { value: this.#hexString(), rgba: { ...rgb, a: this.#color.alpha / 100 } };
|
|
213
239
|
}
|
|
214
240
|
/** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
|
|
215
241
|
#hexString() {
|
|
@@ -18,6 +18,35 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
18
18
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// src/utils/before_cache_reset.ts
|
|
22
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
23
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
24
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
25
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
26
|
+
static #onBeforeCache = () => {
|
|
27
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
28
|
+
};
|
|
29
|
+
#rewind;
|
|
30
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
31
|
+
constructor(rewind) {
|
|
32
|
+
this.#rewind = rewind;
|
|
33
|
+
}
|
|
34
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
35
|
+
activate() {
|
|
36
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
37
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
38
|
+
if (first) {
|
|
39
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
43
|
+
deactivate() {
|
|
44
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
45
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
46
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
// src/utils/dates.ts
|
|
22
51
|
function toISODateString(date) {
|
|
23
52
|
const year = date.getFullYear();
|
|
@@ -138,16 +167,34 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
138
167
|
}
|
|
139
168
|
};
|
|
140
169
|
|
|
170
|
+
// src/utils/string_list.ts
|
|
171
|
+
function parseStringList(raw, fallback = []) {
|
|
172
|
+
const text = raw.trim();
|
|
173
|
+
if (text.length === 0) return [...fallback];
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(text);
|
|
177
|
+
} catch {
|
|
178
|
+
return [...fallback];
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(parsed)) return [...fallback];
|
|
181
|
+
return parsed.filter((entry) => typeof entry === "string");
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
// src/controllers/date_range_picker_controller.ts
|
|
142
185
|
var GRID_SIZE = 42;
|
|
143
186
|
var DateRangePickerController = class extends Controller {
|
|
144
187
|
static targets = ["grid", "monthLabel", "cell", "status", "startField", "endField"];
|
|
145
188
|
static values = {
|
|
146
189
|
min: { type: String, default: "" },
|
|
147
|
-
max: { type: String, default: "" }
|
|
190
|
+
max: { type: String, default: "" },
|
|
191
|
+
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
192
|
+
// type: that reader throws out of the value observer before any callback
|
|
193
|
+
// runs, so one malformed attribute would stop the picker from connecting.
|
|
194
|
+
disabledDates: { type: String, default: "" }
|
|
148
195
|
};
|
|
149
196
|
static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
|
|
150
|
-
static events = ["change"];
|
|
197
|
+
static events = ["change", "monthchange"];
|
|
151
198
|
/** The month currently rendered, as `YYYY-MM`. */
|
|
152
199
|
#viewMonth = "";
|
|
153
200
|
/** The confirmed range endpoints (ISO), or "" when unset. */
|
|
@@ -161,7 +208,12 @@ var DateRangePickerController = class extends Controller {
|
|
|
161
208
|
#focusedDate = /* @__PURE__ */ new Date();
|
|
162
209
|
/** Deferred focus after an async month transition (cancelled on teardown). */
|
|
163
210
|
#focusTimer = new SafeTimeout();
|
|
164
|
-
/**
|
|
211
|
+
/** The declared unavailable dates, indexed for the per-cell paint lookup. */
|
|
212
|
+
#disabledDates = /* @__PURE__ */ new Set();
|
|
213
|
+
/** Rewinds an unfinished selection before Turbo snapshots the page. */
|
|
214
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
215
|
+
/** Last painted month, or `null` until the initial paint has settled. */
|
|
216
|
+
#announcedMonth = null;
|
|
165
217
|
/**
|
|
166
218
|
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
167
219
|
* pass Stimulus delivers before `connect()`.
|
|
@@ -169,10 +221,17 @@ var DateRangePickerController = class extends Controller {
|
|
|
169
221
|
#repaint = new MicrotaskCoalescer(() => {
|
|
170
222
|
this.#render();
|
|
171
223
|
});
|
|
224
|
+
/** Seeds and normalizes the range from optional hidden fields, then paints the grid. */
|
|
172
225
|
connect() {
|
|
173
226
|
this.#repaint.activate();
|
|
174
|
-
this.#
|
|
175
|
-
|
|
227
|
+
this.#beforeCache.activate();
|
|
228
|
+
const authoredStart = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
|
|
229
|
+
const authoredEnd = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
|
|
230
|
+
[this.#startDate, this.#endDate] = orderRange(authoredStart, authoredEnd);
|
|
231
|
+
this.#pendingStart = "";
|
|
232
|
+
this.#previewDate = "";
|
|
233
|
+
this.#announcedMonth = null;
|
|
234
|
+
this.#commitFields();
|
|
176
235
|
const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
|
|
177
236
|
this.#focusedDate = anchor;
|
|
178
237
|
this.#viewMonth = toISOMonthString(anchor);
|
|
@@ -182,6 +241,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
182
241
|
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
183
242
|
disconnect() {
|
|
184
243
|
this.#repaint.cancel();
|
|
244
|
+
this.#beforeCache.deactivate();
|
|
185
245
|
this.#focusTimer.clearAll();
|
|
186
246
|
}
|
|
187
247
|
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
@@ -192,6 +252,11 @@ var DateRangePickerController = class extends Controller {
|
|
|
192
252
|
maxValueChanged() {
|
|
193
253
|
this.#repaint.schedule();
|
|
194
254
|
}
|
|
255
|
+
/** Re-indexes the unavailable dates and repaints when the declared set changes. */
|
|
256
|
+
disabledDatesValueChanged() {
|
|
257
|
+
this.#disabledDates = new Set(parseStringList(this.disabledDatesValue));
|
|
258
|
+
this.#repaint.schedule();
|
|
259
|
+
}
|
|
195
260
|
/** Navigates to the previous month. */
|
|
196
261
|
prev(event) {
|
|
197
262
|
event?.preventDefault();
|
|
@@ -207,7 +272,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
207
272
|
const cell = this.#cellFrom(event.target);
|
|
208
273
|
if (!cell) return;
|
|
209
274
|
const date = cell.getAttribute("data-date");
|
|
210
|
-
if (!date ||
|
|
275
|
+
if (!date || !this.#isSelectable(date)) return;
|
|
211
276
|
this.#choose(date);
|
|
212
277
|
}
|
|
213
278
|
/** Previews the range up to a hovered/focused cell while selecting. */
|
|
@@ -216,28 +281,27 @@ var DateRangePickerController = class extends Controller {
|
|
|
216
281
|
if (!cell) return;
|
|
217
282
|
const date = cell.getAttribute("data-date");
|
|
218
283
|
if (!date) return;
|
|
284
|
+
let shouldRender = false;
|
|
219
285
|
if (event.type.startsWith("focus")) {
|
|
220
286
|
const parsed = parseISODateString(date);
|
|
221
287
|
if (parsed) this.#focusedDate = parsed;
|
|
222
|
-
|
|
223
|
-
this.#render();
|
|
224
|
-
}
|
|
288
|
+
shouldRender = cell.getAttribute("tabindex") !== "0";
|
|
225
289
|
}
|
|
226
|
-
if (this.#pendingStart) {
|
|
290
|
+
if (this.#pendingStart && this.#isSelectable(date) && this.#previewDate !== date) {
|
|
227
291
|
this.#previewDate = date;
|
|
228
|
-
|
|
292
|
+
shouldRender = true;
|
|
229
293
|
}
|
|
294
|
+
if (shouldRender) this.#render();
|
|
230
295
|
}
|
|
231
296
|
/** Applies a named preset (`today` / `last7` / `last30` / `thisMonth`). */
|
|
232
297
|
applyPreset(event) {
|
|
233
298
|
const button = event.target?.closest("[data-range]");
|
|
234
|
-
const
|
|
235
|
-
if (!name) return;
|
|
236
|
-
const range = computePreset(name);
|
|
299
|
+
const range = computePreset(button?.getAttribute("data-range") ?? "");
|
|
237
300
|
if (!range) return;
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
301
|
+
const intersection = this.#intersectRange(range);
|
|
302
|
+
if (!intersection) return;
|
|
303
|
+
const { start, end } = intersection;
|
|
304
|
+
if (!this.#isSelectable(start) || !this.#isSelectable(end)) return;
|
|
241
305
|
this.#startDate = start;
|
|
242
306
|
this.#endDate = end;
|
|
243
307
|
this.#pendingStart = "";
|
|
@@ -255,12 +319,12 @@ var DateRangePickerController = class extends Controller {
|
|
|
255
319
|
if (isReservedArrowChord(event)) return;
|
|
256
320
|
const cell = this.#cellFrom(event.target);
|
|
257
321
|
if (!cell) return;
|
|
258
|
-
const dateStr = cell.getAttribute("data-date");
|
|
259
|
-
const date =
|
|
322
|
+
const dateStr = cell.getAttribute("data-date") ?? "";
|
|
323
|
+
const date = parseISODateString(dateStr);
|
|
260
324
|
if (!date) return;
|
|
261
325
|
if (event.key === "Enter" || event.key === " ") {
|
|
262
326
|
event.preventDefault();
|
|
263
|
-
if (dateStr
|
|
327
|
+
if (this.#isSelectable(dateStr)) this.#choose(dateStr);
|
|
264
328
|
return;
|
|
265
329
|
}
|
|
266
330
|
if (event.key === "Escape") {
|
|
@@ -293,10 +357,10 @@ var DateRangePickerController = class extends Controller {
|
|
|
293
357
|
next = addDays(date, 6 - date.getDay());
|
|
294
358
|
break;
|
|
295
359
|
case "PageUp":
|
|
296
|
-
next = shiftMonthClamped(date, -1);
|
|
360
|
+
next = event.shiftKey ? shiftYearClamped(date, -1) : shiftMonthClamped(date, -1);
|
|
297
361
|
break;
|
|
298
362
|
case "PageDown":
|
|
299
|
-
next = shiftMonthClamped(date, 1);
|
|
363
|
+
next = event.shiftKey ? shiftYearClamped(date, 1) : shiftMonthClamped(date, 1);
|
|
300
364
|
break;
|
|
301
365
|
default:
|
|
302
366
|
return;
|
|
@@ -327,12 +391,14 @@ var DateRangePickerController = class extends Controller {
|
|
|
327
391
|
/** Moves roving focus to `date`, transitioning the month when needed. */
|
|
328
392
|
#moveFocusTo(date) {
|
|
329
393
|
this.#focusedDate = date;
|
|
330
|
-
|
|
331
|
-
this.#
|
|
394
|
+
const iso = toISODateString(date);
|
|
395
|
+
if (this.#pendingStart && this.#isSelectable(iso)) this.#previewDate = iso;
|
|
396
|
+
this.#transitionTo(toISOMonthString(date), iso);
|
|
332
397
|
}
|
|
333
398
|
/** Renders `month`, then focuses the cell for `dateStr` (deferred if async). */
|
|
334
399
|
#transitionTo(month, dateStr) {
|
|
335
400
|
const isTransition = month !== this.#viewMonth;
|
|
401
|
+
this.#focusTimer.clearAll();
|
|
336
402
|
this.#viewMonth = month;
|
|
337
403
|
this.#render();
|
|
338
404
|
const focusCell = () => {
|
|
@@ -364,17 +430,19 @@ var DateRangePickerController = class extends Controller {
|
|
|
364
430
|
const info = parseISOMonthString(this.#viewMonth);
|
|
365
431
|
if (!info) return;
|
|
366
432
|
const { year, month } = info;
|
|
433
|
+
if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
|
|
367
434
|
if (this.hasMonthLabelTarget) {
|
|
368
435
|
const lang = document.documentElement.lang || "en";
|
|
369
|
-
const formatter =
|
|
436
|
+
const formatter = monthFormatter(lang);
|
|
370
437
|
this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
|
|
371
438
|
}
|
|
372
439
|
const [rangeStart, rangeEnd] = this.#visualRange();
|
|
373
440
|
const days = gridDays(year, month);
|
|
374
441
|
const focusedStr = toISODateString(this.#focusedDate);
|
|
375
442
|
const todayStr = toISODateString(/* @__PURE__ */ new Date());
|
|
443
|
+
const cells = this.cellTargets;
|
|
376
444
|
for (let i = 0; i < GRID_SIZE; i++) {
|
|
377
|
-
const el =
|
|
445
|
+
const el = cells[i];
|
|
378
446
|
const date = days[i];
|
|
379
447
|
if (!el || !date) continue;
|
|
380
448
|
const iso = toISODateString(date);
|
|
@@ -383,8 +451,8 @@ var DateRangePickerController = class extends Controller {
|
|
|
383
451
|
el.setAttribute("data-outside", String(date.getMonth() !== month - 1));
|
|
384
452
|
el.setAttribute("data-today", String(iso === todayStr));
|
|
385
453
|
el.setAttribute("tabindex", iso === focusedStr ? "0" : "-1");
|
|
386
|
-
if (this.#
|
|
387
|
-
else el.
|
|
454
|
+
if (this.#isSelectable(iso)) el.removeAttribute("aria-disabled");
|
|
455
|
+
else el.setAttribute("aria-disabled", "true");
|
|
388
456
|
const isStart = !!rangeStart && iso === rangeStart;
|
|
389
457
|
const isEnd = !!rangeEnd && iso === rangeEnd && rangeEnd !== rangeStart;
|
|
390
458
|
const inside = !!rangeStart && !!rangeEnd && iso > rangeStart && iso < rangeEnd;
|
|
@@ -398,6 +466,16 @@ var DateRangePickerController = class extends Controller {
|
|
|
398
466
|
)
|
|
399
467
|
);
|
|
400
468
|
}
|
|
469
|
+
for (const extra of cells.slice(GRID_SIZE)) extra.setAttribute("tabindex", "-1");
|
|
470
|
+
if (!cells.some((cell) => cell.getAttribute("tabindex") === "0")) {
|
|
471
|
+
const fallback = cells.find((cell) => cell.getAttribute("data-outside") === "false") ?? cells[0];
|
|
472
|
+
fallback?.setAttribute("tabindex", "0");
|
|
473
|
+
}
|
|
474
|
+
const previous = this.#announcedMonth;
|
|
475
|
+
this.#announcedMonth = this.#viewMonth;
|
|
476
|
+
if (previous !== null && previous !== this.#viewMonth) {
|
|
477
|
+
this.dispatch("monthchange", { detail: { month: this.#viewMonth } });
|
|
478
|
+
}
|
|
401
479
|
}
|
|
402
480
|
/** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
|
|
403
481
|
#visualRange() {
|
|
@@ -418,15 +496,25 @@ var DateRangePickerController = class extends Controller {
|
|
|
418
496
|
this.statusTarget.textContent = `${this.#startDate} \u2013 ${this.#endDate}`;
|
|
419
497
|
}
|
|
420
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* True when `iso` may be chosen as a range endpoint.
|
|
501
|
+
*
|
|
502
|
+
* The single availability question in the controller: the grid paint, the
|
|
503
|
+
* preview, and both commit paths ask it, so a cell's `aria-disabled` and what
|
|
504
|
+
* a click on that cell does are the same decision rather than two that have to
|
|
505
|
+
* be kept in step.
|
|
506
|
+
*/
|
|
507
|
+
#isSelectable(iso) {
|
|
508
|
+
return !this.#outOfBounds(iso) && !this.#disabledDates.has(iso);
|
|
509
|
+
}
|
|
421
510
|
/** True when `iso` falls outside the `[min, max]` bounds. */
|
|
422
511
|
#outOfBounds(iso) {
|
|
423
512
|
if (this.minValue && iso < this.minValue) return true;
|
|
424
513
|
if (this.maxValue && iso > this.maxValue) return true;
|
|
425
514
|
return false;
|
|
426
515
|
}
|
|
427
|
-
/** Clamps
|
|
516
|
+
/** Clamps a generated ISO date string into `[min, max]`. */
|
|
428
517
|
#clampISO(iso) {
|
|
429
|
-
if (!iso) return "";
|
|
430
518
|
if (this.minValue && iso < this.minValue) return this.minValue;
|
|
431
519
|
if (this.maxValue && iso > this.maxValue) return this.maxValue;
|
|
432
520
|
return iso;
|
|
@@ -436,6 +524,27 @@ var DateRangePickerController = class extends Controller {
|
|
|
436
524
|
const clamped = this.#clampISO(toISODateString(date));
|
|
437
525
|
return parseISODateString(clamped);
|
|
438
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Intersects a preset with `[min, max]`, rejecting a disjoint interval.
|
|
529
|
+
*
|
|
530
|
+
* Only the bounds narrow a preset here: `disabledDates` excludes single days,
|
|
531
|
+
* not sub-intervals, so it cannot shrink one to a still-contiguous range. A
|
|
532
|
+
* day it excludes is still refused as an endpoint — the caller checks that —
|
|
533
|
+
* but a preset that merely spans one keeps its span.
|
|
534
|
+
*/
|
|
535
|
+
#intersectRange(range) {
|
|
536
|
+
const start = this.minValue && range.start < this.minValue ? this.minValue : range.start;
|
|
537
|
+
const end = this.maxValue && range.end > this.maxValue ? this.maxValue : range.end;
|
|
538
|
+
return start <= end ? { start, end } : null;
|
|
539
|
+
}
|
|
540
|
+
/** Removes provisional range state before Turbo freezes a cached snapshot. */
|
|
541
|
+
#rewindForCache() {
|
|
542
|
+
this.#focusTimer.clearAll();
|
|
543
|
+
if (!this.#pendingStart && !this.#previewDate) return;
|
|
544
|
+
this.#pendingStart = "";
|
|
545
|
+
this.#previewDate = "";
|
|
546
|
+
this.#render();
|
|
547
|
+
}
|
|
439
548
|
/** Resolves the cell element from an event target, or null. */
|
|
440
549
|
#cellFrom(target) {
|
|
441
550
|
return target?.closest(
|
|
@@ -454,6 +563,12 @@ function shiftMonthClamped(date, delta) {
|
|
|
454
563
|
target.setDate(Math.min(date.getDate(), lastDay));
|
|
455
564
|
return target;
|
|
456
565
|
}
|
|
566
|
+
function shiftYearClamped(date, delta) {
|
|
567
|
+
const target = new Date(date.getFullYear() + delta, date.getMonth(), 1);
|
|
568
|
+
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
569
|
+
target.setDate(Math.min(date.getDate(), lastDay));
|
|
570
|
+
return target;
|
|
571
|
+
}
|
|
457
572
|
function gridDays(year, month) {
|
|
458
573
|
const first = new Date(year, month - 1, 1);
|
|
459
574
|
const start = new Date(first);
|
|
@@ -470,6 +585,18 @@ function normalizeISO(value) {
|
|
|
470
585
|
const date = parseISODateString(value.trim());
|
|
471
586
|
return date ? toISODateString(date) : "";
|
|
472
587
|
}
|
|
588
|
+
function orderRange(start, end) {
|
|
589
|
+
return start && end && end < start ? [end, start] : [start, end];
|
|
590
|
+
}
|
|
591
|
+
function monthFormatter(locale) {
|
|
592
|
+
const options = { month: "long", year: "numeric" };
|
|
593
|
+
try {
|
|
594
|
+
return new Intl.DateTimeFormat(locale, options);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (!(error instanceof RangeError)) throw error;
|
|
597
|
+
return new Intl.DateTimeFormat("en", options);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
473
600
|
function computePreset(name) {
|
|
474
601
|
const today = /* @__PURE__ */ new Date();
|
|
475
602
|
const todayStr = toISODateString(today);
|