stimeo-ui 0.16.0 → 0.17.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 +31 -0
- data/README.md +8 -1
- data/dist/cable/index.js +363 -436
- data/dist/controllers/accordion_controller.js +166 -55
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -198
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +271 -127
- data/dist/controllers/avatar_controller.js +171 -105
- data/dist/controllers/breadcrumb_controller.js +0 -130
- data/dist/controllers/bulk_select_controller.js +262 -52
- data/dist/controllers/calendar_controller.js +175 -209
- data/dist/controllers/carousel_controller.js +461 -399
- data/dist/controllers/character_counter_controller.js +246 -156
- data/dist/controllers/checkbox_controller.js +32 -74
- data/dist/controllers/clipboard_controller.js +168 -147
- data/dist/controllers/collapsible_controller.js +301 -120
- data/dist/controllers/color_picker_controller.js +149 -139
- data/dist/controllers/combobox_controller.js +360 -133
- data/dist/controllers/command_palette_controller.js +970 -358
- data/dist/controllers/conditional_fields_controller.js +166 -124
- data/dist/controllers/confirm_controller.js +939 -248
- data/dist/controllers/context_menu_controller.js +256 -128
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +183 -148
- data/dist/controllers/currency_input_controller.js +134 -158
- data/dist/controllers/data_grid_controller.js +114 -186
- data/dist/controllers/date_range_picker_controller.js +288 -206
- data/dist/controllers/dialog_controller.js +858 -223
- data/dist/controllers/direct_upload_controller.js +73 -153
- data/dist/controllers/dirty_form_controller.js +14 -48
- data/dist/controllers/dismissible_controller.js +175 -21
- data/dist/controllers/drawer_controller.js +933 -341
- data/dist/controllers/dropdown_controller.js +312 -82
- data/dist/controllers/editable_controller.js +158 -68
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +311 -207
- data/dist/controllers/filter_controller.js +67 -39
- data/dist/controllers/flash_controller.js +172 -314
- data/dist/controllers/focus_controller.js +654 -240
- data/dist/controllers/form_field_controller.js +147 -132
- data/dist/controllers/form_validation_controller.js +13 -86
- data/dist/controllers/frame_loading_controller.js +264 -213
- data/dist/controllers/highlight_controller.js +107 -75
- data/dist/controllers/hover_card_controller.js +309 -118
- data/dist/controllers/idle_controller.js +267 -101
- data/dist/controllers/input_mask_controller.js +71 -72
- data/dist/controllers/intersection_controller.js +147 -106
- data/dist/controllers/lazy_frame_controller.js +9 -65
- data/dist/controllers/listbox_controller.js +306 -204
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -93
- data/dist/controllers/menu_controller.js +214 -156
- data/dist/controllers/menubar_controller.js +170 -329
- data/dist/controllers/meter_controller.js +151 -67
- data/dist/controllers/multi_select_controller.js +593 -358
- data/dist/controllers/navigation_menu_controller.js +124 -232
- data/dist/controllers/nested_form_controller.js +352 -182
- data/dist/controllers/network_status_controller.js +242 -54
- data/dist/controllers/number_input_controller.js +320 -263
- data/dist/controllers/optimistic_controller.js +16 -76
- data/dist/controllers/otp_controller.js +198 -193
- data/dist/controllers/overflow_indicator_controller.js +116 -113
- data/dist/controllers/overflow_menu_controller.js +343 -283
- data/dist/controllers/pagination_controller.js +295 -119
- data/dist/controllers/password_reveal_controller.js +389 -121
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +237 -103
- data/dist/controllers/pointer_drag_controller.js +185 -165
- data/dist/controllers/popover_controller.js +236 -97
- data/dist/controllers/portal_controller.js +209 -76
- data/dist/controllers/preview_guard_controller.js +232 -116
- data/dist/controllers/progress_controller.js +125 -56
- data/dist/controllers/radio_group_controller.js +117 -137
- data/dist/controllers/range_slider_controller.js +192 -105
- data/dist/controllers/rating_controller.js +418 -207
- data/dist/controllers/read_more_controller.js +134 -63
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +255 -139
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -225
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -145
- data/dist/controllers/scrollspy_controller.js +181 -242
- data/dist/controllers/separator_controller.js +267 -140
- data/dist/controllers/sidebar_controller.js +1023 -386
- data/dist/controllers/skeleton_controller.js +248 -97
- data/dist/controllers/slider_controller.js +159 -79
- data/dist/controllers/smart_sticky_header_controller.js +162 -63
- data/dist/controllers/sortable_controller.js +0 -112
- data/dist/controllers/spinner_controller.js +277 -169
- data/dist/controllers/step_indicator_controller.js +99 -46
- data/dist/controllers/stepper_controller.js +114 -73
- data/dist/controllers/stick_to_bottom_controller.js +262 -138
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -205
- data/dist/controllers/switch_controller.js +33 -60
- data/dist/controllers/tabs_controller.js +35 -29
- data/dist/controllers/tags_input_controller.js +136 -145
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +130 -112
- data/dist/controllers/time_picker_controller.js +146 -80
- data/dist/controllers/toast_controller.js +186 -297
- data/dist/controllers/toggle_group_controller.js +95 -136
- data/dist/controllers/toolbar_controller.js +68 -98
- data/dist/controllers/tooltip_controller.js +158 -124
- data/dist/controllers/transition_controller.js +123 -119
- data/dist/controllers/tree_view_controller.js +150 -345
- data/dist/index.js +6370 -9346
- data/dist/positioning/index.js +148 -47
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
// src/controllers/textarea_autosize_controller.ts
|
|
4
|
-
|
|
5
|
-
// src/utils/layout_observer.ts
|
|
6
3
|
var LayoutObserver = class {
|
|
7
4
|
#callback;
|
|
8
5
|
#resizeObserverFactory;
|
|
9
6
|
#resizeObserver = null;
|
|
10
7
|
#observingViewport = false;
|
|
11
8
|
#loadContainer = null;
|
|
12
|
-
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
13
9
|
#handleViewportResize = () => {
|
|
14
10
|
this.#callback();
|
|
15
11
|
};
|
|
16
|
-
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
17
12
|
#handleDescendantLoad = () => {
|
|
18
13
|
this.#callback();
|
|
19
14
|
};
|
|
@@ -21,11 +16,6 @@ var LayoutObserver = class {
|
|
|
21
16
|
this.#callback = callback;
|
|
22
17
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
23
18
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Starts observing an element's size. Repeated calls observe additional
|
|
26
|
-
* elements through the same shared observer. No-ops when no
|
|
27
|
-
* `ResizeObserver` implementation is available.
|
|
28
|
-
*/
|
|
29
19
|
observe(element) {
|
|
30
20
|
if (!this.#resizeObserverFactory) return;
|
|
31
21
|
if (!this.#resizeObserver) {
|
|
@@ -35,47 +25,28 @@ var LayoutObserver = class {
|
|
|
35
25
|
}
|
|
36
26
|
this.#resizeObserver.observe(element);
|
|
37
27
|
}
|
|
38
|
-
/** Stops observing a single element while leaving any others in place. */
|
|
39
28
|
unobserve(element) {
|
|
40
29
|
this.#resizeObserver?.unobserve(element);
|
|
41
30
|
}
|
|
42
|
-
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
43
31
|
observeViewport() {
|
|
44
32
|
if (this.#observingViewport) return;
|
|
45
33
|
this.#observingViewport = true;
|
|
46
34
|
window.addEventListener("resize", this.#handleViewportResize);
|
|
47
35
|
}
|
|
48
|
-
/** Stops observing viewport resizes without affecting element observation. */
|
|
49
36
|
unobserveViewport() {
|
|
50
37
|
if (!this.#observingViewport) return;
|
|
51
38
|
this.#observingViewport = false;
|
|
52
39
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
53
40
|
}
|
|
54
|
-
/**
|
|
55
|
-
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
56
|
-
* frame settling changes the box it sits in, and it measures as zero high until
|
|
57
|
-
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
58
|
-
* on the container itself and nothing the caller spells.
|
|
59
|
-
*
|
|
60
|
-
* **One container at a time.** A further call moves the observation, so a widget
|
|
61
|
-
* whose content element is swapped at runtime releases the element it let go by
|
|
62
|
-
* naming the new one — there is no second place for the release to drift from.
|
|
63
|
-
*/
|
|
64
41
|
observeDescendantLoads(container) {
|
|
65
42
|
this.unobserveDescendantLoads();
|
|
66
43
|
this.#loadContainer = container;
|
|
67
44
|
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
68
45
|
}
|
|
69
|
-
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
70
46
|
unobserveDescendantLoads() {
|
|
71
47
|
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
72
48
|
this.#loadContainer = null;
|
|
73
49
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
76
|
-
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
77
|
-
* this from a controller's `disconnect()`.
|
|
78
|
-
*/
|
|
79
50
|
disconnect() {
|
|
80
51
|
this.#resizeObserver?.disconnect();
|
|
81
52
|
this.#resizeObserver = null;
|
|
@@ -84,16 +55,64 @@ var LayoutObserver = class {
|
|
|
84
55
|
}
|
|
85
56
|
};
|
|
86
57
|
|
|
87
|
-
|
|
58
|
+
var NUMBER_BOUNDS = {
|
|
59
|
+
nonNegative: { finite: true, min: 0 }};
|
|
60
|
+
function matchesNumberBounds(value, bounds) {
|
|
61
|
+
if (!Number.isFinite(value)) {
|
|
62
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
63
|
+
if (direction === null) return false;
|
|
64
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
65
|
+
}
|
|
66
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
67
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
68
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
69
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
70
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readNumber(raw, fallback, bounds) {
|
|
75
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var NumberValueReader = class {
|
|
79
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
80
|
+
read(owner, name, raw, fallback, bounds) {
|
|
81
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
82
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
83
|
+
this.#lastRejected.delete(name);
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
87
|
+
const literal = owner.element.getAttribute(attribute);
|
|
88
|
+
if (literal === null) {
|
|
89
|
+
this.#lastRejected.delete(name);
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|
|
92
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
93
|
+
this.#lastRejected.set(name, literal);
|
|
94
|
+
console.warn(
|
|
95
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return resolved;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
function px(value) {
|
|
89
103
|
const n = Number.parseFloat(value);
|
|
90
104
|
return Number.isNaN(n) ? 0 : n;
|
|
91
105
|
}
|
|
92
|
-
var TextareaAutosizeController = class extends Controller {
|
|
106
|
+
var TextareaAutosizeController = class _TextareaAutosizeController extends Controller {
|
|
107
|
+
#numbers = new NumberValueReader();
|
|
93
108
|
static values = {
|
|
94
109
|
minRows: { type: Number, default: 1 },
|
|
95
110
|
maxRows: { type: Number, default: 0 }
|
|
96
111
|
};
|
|
112
|
+
static valueConstraints = {
|
|
113
|
+
minRows: { finite: true, min: 1 },
|
|
114
|
+
maxRows: NUMBER_BOUNDS.nonNegative
|
|
115
|
+
};
|
|
97
116
|
static actions = ["resize"];
|
|
98
117
|
static events = ["resize"];
|
|
99
118
|
#lastHeight = -1;
|
|
@@ -101,12 +120,6 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
101
120
|
#started = false;
|
|
102
121
|
#pending = false;
|
|
103
122
|
#fonts = null;
|
|
104
|
-
/**
|
|
105
|
-
* Content triggers arriving while the box is collapsed (`display: none`
|
|
106
|
-
* ancestors) are deferred: `scrollHeight` is 0 there, so measuring would
|
|
107
|
-
* clamp the kept height to the `minRows` floor and dispatch a bogus
|
|
108
|
-
* `resize`. The next report from a rendered box flushes the deferral.
|
|
109
|
-
*/
|
|
110
123
|
#remeasure = () => {
|
|
111
124
|
if (this.element.clientWidth === 0) {
|
|
112
125
|
this.#pending = true;
|
|
@@ -114,16 +127,6 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
114
127
|
}
|
|
115
128
|
this.resize();
|
|
116
129
|
};
|
|
117
|
-
/**
|
|
118
|
-
* Re-measures when the content width changed (re-wrapping, including a
|
|
119
|
-
* horizontal padding change under `border-box`), the applied inline height
|
|
120
|
-
* was stripped (a Turbo morph syncing attributes from server HTML that has no
|
|
121
|
-
* `style`), or a content trigger was deferred while the box was collapsed.
|
|
122
|
-
* This instance's own height writes match none of these signals, so they
|
|
123
|
-
* cannot re-enter; an authored inline height (e.g. a user dragging the native
|
|
124
|
-
* resize handle) is left alone until the next content trigger. Reports from a
|
|
125
|
-
* still-collapsed box are skipped (nothing is measurable there).
|
|
126
|
-
*/
|
|
127
130
|
#layout = new LayoutObserver(() => {
|
|
128
131
|
if (this.element.clientWidth === 0) return;
|
|
129
132
|
const width = this.#contentWidth();
|
|
@@ -149,15 +152,12 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
149
152
|
this.#layout.disconnect();
|
|
150
153
|
this.#unbindFonts();
|
|
151
154
|
}
|
|
152
|
-
/** Re-clamps when application code or a Turbo morph changes `minRows`. */
|
|
153
155
|
minRowsValueChanged() {
|
|
154
156
|
if (this.#started) this.#remeasure();
|
|
155
157
|
}
|
|
156
|
-
/** Re-clamps when application code or a Turbo morph changes `maxRows`. */
|
|
157
158
|
maxRowsValueChanged() {
|
|
158
159
|
if (this.#started) this.#remeasure();
|
|
159
160
|
}
|
|
160
|
-
/** Re-measures the content and applies the clamped height. */
|
|
161
161
|
resize() {
|
|
162
162
|
this.#pending = false;
|
|
163
163
|
const el = this.element;
|
|
@@ -169,10 +169,10 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
169
169
|
el.style.height = "auto";
|
|
170
170
|
const contentHeight = Math.max(0, el.scrollHeight - paddingV);
|
|
171
171
|
const rows = Math.max(1, Math.round(contentHeight / lineHeight));
|
|
172
|
-
let targetContent = Math.max(contentHeight, this
|
|
172
|
+
let targetContent = Math.max(contentHeight, this.#safeMinRows * lineHeight);
|
|
173
173
|
let atMax = false;
|
|
174
|
-
if (this
|
|
175
|
-
const maxContent = this
|
|
174
|
+
if (this.#safeMaxRows > 0) {
|
|
175
|
+
const maxContent = this.#safeMaxRows * lineHeight;
|
|
176
176
|
if (targetContent > maxContent) {
|
|
177
177
|
targetContent = maxContent;
|
|
178
178
|
atMax = true;
|
|
@@ -193,19 +193,16 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
193
193
|
this.dispatch("resize", { detail: { height, rows } });
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
-
/** Content-box width — the wrapping input, unaffected by this instance's height writes. */
|
|
197
196
|
#contentWidth() {
|
|
198
197
|
const style = window.getComputedStyle(this.element);
|
|
199
198
|
return Math.max(0, this.element.clientWidth - px(style.paddingLeft) - px(style.paddingRight));
|
|
200
199
|
}
|
|
201
|
-
/** Resolved line height, falling back to ~1.2× font-size when `normal`. */
|
|
202
200
|
#lineHeight(style) {
|
|
203
201
|
const lh = px(style.lineHeight);
|
|
204
202
|
if (lh > 0) return lh;
|
|
205
203
|
const fontSize = px(style.fontSize);
|
|
206
204
|
return fontSize > 0 ? fontSize * 1.2 : 16;
|
|
207
205
|
}
|
|
208
|
-
/** Subscribes to font completion when the current engine exposes `document.fonts`. */
|
|
209
206
|
#bindFonts() {
|
|
210
207
|
const fonts = this.element.ownerDocument.fonts;
|
|
211
208
|
if (!fonts) return;
|
|
@@ -213,14 +210,30 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
213
210
|
fonts.addEventListener("loadingdone", this.#remeasure);
|
|
214
211
|
fonts.addEventListener("loadingerror", this.#remeasure);
|
|
215
212
|
}
|
|
216
|
-
/** Removes the font completion subscriptions. */
|
|
217
213
|
#unbindFonts() {
|
|
218
214
|
this.#fonts?.removeEventListener("loadingdone", this.#remeasure);
|
|
219
215
|
this.#fonts?.removeEventListener("loadingerror", this.#remeasure);
|
|
220
216
|
this.#fonts = null;
|
|
221
217
|
}
|
|
218
|
+
get #safeMinRows() {
|
|
219
|
+
return this.#numbers.read(
|
|
220
|
+
this,
|
|
221
|
+
"minRows",
|
|
222
|
+
this.minRowsValue,
|
|
223
|
+
_TextareaAutosizeController.values.minRows.default,
|
|
224
|
+
_TextareaAutosizeController.valueConstraints.minRows
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
get #safeMaxRows() {
|
|
228
|
+
return this.#numbers.read(
|
|
229
|
+
this,
|
|
230
|
+
"maxRows",
|
|
231
|
+
this.maxRowsValue,
|
|
232
|
+
_TextareaAutosizeController.values.maxRows.default,
|
|
233
|
+
_TextareaAutosizeController.valueConstraints.maxRows
|
|
234
|
+
);
|
|
235
|
+
}
|
|
222
236
|
};
|
|
223
237
|
|
|
224
238
|
export { TextareaAutosizeController };
|
|
225
|
-
//# sourceMappingURL=textarea_autosize_controller.js.map
|
|
226
239
|
//# sourceMappingURL=textarea_autosize_controller.js.map
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
4
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
5
|
+
function stateReasonFor(event) {
|
|
6
|
+
if (!event) return "api";
|
|
7
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
8
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
9
|
+
return "user";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function actionSource(source) {
|
|
13
|
+
if (source instanceof HTMLElement) {
|
|
14
|
+
return { event: null, host: source, origin: source, reason: "api" };
|
|
15
|
+
}
|
|
16
|
+
const event = source ?? null;
|
|
17
|
+
return {
|
|
18
|
+
event,
|
|
19
|
+
host: event?.currentTarget instanceof Element ? event.currentTarget : null,
|
|
20
|
+
origin: event?.target instanceof Element ? event.target : null,
|
|
21
|
+
reason: stateReasonFor(event)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
4
24
|
|
|
5
|
-
// src/utils/logical_scroll.ts
|
|
6
25
|
function isRtl(element) {
|
|
7
26
|
return window.getComputedStyle(element).direction === "rtl";
|
|
8
27
|
}
|
|
9
28
|
|
|
10
|
-
// src/utils/arrow_step.ts
|
|
11
29
|
function logicalArrowStep(key, element) {
|
|
12
30
|
if (key === "ArrowDown") return 1;
|
|
13
31
|
if (key === "ArrowUp") return -1;
|
|
@@ -23,7 +41,6 @@ function hasModifierChord(event) {
|
|
|
23
41
|
return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
// src/utils/declared_value.ts
|
|
27
44
|
function parseDeclared(raw, parse, fallback) {
|
|
28
45
|
try {
|
|
29
46
|
return parse(raw);
|
|
@@ -43,31 +60,78 @@ function validSelector(element, raw, fallback) {
|
|
|
43
60
|
);
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
|
|
63
|
+
var ListenerSet = class {
|
|
64
|
+
#abort = new AbortController();
|
|
65
|
+
add(target, type, handler, options) {
|
|
66
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
67
|
+
}
|
|
68
|
+
dispose() {
|
|
69
|
+
this.#abort.abort();
|
|
70
|
+
this.#abort = new AbortController();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
var MicrotaskCoalescer = class {
|
|
75
|
+
#run;
|
|
76
|
+
#queued = false;
|
|
77
|
+
#active = false;
|
|
78
|
+
#generation = 0;
|
|
79
|
+
constructor(run) {
|
|
80
|
+
this.#run = run;
|
|
81
|
+
}
|
|
82
|
+
activate() {
|
|
83
|
+
this.#active = true;
|
|
84
|
+
}
|
|
85
|
+
cancel() {
|
|
86
|
+
this.#active = false;
|
|
87
|
+
this.#queued = false;
|
|
88
|
+
this.#generation += 1;
|
|
89
|
+
}
|
|
90
|
+
schedule() {
|
|
91
|
+
if (!this.#active || this.#queued) return;
|
|
92
|
+
this.#queued = true;
|
|
93
|
+
const generation = this.#generation;
|
|
94
|
+
queueMicrotask(() => {
|
|
95
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
96
|
+
this.#queued = false;
|
|
97
|
+
this.#run();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
var MorphRenderWatcher = class {
|
|
103
|
+
#listeners = new ListenerSet();
|
|
104
|
+
#pass;
|
|
105
|
+
#element = null;
|
|
106
|
+
#onMorph = () => this.#pass.schedule();
|
|
107
|
+
constructor(run) {
|
|
108
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
109
|
+
}
|
|
110
|
+
observe(element) {
|
|
111
|
+
if (this.#element === element) return;
|
|
112
|
+
this.disconnect();
|
|
113
|
+
this.#element = element;
|
|
114
|
+
this.#pass.activate();
|
|
115
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
116
|
+
}
|
|
117
|
+
schedule() {
|
|
118
|
+
this.#pass.schedule();
|
|
119
|
+
}
|
|
120
|
+
disconnect() {
|
|
121
|
+
this.#listeners.dispose();
|
|
122
|
+
this.#pass.cancel();
|
|
123
|
+
this.#element = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
47
127
|
var RovingTabindex = class {
|
|
48
|
-
/** Returns the current ordered item elements; called on every operation. */
|
|
49
128
|
#getItems;
|
|
50
|
-
/**
|
|
51
|
-
* @param getItems - Returns the current ordered item elements. Called on every
|
|
52
|
-
* operation so the live target list is always used.
|
|
53
|
-
*/
|
|
54
129
|
constructor(getItems) {
|
|
55
130
|
this.#getItems = getItems;
|
|
56
131
|
}
|
|
57
|
-
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
58
132
|
get activeIndex() {
|
|
59
133
|
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
60
134
|
}
|
|
61
|
-
/**
|
|
62
|
-
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
63
|
-
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
64
|
-
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
65
|
-
* "nothing is currently tabbable".
|
|
66
|
-
*
|
|
67
|
-
* @param index - Position of the item to make tabbable.
|
|
68
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
69
|
-
* and `items` to reuse an event-scoped collection snapshot.
|
|
70
|
-
*/
|
|
71
135
|
setActive(index, options = {}) {
|
|
72
136
|
const { focus = false } = options;
|
|
73
137
|
const items = options.items ?? this.#getItems();
|
|
@@ -83,7 +147,6 @@ function rovingMove(current, length, delta, wrap) {
|
|
|
83
147
|
return (next + length) % length;
|
|
84
148
|
}
|
|
85
149
|
|
|
86
|
-
// src/utils/safe_storage.ts
|
|
87
150
|
function readLocalStorage(key) {
|
|
88
151
|
try {
|
|
89
152
|
return { ok: true, value: window.localStorage.getItem(key) };
|
|
@@ -100,7 +163,10 @@ function writeLocalStorage(key, value) {
|
|
|
100
163
|
}
|
|
101
164
|
}
|
|
102
165
|
|
|
103
|
-
|
|
166
|
+
function targetSelector(identifier, name) {
|
|
167
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
var MODES = ["light", "dark", "system"];
|
|
105
171
|
var isMode = (value) => typeof value === "string" && MODES.includes(value);
|
|
106
172
|
var DEFAULT_MODE = "system";
|
|
@@ -114,30 +180,19 @@ var ThemeController = class extends Controller {
|
|
|
114
180
|
};
|
|
115
181
|
static actions = ["set", "toggle"];
|
|
116
182
|
static events = ["change", "reconcile"];
|
|
117
|
-
/** The OS dark-mode query, watched so `system` tracks live changes. */
|
|
118
183
|
#media = null;
|
|
119
|
-
/** Gate for the target and `mode` callbacks, which Stimulus runs before `connect()`. */
|
|
120
184
|
#connected = false;
|
|
121
|
-
/** The `target` declaration after validation; the default when unparsable. */
|
|
122
185
|
#targetSelector = DEFAULT_TARGET;
|
|
123
|
-
|
|
186
|
+
#storageKeyRead = null;
|
|
124
187
|
#roving = new RovingTabindex(() => this.optionTargets);
|
|
125
|
-
/**
|
|
126
|
-
* The pair on screen as last reported or re-seeded, so a move can be told from a
|
|
127
|
-
* repeat. Neither side is readable after the fact — assigning the Value updates the
|
|
128
|
-
* mode before any comparison, and the OS query has already flipped by the time it
|
|
129
|
-
* notifies — so what was reported has to be kept rather than recomputed.
|
|
130
|
-
*/
|
|
131
188
|
#published = {
|
|
132
189
|
mode: DEFAULT_MODE,
|
|
133
190
|
resolved: "light"
|
|
134
191
|
};
|
|
135
|
-
/** Re-resolves while in `system` mode when the OS preference flips. */
|
|
136
192
|
#onMediaChange = () => {
|
|
137
193
|
if (this.#mode !== "system") return;
|
|
138
|
-
this.#commit();
|
|
194
|
+
this.#commit("media");
|
|
139
195
|
};
|
|
140
|
-
/** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
|
|
141
196
|
#onKeydown = (event) => {
|
|
142
197
|
if (event.defaultPrevented) return;
|
|
143
198
|
if (isReservedArrowChord(event)) return;
|
|
@@ -169,7 +224,13 @@ var ThemeController = class extends Controller {
|
|
|
169
224
|
const mode = this.#optionMode(option);
|
|
170
225
|
if (mode) this.#setMode(mode);
|
|
171
226
|
};
|
|
227
|
+
#morph = new MorphRenderWatcher(() => {
|
|
228
|
+
this.#applyTheme();
|
|
229
|
+
this.#reconcileControls();
|
|
230
|
+
});
|
|
172
231
|
connect() {
|
|
232
|
+
this.#morph.observe(this.element);
|
|
233
|
+
this.#storageKeyRead = this.storageKeyValue;
|
|
173
234
|
const stored = this.#readStored();
|
|
174
235
|
if (stored) this.modeValue = stored;
|
|
175
236
|
this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
@@ -181,25 +242,15 @@ var ThemeController = class extends Controller {
|
|
|
181
242
|
this.#published = this.#current;
|
|
182
243
|
}
|
|
183
244
|
disconnect() {
|
|
245
|
+
this.#morph.disconnect();
|
|
184
246
|
this.#connected = false;
|
|
185
247
|
this.#media?.removeEventListener("change", this.#onMediaChange);
|
|
186
248
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
187
249
|
}
|
|
188
|
-
/** Validates the `target` declaration once, so the render path never parses. */
|
|
189
250
|
targetValueChanged() {
|
|
190
251
|
this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
|
|
252
|
+
this.#morph.schedule();
|
|
191
253
|
}
|
|
192
|
-
/**
|
|
193
|
-
* Re-renders for a `mode` declaration changed after connect — a Turbo morph
|
|
194
|
-
* re-rendering the server's markup over the live element, or a script.
|
|
195
|
-
*
|
|
196
|
-
* A stored choice outranks the declaration: a Value that disagrees with it is written
|
|
197
|
-
* back to it, so a morph that brings the server's default never overrides what the
|
|
198
|
-
* user saved. Without one, the declaration is applied. Either way storage is left
|
|
199
|
-
* alone, and a pair on screen that moved is reported as `reconcile`, not `change` —
|
|
200
|
-
* the page moved the declaration, not a selection. The reported pair is re-seeded
|
|
201
|
-
* first, so the next selection is compared with what is on screen.
|
|
202
|
-
*/
|
|
203
254
|
modeValueChanged() {
|
|
204
255
|
if (!this.#connected) return;
|
|
205
256
|
if (this.modeValue === this.#published.mode) return;
|
|
@@ -208,67 +259,53 @@ var ThemeController = class extends Controller {
|
|
|
208
259
|
this.#applyTheme();
|
|
209
260
|
this.#reconcileControls();
|
|
210
261
|
}
|
|
211
|
-
|
|
262
|
+
storageKeyValueChanged() {
|
|
263
|
+
if (!this.#connected) return;
|
|
264
|
+
const key = this.storageKeyValue;
|
|
265
|
+
if (key === this.#storageKeyRead) return;
|
|
266
|
+
this.#storageKeyRead = key;
|
|
267
|
+
const stored = this.#readStored();
|
|
268
|
+
if (stored === null || stored === this.modeValue) return;
|
|
269
|
+
this.modeValue = stored;
|
|
270
|
+
this.#applyTheme();
|
|
271
|
+
this.#reconcileControls();
|
|
272
|
+
}
|
|
212
273
|
optionTargetConnected() {
|
|
213
|
-
if (this.#connected) this.#
|
|
274
|
+
if (this.#connected) this.#morph.schedule();
|
|
214
275
|
}
|
|
215
|
-
/** Re-derives them again when an option leaves, so a Tab stop always remains. */
|
|
216
276
|
optionTargetDisconnected() {
|
|
217
|
-
if (this.#connected) this.#
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
set(event) {
|
|
226
|
-
const option = event.currentTarget;
|
|
227
|
-
if (!(option instanceof HTMLElement)) return;
|
|
277
|
+
if (this.#connected) this.#morph.schedule();
|
|
278
|
+
}
|
|
279
|
+
set(source) {
|
|
280
|
+
const { host, origin, reason } = actionSource(source);
|
|
281
|
+
const option = host?.closest(targetSelector(this.identifier, "option"));
|
|
282
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
283
|
+
if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
|
|
284
|
+
return;
|
|
228
285
|
const mode = this.#optionMode(option);
|
|
229
|
-
if (mode) this.#setMode(mode);
|
|
286
|
+
if (mode) this.#setMode(mode, reason);
|
|
230
287
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
288
|
+
toggle(event) {
|
|
289
|
+
const mode = this.#readStored() ?? this.#mode;
|
|
290
|
+
const dark = mode === "dark" || mode === "system" && this.#media?.matches === true;
|
|
291
|
+
this.#setMode(dark ? "light" : "dark", stateReasonFor(event));
|
|
234
292
|
}
|
|
235
|
-
|
|
236
|
-
#setMode(mode) {
|
|
293
|
+
#setMode(mode, reason = "user") {
|
|
237
294
|
this.modeValue = mode;
|
|
238
295
|
this.#writeStored(mode);
|
|
239
|
-
this.#commit();
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
* Applies the current mode and reports it, but reports only a real move: the
|
|
243
|
-
* event means "the selection or the effective theme moved", so re-choosing the
|
|
244
|
-
* option already chosen is not one.
|
|
245
|
-
*/
|
|
246
|
-
#commit() {
|
|
296
|
+
this.#commit(reason);
|
|
297
|
+
}
|
|
298
|
+
#commit(reason) {
|
|
247
299
|
this.#applyTheme();
|
|
248
300
|
this.#syncControls();
|
|
249
301
|
const moved = this.#settle();
|
|
250
|
-
if (moved) this.dispatch("change", { detail: moved });
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Re-derives the controls from the mode on screen for a change the page made — a
|
|
254
|
-
* `mode` declaration or an option set — and reports a pair that moved since the
|
|
255
|
-
* last report as `reconcile`. An option coming or going moves neither half of the
|
|
256
|
-
* pair, so of these changes only a declaration reports.
|
|
257
|
-
*/
|
|
302
|
+
if (moved) this.dispatch("change", { detail: { ...moved, reason } });
|
|
303
|
+
}
|
|
258
304
|
#reconcileControls() {
|
|
259
305
|
this.#syncControls();
|
|
260
306
|
const moved = this.#settle();
|
|
261
307
|
if (moved) this.dispatch("reconcile", { detail: moved });
|
|
262
308
|
}
|
|
263
|
-
/**
|
|
264
|
-
* Takes the pair on screen as the reported one and returns it when it differs
|
|
265
|
-
* from the one reported before, or `null` when neither half moved.
|
|
266
|
-
*
|
|
267
|
-
* The baseline moves before anything is dispatched, so a listener that selects
|
|
268
|
-
* another mode is measured from the pair it was just told about. What is
|
|
269
|
-
* returned is a copy: the baseline has to survive a listener that writes to what
|
|
270
|
-
* it was handed, or the next unchanged operation reads as a move.
|
|
271
|
-
*/
|
|
272
309
|
#settle() {
|
|
273
310
|
const next = this.#current;
|
|
274
311
|
const last = this.#published;
|
|
@@ -276,15 +313,9 @@ var ThemeController = class extends Controller {
|
|
|
276
313
|
if (last.mode === next.mode && last.resolved === next.resolved) return null;
|
|
277
314
|
return { ...next };
|
|
278
315
|
}
|
|
279
|
-
/** The pair the `change` and `reconcile` details carry, read from current state. */
|
|
280
316
|
get #current() {
|
|
281
317
|
return { mode: this.#mode, resolved: this.#resolved() };
|
|
282
318
|
}
|
|
283
|
-
/**
|
|
284
|
-
* Writes `data-theme` + `color-scheme` (the resolved theme) onto the target.
|
|
285
|
-
*
|
|
286
|
-
* @stimeoRenderRoot
|
|
287
|
-
*/
|
|
288
319
|
#applyTheme() {
|
|
289
320
|
const root = this.#targetElement();
|
|
290
321
|
if (!root) return;
|
|
@@ -292,11 +323,6 @@ var ThemeController = class extends Controller {
|
|
|
292
323
|
root.setAttribute("data-theme", resolved);
|
|
293
324
|
root.style.setProperty("color-scheme", resolved);
|
|
294
325
|
}
|
|
295
|
-
/**
|
|
296
|
-
* Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync.
|
|
297
|
-
*
|
|
298
|
-
* @stimeoRenderRoot
|
|
299
|
-
*/
|
|
300
326
|
#syncControls() {
|
|
301
327
|
const options = this.optionTargets;
|
|
302
328
|
if (options.length > 0) {
|
|
@@ -314,43 +340,35 @@ var ThemeController = class extends Controller {
|
|
|
314
340
|
this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
|
|
315
341
|
}
|
|
316
342
|
}
|
|
317
|
-
/** Whether the controller element is the button of the 2-value contract. */
|
|
318
343
|
get #isToggleButton() {
|
|
319
344
|
return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
|
|
320
345
|
}
|
|
321
|
-
/** The selected mode after validation; an unreadable declaration is the default. */
|
|
322
346
|
get #mode() {
|
|
323
347
|
return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
|
|
324
348
|
}
|
|
325
|
-
/** The effective theme: the OS preference when `system`, else the mode itself. */
|
|
326
349
|
#resolved() {
|
|
327
350
|
const mode = this.#mode;
|
|
328
351
|
if (mode === "dark") return "dark";
|
|
329
352
|
if (mode === "light") return "light";
|
|
330
353
|
return this.#media?.matches ? "dark" : "light";
|
|
331
354
|
}
|
|
332
|
-
/** An option's mode from its `data-value`, or `null` when that is not one of the three. */
|
|
333
355
|
#optionMode(option) {
|
|
334
356
|
const mode = option.getAttribute("data-value");
|
|
335
357
|
return isMode(mode) ? mode : null;
|
|
336
358
|
}
|
|
337
|
-
/** Resolves the state-hook target (`<html>` by default). */
|
|
338
359
|
#targetElement() {
|
|
339
360
|
const selector = this.#targetSelector;
|
|
340
361
|
if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
|
|
341
362
|
return document.querySelector(selector);
|
|
342
363
|
}
|
|
343
|
-
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
344
364
|
#readStored() {
|
|
345
365
|
const result = readLocalStorage(this.storageKeyValue);
|
|
346
366
|
return result.ok && isMode(result.value) ? result.value : null;
|
|
347
367
|
}
|
|
348
|
-
/** Persists the mode, swallowing storage errors (private mode / quota). */
|
|
349
368
|
#writeStored(mode) {
|
|
350
369
|
writeLocalStorage(this.storageKeyValue, mode);
|
|
351
370
|
}
|
|
352
371
|
};
|
|
353
372
|
|
|
354
373
|
export { ThemeController };
|
|
355
|
-
//# sourceMappingURL=theme_controller.js.map
|
|
356
374
|
//# sourceMappingURL=theme_controller.js.map
|