stimeo-ui 0.2.0 → 0.3.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 +163 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +313 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +252 -0
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +246 -0
- data/dist/controllers/date_range_picker_controller.js +441 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +169 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +67 -19
- data/dist/controllers/lazy_frame_controller.js +54 -11
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +785 -0
- data/dist/controllers/multi_select_controller.js +755 -0
- data/dist/controllers/navigation_menu_controller.js +511 -0
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +246 -27
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +192 -0
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +238 -0
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +101 -14
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +40 -6
- data/dist/controllers/scrollspy_controller.js +369 -74
- data/dist/controllers/separator_controller.js +96 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +9 -4
- data/dist/controllers/sticky_observer_controller.js +109 -20
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +295 -0
- data/dist/controllers/theme_controller.js +42 -13
- data/dist/controllers/time_picker_controller.js +231 -0
- data/dist/controllers/toast_controller.js +40 -14
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +691 -0
- data/dist/index.js +4256 -915
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +28 -2
|
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/breadcrumb_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/blur_deferral.ts
|
|
6
|
+
var BlurDeferral = class {
|
|
7
|
+
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
8
|
+
#pending = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Called after a pending element blurs and has been detached. */
|
|
10
|
+
#onRelease;
|
|
11
|
+
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
12
|
+
constructor(onRelease) {
|
|
13
|
+
this.#onRelease = onRelease;
|
|
14
|
+
}
|
|
15
|
+
/** Number of elements currently holding an update back. */
|
|
16
|
+
get size() {
|
|
17
|
+
return this.#pending.size;
|
|
18
|
+
}
|
|
19
|
+
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
20
|
+
get elements() {
|
|
21
|
+
return [...this.#pending.keys()];
|
|
22
|
+
}
|
|
23
|
+
/** Whether `element` is currently holding an update back. */
|
|
24
|
+
has(element) {
|
|
25
|
+
return this.#pending.has(element);
|
|
26
|
+
}
|
|
27
|
+
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
28
|
+
defer(element) {
|
|
29
|
+
if (this.#pending.has(element)) return;
|
|
30
|
+
const onBlur = () => {
|
|
31
|
+
this.#detach(element);
|
|
32
|
+
this.#onRelease(element);
|
|
33
|
+
};
|
|
34
|
+
this.#pending.set(element, onBlur);
|
|
35
|
+
element.addEventListener("blur", onBlur);
|
|
36
|
+
}
|
|
37
|
+
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
38
|
+
deferOnly(element) {
|
|
39
|
+
for (const pending of this.elements) {
|
|
40
|
+
if (pending !== element) this.#detach(pending);
|
|
41
|
+
}
|
|
42
|
+
this.defer(element);
|
|
43
|
+
}
|
|
44
|
+
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
45
|
+
release(element) {
|
|
46
|
+
this.#detach(element);
|
|
47
|
+
}
|
|
48
|
+
/** Cancels every deferral without completing any of them. */
|
|
49
|
+
releaseAll() {
|
|
50
|
+
for (const element of this.elements) this.#detach(element);
|
|
51
|
+
}
|
|
52
|
+
/** Removes the `blur` listener for `element` and forgets it. */
|
|
53
|
+
#detach(element) {
|
|
54
|
+
const onBlur = this.#pending.get(element);
|
|
55
|
+
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
56
|
+
this.#pending.delete(element);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
5
60
|
// src/utils/layout_observer.ts
|
|
6
61
|
var LayoutObserver = class {
|
|
7
62
|
#callback;
|
|
@@ -59,37 +114,152 @@ var LayoutObserver = class {
|
|
|
59
114
|
};
|
|
60
115
|
|
|
61
116
|
// src/controllers/breadcrumb_controller.ts
|
|
117
|
+
var OVERFLOW_EPSILON = 1;
|
|
62
118
|
var BreadcrumbController = class extends Controller {
|
|
63
119
|
static targets = ["list", "collapsible", "ellipsis", "trigger"];
|
|
64
|
-
static actions = ["toggle"];
|
|
120
|
+
static actions = ["toggle", "update"];
|
|
65
121
|
static events = ["toggle"];
|
|
122
|
+
/** Guards target callbacks that can fire outside the connected window. */
|
|
123
|
+
#connected = false;
|
|
66
124
|
/** Whether the trail currently overflows its container. */
|
|
67
125
|
#overflowing = false;
|
|
68
126
|
/** Whether the user has expanded the collapsed items via the disclosure. */
|
|
69
127
|
#expanded = false;
|
|
70
|
-
|
|
128
|
+
/** The list element currently observed (resize + mutations), if any. */
|
|
129
|
+
#observedList = null;
|
|
130
|
+
#listMutations = null;
|
|
131
|
+
#layout = new LayoutObserver(() => this.update());
|
|
132
|
+
#onListMutation = () => {
|
|
133
|
+
this.update();
|
|
134
|
+
};
|
|
135
|
+
/** Holds the ellipsis hide back while it contains focus; re-renders on blur. */
|
|
136
|
+
#deferredHide = new BlurDeferral(() => {
|
|
137
|
+
this.#render();
|
|
138
|
+
});
|
|
71
139
|
/** Starts observing for overflow and renders the initial state. */
|
|
72
140
|
connect() {
|
|
73
|
-
|
|
141
|
+
this.#connected = true;
|
|
142
|
+
this.#expanded = this.#initialExpanded();
|
|
74
143
|
this.#layout.observeViewport();
|
|
75
|
-
this.#
|
|
144
|
+
this.#syncListObservation();
|
|
145
|
+
this.update();
|
|
76
146
|
}
|
|
77
|
-
/** Releases the resize observation (Turbo navigation included). */
|
|
147
|
+
/** Releases the resize/mutation observation (Turbo navigation included). */
|
|
78
148
|
disconnect() {
|
|
149
|
+
this.#connected = false;
|
|
150
|
+
this.#deferredHide.releaseAll();
|
|
151
|
+
this.#stopObservingList();
|
|
79
152
|
this.#layout.disconnect();
|
|
80
153
|
}
|
|
81
|
-
|
|
154
|
+
listTargetConnected() {
|
|
155
|
+
this.#resync();
|
|
156
|
+
}
|
|
157
|
+
listTargetDisconnected() {
|
|
158
|
+
this.#resync();
|
|
159
|
+
}
|
|
160
|
+
collapsibleTargetConnected() {
|
|
161
|
+
this.#resync();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Re-measures when the disclosure set gains or loses a member. The set is a
|
|
165
|
+
* precondition for collapsing at all, so it needs the same watching the list
|
|
166
|
+
* and items get: without these callbacks a swap that breaks or completes the
|
|
167
|
+
* set would only take effect at the next resize or list mutation, which may
|
|
168
|
+
* never come.
|
|
169
|
+
*/
|
|
170
|
+
ellipsisTargetConnected() {
|
|
171
|
+
this.#resync();
|
|
172
|
+
}
|
|
173
|
+
ellipsisTargetDisconnected() {
|
|
174
|
+
this.#resync();
|
|
175
|
+
}
|
|
176
|
+
triggerTargetConnected() {
|
|
177
|
+
this.#resync();
|
|
178
|
+
}
|
|
179
|
+
triggerTargetDisconnected() {
|
|
180
|
+
this.#resync();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Hands `hidden` back when an element stops being a `collapsible` target.
|
|
184
|
+
*
|
|
185
|
+
* The marker is the source of truth for what may be collapsed, so an element
|
|
186
|
+
* that loses it while the controller is live is an always-visible item again —
|
|
187
|
+
* and `#render` only walks the *current* targets, so nothing else would ever
|
|
188
|
+
* clear the `hidden` this controller put there.
|
|
189
|
+
*
|
|
190
|
+
* The `#connected` guard is load-bearing, not defensive. Stimulus fires this
|
|
191
|
+
* callback for **every** target during teardown as well (`Context.disconnect()`
|
|
192
|
+
* stops the target observer after `disconnect()` has run), and there the
|
|
193
|
+
* collapsed DOM must survive untouched so Turbo's cache restores the trail in
|
|
194
|
+
* the state the user left it (see the ownership note in {@link BreadcrumbController}).
|
|
195
|
+
*
|
|
196
|
+
* @param element - the element that just left the `collapsible` target set
|
|
197
|
+
*/
|
|
198
|
+
collapsibleTargetDisconnected(element) {
|
|
199
|
+
if (this.#connected) element.hidden = false;
|
|
200
|
+
this.#resync();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Expands or re-collapses the trail and dispatches `toggle`.
|
|
204
|
+
*
|
|
205
|
+
* No-op while the trail fits: there is nothing collapsed to reveal, and the
|
|
206
|
+
* (hidden) trigger would otherwise keep a stale `aria-expanded="true"` that
|
|
207
|
+
* surfaces pre-expanded the next time the trail overflows. No event is
|
|
208
|
+
* dispatched in that case.
|
|
209
|
+
*/
|
|
82
210
|
toggle() {
|
|
211
|
+
if (!this.#connected || !this.#overflowing) return;
|
|
83
212
|
this.#expanded = !this.#expanded;
|
|
84
213
|
this.#render();
|
|
85
214
|
this.dispatch("toggle", { detail: { expanded: this.#expanded } });
|
|
86
215
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Re-measures overflow and re-renders. Wired to resizes and list mutations;
|
|
218
|
+
* exposed as an action so consumers can re-measure after a change the
|
|
219
|
+
* observers cannot see (e.g. a web font swap that only alters text width).
|
|
220
|
+
*/
|
|
221
|
+
update() {
|
|
222
|
+
if (!this.#connected) return;
|
|
89
223
|
this.#overflowing = this.#measureOverflow();
|
|
90
224
|
if (!this.#overflowing) this.#expanded = false;
|
|
91
225
|
this.#render();
|
|
92
226
|
}
|
|
227
|
+
/** Re-attaches the list observation (targets may have been swapped) and re-measures. */
|
|
228
|
+
#resync() {
|
|
229
|
+
if (!this.#connected) return;
|
|
230
|
+
this.#syncListObservation();
|
|
231
|
+
this.update();
|
|
232
|
+
}
|
|
233
|
+
/** Reads the expanded state back from its DOM home (the trigger's `aria-expanded`). */
|
|
234
|
+
#initialExpanded() {
|
|
235
|
+
return this.hasTriggerTarget && this.triggerTarget.getAttribute("aria-expanded") === "true";
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Points the resize + mutation observation at the current `list` target.
|
|
239
|
+
*
|
|
240
|
+
* The list is re-resolved rather than captured at connect so a target swapped
|
|
241
|
+
* at runtime (Turbo Stream replacement of the `<ol>`) is observed too. Only
|
|
242
|
+
* `childList` / `subtree` / `characterData` are watched — deliberately **not**
|
|
243
|
+
* `attributes`, which the controller's own `hidden` writes would re-trigger.
|
|
244
|
+
*/
|
|
245
|
+
#syncListObservation() {
|
|
246
|
+
const next = this.hasListTarget ? this.listTarget : null;
|
|
247
|
+
if (next === this.#observedList) return;
|
|
248
|
+
this.#stopObservingList();
|
|
249
|
+
if (!next) return;
|
|
250
|
+
this.#observedList = next;
|
|
251
|
+
this.#layout.observe(next);
|
|
252
|
+
if (typeof MutationObserver !== "undefined") {
|
|
253
|
+
this.#listMutations = new MutationObserver(this.#onListMutation);
|
|
254
|
+
this.#listMutations.observe(next, { childList: true, subtree: true, characterData: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
#stopObservingList() {
|
|
258
|
+
if (this.#observedList) this.#layout.unobserve(this.#observedList);
|
|
259
|
+
this.#observedList = null;
|
|
260
|
+
this.#listMutations?.disconnect();
|
|
261
|
+
this.#listMutations = null;
|
|
262
|
+
}
|
|
93
263
|
/**
|
|
94
264
|
* Measures overflow against the **fully expanded** layout so hiding items does
|
|
95
265
|
* not make the condition oscillate. Reveals every item, then compares the list's
|
|
@@ -100,21 +270,63 @@ var BreadcrumbController = class extends Controller {
|
|
|
100
270
|
* check is independent of any padding/border on the host — comparing against the
|
|
101
271
|
* host's `clientWidth` would over-report the available space by its padding and
|
|
102
272
|
* miss real overflow in a padded container.
|
|
273
|
+
*
|
|
274
|
+
* With no `collapsible` items there is nothing to collapse, so the trail is
|
|
275
|
+
* never reported as overflowing (which also keeps the disclosure out of the
|
|
276
|
+
* tab order — a button controlling nothing).
|
|
277
|
+
*
|
|
278
|
+
* The full disclosure set is a precondition: collapsing without an `ellipsis`
|
|
279
|
+
* *and* a `trigger` would hide items behind a control that does not exist, so
|
|
280
|
+
* an incomplete set reports "not overflowing" and `#render` degrades the trail
|
|
281
|
+
* to a plain breadcrumb. Losing layout is recoverable; losing the path is not.
|
|
103
282
|
*/
|
|
104
283
|
#measureOverflow() {
|
|
105
|
-
if (!this.hasListTarget) return false;
|
|
284
|
+
if (!this.hasListTarget || this.collapsibleTargets.length === 0) return false;
|
|
285
|
+
if (!this.hasEllipsisTarget || !this.hasTriggerTarget) return false;
|
|
106
286
|
for (const item of this.collapsibleTargets) item.hidden = false;
|
|
107
|
-
|
|
108
|
-
return this.listTarget.scrollWidth > this.listTarget.clientWidth;
|
|
287
|
+
this.ellipsisTarget.hidden = true;
|
|
288
|
+
return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
|
|
109
289
|
}
|
|
110
290
|
/** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
|
|
111
291
|
#render() {
|
|
112
292
|
const collapsed = this.#overflowing && !this.#expanded;
|
|
293
|
+
const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
|
|
294
|
+
const rescueFocus = collapsed && this.hasTriggerTarget && this.collapsibleTargets.some((i) => this.#holdsFocus(i));
|
|
113
295
|
for (const item of this.collapsibleTargets) item.hidden = collapsed;
|
|
114
|
-
if (this.hasEllipsisTarget) this
|
|
296
|
+
if (this.hasEllipsisTarget) this.#applyEllipsisVisibility(showEllipsis);
|
|
115
297
|
if (this.hasTriggerTarget) {
|
|
116
|
-
|
|
298
|
+
const expanded = this.#overflowing && this.#expanded;
|
|
299
|
+
this.triggerTarget.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
300
|
+
}
|
|
301
|
+
if (rescueFocus) this.triggerTarget.focus();
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Shows or hides the ellipsis item, deferring a hide that would blur the user.
|
|
305
|
+
*
|
|
306
|
+
* Hiding the element that currently holds focus drops focus to `<body>`, so the
|
|
307
|
+
* hide waits for that element's `blur` and is re-applied then (the shared
|
|
308
|
+
* `BlurDeferral` registry).
|
|
309
|
+
*/
|
|
310
|
+
#applyEllipsisVisibility(show) {
|
|
311
|
+
const ellipsis = this.ellipsisTarget;
|
|
312
|
+
if (show) {
|
|
313
|
+
this.#deferredHide.releaseAll();
|
|
314
|
+
ellipsis.hidden = false;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const active = document.activeElement;
|
|
318
|
+
if (active instanceof HTMLElement && ellipsis.contains(active)) {
|
|
319
|
+
ellipsis.hidden = false;
|
|
320
|
+
this.#deferredHide.deferOnly(active);
|
|
321
|
+
return;
|
|
117
322
|
}
|
|
323
|
+
this.#deferredHide.releaseAll();
|
|
324
|
+
ellipsis.hidden = true;
|
|
325
|
+
}
|
|
326
|
+
/** Whether `element` contains (or is) the currently focused element. */
|
|
327
|
+
#holdsFocus(element) {
|
|
328
|
+
const active = document.activeElement;
|
|
329
|
+
return active instanceof HTMLElement && element.contains(active);
|
|
118
330
|
}
|
|
119
331
|
};
|
|
120
332
|
|
|
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/calendar_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
// src/utils/dates.ts
|
|
6
22
|
function toISODateString(date) {
|
|
7
23
|
const year = date.getFullYear();
|
|
@@ -32,6 +48,9 @@ function parseISOMonthString(monthStr) {
|
|
|
32
48
|
if (month < 1 || month > 12) return null;
|
|
33
49
|
return { year: Number(y), month };
|
|
34
50
|
}
|
|
51
|
+
function toISOMonthString(date) {
|
|
52
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
// src/utils/safe_timeout.ts
|
|
37
56
|
var TimerRegistry = class {
|
|
@@ -87,6 +106,7 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
87
106
|
};
|
|
88
107
|
|
|
89
108
|
// src/controllers/calendar_controller.ts
|
|
109
|
+
var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
|
|
90
110
|
var CalendarController = class extends Controller {
|
|
91
111
|
static targets = ["label", "grid", "day"];
|
|
92
112
|
static values = {
|
|
@@ -107,6 +127,13 @@ var CalendarController = class extends Controller {
|
|
|
107
127
|
* controller never steals focus after the element leaves the DOM (Turbo).
|
|
108
128
|
*/
|
|
109
129
|
#focusTimer = new SafeTimeout();
|
|
130
|
+
/**
|
|
131
|
+
* The painted month the last `monthchange` reported, or `null` before the first
|
|
132
|
+
* paint. Settling on the initial month — whether it comes from the attribute or
|
|
133
|
+
* is derived here — is the grid describing itself, not a navigation, so it must
|
|
134
|
+
* not reach a listener that refetches inventory or pushes history.
|
|
135
|
+
*/
|
|
136
|
+
#announcedMonth = null;
|
|
110
137
|
connect() {
|
|
111
138
|
if (!this.monthValue) {
|
|
112
139
|
const today = /* @__PURE__ */ new Date();
|
|
@@ -129,7 +156,6 @@ var CalendarController = class extends Controller {
|
|
|
129
156
|
if (!this.monthValue) return;
|
|
130
157
|
this.#syncFocusedDateWithMonth();
|
|
131
158
|
this.render();
|
|
132
|
-
this.dispatch("monthchange", { detail: { month: this.monthValue } });
|
|
133
159
|
}
|
|
134
160
|
/**
|
|
135
161
|
* Stimulus lifecycle callback triggered automatically when the selectedValue changes.
|
|
@@ -156,25 +182,25 @@ var CalendarController = class extends Controller {
|
|
|
156
182
|
}
|
|
157
183
|
/** Handles day selection when a gridcell is clicked. */
|
|
158
184
|
selectByClick(event) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
const dayElement = event.target?.closest(
|
|
186
|
+
"[data-stimeo--calendar-target='day']"
|
|
187
|
+
);
|
|
162
188
|
if (!dayElement) return;
|
|
163
189
|
this.selectDayElement(dayElement);
|
|
164
190
|
}
|
|
165
191
|
/** Handles grid cell keyboard navigation and triggers selection. */
|
|
166
192
|
onKeydown(event) {
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
const dayElement = target
|
|
193
|
+
if (event.defaultPrevented) return;
|
|
194
|
+
if (isReservedArrowChord(event)) return;
|
|
195
|
+
const dayElement = event.target?.closest(
|
|
196
|
+
"[data-stimeo--calendar-target='day']"
|
|
197
|
+
);
|
|
170
198
|
if (!dayElement) return;
|
|
171
|
-
const
|
|
172
|
-
if (!dateStr) return;
|
|
173
|
-
const date = parseISODateString(dateStr);
|
|
199
|
+
const date = parseISODateString(dayElement.getAttribute("data-date") ?? "");
|
|
174
200
|
if (!date) return;
|
|
175
201
|
let handled = true;
|
|
176
202
|
let nextDate = new Date(date);
|
|
177
|
-
switch (event.key) {
|
|
203
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
178
204
|
case "ArrowLeft":
|
|
179
205
|
nextDate.setDate(nextDate.getDate() - 1);
|
|
180
206
|
break;
|
|
@@ -209,6 +235,10 @@ var CalendarController = class extends Controller {
|
|
|
209
235
|
break;
|
|
210
236
|
case "t":
|
|
211
237
|
case "T": {
|
|
238
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
239
|
+
handled = false;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
212
242
|
const now = /* @__PURE__ */ new Date();
|
|
213
243
|
nextDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
214
244
|
break;
|
|
@@ -227,25 +257,50 @@ var CalendarController = class extends Controller {
|
|
|
227
257
|
this.#focusAndNavigateToDate(nextDate);
|
|
228
258
|
}
|
|
229
259
|
}
|
|
230
|
-
/**
|
|
231
|
-
|
|
260
|
+
/**
|
|
261
|
+
* First day of the month the grid paints: the `month` Value when it parses,
|
|
262
|
+
* otherwise the focused date's month.
|
|
263
|
+
*
|
|
264
|
+
* A malformed `month` falls back instead of stopping the paint: every cell
|
|
265
|
+
* keeps its `aria-selected` and the grid keeps its tab stop, so a Value typo
|
|
266
|
+
* still leaves the grid reachable by Tab and the author can see what they
|
|
267
|
+
* typed. Every consumer of "which month is on screen" reads it from here, so
|
|
268
|
+
* the paint and the `monthchange` report cannot name different months.
|
|
269
|
+
*/
|
|
270
|
+
#paintedMonthStart() {
|
|
232
271
|
const monthInfo = parseISOMonthString(this.monthValue);
|
|
233
|
-
|
|
234
|
-
|
|
272
|
+
return monthInfo ? new Date(monthInfo.year, monthInfo.month - 1, 1) : new Date(this.focusedDate.getFullYear(), this.focusedDate.getMonth(), 1);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Renders the grid days and updates the month/year label, then reports the
|
|
276
|
+
* painted month once it differs from the one already announced.
|
|
277
|
+
*
|
|
278
|
+
* The report belongs to the paint, not to the `month` Value: the Value is only
|
|
279
|
+
* one of the things that decide the month on screen. While a malformed `month`
|
|
280
|
+
* falls back, changing `selected` and selecting a neighbouring month's cell
|
|
281
|
+
* move the painted month too, and both repaint from here. Reading the report
|
|
282
|
+
* off the paint keeps the event naming the month the grid shows, in the
|
|
283
|
+
* `YYYY-MM` the detail contract promises, whichever route repainted.
|
|
284
|
+
*/
|
|
285
|
+
render() {
|
|
286
|
+
const monthStart = this.#paintedMonthStart();
|
|
287
|
+
const year = monthStart.getFullYear();
|
|
288
|
+
const month = monthStart.getMonth() + 1;
|
|
235
289
|
if (this.hasLabelTarget) {
|
|
236
290
|
const lang = document.documentElement.lang || "en";
|
|
237
|
-
const labelDate = new Date(year, month - 1, 1);
|
|
238
291
|
const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
|
|
239
|
-
this.labelTarget.textContent = formatter.format(
|
|
292
|
+
this.labelTarget.textContent = formatter.format(monthStart);
|
|
240
293
|
}
|
|
241
294
|
const days = this.#calculateGridDays(year, month);
|
|
242
295
|
const dayElements = this.dayTargets;
|
|
243
|
-
for (
|
|
244
|
-
const el = dayElements[
|
|
296
|
+
for (const [index, date] of days.entries()) {
|
|
297
|
+
const el = dayElements[index];
|
|
245
298
|
if (!el) continue;
|
|
246
|
-
const date = days[i];
|
|
247
|
-
if (!date) continue;
|
|
248
299
|
const dateStr = toISODateString(date);
|
|
300
|
+
if (el.getAttribute("data-date") !== dateStr) {
|
|
301
|
+
el.removeAttribute("aria-disabled");
|
|
302
|
+
el.removeAttribute(OWNED_DISABLED);
|
|
303
|
+
}
|
|
249
304
|
el.setAttribute("data-date", dateStr);
|
|
250
305
|
el.textContent = String(date.getDate());
|
|
251
306
|
const isOutside = date.getFullYear() !== year || date.getMonth() !== month - 1;
|
|
@@ -258,11 +313,23 @@ var CalendarController = class extends Controller {
|
|
|
258
313
|
el.setAttribute("tabindex", isFocused ? "0" : "-1");
|
|
259
314
|
const isDisabled = this.#isDateOutOfBounds(dateStr);
|
|
260
315
|
if (isDisabled) {
|
|
316
|
+
if (!el.hasAttribute("aria-disabled")) el.setAttribute(OWNED_DISABLED, "");
|
|
261
317
|
el.setAttribute("aria-disabled", "true");
|
|
262
|
-
} else {
|
|
318
|
+
} else if (el.hasAttribute(OWNED_DISABLED)) {
|
|
263
319
|
el.removeAttribute("aria-disabled");
|
|
320
|
+
el.removeAttribute(OWNED_DISABLED);
|
|
264
321
|
}
|
|
265
322
|
}
|
|
323
|
+
if (!dayElements.some((el) => el.getAttribute("tabindex") === "0")) {
|
|
324
|
+
const fallback = dayElements.find((el) => el.getAttribute("data-outside") === "false") ?? dayElements[0];
|
|
325
|
+
fallback?.setAttribute("tabindex", "0");
|
|
326
|
+
}
|
|
327
|
+
const painted = toISOMonthString(monthStart);
|
|
328
|
+
const previous = this.#announcedMonth;
|
|
329
|
+
this.#announcedMonth = painted;
|
|
330
|
+
if (previous !== null && previous !== painted) {
|
|
331
|
+
this.dispatch("monthchange", { detail: { month: painted } });
|
|
332
|
+
}
|
|
266
333
|
}
|
|
267
334
|
selectDayElement(dayElement) {
|
|
268
335
|
if (dayElement.getAttribute("aria-disabled") === "true") return;
|