stimeo-ui 0.1.0.pre.alpha.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +72 -0
- data/dist/controllers/accordion_controller.js +76 -0
- data/dist/controllers/announcer_controller.js +184 -0
- data/dist/controllers/aspect_ratio_controller.js +36 -0
- data/dist/controllers/auto_submit_controller.js +147 -0
- data/dist/controllers/avatar_controller.js +66 -0
- data/dist/controllers/breadcrumb_controller.js +123 -0
- data/dist/controllers/bulk_select_controller.js +104 -0
- data/dist/controllers/calendar_controller.js +394 -0
- data/dist/controllers/character_counter_controller.js +179 -0
- data/dist/controllers/checkbox_controller.js +73 -0
- data/dist/controllers/combobox_controller.js +186 -0
- data/dist/controllers/command_palette_controller.js +381 -0
- data/dist/controllers/conditional_fields_controller.js +112 -0
- data/dist/controllers/confirm_controller.js +276 -0
- data/dist/controllers/context_menu_controller.js +112 -0
- data/dist/controllers/countdown_controller.js +202 -0
- data/dist/controllers/dialog_controller.js +207 -0
- data/dist/controllers/direct_upload_controller.js +212 -0
- data/dist/controllers/dirty_form_controller.js +128 -0
- data/dist/controllers/dropdown_controller.js +66 -0
- data/dist/controllers/empty_state_controller.js +67 -0
- data/dist/controllers/flash_controller.js +221 -0
- data/dist/controllers/focus_controller.js +216 -0
- data/dist/controllers/form_field_controller.js +154 -0
- data/dist/controllers/form_validation_controller.js +202 -0
- data/dist/controllers/frame_loading_controller.js +177 -0
- data/dist/controllers/highlight_controller.js +107 -0
- data/dist/controllers/hover_card_controller.js +165 -0
- data/dist/controllers/idle_controller.js +141 -0
- data/dist/controllers/input_mask_controller.js +166 -0
- data/dist/controllers/lazy_frame_controller.js +68 -0
- data/dist/controllers/listbox_controller.js +256 -0
- data/dist/controllers/local_time_controller.js +81 -0
- data/dist/controllers/menu_controller.js +134 -0
- data/dist/controllers/meter_controller.js +96 -0
- data/dist/controllers/nested_form_controller.js +131 -0
- data/dist/controllers/network_status_controller.js +126 -0
- data/dist/controllers/number_input_controller.js +306 -0
- data/dist/controllers/otp_controller.js +201 -0
- data/dist/controllers/overflow_indicator_controller.js +169 -0
- data/dist/controllers/overflow_menu_controller.js +274 -0
- data/dist/controllers/pagination_controller.js +89 -0
- data/dist/controllers/password_strength_controller.js +175 -0
- data/dist/controllers/persist_controller.js +259 -0
- data/dist/controllers/popover_controller.js +94 -0
- data/dist/controllers/portal_controller.js +63 -0
- data/dist/controllers/preview_guard_controller.js +69 -0
- data/dist/controllers/progress_controller.js +93 -0
- data/dist/controllers/radio_group_controller.js +128 -0
- data/dist/controllers/rating_controller.js +179 -0
- data/dist/controllers/relative_time_controller.js +129 -0
- data/dist/controllers/reset_before_cache_controller.js +62 -0
- data/dist/controllers/resizable_controller.js +163 -0
- data/dist/controllers/roving_controller.js +116 -0
- data/dist/controllers/scroll_area_controller.js +183 -0
- data/dist/controllers/scroll_visibility_controller.js +103 -0
- data/dist/controllers/scrollspy_controller.js +171 -0
- data/dist/controllers/skeleton_controller.js +125 -0
- data/dist/controllers/slider_controller.js +109 -0
- data/dist/controllers/spinner_controller.js +164 -0
- data/dist/controllers/step_indicator_controller.js +55 -0
- data/dist/controllers/stepper_controller.js +78 -0
- data/dist/controllers/stick_to_bottom_controller.js +100 -0
- data/dist/controllers/sticky_observer_controller.js +53 -0
- data/dist/controllers/submit_once_controller.js +206 -0
- data/dist/controllers/switch_controller.js +50 -0
- data/dist/controllers/tabs_controller.js +63 -0
- data/dist/controllers/textarea_autosize_controller.js +72 -0
- data/dist/controllers/theme_controller.js +154 -0
- data/dist/controllers/toast_controller.js +310 -0
- data/dist/controllers/toggle_group_controller.js +130 -0
- data/dist/controllers/toolbar_controller.js +113 -0
- data/dist/controllers/tooltip_controller.js +165 -0
- data/dist/controllers/transition_controller.js +203 -0
- data/dist/index.js +12241 -0
- data/dist/positioning/index.js +145 -0
- data/lib/generators/stimeo/install/install_generator.rb +114 -0
- data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
- data/lib/stimeo/ui/version.rb +10 -0
- data/lib/stimeo/ui.rb +19 -0
- data/lib/stimeo-ui.rb +4 -0
- metadata +152 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/overflow_menu_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/layout_observer.ts
|
|
6
|
+
var LayoutObserver = class {
|
|
7
|
+
#callback;
|
|
8
|
+
#resizeObserverFactory;
|
|
9
|
+
#resizeObserver = null;
|
|
10
|
+
#observingViewport = false;
|
|
11
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
|
+
#handleViewportResize = () => {
|
|
13
|
+
this.#callback();
|
|
14
|
+
};
|
|
15
|
+
constructor(callback, options = {}) {
|
|
16
|
+
this.#callback = callback;
|
|
17
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
21
|
+
* elements through the same shared observer. No-ops when no
|
|
22
|
+
* `ResizeObserver` implementation is available.
|
|
23
|
+
*/
|
|
24
|
+
observe(element) {
|
|
25
|
+
if (!this.#resizeObserverFactory) return;
|
|
26
|
+
if (!this.#resizeObserver) {
|
|
27
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
28
|
+
this.#callback();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
this.#resizeObserver.observe(element);
|
|
32
|
+
}
|
|
33
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
34
|
+
unobserve(element) {
|
|
35
|
+
this.#resizeObserver?.unobserve(element);
|
|
36
|
+
}
|
|
37
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
38
|
+
observeViewport() {
|
|
39
|
+
if (this.#observingViewport) return;
|
|
40
|
+
this.#observingViewport = true;
|
|
41
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
42
|
+
}
|
|
43
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
44
|
+
unobserveViewport() {
|
|
45
|
+
if (!this.#observingViewport) return;
|
|
46
|
+
this.#observingViewport = false;
|
|
47
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
51
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
52
|
+
* controller's `disconnect()`.
|
|
53
|
+
*/
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#resizeObserver?.disconnect();
|
|
56
|
+
this.#resizeObserver = null;
|
|
57
|
+
this.unobserveViewport();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/utils/safe_timeout.ts
|
|
62
|
+
var TimerRegistry = class {
|
|
63
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
64
|
+
ids = /* @__PURE__ */ new Set();
|
|
65
|
+
/**
|
|
66
|
+
* Cancels a single tracked timer.
|
|
67
|
+
*
|
|
68
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
69
|
+
* registry), so callers can clear defensively without guarding.
|
|
70
|
+
*/
|
|
71
|
+
clear(id) {
|
|
72
|
+
if (this.ids.delete(id)) {
|
|
73
|
+
this.cancel(id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
78
|
+
* to guarantee no timer outlives the element.
|
|
79
|
+
*/
|
|
80
|
+
clearAll() {
|
|
81
|
+
for (const id of this.ids) {
|
|
82
|
+
this.cancel(id);
|
|
83
|
+
}
|
|
84
|
+
this.ids.clear();
|
|
85
|
+
}
|
|
86
|
+
/** Number of timers currently tracked (pending). */
|
|
87
|
+
get size() {
|
|
88
|
+
return this.ids.size;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
92
|
+
/**
|
|
93
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
94
|
+
*
|
|
95
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
96
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
97
|
+
*/
|
|
98
|
+
set(callback, delay) {
|
|
99
|
+
const id = this.schedule(() => {
|
|
100
|
+
this.ids.delete(id);
|
|
101
|
+
callback();
|
|
102
|
+
}, delay);
|
|
103
|
+
this.ids.add(id);
|
|
104
|
+
return id;
|
|
105
|
+
}
|
|
106
|
+
schedule(callback, delay) {
|
|
107
|
+
return window.setTimeout(callback, delay);
|
|
108
|
+
}
|
|
109
|
+
cancel(id) {
|
|
110
|
+
window.clearTimeout(id);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// src/controllers/overflow_menu_controller.ts
|
|
115
|
+
var OverflowMenuController = class extends Controller {
|
|
116
|
+
static targets = ["items", "more"];
|
|
117
|
+
static values = {
|
|
118
|
+
moreLabel: { type: String, default: "More" },
|
|
119
|
+
debounce: { type: Number, default: 100 }
|
|
120
|
+
};
|
|
121
|
+
static actions = ["update"];
|
|
122
|
+
static events = ["change"];
|
|
123
|
+
#layout = new LayoutObserver(() => this.#scheduleUpdate());
|
|
124
|
+
#timers = new SafeTimeout();
|
|
125
|
+
/** The managed items in canonical (authored) order, captured at connect. */
|
|
126
|
+
#items = [];
|
|
127
|
+
#index = /* @__PURE__ */ new WeakMap();
|
|
128
|
+
/**
|
|
129
|
+
* Cached bar-context width per item. An item's natural width is location-independent,
|
|
130
|
+
* so this lets a measure pass read only the items currently in the bar (menu items
|
|
131
|
+
* reuse their last value) instead of pulling everything back to measure — one reflow,
|
|
132
|
+
* not two.
|
|
133
|
+
*/
|
|
134
|
+
#widths = /* @__PURE__ */ new WeakMap();
|
|
135
|
+
/** Last reported overflow count, so `change` fires only on transitions. */
|
|
136
|
+
#lastHidden = null;
|
|
137
|
+
connect() {
|
|
138
|
+
if (!this.hasItemsTarget || !this.hasMoreTarget) return;
|
|
139
|
+
const trigger = this.#trigger();
|
|
140
|
+
if (trigger && (trigger.textContent ?? "").trim() === "") {
|
|
141
|
+
trigger.textContent = this.moreLabelValue;
|
|
142
|
+
}
|
|
143
|
+
this.#layout.observe(this.element);
|
|
144
|
+
this.#layout.observeViewport();
|
|
145
|
+
this.update();
|
|
146
|
+
}
|
|
147
|
+
disconnect() {
|
|
148
|
+
this.#layout.disconnect();
|
|
149
|
+
this.#timers.clearAll();
|
|
150
|
+
this.#lastHidden = null;
|
|
151
|
+
}
|
|
152
|
+
/** Re-measures and rebalances items between the bar and the More menu. */
|
|
153
|
+
update() {
|
|
154
|
+
if (!this.hasItemsTarget || !this.hasMoreTarget) return;
|
|
155
|
+
this.#syncItems();
|
|
156
|
+
this.moreTarget.hidden = false;
|
|
157
|
+
for (const item of this.#items) {
|
|
158
|
+
if (item.parentElement === this.itemsTarget) this.#widths.set(item, item.offsetWidth);
|
|
159
|
+
}
|
|
160
|
+
const moreWidth = (this.#trigger() ?? this.moreTarget).offsetWidth;
|
|
161
|
+
const gap = this.#gap();
|
|
162
|
+
const widthOf = (el) => this.#widths.get(el) ?? el.offsetWidth;
|
|
163
|
+
const containerWidth = this.element.clientWidth;
|
|
164
|
+
const itemsWidth = this.#items.reduce((sum, el) => sum + widthOf(el), 0);
|
|
165
|
+
let visibleWidth = itemsWidth + Math.max(0, this.#items.length - 1) * gap;
|
|
166
|
+
const hidden = /* @__PURE__ */ new Set();
|
|
167
|
+
if (visibleWidth > containerWidth) {
|
|
168
|
+
const budget = containerWidth - moreWidth - gap;
|
|
169
|
+
const dropOrder = [...this.#items].sort(
|
|
170
|
+
(a, b) => this.#rank(b) - this.#rank(a) || this.#indexOf(b) - this.#indexOf(a)
|
|
171
|
+
);
|
|
172
|
+
for (const item of dropOrder) {
|
|
173
|
+
if (visibleWidth <= budget) break;
|
|
174
|
+
hidden.add(item);
|
|
175
|
+
visibleWidth -= widthOf(item) + gap;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const menu = this.#menuList();
|
|
179
|
+
const active = document.activeElement;
|
|
180
|
+
const refocusTrigger = active instanceof HTMLElement && hidden.has(active);
|
|
181
|
+
for (const item of this.#items) {
|
|
182
|
+
if (hidden.has(item)) this.#toMenu(item, menu);
|
|
183
|
+
else this.#toBar(item);
|
|
184
|
+
}
|
|
185
|
+
if (refocusTrigger) this.#trigger()?.focus();
|
|
186
|
+
const count = hidden.size;
|
|
187
|
+
this.moreTarget.hidden = count === 0;
|
|
188
|
+
if (count > 0) this.element.setAttribute("data-overflowing", "true");
|
|
189
|
+
else this.element.removeAttribute("data-overflowing");
|
|
190
|
+
this.element.setAttribute("data-overflow-count", String(count));
|
|
191
|
+
if (this.#lastHidden !== count) {
|
|
192
|
+
this.#lastHidden = count;
|
|
193
|
+
this.dispatch("change", { detail: { visible: this.#items.length - count, hidden: count } });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** The flex `column-gap` between items in px (0 when none / unsupported). */
|
|
197
|
+
#gap() {
|
|
198
|
+
if (typeof window.getComputedStyle !== "function") return 0;
|
|
199
|
+
const style = window.getComputedStyle(this.itemsTarget);
|
|
200
|
+
const value = Number.parseFloat(style.columnGap || style.gap || "");
|
|
201
|
+
return Number.isNaN(value) ? 0 : value;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Reconciles the managed item set with the DOM each pass: adopts items appended to the
|
|
205
|
+
* bar since last time (so the documented "add items, then call `update`" flow works)
|
|
206
|
+
* and drops any the consumer removed, then renumbers canonical indices. Banked items
|
|
207
|
+
* live in the menu (still within the controller element) and are retained.
|
|
208
|
+
*/
|
|
209
|
+
#syncItems() {
|
|
210
|
+
const known = new Set(this.#items);
|
|
211
|
+
for (const el of this.itemsTarget.children) {
|
|
212
|
+
if (el instanceof HTMLElement && !known.has(el)) this.#items.push(el);
|
|
213
|
+
}
|
|
214
|
+
this.#items = this.#items.filter((el) => this.element.contains(el));
|
|
215
|
+
this.#index = /* @__PURE__ */ new WeakMap();
|
|
216
|
+
this.#items.forEach((el, i) => {
|
|
217
|
+
this.#index.set(el, i);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Debounced re-measure for resize-driven churn. */
|
|
221
|
+
#scheduleUpdate() {
|
|
222
|
+
this.#timers.clearAll();
|
|
223
|
+
this.#timers.set(() => this.update(), this.debounceValue);
|
|
224
|
+
}
|
|
225
|
+
/** Returns the menu list the items are banked into (falls back to the More wrapper). */
|
|
226
|
+
#menuList() {
|
|
227
|
+
return this.moreTarget.querySelector('[data-stimeo--menu-target="menu"]') ?? this.moreTarget;
|
|
228
|
+
}
|
|
229
|
+
#trigger() {
|
|
230
|
+
return this.moreTarget.querySelector('[data-stimeo--menu-target="trigger"]');
|
|
231
|
+
}
|
|
232
|
+
/** Restores an item to the bar, stripping the menu semantics we may have added. */
|
|
233
|
+
#toBar(item) {
|
|
234
|
+
this.itemsTarget.appendChild(item);
|
|
235
|
+
if (item.dataset.overflowMenuized === void 0) return;
|
|
236
|
+
item.removeAttribute("data-stimeo--menu-target");
|
|
237
|
+
this.#restoreAttr(item, "role", item.dataset.overflowOrigRole);
|
|
238
|
+
this.#restoreAttr(item, "tabindex", item.dataset.overflowOrigTabindex);
|
|
239
|
+
delete item.dataset.overflowMenuized;
|
|
240
|
+
delete item.dataset.overflowOrigRole;
|
|
241
|
+
delete item.dataset.overflowOrigTabindex;
|
|
242
|
+
}
|
|
243
|
+
/** Banks an item into the menu with menuitem semantics for Menu to manage. */
|
|
244
|
+
#toMenu(item, menu) {
|
|
245
|
+
if (item.dataset.overflowMenuized === void 0) {
|
|
246
|
+
item.dataset.overflowMenuized = "true";
|
|
247
|
+
item.dataset.overflowOrigRole = item.getAttribute("role") ?? "";
|
|
248
|
+
item.dataset.overflowOrigTabindex = item.getAttribute("tabindex") ?? "";
|
|
249
|
+
item.setAttribute("role", "menuitem");
|
|
250
|
+
item.setAttribute("tabindex", "-1");
|
|
251
|
+
item.setAttribute("data-stimeo--menu-target", "item");
|
|
252
|
+
}
|
|
253
|
+
menu.appendChild(item);
|
|
254
|
+
}
|
|
255
|
+
/** Re-applies a saved attribute value, or removes the attribute when it was absent. */
|
|
256
|
+
#restoreAttr(item, name, original) {
|
|
257
|
+
if (original === void 0 || original === "") item.removeAttribute(name);
|
|
258
|
+
else item.setAttribute(name, original);
|
|
259
|
+
}
|
|
260
|
+
#indexOf(item) {
|
|
261
|
+
return this.#index.get(item) ?? 0;
|
|
262
|
+
}
|
|
263
|
+
/** Retention rank: lower keeps longer; no `data-priority` ranks lowest (drops first). */
|
|
264
|
+
#rank(item) {
|
|
265
|
+
const raw = item.getAttribute("data-priority");
|
|
266
|
+
if (raw === null) return Number.POSITIVE_INFINITY;
|
|
267
|
+
const value = Number(raw);
|
|
268
|
+
return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export { OverflowMenuController };
|
|
273
|
+
//# sourceMappingURL=overflow_menu_controller.js.map
|
|
274
|
+
//# sourceMappingURL=overflow_menu_controller.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/pagination_controller.ts
|
|
4
|
+
var PaginationController = class extends Controller {
|
|
5
|
+
static targets = ["page", "prev", "next"];
|
|
6
|
+
static values = {
|
|
7
|
+
page: { type: Number, default: 1 },
|
|
8
|
+
total: { type: Number, default: 1 }
|
|
9
|
+
};
|
|
10
|
+
static actions = ["next", "prev", "select"];
|
|
11
|
+
static events = ["change"];
|
|
12
|
+
/** Normalizes out-of-range initial values and renders the initial state. */
|
|
13
|
+
connect() {
|
|
14
|
+
this.totalValue = Math.max(1, Math.trunc(this.totalValue));
|
|
15
|
+
this.pageValue = Math.min(this.totalValue, Math.max(1, Math.trunc(this.pageValue)));
|
|
16
|
+
this.#render();
|
|
17
|
+
}
|
|
18
|
+
/** Makes the clicked page button (its `data-page`) current. */
|
|
19
|
+
select(event) {
|
|
20
|
+
const button = event.currentTarget;
|
|
21
|
+
const page = Number(button.dataset.page);
|
|
22
|
+
if (!Number.isFinite(page)) return;
|
|
23
|
+
this.#goto(page);
|
|
24
|
+
}
|
|
25
|
+
/** Steps to the previous page. */
|
|
26
|
+
prev() {
|
|
27
|
+
this.#goto(this.pageValue - 1);
|
|
28
|
+
}
|
|
29
|
+
/** Steps to the next page. */
|
|
30
|
+
next() {
|
|
31
|
+
this.#goto(this.pageValue + 1);
|
|
32
|
+
}
|
|
33
|
+
/** Moves to `page` (clamped to `[1, total]`), re-renders, and dispatches `change`. */
|
|
34
|
+
#goto(page) {
|
|
35
|
+
const total = Math.max(1, Math.trunc(this.totalValue));
|
|
36
|
+
const target = Math.min(total, Math.max(1, Math.trunc(page)));
|
|
37
|
+
if (target === this.pageValue) return;
|
|
38
|
+
const previous = this.pageValue;
|
|
39
|
+
this.pageValue = target;
|
|
40
|
+
this.#render();
|
|
41
|
+
this.dispatch("change", {
|
|
42
|
+
detail: { page: target, total: this.totalValue, previous }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Syncs `aria-current` on the page buttons and the prev/next `disabled` state. */
|
|
46
|
+
#render() {
|
|
47
|
+
for (const button of this.pageTargets) {
|
|
48
|
+
if (Number(button.dataset.page) === this.pageValue) {
|
|
49
|
+
button.setAttribute("aria-current", "page");
|
|
50
|
+
} else {
|
|
51
|
+
button.removeAttribute("aria-current");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
this.#setDisabled(this.hasPrevTarget ? this.prevTarget : null, this.pageValue <= 1);
|
|
55
|
+
this.#setDisabled(
|
|
56
|
+
this.hasNextTarget ? this.nextTarget : null,
|
|
57
|
+
this.pageValue >= this.totalValue
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sets `disabled` on a boundary button, first moving focus off it if it is the
|
|
62
|
+
* active element so disabling never strands focus.
|
|
63
|
+
*/
|
|
64
|
+
#setDisabled(button, disabled) {
|
|
65
|
+
if (!button) return;
|
|
66
|
+
if (disabled && button === document.activeElement) {
|
|
67
|
+
this.#moveFocusAwayFrom(button);
|
|
68
|
+
}
|
|
69
|
+
button.disabled = disabled;
|
|
70
|
+
}
|
|
71
|
+
/** Moves focus to the opposite enabled control, falling back to the current page. */
|
|
72
|
+
#moveFocusAwayFrom(button) {
|
|
73
|
+
const opposite = button === (this.hasPrevTarget ? this.prevTarget : null) ? this.hasNextTarget ? this.nextTarget : null : this.hasPrevTarget ? this.prevTarget : null;
|
|
74
|
+
const currentPage = this.pageTargets.find(
|
|
75
|
+
(candidate) => Number(candidate.dataset.page) === this.pageValue
|
|
76
|
+
);
|
|
77
|
+
const destination = opposite && !opposite.disabled ? opposite : currentPage;
|
|
78
|
+
if (destination) {
|
|
79
|
+
destination.focus();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!this.element.hasAttribute("tabindex")) this.element.setAttribute("tabindex", "-1");
|
|
83
|
+
this.element.focus();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export { PaginationController };
|
|
88
|
+
//# sourceMappingURL=pagination_controller.js.map
|
|
89
|
+
//# sourceMappingURL=pagination_controller.js.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/password_strength_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_strength_controller.ts
|
|
59
|
+
var CLASS_PATTERNS = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/];
|
|
60
|
+
var LENGTH_MILESTONES = [8, 12, 16];
|
|
61
|
+
var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
|
|
62
|
+
var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
|
|
63
|
+
var PasswordStrengthController = class _PasswordStrengthController extends Controller {
|
|
64
|
+
static targets = ["input", "meter", "label"];
|
|
65
|
+
static values = {
|
|
66
|
+
minScore: { type: Number, default: 0 },
|
|
67
|
+
levels: { type: Array, default: ["weak", "fair", "good", "strong"] }
|
|
68
|
+
};
|
|
69
|
+
static actions = ["evaluate"];
|
|
70
|
+
static events = ["change"];
|
|
71
|
+
/** Delay (ms) before the polite live-region label is written, to throttle SR flooding. */
|
|
72
|
+
static #announceDelay = 200;
|
|
73
|
+
#timers = new SafeTimeout();
|
|
74
|
+
#announceId = null;
|
|
75
|
+
connect() {
|
|
76
|
+
this.#update({ announce: false });
|
|
77
|
+
}
|
|
78
|
+
disconnect() {
|
|
79
|
+
this.#timers.clearAll();
|
|
80
|
+
this.#announceId = null;
|
|
81
|
+
}
|
|
82
|
+
/** Re-evaluates strength from the input. Bound via `data-action` (`input`). */
|
|
83
|
+
evaluate() {
|
|
84
|
+
this.#update();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Recomputes the strength. The meter ARIA, data hooks, the custom property and
|
|
88
|
+
* the `change` event apply immediately; the live-region label text is debounced
|
|
89
|
+
* unless `announce` is `false` (the initial render).
|
|
90
|
+
*/
|
|
91
|
+
#update(options = {}) {
|
|
92
|
+
const password = this.hasInputTarget ? this.inputTarget.value : "";
|
|
93
|
+
const labels = this.levelsValue;
|
|
94
|
+
const max = labels.length;
|
|
95
|
+
const score = this.#score(password, max);
|
|
96
|
+
const label = score > 0 ? labels[score - 1] ?? "" : "";
|
|
97
|
+
this.#reflectMeter(score, max);
|
|
98
|
+
this.#reflectRoot(score, max);
|
|
99
|
+
if (options.announce === false) {
|
|
100
|
+
this.#writeLabel(label);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.dispatch("change", {
|
|
104
|
+
detail: { score, level: label, max, meetsMin: score > 0 && score >= this.minScoreValue }
|
|
105
|
+
});
|
|
106
|
+
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
107
|
+
this.#announceId = this.#timers.set(() => {
|
|
108
|
+
this.#writeLabel(label);
|
|
109
|
+
this.#announceId = null;
|
|
110
|
+
}, _PasswordStrengthController.#announceDelay);
|
|
111
|
+
}
|
|
112
|
+
/** Syncs the meter target's ARIA value attributes (`0..levels.length`). */
|
|
113
|
+
#reflectMeter(score, max) {
|
|
114
|
+
if (!this.hasMeterTarget) return;
|
|
115
|
+
this.meterTarget.setAttribute("aria-valuemin", "0");
|
|
116
|
+
this.meterTarget.setAttribute("aria-valuemax", String(max));
|
|
117
|
+
this.meterTarget.setAttribute("aria-valuenow", String(score));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reflects the level onto the root: the stable `data-strength` band (absent when
|
|
121
|
+
* empty), the `data-below-min` hook when the score is under `minScore`, and the
|
|
122
|
+
* `0–1` fill the consumer's CSS turns into the bar width.
|
|
123
|
+
*/
|
|
124
|
+
#reflectRoot(score, max) {
|
|
125
|
+
const band = this.#band(score, max);
|
|
126
|
+
this.#toggle("data-strength", band, band.length > 0);
|
|
127
|
+
this.#toggle("data-below-min", "true", score > 0 && score < this.minScoreValue);
|
|
128
|
+
const ratio = max > 0 ? score / max : 0;
|
|
129
|
+
this.element.style.setProperty("--stimeo-password-strength", String(ratio));
|
|
130
|
+
}
|
|
131
|
+
#writeLabel(label) {
|
|
132
|
+
if (this.hasLabelTarget) this.labelTarget.textContent = label;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Locale-independent styling band (one of {@link STRENGTH_BANDS}) for `score`
|
|
136
|
+
* out of `max`. Empty input → `""`. Quantizes the `score/max` ratio into the
|
|
137
|
+
* four fixed bands, so a non-default level count still maps onto a stable hook.
|
|
138
|
+
*/
|
|
139
|
+
#band(score, max) {
|
|
140
|
+
if (score <= 0 || max <= 0) return "";
|
|
141
|
+
const index = Math.ceil(score / max * STRENGTH_BANDS.length) - 1;
|
|
142
|
+
return STRENGTH_BANDS[Math.min(STRENGTH_BANDS.length - 1, Math.max(0, index))] ?? "";
|
|
143
|
+
}
|
|
144
|
+
/** Sets `name` to `value` when `on`, else removes it (value/presence data hook). */
|
|
145
|
+
#toggle(name, value, on) {
|
|
146
|
+
if (on) {
|
|
147
|
+
this.element.setAttribute(name, value);
|
|
148
|
+
} else {
|
|
149
|
+
this.element.removeAttribute(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Lightweight zero-dependency strength heuristic returning an integer in
|
|
154
|
+
* `[0, max]` (`max` = number of levels). Empty input is `0` (no level); any
|
|
155
|
+
* non-empty password is at least `1`. Points accrue from length milestones and
|
|
156
|
+
* character-class variety, then bucket into the level scale. A tiny alphabet
|
|
157
|
+
* (≤ 2 distinct characters, e.g. "aaaa") is capped as the weakest, so length
|
|
158
|
+
* alone cannot mask trivial repetition.
|
|
159
|
+
*/
|
|
160
|
+
#score(password, max) {
|
|
161
|
+
if (password.length === 0 || max === 0) return 0;
|
|
162
|
+
let points = 0;
|
|
163
|
+
for (const milestone of LENGTH_MILESTONES) {
|
|
164
|
+
if (password.length >= milestone) points += 1;
|
|
165
|
+
}
|
|
166
|
+
points += CLASS_PATTERNS.filter((re) => re.test(password)).length - 1;
|
|
167
|
+
if (new Set(password).size <= 2) points = 0;
|
|
168
|
+
const bucketed = Math.round(points / MAX_POINTS * max);
|
|
169
|
+
return Math.min(max, Math.max(1, bucketed));
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export { PasswordStrengthController };
|
|
174
|
+
//# sourceMappingURL=password_strength_controller.js.map
|
|
175
|
+
//# sourceMappingURL=password_strength_controller.js.map
|