stimeo-ui 0.1.0.pre.beta.3 → 0.2.1
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 +149 -0
- data/README.md +6 -6
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/editable_controller.js +168 -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/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +144 -38
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +1644 -643
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +28 -2
|
@@ -58,7 +58,30 @@ var LayoutObserver = class {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// src/utils/logical_scroll.ts
|
|
62
|
+
function isRtl(element) {
|
|
63
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
64
|
+
}
|
|
65
|
+
function logicalScrollMetrics(element, horizontal) {
|
|
66
|
+
const max = Math.max(
|
|
67
|
+
0,
|
|
68
|
+
horizontal ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight
|
|
69
|
+
);
|
|
70
|
+
const raw = horizontal ? element.scrollLeft : element.scrollTop;
|
|
71
|
+
const position = horizontal && isRtl(element) ? -raw : raw;
|
|
72
|
+
return { position: Math.min(max, Math.max(0, position)), max };
|
|
73
|
+
}
|
|
74
|
+
function physicalScrollDelta(element, horizontal, logicalDelta) {
|
|
75
|
+
return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/utils/reduced_motion.ts
|
|
79
|
+
function prefersReducedMotion() {
|
|
80
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
// src/controllers/overflow_indicator_controller.ts
|
|
84
|
+
var DIRECTION_BUTTON_SELECTOR = "[data-stimeo--overflow-indicator-direction-param]";
|
|
62
85
|
var OverflowIndicatorController = class extends Controller {
|
|
63
86
|
static targets = ["viewport"];
|
|
64
87
|
static values = {
|
|
@@ -67,38 +90,50 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
67
90
|
};
|
|
68
91
|
static actions = ["scrollByPage", "update"];
|
|
69
92
|
static events = ["change"];
|
|
70
|
-
#layout = new LayoutObserver(() =>
|
|
93
|
+
#layout = new LayoutObserver(() => {
|
|
94
|
+
if (this.#connected) this.update();
|
|
95
|
+
});
|
|
96
|
+
#connected = false;
|
|
97
|
+
#observedViewport = null;
|
|
98
|
+
#observedContent = /* @__PURE__ */ new Set();
|
|
71
99
|
#mutationObserver = null;
|
|
100
|
+
#pendingButtonDisables = /* @__PURE__ */ new Map();
|
|
72
101
|
/** Last reported room, so `change` fires only on transitions. */
|
|
73
102
|
#state = null;
|
|
74
103
|
connect() {
|
|
75
|
-
|
|
76
|
-
this.#
|
|
77
|
-
this.#layout.observeViewport();
|
|
78
|
-
if (typeof MutationObserver !== "undefined") {
|
|
79
|
-
this.#mutationObserver = new MutationObserver(() => this.update());
|
|
80
|
-
this.#mutationObserver.observe(this.viewportTarget, {
|
|
81
|
-
childList: true,
|
|
82
|
-
subtree: true,
|
|
83
|
-
characterData: true
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
this.update();
|
|
104
|
+
this.#connected = true;
|
|
105
|
+
this.#syncViewport();
|
|
87
106
|
}
|
|
88
107
|
disconnect() {
|
|
108
|
+
this.#connected = false;
|
|
109
|
+
this.#stopObservingViewport();
|
|
89
110
|
this.#layout.disconnect();
|
|
90
|
-
this.#
|
|
91
|
-
this.#mutationObserver = null;
|
|
111
|
+
this.#clearPendingButtonDisables();
|
|
92
112
|
this.#state = null;
|
|
93
113
|
}
|
|
94
|
-
|
|
114
|
+
viewportTargetConnected() {
|
|
115
|
+
this.#syncViewport();
|
|
116
|
+
}
|
|
117
|
+
viewportTargetDisconnected(viewport) {
|
|
118
|
+
if (this.#observedViewport === viewport) this.#stopObservingViewport();
|
|
119
|
+
this.#syncViewport();
|
|
120
|
+
}
|
|
121
|
+
orientationValueChanged() {
|
|
122
|
+
if (this.#connected) this.update();
|
|
123
|
+
}
|
|
124
|
+
thresholdValueChanged() {
|
|
125
|
+
if (this.#connected) this.update();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Re-measures remaining scroll room and reflects the state hooks.
|
|
129
|
+
* Public so it can be wired to the viewport's `scroll`.
|
|
130
|
+
*/
|
|
95
131
|
update() {
|
|
96
132
|
if (!this.hasViewportTarget) return;
|
|
97
133
|
const vp = this.viewportTarget;
|
|
98
134
|
const horizontal = this.orientationValue !== "vertical";
|
|
99
|
-
const t = this
|
|
100
|
-
const scrollPos
|
|
101
|
-
const maxScroll = horizontal ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
|
|
135
|
+
const t = this.#threshold;
|
|
136
|
+
const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontal);
|
|
102
137
|
const start = scrollPos > t;
|
|
103
138
|
const end = scrollPos < maxScroll - t;
|
|
104
139
|
vp.setAttribute("data-overflow-start", start ? "true" : "false");
|
|
@@ -114,11 +149,13 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
114
149
|
if (!this.hasViewportTarget) return;
|
|
115
150
|
const direction = this.#directionFromEvent(event);
|
|
116
151
|
if (!direction) return;
|
|
152
|
+
if (this.#state && !this.#state[direction]) return;
|
|
117
153
|
const vp = this.viewportTarget;
|
|
118
154
|
const horizontal = this.orientationValue !== "vertical";
|
|
119
155
|
const page = horizontal ? vp.clientWidth : vp.clientHeight;
|
|
120
|
-
const
|
|
121
|
-
const
|
|
156
|
+
const logicalDelta = direction === "start" ? -page : page;
|
|
157
|
+
const delta = physicalScrollDelta(vp, horizontal, logicalDelta);
|
|
158
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
122
159
|
if (horizontal) {
|
|
123
160
|
vp.scrollBy({ left: delta, behavior });
|
|
124
161
|
} else {
|
|
@@ -127,10 +164,16 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
127
164
|
}
|
|
128
165
|
/** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
|
|
129
166
|
#syncButtons(start, end) {
|
|
130
|
-
const
|
|
131
|
-
"[data-stimeo--overflow-indicator
|
|
132
|
-
|
|
167
|
+
for (const button of [...this.#pendingButtonDisables.keys()]) {
|
|
168
|
+
if (!button.isConnected || button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
|
|
169
|
+
this.#cancelPendingButtonDisable(button);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const buttons = this.element.querySelectorAll(DIRECTION_BUTTON_SELECTOR);
|
|
133
173
|
for (const button of buttons) {
|
|
174
|
+
if (button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
134
177
|
const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
|
|
135
178
|
if (direction === "start") this.#toggleButton(button, start);
|
|
136
179
|
else if (direction === "end") this.#toggleButton(button, end);
|
|
@@ -143,7 +186,11 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
143
186
|
* whole control disabled) is therefore never blindly re-enabled.
|
|
144
187
|
*/
|
|
145
188
|
#toggleButton(button, hasRoom) {
|
|
189
|
+
if (!this.#pendingButtonDisables.has(button) && button.hasAttribute("data-overflow-indicator-pending-disabled")) {
|
|
190
|
+
this.#cancelPendingButtonDisable(button);
|
|
191
|
+
}
|
|
146
192
|
if (hasRoom) {
|
|
193
|
+
this.#cancelPendingButtonDisable(button);
|
|
147
194
|
if (button.hasAttribute("data-overflow-indicator-disabled")) {
|
|
148
195
|
button.disabled = false;
|
|
149
196
|
button.removeAttribute("data-overflow-indicator-disabled");
|
|
@@ -151,17 +198,121 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
151
198
|
return;
|
|
152
199
|
}
|
|
153
200
|
if (button.disabled) return;
|
|
201
|
+
if (document.activeElement === button) {
|
|
202
|
+
this.#deferButtonDisable(button);
|
|
203
|
+
} else {
|
|
204
|
+
this.#disableButton(button);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Keeps a boundary button focusable until native blur, exposing its temporary
|
|
209
|
+
* inoperability with `aria-disabled` and making its action a no-op meanwhile.
|
|
210
|
+
*/
|
|
211
|
+
#deferButtonDisable(button) {
|
|
212
|
+
if (this.#pendingButtonDisables.has(button)) return;
|
|
213
|
+
button.setAttribute("data-overflow-indicator-pending-disabled", "");
|
|
214
|
+
button.setAttribute(
|
|
215
|
+
"data-overflow-indicator-aria-disabled",
|
|
216
|
+
button.getAttribute("aria-disabled") ?? ""
|
|
217
|
+
);
|
|
218
|
+
button.setAttribute("aria-disabled", "true");
|
|
219
|
+
const onBlur = () => {
|
|
220
|
+
this.#cancelPendingButtonDisable(button);
|
|
221
|
+
if (!button.disabled) this.#disableButton(button);
|
|
222
|
+
};
|
|
223
|
+
this.#pendingButtonDisables.set(button, { onBlur });
|
|
224
|
+
button.addEventListener("blur", onBlur);
|
|
225
|
+
}
|
|
226
|
+
#disableButton(button) {
|
|
154
227
|
button.disabled = true;
|
|
155
228
|
button.setAttribute("data-overflow-indicator-disabled", "");
|
|
156
229
|
}
|
|
230
|
+
#cancelPendingButtonDisable(button) {
|
|
231
|
+
const pending = this.#pendingButtonDisables.get(button);
|
|
232
|
+
if (pending) button.removeEventListener("blur", pending.onBlur);
|
|
233
|
+
this.#pendingButtonDisables.delete(button);
|
|
234
|
+
button.removeAttribute("data-overflow-indicator-pending-disabled");
|
|
235
|
+
const displaced = button.getAttribute("data-overflow-indicator-aria-disabled");
|
|
236
|
+
if (displaced !== null) {
|
|
237
|
+
button.removeAttribute("data-overflow-indicator-aria-disabled");
|
|
238
|
+
if (button.getAttribute("aria-disabled") === "true") {
|
|
239
|
+
if (displaced === "") button.removeAttribute("aria-disabled");
|
|
240
|
+
else button.setAttribute("aria-disabled", displaced);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
#clearPendingButtonDisables() {
|
|
245
|
+
for (const button of [...this.#pendingButtonDisables.keys()]) {
|
|
246
|
+
this.#cancelPendingButtonDisable(button);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Moves resize/mutation/load observation to the current viewport target. */
|
|
250
|
+
#syncViewport() {
|
|
251
|
+
if (!this.#connected) return;
|
|
252
|
+
const next = this.hasViewportTarget ? this.viewportTarget : null;
|
|
253
|
+
if (next === this.#observedViewport) return;
|
|
254
|
+
this.#stopObservingViewport();
|
|
255
|
+
if (!next) return;
|
|
256
|
+
this.#observedViewport = next;
|
|
257
|
+
this.#layout.observe(next);
|
|
258
|
+
this.#layout.observeViewport();
|
|
259
|
+
next.addEventListener("load", this.#onContentLoad, true);
|
|
260
|
+
this.#syncContentObservation();
|
|
261
|
+
if (typeof MutationObserver !== "undefined") {
|
|
262
|
+
this.#mutationObserver = new MutationObserver(() => {
|
|
263
|
+
if (!this.#connected || this.#observedViewport !== next) return;
|
|
264
|
+
this.#syncContentObservation();
|
|
265
|
+
this.update();
|
|
266
|
+
});
|
|
267
|
+
this.#mutationObserver.observe(this.element, {
|
|
268
|
+
childList: true,
|
|
269
|
+
subtree: true,
|
|
270
|
+
characterData: true,
|
|
271
|
+
attributes: true,
|
|
272
|
+
attributeFilter: ["class", "style", "hidden"]
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
this.#state = null;
|
|
276
|
+
this.update();
|
|
277
|
+
}
|
|
278
|
+
/** Observes direct content boxes whose resize can change the viewport's scroll extent. */
|
|
279
|
+
#syncContentObservation() {
|
|
280
|
+
const next = new Set(this.#observedViewport?.children ?? []);
|
|
281
|
+
for (const content of this.#observedContent) {
|
|
282
|
+
if (!next.has(content)) {
|
|
283
|
+
this.#layout.unobserve(content);
|
|
284
|
+
this.#observedContent.delete(content);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const content of next) {
|
|
288
|
+
if (!this.#observedContent.has(content)) {
|
|
289
|
+
this.#observedContent.add(content);
|
|
290
|
+
this.#layout.observe(content);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
#stopObservingViewport() {
|
|
295
|
+
this.#mutationObserver?.disconnect();
|
|
296
|
+
this.#mutationObserver = null;
|
|
297
|
+
this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
|
|
298
|
+
if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
|
|
299
|
+
for (const content of this.#observedContent) this.#layout.unobserve(content);
|
|
300
|
+
this.#observedContent.clear();
|
|
301
|
+
this.#observedViewport = null;
|
|
302
|
+
this.#layout.unobserveViewport();
|
|
303
|
+
}
|
|
304
|
+
#onContentLoad = () => {
|
|
305
|
+
if (this.#connected) this.update();
|
|
306
|
+
};
|
|
307
|
+
get #threshold() {
|
|
308
|
+
const value = this.thresholdValue;
|
|
309
|
+
return Number.isFinite(value) ? Math.max(0, value) : 1;
|
|
310
|
+
}
|
|
157
311
|
#directionFromEvent(event) {
|
|
158
312
|
const params = event.params;
|
|
159
313
|
const direction = params?.direction;
|
|
160
314
|
return direction === "start" || direction === "end" ? direction : null;
|
|
161
315
|
}
|
|
162
|
-
#prefersReducedMotion() {
|
|
163
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
164
|
-
}
|
|
165
316
|
};
|
|
166
317
|
|
|
167
318
|
export { OverflowIndicatorController };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/password_reveal_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/controllers/password_reveal_controller.ts
|
|
59
|
+
var PasswordRevealController = class extends Controller {
|
|
60
|
+
static targets = ["input", "toggle"];
|
|
61
|
+
static values = {
|
|
62
|
+
autoHide: { type: Number, default: 0 }
|
|
63
|
+
};
|
|
64
|
+
static actions = ["toggle"];
|
|
65
|
+
static events = ["toggle"];
|
|
66
|
+
/** Auto re-mask timer; torn down on disconnect. */
|
|
67
|
+
#timers = new SafeTimeout();
|
|
68
|
+
connect() {
|
|
69
|
+
this.#reflect(this.#isVisible);
|
|
70
|
+
}
|
|
71
|
+
disconnect() {
|
|
72
|
+
this.#timers.clearAll();
|
|
73
|
+
}
|
|
74
|
+
/** Toggles the input between masked and revealed. Bound via `data-action`. */
|
|
75
|
+
toggle() {
|
|
76
|
+
this.#setVisible(!this.#isVisible);
|
|
77
|
+
}
|
|
78
|
+
/** Whether the input is currently revealed (`type="text"`). */
|
|
79
|
+
get #isVisible() {
|
|
80
|
+
return this.hasInputTarget && this.inputTarget.type === "text";
|
|
81
|
+
}
|
|
82
|
+
/** Switches the masked/revealed state, preserving focus and caret. */
|
|
83
|
+
#setVisible(visible) {
|
|
84
|
+
if (!this.hasInputTarget) return;
|
|
85
|
+
const input = this.inputTarget;
|
|
86
|
+
const restoreInputFocus = document.activeElement === input;
|
|
87
|
+
const selectionStart = input.selectionStart;
|
|
88
|
+
const selectionEnd = input.selectionEnd;
|
|
89
|
+
input.type = visible ? "text" : "password";
|
|
90
|
+
if (restoreInputFocus) {
|
|
91
|
+
input.focus();
|
|
92
|
+
if (selectionStart !== null && selectionEnd !== null) {
|
|
93
|
+
try {
|
|
94
|
+
input.setSelectionRange(selectionStart, selectionEnd);
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.#reflect(visible);
|
|
100
|
+
this.dispatch("toggle", { detail: { visible } });
|
|
101
|
+
this.#timers.clearAll();
|
|
102
|
+
if (visible && this.autoHideValue > 0) {
|
|
103
|
+
this.#timers.set(() => this.#setVisible(false), this.autoHideValue);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Reflects the visible state onto `aria-pressed` and `data-state`. */
|
|
107
|
+
#reflect(visible) {
|
|
108
|
+
if (this.hasToggleTarget) {
|
|
109
|
+
this.toggleTarget.setAttribute("aria-pressed", visible ? "true" : "false");
|
|
110
|
+
}
|
|
111
|
+
this.element.setAttribute("data-state", visible ? "visible" : "hidden");
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export { PasswordRevealController };
|
|
116
|
+
//# sourceMappingURL=password_reveal_controller.js.map
|
|
117
|
+
//# sourceMappingURL=password_reveal_controller.js.map
|
|
@@ -187,6 +187,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
187
187
|
const handle = this.#handleFor(event.target);
|
|
188
188
|
if (!handle) return;
|
|
189
189
|
if (event.key === "Escape") {
|
|
190
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
190
191
|
if (this.#pointer?.started) {
|
|
191
192
|
const { pointerType } = this.#pointer;
|
|
192
193
|
this.#teardownSessions();
|
|
@@ -299,9 +300,8 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
299
300
|
}
|
|
300
301
|
/**
|
|
301
302
|
* Silently tears down whatever session is live (disconnect / disabled /
|
|
302
|
-
* Escape). Composed from the two single-session teardowns so
|
|
303
|
-
*
|
|
304
|
-
* the capture-release fix).
|
|
303
|
+
* Escape). Composed from the two single-session teardowns so pointer and
|
|
304
|
+
* keyboard cleanup cannot diverge as either path evolves.
|
|
305
305
|
*/
|
|
306
306
|
#teardownSessions() {
|
|
307
307
|
this.#endPointerSession();
|
|
@@ -2,6 +2,96 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/popover_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
function claimsWhileFocusWithin(element) {
|
|
7
|
+
return () => {
|
|
8
|
+
const active = element.ownerDocument.activeElement;
|
|
9
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
var EscapeLayer = class _EscapeLayer {
|
|
13
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
#ownerDocument = null;
|
|
15
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
16
|
+
#onDismiss = null;
|
|
17
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
18
|
+
#claims = null;
|
|
19
|
+
/**
|
|
20
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
21
|
+
* the document's shared resolver listener if this is its first layer.
|
|
22
|
+
* Re-activating an already-active layer moves it to the top.
|
|
23
|
+
*/
|
|
24
|
+
activate(ownerDocument = document, options) {
|
|
25
|
+
this.deactivate();
|
|
26
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
27
|
+
if (!registry) {
|
|
28
|
+
registry = _EscapeLayer.#createRegistry();
|
|
29
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
30
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
31
|
+
}
|
|
32
|
+
registry.stack.push(this);
|
|
33
|
+
this.#ownerDocument = ownerDocument;
|
|
34
|
+
this.#onDismiss = options.onDismiss;
|
|
35
|
+
this.#claims = options.claims ?? null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
39
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
40
|
+
*/
|
|
41
|
+
deactivate() {
|
|
42
|
+
const ownerDocument = this.#ownerDocument;
|
|
43
|
+
if (!ownerDocument) return;
|
|
44
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
45
|
+
if (registry) {
|
|
46
|
+
const index = registry.stack.lastIndexOf(this);
|
|
47
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
48
|
+
if (registry.stack.length === 0) {
|
|
49
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
50
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.#ownerDocument = null;
|
|
54
|
+
this.#onDismiss = null;
|
|
55
|
+
this.#claims = null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
59
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
60
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
61
|
+
*/
|
|
62
|
+
get ownsEscape() {
|
|
63
|
+
const ownerDocument = this.#ownerDocument;
|
|
64
|
+
if (!ownerDocument) return false;
|
|
65
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
66
|
+
if (!registry) return false;
|
|
67
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
68
|
+
}
|
|
69
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
70
|
+
static #createRegistry() {
|
|
71
|
+
const registry = {
|
|
72
|
+
stack: [],
|
|
73
|
+
onKeydown: (event) => {
|
|
74
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
75
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
76
|
+
if (!owner) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
owner.#onDismiss?.();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return registry;
|
|
82
|
+
}
|
|
83
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
84
|
+
static #resolveOwner(stack) {
|
|
85
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
86
|
+
const layer = stack[index];
|
|
87
|
+
if (!layer) continue;
|
|
88
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
89
|
+
return layer;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
5
95
|
// src/utils/scroll_dismiss.ts
|
|
6
96
|
function observeScrollDismiss(element, onScroll) {
|
|
7
97
|
const targets = [...scrollParents(element), window];
|
|
@@ -36,24 +126,26 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
36
126
|
static actions = ["close", "open", "toggle"];
|
|
37
127
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
38
128
|
#stopScrollDismiss = null;
|
|
129
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
130
|
+
#escapeLayer = new EscapeLayer();
|
|
39
131
|
/** Selector for natively focusable elements used to find the first one. */
|
|
40
132
|
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
41
|
-
/** Starts closed and registers the
|
|
133
|
+
/** Starts closed and registers the standing dismissal listeners. */
|
|
42
134
|
connect() {
|
|
43
135
|
this.close();
|
|
44
136
|
document.addEventListener("click", this.#onOutsideClick);
|
|
45
|
-
|
|
137
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
46
138
|
}
|
|
47
139
|
/**
|
|
48
|
-
* Removes
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
140
|
+
* Removes every standing listener registered in {@link connect} plus any active
|
|
141
|
+
* dismiss-on-scroll observers. `removeEventListener` is a no-op when it was
|
|
142
|
+
* never added, so this is safe in the closed state too — no listener outlives
|
|
143
|
+
* the element after a Turbo navigation.
|
|
52
144
|
*/
|
|
53
145
|
disconnect() {
|
|
146
|
+
this.#escapeLayer.deactivate();
|
|
54
147
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
55
|
-
|
|
56
|
-
if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
148
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
57
149
|
this.#stopScrollDismiss?.();
|
|
58
150
|
this.#stopScrollDismiss = null;
|
|
59
151
|
}
|
|
@@ -70,7 +162,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
70
162
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
71
163
|
this.panelTarget.hidden = false;
|
|
72
164
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
73
|
-
this.
|
|
165
|
+
this.#escapeLayer.activate(document, {
|
|
166
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
167
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
168
|
+
});
|
|
74
169
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
75
170
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
|
|
76
171
|
}
|
|
@@ -78,11 +173,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
78
173
|
}
|
|
79
174
|
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
80
175
|
close() {
|
|
81
|
-
|
|
82
|
-
this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
176
|
+
this.#escapeLayer.deactivate();
|
|
83
177
|
this.#stopScrollDismiss?.();
|
|
84
178
|
this.#stopScrollDismiss = null;
|
|
85
|
-
this.panelTarget.hidden = true;
|
|
179
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = true;
|
|
86
180
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
87
181
|
}
|
|
88
182
|
/** Moves focus to the first focusable element in the panel, or the panel itself. */
|
|
@@ -95,31 +189,28 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
95
189
|
if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
|
|
96
190
|
this.panelTarget.focus();
|
|
97
191
|
}
|
|
98
|
-
/** Closes and restores focus to the trigger
|
|
192
|
+
/** Closes and restores focus to the trigger for explicit keyboard dismissal. */
|
|
99
193
|
#closeAndRestore() {
|
|
100
194
|
this.close();
|
|
101
195
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
102
196
|
}
|
|
103
|
-
/** Closes
|
|
197
|
+
/** Closes without moving focus when a click lands outside the controller element. */
|
|
104
198
|
#onOutsideClick = (event) => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
/** Closes (restoring focus) on `Escape` while open. */
|
|
108
|
-
#onKeydown = (event) => {
|
|
109
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
110
|
-
event.preventDefault();
|
|
111
|
-
this.#closeAndRestore();
|
|
112
|
-
}
|
|
199
|
+
const target = event.target;
|
|
200
|
+
if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
|
|
113
201
|
};
|
|
114
202
|
/**
|
|
115
|
-
* Closes when focus leaves the
|
|
116
|
-
* (e.g.
|
|
117
|
-
* destination is kept, which is the modeless
|
|
118
|
-
*
|
|
203
|
+
* Closes when focus leaves the controller for a known external destination
|
|
204
|
+
* (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is
|
|
205
|
+
* not restored — the natural destination is kept, which is the modeless
|
|
206
|
+
* contract. A null/non-Node destination is indeterminate: browsers use it for
|
|
207
|
+
* clicks on non-focusable content and window deactivation, so the later outside
|
|
208
|
+
* click handler decides pointer dismissal instead.
|
|
119
209
|
*/
|
|
120
210
|
#onFocusOut = (event) => {
|
|
211
|
+
if (!this.#isOpen) return;
|
|
121
212
|
const next = event.relatedTarget;
|
|
122
|
-
if (next
|
|
213
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
123
214
|
this.close();
|
|
124
215
|
};
|
|
125
216
|
/** Whether the panel is currently visible. */
|