stimeo-ui 0.2.1 → 0.4.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 +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- 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/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -2,6 +2,56 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/countdown_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
|
+
|
|
22
|
+
// src/utils/microtask_coalescer.ts
|
|
23
|
+
var MicrotaskCoalescer = class {
|
|
24
|
+
#run;
|
|
25
|
+
#queued = false;
|
|
26
|
+
#active = false;
|
|
27
|
+
#generation = 0;
|
|
28
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
29
|
+
constructor(run) {
|
|
30
|
+
this.#run = run;
|
|
31
|
+
}
|
|
32
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
33
|
+
activate() {
|
|
34
|
+
this.#active = true;
|
|
35
|
+
}
|
|
36
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
37
|
+
cancel() {
|
|
38
|
+
this.#active = false;
|
|
39
|
+
this.#queued = false;
|
|
40
|
+
this.#generation += 1;
|
|
41
|
+
}
|
|
42
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
43
|
+
schedule() {
|
|
44
|
+
if (!this.#active || this.#queued) return;
|
|
45
|
+
this.#queued = true;
|
|
46
|
+
const generation = this.#generation;
|
|
47
|
+
queueMicrotask(() => {
|
|
48
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
49
|
+
this.#queued = false;
|
|
50
|
+
this.#run();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
5
55
|
// src/utils/safe_timeout.ts
|
|
6
56
|
var TimerRegistry = class {
|
|
7
57
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -48,6 +98,7 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
48
98
|
};
|
|
49
99
|
|
|
50
100
|
// src/controllers/countdown_controller.ts
|
|
101
|
+
var SECOND_MS = 1e3;
|
|
51
102
|
var CountdownController = class extends Controller {
|
|
52
103
|
static targets = ["days", "hours", "minutes", "seconds", "status"];
|
|
53
104
|
static values = {
|
|
@@ -55,34 +106,79 @@ var CountdownController = class extends Controller {
|
|
|
55
106
|
interval: { type: Number, default: 1e3 },
|
|
56
107
|
direction: { type: String, default: "down" },
|
|
57
108
|
autostart: { type: Boolean, default: true },
|
|
58
|
-
completeLabel: { type: String, default: "" }
|
|
109
|
+
completeLabel: { type: String, default: "" },
|
|
110
|
+
announceText: { type: String, default: "" }
|
|
59
111
|
};
|
|
60
112
|
static actions = ["pause", "reset", "resume", "start"];
|
|
61
113
|
static events = ["complete", "tick"];
|
|
62
114
|
#intervals = new SafeInterval();
|
|
63
115
|
#intervalId = null;
|
|
116
|
+
/** Collapses a morph that swaps several render inputs at once into one re-derive. */
|
|
117
|
+
#resync = new MicrotaskCoalescer(() => this.#resyncToValues());
|
|
64
118
|
/** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
|
|
65
119
|
#reference = 0;
|
|
66
120
|
/** Amount (ms) captured at pause, so resume can restore the same display. */
|
|
67
121
|
#pausedAmount = 0;
|
|
122
|
+
/**
|
|
123
|
+
* The amount the slots are currently showing, floored to the second they render.
|
|
124
|
+
* It lags {@link currentAmount} by up to one tick, and it — not the live reading —
|
|
125
|
+
* is what a pause has to preserve: storing the fraction behind the display instead
|
|
126
|
+
* makes the first tick after a resume step by two units.
|
|
127
|
+
*/
|
|
128
|
+
#renderedAmount = 0;
|
|
68
129
|
connect() {
|
|
130
|
+
this.#resync.activate();
|
|
69
131
|
this.#initReference();
|
|
70
|
-
this.#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
132
|
+
const amount = this.#currentAmount();
|
|
133
|
+
this.#render(amount);
|
|
134
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
135
|
+
const authored = this.element.getAttribute("data-state");
|
|
136
|
+
if (authored === null) {
|
|
137
|
+
if (this.autostartValue && this.#isValidDeadline) {
|
|
138
|
+
this.start();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
74
141
|
this.element.setAttribute("data-state", "paused");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (authored === "complete" && this.#isDown && amount <= 0) {
|
|
145
|
+
return;
|
|
75
146
|
}
|
|
147
|
+
const wasRunning = authored === "running";
|
|
148
|
+
this.element.setAttribute("data-state", "paused");
|
|
149
|
+
if (wasRunning) this.start();
|
|
76
150
|
}
|
|
77
151
|
disconnect() {
|
|
152
|
+
this.#resync.cancel();
|
|
78
153
|
this.#intervals.clearAll();
|
|
79
154
|
this.#intervalId = null;
|
|
80
155
|
}
|
|
156
|
+
/** Re-derives the display when a morph swaps the deadline in place. */
|
|
157
|
+
deadlineValueChanged() {
|
|
158
|
+
this.#resync.schedule();
|
|
159
|
+
}
|
|
160
|
+
/** Re-derives the display when a morph flips the counting direction in place. */
|
|
161
|
+
directionValueChanged() {
|
|
162
|
+
this.#resync.schedule();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Points the anchor at the current `deadline` / `direction` and repaints.
|
|
166
|
+
*
|
|
167
|
+
* Render only: it starts no interval and emits no event, so a morph cannot make a
|
|
168
|
+
* paused timer run or replay a milestone. A running one needs no restart either —
|
|
169
|
+
* every tick reads the anchor, so moving it is enough. While paused the stored
|
|
170
|
+
* amount follows the new reading, or resume would continue from the old deadline.
|
|
171
|
+
*/
|
|
172
|
+
#resyncToValues() {
|
|
173
|
+
this.#initReference();
|
|
174
|
+
this.#render(this.#currentAmount());
|
|
175
|
+
if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
|
|
176
|
+
}
|
|
81
177
|
/** Starts (or restarts after pause) ticking toward the deadline. */
|
|
82
178
|
start() {
|
|
83
179
|
if (this.#state === "running" || !this.#isValidDeadline) return;
|
|
84
180
|
if (this.#isDown && this.#currentAmount() <= 0) {
|
|
85
|
-
this.#complete();
|
|
181
|
+
if (this.#state !== "complete") this.#complete();
|
|
86
182
|
return;
|
|
87
183
|
}
|
|
88
184
|
this.#runInterval();
|
|
@@ -90,7 +186,7 @@ var CountdownController = class extends Controller {
|
|
|
90
186
|
/** Pauses ticking, preserving the currently displayed amount. */
|
|
91
187
|
pause() {
|
|
92
188
|
if (this.#state !== "running") return;
|
|
93
|
-
this.#pausedAmount = this.#
|
|
189
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
94
190
|
this.#teardownInterval();
|
|
95
191
|
this.element.setAttribute("data-state", "paused");
|
|
96
192
|
}
|
|
@@ -106,8 +202,8 @@ var CountdownController = class extends Controller {
|
|
|
106
202
|
* run state**: a running timer keeps counting down from the reset amount, while a
|
|
107
203
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
108
204
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
109
|
-
* not re-derived from the declarative `autostart` Value (which only
|
|
110
|
-
*
|
|
205
|
+
* not re-derived from the declarative `autostart` Value (which governs only markup
|
|
206
|
+
* that states no run state at all); re-deriving it would override a user's pause —
|
|
111
207
|
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
112
208
|
*/
|
|
113
209
|
reset() {
|
|
@@ -116,13 +212,15 @@ var CountdownController = class extends Controller {
|
|
|
116
212
|
this.#initReference();
|
|
117
213
|
const amount = this.#currentAmount();
|
|
118
214
|
this.#render(amount);
|
|
119
|
-
if (this.hasStatusTarget
|
|
215
|
+
if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
|
|
216
|
+
this.statusTarget.textContent = "";
|
|
217
|
+
}
|
|
120
218
|
this.element.setAttribute("data-state", "paused");
|
|
121
219
|
if (wasRunning && this.#isValidDeadline) {
|
|
122
220
|
this.#pausedAmount = 0;
|
|
123
221
|
this.start();
|
|
124
222
|
} else {
|
|
125
|
-
this.#pausedAmount =
|
|
223
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
126
224
|
}
|
|
127
225
|
}
|
|
128
226
|
/** Schedules the repeating tick and marks the timer running. */
|
|
@@ -157,6 +255,7 @@ var CountdownController = class extends Controller {
|
|
|
157
255
|
this.statusTarget.textContent = this.completeLabelValue;
|
|
158
256
|
}
|
|
159
257
|
this.dispatch("complete", { detail: {} });
|
|
258
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
160
259
|
}
|
|
161
260
|
/** Sets the time anchor from the `deadline` value. */
|
|
162
261
|
#initReference() {
|
|
@@ -171,7 +270,8 @@ var CountdownController = class extends Controller {
|
|
|
171
270
|
}
|
|
172
271
|
/** Writes the amount into the day/hour/minute/second slots. */
|
|
173
272
|
#render(amount) {
|
|
174
|
-
const totalSeconds = Math.floor(amount /
|
|
273
|
+
const totalSeconds = Math.floor(amount / SECOND_MS);
|
|
274
|
+
this.#renderedAmount = totalSeconds * SECOND_MS;
|
|
175
275
|
const days = Math.floor(totalSeconds / 86400);
|
|
176
276
|
const hours = Math.floor(totalSeconds % 86400 / 3600);
|
|
177
277
|
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/data_grid_controller.ts
|
|
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
|
+
|
|
3
21
|
// src/controllers/data_grid_controller.ts
|
|
4
22
|
var SORT_CYCLE = ["none", "ascending", "descending"];
|
|
5
23
|
function nextSortDirection(current) {
|
|
@@ -14,11 +32,27 @@ var DataGridController = class extends Controller {
|
|
|
14
32
|
};
|
|
15
33
|
static actions = ["onKeydown", "sort", "toggleSelect"];
|
|
16
34
|
static events = ["selectionchange", "sort"];
|
|
17
|
-
/**
|
|
35
|
+
/** Gates the row callback so it does not re-walk every row once per authored row on mount. */
|
|
36
|
+
#connected = false;
|
|
37
|
+
/**
|
|
38
|
+
* Establishes a single tab stop across all navigable cells/headers and brings
|
|
39
|
+
* the rows to their baseline.
|
|
40
|
+
*
|
|
41
|
+
* Normalizing here rather than leaving it to {@link selectionValueChanged}
|
|
42
|
+
* guarantees exactly one pass per mount: a re-attached element reuses its
|
|
43
|
+
* cached Stimulus context, whose value observer already knows the `selection`
|
|
44
|
+
* attribute, so the Value callback does not fire a second time.
|
|
45
|
+
*/
|
|
18
46
|
connect() {
|
|
19
47
|
const cells = this.#navigableCells();
|
|
20
48
|
const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
|
|
21
49
|
this.#setActiveCell(active, { focus: false });
|
|
50
|
+
this.#normalizeSelection();
|
|
51
|
+
this.#connected = true;
|
|
52
|
+
}
|
|
53
|
+
/** Reopens the row callback for the next mount. */
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#connected = false;
|
|
22
56
|
}
|
|
23
57
|
/**
|
|
24
58
|
* Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
|
|
@@ -27,6 +61,45 @@ var DataGridController = class extends Controller {
|
|
|
27
61
|
*/
|
|
28
62
|
selectionValueChanged() {
|
|
29
63
|
this.#syncSelectable();
|
|
64
|
+
this.#normalizeSelection();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Re-establishes the row baseline for a row added after connect.
|
|
68
|
+
*
|
|
69
|
+
* Each pass walks every row, and Stimulus reports the authored rows one by one
|
|
70
|
+
* before `connect()`, so the mount is gated to keep it linear in the row count
|
|
71
|
+
* rather than quadratic; `connect()` runs the single baseline pass instead.
|
|
72
|
+
*/
|
|
73
|
+
rowTargetConnected() {
|
|
74
|
+
if (!this.#connected) return;
|
|
75
|
+
this.#normalizeSelection();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Brings the authored rows to the shape the APG requires, without changing
|
|
79
|
+
* which rows the author chose.
|
|
80
|
+
*
|
|
81
|
+
* Every selectable row gets an explicit value — an absent `aria-selected` means
|
|
82
|
+
* "not selectable" in ARIA, so a forgotten attribute hides a selectable row —
|
|
83
|
+
* and a single-select grid keeps at most one `true`, first in DOM order.
|
|
84
|
+
* A grid that declares `selection="none"` has no selectable rows, so the
|
|
85
|
+
* attribute is removed rather than written: in ARIA its absence is what "not
|
|
86
|
+
* selectable" looks like.
|
|
87
|
+
*/
|
|
88
|
+
#normalizeSelection() {
|
|
89
|
+
const rows = this.rowTargets;
|
|
90
|
+
if (this.selectionValue === "none") {
|
|
91
|
+
for (const row of rows) row.removeAttribute("aria-selected");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const single = this.selectionValue === "single";
|
|
95
|
+
const first = rows.find((row) => row.getAttribute("aria-selected") === "true");
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
if (single) {
|
|
98
|
+
row.setAttribute("aria-selected", row === first ? "true" : "false");
|
|
99
|
+
} else if (row.getAttribute("aria-selected") !== "true") {
|
|
100
|
+
row.setAttribute("aria-selected", "false");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
30
103
|
}
|
|
31
104
|
/**
|
|
32
105
|
* Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
|
|
@@ -53,11 +126,14 @@ var DataGridController = class extends Controller {
|
|
|
53
126
|
}
|
|
54
127
|
/** Toggles selection of the row owning the event target. Bound optionally. */
|
|
55
128
|
toggleSelect(event) {
|
|
129
|
+
if (this.selectionValue === "none") return;
|
|
56
130
|
const row = event.currentTarget.closest("[role='row']");
|
|
57
131
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
58
132
|
}
|
|
59
133
|
/** Grid navigation + sort/select activation. Bound to cells and headers. */
|
|
60
134
|
onKeydown(event) {
|
|
135
|
+
if (event.defaultPrevented) return;
|
|
136
|
+
if (isReservedArrowChord(event)) return;
|
|
61
137
|
const cell = event.currentTarget;
|
|
62
138
|
const matrix = this.#matrix();
|
|
63
139
|
const position = this.#locate(matrix, cell);
|
|
@@ -65,7 +141,7 @@ var DataGridController = class extends Controller {
|
|
|
65
141
|
const [row, col] = position;
|
|
66
142
|
const rowCells = matrix[row] ?? [];
|
|
67
143
|
let target;
|
|
68
|
-
switch (event.key) {
|
|
144
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
69
145
|
case "ArrowRight":
|
|
70
146
|
target = this.#cellInRow(matrix, row, col + 1);
|
|
71
147
|
break;
|
|
@@ -118,8 +194,10 @@ var DataGridController = class extends Controller {
|
|
|
118
194
|
/** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
|
|
119
195
|
#toggleRow(row) {
|
|
120
196
|
const selected = row.getAttribute("aria-selected") === "true";
|
|
121
|
-
if (this.selectionValue === "single"
|
|
122
|
-
for (const other of this.rowTargets)
|
|
197
|
+
if (this.selectionValue === "single") {
|
|
198
|
+
for (const other of this.rowTargets) {
|
|
199
|
+
if (other !== row) other.setAttribute("aria-selected", "false");
|
|
200
|
+
}
|
|
123
201
|
}
|
|
124
202
|
row.setAttribute("aria-selected", selected ? "false" : "true");
|
|
125
203
|
const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
|
|
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/date_range_picker_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();
|
|
@@ -36,6 +52,39 @@ function toISOMonthString(date) {
|
|
|
36
52
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
37
53
|
}
|
|
38
54
|
|
|
55
|
+
// src/utils/microtask_coalescer.ts
|
|
56
|
+
var MicrotaskCoalescer = class {
|
|
57
|
+
#run;
|
|
58
|
+
#queued = false;
|
|
59
|
+
#active = false;
|
|
60
|
+
#generation = 0;
|
|
61
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
62
|
+
constructor(run) {
|
|
63
|
+
this.#run = run;
|
|
64
|
+
}
|
|
65
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
66
|
+
activate() {
|
|
67
|
+
this.#active = true;
|
|
68
|
+
}
|
|
69
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
70
|
+
cancel() {
|
|
71
|
+
this.#active = false;
|
|
72
|
+
this.#queued = false;
|
|
73
|
+
this.#generation += 1;
|
|
74
|
+
}
|
|
75
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
76
|
+
schedule() {
|
|
77
|
+
if (!this.#active || this.#queued) return;
|
|
78
|
+
this.#queued = true;
|
|
79
|
+
const generation = this.#generation;
|
|
80
|
+
queueMicrotask(() => {
|
|
81
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
82
|
+
this.#queued = false;
|
|
83
|
+
this.#run();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
39
88
|
// src/utils/safe_timeout.ts
|
|
40
89
|
var TimerRegistry = class {
|
|
41
90
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -113,18 +162,36 @@ var DateRangePickerController = class extends Controller {
|
|
|
113
162
|
/** Deferred focus after an async month transition (cancelled on teardown). */
|
|
114
163
|
#focusTimer = new SafeTimeout();
|
|
115
164
|
/** Seeds the range from any pre-filled hidden fields and renders the grid. */
|
|
165
|
+
/**
|
|
166
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
167
|
+
* pass Stimulus delivers before `connect()`.
|
|
168
|
+
*/
|
|
169
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
170
|
+
this.#render();
|
|
171
|
+
});
|
|
116
172
|
connect() {
|
|
173
|
+
this.#repaint.activate();
|
|
117
174
|
this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
|
|
118
175
|
this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
|
|
119
176
|
const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
|
|
120
177
|
this.#focusedDate = anchor;
|
|
121
178
|
this.#viewMonth = toISOMonthString(anchor);
|
|
179
|
+
if (this.hasGridTarget) this.gridTarget.setAttribute("aria-multiselectable", "true");
|
|
122
180
|
this.#render();
|
|
123
181
|
}
|
|
124
182
|
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
125
183
|
disconnect() {
|
|
184
|
+
this.#repaint.cancel();
|
|
126
185
|
this.#focusTimer.clearAll();
|
|
127
186
|
}
|
|
187
|
+
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
188
|
+
minValueChanged() {
|
|
189
|
+
this.#repaint.schedule();
|
|
190
|
+
}
|
|
191
|
+
/** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
|
|
192
|
+
maxValueChanged() {
|
|
193
|
+
this.#repaint.schedule();
|
|
194
|
+
}
|
|
128
195
|
/** Navigates to the previous month. */
|
|
129
196
|
prev(event) {
|
|
130
197
|
event?.preventDefault();
|
|
@@ -184,6 +251,8 @@ var DateRangePickerController = class extends Controller {
|
|
|
184
251
|
}
|
|
185
252
|
/** Grid keyboard navigation, selection (Enter/Space), and Escape-to-cancel. */
|
|
186
253
|
onKeydown(event) {
|
|
254
|
+
if (event.defaultPrevented) return;
|
|
255
|
+
if (isReservedArrowChord(event)) return;
|
|
187
256
|
const cell = this.#cellFrom(event.target);
|
|
188
257
|
if (!cell) return;
|
|
189
258
|
const dateStr = cell.getAttribute("data-date");
|
|
@@ -195,7 +264,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
195
264
|
return;
|
|
196
265
|
}
|
|
197
266
|
if (event.key === "Escape") {
|
|
198
|
-
if (this.#pendingStart && !event.
|
|
267
|
+
if (this.#pendingStart && !event.isComposing) {
|
|
199
268
|
event.preventDefault();
|
|
200
269
|
this.#pendingStart = "";
|
|
201
270
|
this.#previewDate = "";
|
|
@@ -204,7 +273,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
204
273
|
return;
|
|
205
274
|
}
|
|
206
275
|
let next = null;
|
|
207
|
-
switch (event.key) {
|
|
276
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
208
277
|
case "ArrowLeft":
|
|
209
278
|
next = addDays(date, -1);
|
|
210
279
|
break;
|
|
@@ -318,7 +387,12 @@ var DateRangePickerController = class extends Controller {
|
|
|
318
387
|
el.toggleAttribute("data-range-start", isStart);
|
|
319
388
|
el.toggleAttribute("data-range-end", isEnd);
|
|
320
389
|
el.toggleAttribute("data-in-range", inside);
|
|
321
|
-
el.setAttribute(
|
|
390
|
+
el.setAttribute(
|
|
391
|
+
"aria-selected",
|
|
392
|
+
String(
|
|
393
|
+
!!this.#startDate && iso === this.#startDate || !!this.#endDate && iso === this.#endDate
|
|
394
|
+
)
|
|
395
|
+
);
|
|
322
396
|
}
|
|
323
397
|
}
|
|
324
398
|
/** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dialog_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/drawer_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -107,6 +107,7 @@ var EditableController = class extends Controller {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
if (event.key === "Enter") {
|
|
110
|
+
if (event.defaultPrevented) return;
|
|
110
111
|
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
111
112
|
event.preventDefault();
|
|
112
113
|
this.#save(true);
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/empty_state_controller.ts
|
|
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
|
+
|
|
3
22
|
// src/controllers/empty_state_controller.ts
|
|
4
23
|
var EmptyStateController = class extends Controller {
|
|
5
24
|
static targets = ["list", "empty"];
|
|
6
25
|
static values = {
|
|
7
26
|
itemSelector: { type: String, default: "" },
|
|
8
|
-
|
|
27
|
+
announceText: { type: String, default: "" },
|
|
28
|
+
announceFilledText: { type: String, default: "" }
|
|
9
29
|
};
|
|
10
30
|
static events = ["change"];
|
|
11
31
|
#observer = null;
|
|
@@ -13,10 +33,6 @@ var EmptyStateController = class extends Controller {
|
|
|
13
33
|
#empty = null;
|
|
14
34
|
connect() {
|
|
15
35
|
if (!this.hasListTarget) return;
|
|
16
|
-
if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {
|
|
17
|
-
this.emptyTarget.setAttribute("role", "status");
|
|
18
|
-
this.emptyTarget.setAttribute("aria-live", "polite");
|
|
19
|
-
}
|
|
20
36
|
if (typeof MutationObserver !== "undefined") {
|
|
21
37
|
this.#observer = new MutationObserver(() => this.#apply());
|
|
22
38
|
this.#observer.observe(this.listTarget, { childList: true });
|
|
@@ -42,6 +58,9 @@ var EmptyStateController = class extends Controller {
|
|
|
42
58
|
if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
|
|
43
59
|
if (this.#empty !== null && empty !== this.#empty) {
|
|
44
60
|
this.dispatch("change", { detail: { count, empty } });
|
|
61
|
+
announce(
|
|
62
|
+
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
63
|
+
);
|
|
45
64
|
}
|
|
46
65
|
this.#empty = empty;
|
|
47
66
|
}
|
|
@@ -55,11 +74,6 @@ var EmptyStateController = class extends Controller {
|
|
|
55
74
|
return this.listTarget.childElementCount;
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
|
-
#isLiveRegion(el) {
|
|
59
|
-
if (el.hasAttribute("aria-live")) return true;
|
|
60
|
-
const role = el.getAttribute("role");
|
|
61
|
-
return role === "status" || role === "alert";
|
|
62
|
-
}
|
|
63
77
|
};
|
|
64
78
|
|
|
65
79
|
export { EmptyStateController };
|