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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +72 -0
  4. data/dist/controllers/accordion_controller.js +76 -0
  5. data/dist/controllers/announcer_controller.js +184 -0
  6. data/dist/controllers/aspect_ratio_controller.js +36 -0
  7. data/dist/controllers/auto_submit_controller.js +147 -0
  8. data/dist/controllers/avatar_controller.js +66 -0
  9. data/dist/controllers/breadcrumb_controller.js +123 -0
  10. data/dist/controllers/bulk_select_controller.js +104 -0
  11. data/dist/controllers/calendar_controller.js +394 -0
  12. data/dist/controllers/character_counter_controller.js +179 -0
  13. data/dist/controllers/checkbox_controller.js +73 -0
  14. data/dist/controllers/combobox_controller.js +186 -0
  15. data/dist/controllers/command_palette_controller.js +381 -0
  16. data/dist/controllers/conditional_fields_controller.js +112 -0
  17. data/dist/controllers/confirm_controller.js +276 -0
  18. data/dist/controllers/context_menu_controller.js +112 -0
  19. data/dist/controllers/countdown_controller.js +202 -0
  20. data/dist/controllers/dialog_controller.js +207 -0
  21. data/dist/controllers/direct_upload_controller.js +212 -0
  22. data/dist/controllers/dirty_form_controller.js +128 -0
  23. data/dist/controllers/dropdown_controller.js +66 -0
  24. data/dist/controllers/empty_state_controller.js +67 -0
  25. data/dist/controllers/flash_controller.js +221 -0
  26. data/dist/controllers/focus_controller.js +216 -0
  27. data/dist/controllers/form_field_controller.js +154 -0
  28. data/dist/controllers/form_validation_controller.js +202 -0
  29. data/dist/controllers/frame_loading_controller.js +177 -0
  30. data/dist/controllers/highlight_controller.js +107 -0
  31. data/dist/controllers/hover_card_controller.js +165 -0
  32. data/dist/controllers/idle_controller.js +141 -0
  33. data/dist/controllers/input_mask_controller.js +166 -0
  34. data/dist/controllers/lazy_frame_controller.js +68 -0
  35. data/dist/controllers/listbox_controller.js +256 -0
  36. data/dist/controllers/local_time_controller.js +81 -0
  37. data/dist/controllers/menu_controller.js +134 -0
  38. data/dist/controllers/meter_controller.js +96 -0
  39. data/dist/controllers/nested_form_controller.js +131 -0
  40. data/dist/controllers/network_status_controller.js +126 -0
  41. data/dist/controllers/number_input_controller.js +306 -0
  42. data/dist/controllers/otp_controller.js +201 -0
  43. data/dist/controllers/overflow_indicator_controller.js +169 -0
  44. data/dist/controllers/overflow_menu_controller.js +274 -0
  45. data/dist/controllers/pagination_controller.js +89 -0
  46. data/dist/controllers/password_strength_controller.js +175 -0
  47. data/dist/controllers/persist_controller.js +259 -0
  48. data/dist/controllers/popover_controller.js +94 -0
  49. data/dist/controllers/portal_controller.js +63 -0
  50. data/dist/controllers/preview_guard_controller.js +69 -0
  51. data/dist/controllers/progress_controller.js +93 -0
  52. data/dist/controllers/radio_group_controller.js +128 -0
  53. data/dist/controllers/rating_controller.js +179 -0
  54. data/dist/controllers/relative_time_controller.js +129 -0
  55. data/dist/controllers/reset_before_cache_controller.js +62 -0
  56. data/dist/controllers/resizable_controller.js +163 -0
  57. data/dist/controllers/roving_controller.js +116 -0
  58. data/dist/controllers/scroll_area_controller.js +183 -0
  59. data/dist/controllers/scroll_visibility_controller.js +103 -0
  60. data/dist/controllers/scrollspy_controller.js +171 -0
  61. data/dist/controllers/skeleton_controller.js +125 -0
  62. data/dist/controllers/slider_controller.js +109 -0
  63. data/dist/controllers/spinner_controller.js +164 -0
  64. data/dist/controllers/step_indicator_controller.js +55 -0
  65. data/dist/controllers/stepper_controller.js +78 -0
  66. data/dist/controllers/stick_to_bottom_controller.js +100 -0
  67. data/dist/controllers/sticky_observer_controller.js +53 -0
  68. data/dist/controllers/submit_once_controller.js +206 -0
  69. data/dist/controllers/switch_controller.js +50 -0
  70. data/dist/controllers/tabs_controller.js +63 -0
  71. data/dist/controllers/textarea_autosize_controller.js +72 -0
  72. data/dist/controllers/theme_controller.js +154 -0
  73. data/dist/controllers/toast_controller.js +310 -0
  74. data/dist/controllers/toggle_group_controller.js +130 -0
  75. data/dist/controllers/toolbar_controller.js +113 -0
  76. data/dist/controllers/tooltip_controller.js +165 -0
  77. data/dist/controllers/transition_controller.js +203 -0
  78. data/dist/index.js +12241 -0
  79. data/dist/positioning/index.js +145 -0
  80. data/lib/generators/stimeo/install/install_generator.rb +114 -0
  81. data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
  82. data/lib/stimeo/ui/version.rb +10 -0
  83. data/lib/stimeo/ui.rb +19 -0
  84. data/lib/stimeo-ui.rb +4 -0
  85. metadata +152 -0
@@ -0,0 +1,81 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/local_time_controller.ts
4
+ var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
5
+ function toStyle(value) {
6
+ return STYLES.has(value) ? value : void 0;
7
+ }
8
+ var LocalTimeController = class extends Controller {
9
+ static values = {
10
+ locale: { type: String, default: "" },
11
+ timeZone: { type: String, default: "" },
12
+ dateStyle: { type: String, default: "medium" },
13
+ timeStyle: { type: String, default: "short" },
14
+ titleFormat: { type: String, default: "" }
15
+ };
16
+ static events = ["format"];
17
+ connect() {
18
+ const date = this.#parse();
19
+ if (date === null) return;
20
+ const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
21
+ if (formatted === null) return;
22
+ this.element.textContent = formatted;
23
+ const title = this.#title(date);
24
+ if (title !== null) this.element.setAttribute("title", title);
25
+ this.dispatch("format", { detail: { formatted } });
26
+ }
27
+ /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
28
+ #parse() {
29
+ const raw = this.element.getAttribute("datetime");
30
+ if (!raw) return null;
31
+ const ms = Date.parse(this.#asUtc(raw.trim()));
32
+ return Number.isNaN(ms) ? null : new Date(ms);
33
+ }
34
+ /**
35
+ * Reads a timezone-less date-time as UTC — the documented input contract —
36
+ * since `Date.parse` would otherwise interpret e.g. `"2026-06-08T12:30:00"` in
37
+ * the *runtime's* local zone, contradicting "the server emits UTC". Values that
38
+ * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
39
+ * parsed as UTC) are returned unchanged.
40
+ */
41
+ #asUtc(value) {
42
+ const hasTime = /T\d{2}:\d{2}/.test(value);
43
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(value);
44
+ return hasTime && !hasZone ? `${value}Z` : value;
45
+ }
46
+ /**
47
+ * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
48
+ * keyword applied to *both* date and time; empty (the default) adds no title.
49
+ */
50
+ #title(date) {
51
+ if (this.titleFormatValue.length === 0) return null;
52
+ return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);
53
+ }
54
+ /**
55
+ * Formats `date` with `Intl.DateTimeFormat`, including each style only when it
56
+ * is a valid keyword (so a consumer can show date-only or time-only by clearing
57
+ * the other). Returns `null` when neither style is usable or `Intl` throws, so
58
+ * the caller can leave the authored text untouched.
59
+ */
60
+ #applyFormat(date, dateStyle, timeStyle) {
61
+ const options = {
62
+ dateStyle: toStyle(dateStyle),
63
+ timeStyle: toStyle(timeStyle)
64
+ };
65
+ if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
66
+ if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
67
+ try {
68
+ return new Intl.DateTimeFormat(this.#locale, options).format(date);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** Locale precedence: the value, then the element's `lang`, then the document's. */
74
+ get #locale() {
75
+ return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
76
+ }
77
+ };
78
+
79
+ export { LocalTimeController };
80
+ //# sourceMappingURL=local_time_controller.js.map
81
+ //# sourceMappingURL=local_time_controller.js.map
@@ -0,0 +1,134 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/menu_controller.ts
4
+ var MenuController = class extends Controller {
5
+ static targets = ["trigger", "menu", "item"];
6
+ static actions = [
7
+ "activate",
8
+ "close",
9
+ "onItemKeydown",
10
+ "onTriggerKeydown",
11
+ "open",
12
+ "toggle"
13
+ ];
14
+ /** Starts closed and registers the outside-click listener. */
15
+ connect() {
16
+ this.close();
17
+ document.addEventListener("click", this.#onOutsideClick);
18
+ }
19
+ /** Removes the document-level listener registered in {@link connect}. */
20
+ disconnect() {
21
+ document.removeEventListener("click", this.#onOutsideClick);
22
+ }
23
+ /** Toggles the menu open/closed. Bound via `data-action` (click). */
24
+ toggle() {
25
+ if (this.#isOpen) {
26
+ this.close();
27
+ } else {
28
+ this.open();
29
+ this.#focusFirst();
30
+ }
31
+ }
32
+ /** Opens the menu and reflects the expanded state on the trigger. */
33
+ open() {
34
+ if (!this.hasMenuTarget) return;
35
+ this.menuTarget.hidden = false;
36
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
37
+ }
38
+ /** Closes the menu and reflects the collapsed state on the trigger. */
39
+ close() {
40
+ if (!this.hasMenuTarget) return;
41
+ this.menuTarget.hidden = true;
42
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
43
+ }
44
+ /**
45
+ * Opens the menu with the keyboard per the APG (Down → first, Up → last).
46
+ *
47
+ * Enter/Space are intentionally not handled here: on a native `<button>`
48
+ * trigger the browser turns them into a click, which already runs
49
+ * {@link toggle} (open + focus first item). Handling them again here would
50
+ * open and then immediately re-toggle the menu.
51
+ */
52
+ onTriggerKeydown(event) {
53
+ if (event.key === "ArrowDown") {
54
+ event.preventDefault();
55
+ this.open();
56
+ this.#focusFirst();
57
+ } else if (event.key === "ArrowUp") {
58
+ event.preventDefault();
59
+ this.open();
60
+ this.#focusLast();
61
+ }
62
+ }
63
+ /** Implements roving focus and closing keys inside the menu. */
64
+ onItemKeydown(event) {
65
+ const items = this.#navigableItems;
66
+ const currentIndex = items.indexOf(event.currentTarget);
67
+ switch (event.key) {
68
+ case "ArrowDown":
69
+ event.preventDefault();
70
+ if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();
71
+ break;
72
+ case "ArrowUp":
73
+ event.preventDefault();
74
+ if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();
75
+ break;
76
+ case "Home":
77
+ event.preventDefault();
78
+ items[0]?.focus();
79
+ break;
80
+ case "End":
81
+ event.preventDefault();
82
+ items[items.length - 1]?.focus();
83
+ break;
84
+ case "Escape":
85
+ event.preventDefault();
86
+ this.close();
87
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
88
+ break;
89
+ case "Tab":
90
+ this.close();
91
+ break;
92
+ }
93
+ }
94
+ /** Closes the menu after an item is activated. Bound via `data-action`. */
95
+ activate() {
96
+ this.close();
97
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
98
+ }
99
+ /** Closes the menu when a click lands outside the controller's element. */
100
+ #onOutsideClick = (event) => {
101
+ if (this.#isOpen && !this.element.contains(event.target)) this.close();
102
+ };
103
+ /** Moves focus to the first navigable item (no-op if none). */
104
+ #focusFirst() {
105
+ this.#navigableItems[0]?.focus();
106
+ }
107
+ /** Moves focus to the last navigable item (no-op if none). */
108
+ #focusLast() {
109
+ const items = this.#navigableItems;
110
+ items[items.length - 1]?.focus();
111
+ }
112
+ /** Menu items eligible for roving focus (excludes disabled / hidden). */
113
+ get #navigableItems() {
114
+ return this.itemTargets.filter((item) => this.#isNavigable(item));
115
+ }
116
+ /**
117
+ * An item can take roving focus unless it is `hidden`, `aria-disabled="true"`,
118
+ * or a natively `disabled` form control. CSS-only visibility is not detectable
119
+ * here and is the consumer's responsibility.
120
+ */
121
+ #isNavigable(item) {
122
+ if (item.hasAttribute("hidden")) return false;
123
+ if (item.getAttribute("aria-disabled") === "true") return false;
124
+ return !item.disabled;
125
+ }
126
+ /** Whether the menu is currently visible. */
127
+ get #isOpen() {
128
+ return this.hasMenuTarget && !this.menuTarget.hidden;
129
+ }
130
+ };
131
+
132
+ export { MenuController };
133
+ //# sourceMappingURL=menu_controller.js.map
134
+ //# sourceMappingURL=menu_controller.js.map
@@ -0,0 +1,96 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/meter_controller.ts
4
+
5
+ // src/utils/coerce.ts
6
+ function toFiniteNumber(raw) {
7
+ if (raw === null || raw === void 0 || raw === "") return null;
8
+ const value = typeof raw === "number" ? raw : Number(raw);
9
+ return Number.isFinite(value) ? value : null;
10
+ }
11
+
12
+ // src/controllers/meter_controller.ts
13
+ var MeterController = class extends Controller {
14
+ static targets = ["bar"];
15
+ static values = {
16
+ value: { type: Number, default: 0 },
17
+ min: { type: Number, default: 0 },
18
+ max: { type: Number, default: 100 },
19
+ low: { type: Number, default: 0 },
20
+ high: { type: Number, default: 0 },
21
+ optimum: { type: Number, default: 0 },
22
+ valueText: { type: String, default: "" }
23
+ };
24
+ static actions = ["setValue"];
25
+ static events = ["change"];
26
+ connect() {
27
+ this.#render();
28
+ }
29
+ /**
30
+ * Updates the measured value from an action param (`amount`) or a
31
+ * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
32
+ * `change` with the value, ratio, and computed segment.
33
+ */
34
+ setValue(event) {
35
+ const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
36
+ if (next === null) return;
37
+ this.valueValue = this.#clamp(next);
38
+ this.#render();
39
+ this.dispatch("change", {
40
+ detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state }
41
+ });
42
+ }
43
+ /** Clamps `raw` into the configured `[min, max]` range. */
44
+ #clamp(raw) {
45
+ return Math.min(this.maxValue, Math.max(this.minValue, raw));
46
+ }
47
+ /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
48
+ get #ratio() {
49
+ const span = this.maxValue - this.minValue;
50
+ if (span <= 0) return 0;
51
+ return (this.#clamp(this.valueValue) - this.minValue) / span;
52
+ }
53
+ /** Whether a threshold attribute is present (absent = no threshold). */
54
+ #hasThreshold(name) {
55
+ return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
56
+ }
57
+ /**
58
+ * Classifies the value into a `low`/`medium`/`high` segment. Values at or
59
+ * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.
60
+ * With neither threshold present, everything is `medium`.
61
+ */
62
+ get #state() {
63
+ const value = this.#clamp(this.valueValue);
64
+ if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
65
+ if (this.#hasThreshold("high") && value >= this.highValue) return "high";
66
+ return "medium";
67
+ }
68
+ /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */
69
+ #render() {
70
+ const value = this.#clamp(this.valueValue);
71
+ this.element.setAttribute("aria-valuemin", String(this.minValue));
72
+ this.element.setAttribute("aria-valuemax", String(this.maxValue));
73
+ this.element.setAttribute("aria-valuenow", String(value));
74
+ this.element.style.setProperty("--stimeo-meter-ratio", String(this.#ratio));
75
+ this.element.setAttribute("data-state", this.#state);
76
+ this.#applyValueText(value);
77
+ }
78
+ /**
79
+ * Sets `aria-valuetext` from the consumer-provided template, substituting
80
+ * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library;
81
+ * cleared when no template is given.
82
+ */
83
+ #applyValueText(value) {
84
+ if (this.valueTextValue.length === 0) {
85
+ this.element.removeAttribute("aria-valuetext");
86
+ return;
87
+ }
88
+ const percent = Math.round(this.#ratio * 100);
89
+ const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", this.#state);
90
+ this.element.setAttribute("aria-valuetext", text);
91
+ }
92
+ };
93
+
94
+ export { MeterController };
95
+ //# sourceMappingURL=meter_controller.js.map
96
+ //# sourceMappingURL=meter_controller.js.map
@@ -0,0 +1,131 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/nested_form_controller.ts
4
+ var NestedFormController = class extends Controller {
5
+ static targets = ["list", "template", "add", "remove", "destroyFlag"];
6
+ static values = {
7
+ min: { type: Number, default: 0 },
8
+ max: { type: Number, default: 0 },
9
+ indexPlaceholder: { type: String, default: "__INDEX__" },
10
+ announce: { type: Boolean, default: true },
11
+ countMessage: { type: String, default: "" }
12
+ };
13
+ static actions = ["add"];
14
+ static events = ["add", "remove"];
15
+ /** Monotonic source for unique row indices; never a row-state counter. */
16
+ #lastIndex = 0;
17
+ /** Delegated click handler for the per-row remove buttons (dynamic-safe). */
18
+ #onClick = (event) => {
19
+ const target = event.target;
20
+ const button = target?.closest('[data-stimeo--nested-form-target="remove"]');
21
+ if (!button || !this.element.contains(button)) return;
22
+ const row = this.#rowContaining(button);
23
+ if (row) this.#removeRow(row);
24
+ };
25
+ connect() {
26
+ this.element.addEventListener("click", this.#onClick);
27
+ this.#refresh();
28
+ }
29
+ disconnect() {
30
+ this.element.removeEventListener("click", this.#onClick);
31
+ }
32
+ /**
33
+ * Clones the template row, replaces the index placeholder with a unique value,
34
+ * appends it, focuses its first control, and announces the new count. No-ops at
35
+ * `max`.
36
+ */
37
+ add() {
38
+ if (!this.hasTemplateTarget || !this.hasListTarget || this.#atMax) return;
39
+ const index = this.#nextIndex();
40
+ const markup = this.templateTarget.innerHTML.replaceAll(
41
+ this.indexPlaceholderValue,
42
+ String(index)
43
+ );
44
+ this.listTarget.insertAdjacentHTML("beforeend", markup);
45
+ const row = this.listTarget.lastElementChild;
46
+ if (!row) return;
47
+ this.#refresh();
48
+ this.#firstControl(row)?.focus();
49
+ this.dispatch("add", { detail: { index, element: row } });
50
+ this.#announce();
51
+ }
52
+ /**
53
+ * Removes a row: a persisted row (one carrying a `destroyFlag`) has its flag set
54
+ * to `1` and is hidden so Rails destroys it on submit; an unsaved row is dropped
55
+ * from the DOM. Returns focus to a neighboring row. No-ops at `min`.
56
+ */
57
+ #removeRow(row) {
58
+ if (this.#effectiveRows.length <= this.minValue) return;
59
+ const neighbors = this.#effectiveRows;
60
+ const position = neighbors.indexOf(row);
61
+ const neighbor = neighbors[position + 1] ?? neighbors[position - 1] ?? null;
62
+ const flag = row.querySelector(
63
+ '[data-stimeo--nested-form-target="destroyFlag"]'
64
+ );
65
+ const persisted = flag !== null;
66
+ if (persisted) {
67
+ flag.value = "1";
68
+ row.hidden = true;
69
+ } else {
70
+ row.remove();
71
+ }
72
+ this.#refresh();
73
+ const focusTarget = neighbor ? this.#firstControl(neighbor) : this.hasAddTarget ? this.addTarget : null;
74
+ focusTarget?.focus();
75
+ this.dispatch("remove", { detail: { element: row, persisted } });
76
+ this.#announce();
77
+ }
78
+ /** Recomputes the live count and the min/max state hooks from the DOM. */
79
+ #refresh() {
80
+ const count = this.#effectiveRows.length;
81
+ this.element.setAttribute("data-nested-count", String(count));
82
+ this.#reflect("data-nested-at-max", this.maxValue > 0 && count >= this.maxValue);
83
+ this.#reflect("data-nested-at-min", count <= this.minValue);
84
+ if (this.hasAddTarget) this.addTarget.disabled = this.#atMax;
85
+ }
86
+ /** Bridges the count change to the shared announcer when configured. */
87
+ #announce() {
88
+ if (!this.announceValue || !this.countMessageValue) return;
89
+ const message = this.countMessageValue.replaceAll(
90
+ "{count}",
91
+ String(this.#effectiveRows.length)
92
+ );
93
+ window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
94
+ }
95
+ /** Sets `attribute` to `"true"` when `on`, else removes it. */
96
+ #reflect(attribute, on) {
97
+ if (on) this.element.setAttribute(attribute, "true");
98
+ else this.element.removeAttribute(attribute);
99
+ }
100
+ /** A strictly-increasing unique index (collision-free even on rapid adds). */
101
+ #nextIndex() {
102
+ const index = Math.max(Date.now(), this.#lastIndex + 1);
103
+ this.#lastIndex = index;
104
+ return index;
105
+ }
106
+ get #atMax() {
107
+ return this.maxValue > 0 && this.#effectiveRows.length >= this.maxValue;
108
+ }
109
+ /** Direct child rows of the list that are not flagged for destruction. */
110
+ get #effectiveRows() {
111
+ return Array.from(this.listTarget.children).filter((row) => !row.hidden);
112
+ }
113
+ /** The nearest ancestor of `el` that is a direct child of the list, else null. */
114
+ #rowContaining(el) {
115
+ let node = el;
116
+ while (node && node.parentElement !== this.listTarget) {
117
+ node = node.parentElement;
118
+ }
119
+ return node;
120
+ }
121
+ /** First visible focusable control inside `row` (skips hidden inputs). */
122
+ #firstControl(row) {
123
+ return row.querySelector(
124
+ 'input:not([type="hidden"]), select, textarea, button, [tabindex]'
125
+ );
126
+ }
127
+ };
128
+
129
+ export { NestedFormController };
130
+ //# sourceMappingURL=nested_form_controller.js.map
131
+ //# sourceMappingURL=nested_form_controller.js.map
@@ -0,0 +1,126 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/network_status_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/network_status_controller.ts
59
+ var NetworkStatusController = class extends Controller {
60
+ static targets = ["offline", "online"];
61
+ static values = {
62
+ onlineAutoHide: { type: Number, default: 0 }
63
+ };
64
+ static events = ["change"];
65
+ #timers = new SafeTimeout();
66
+ /** Last known connectivity; guards against duplicate-state re-announcements. */
67
+ #online = true;
68
+ /** Banner text captured from the markup so transitions can re-write it. */
69
+ #offlineMessage = "";
70
+ #onlineMessage = "";
71
+ #handleOnline = () => this.#update(true);
72
+ #handleOffline = () => this.#update(false);
73
+ connect() {
74
+ this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
75
+ this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
76
+ if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
77
+ if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
78
+ this.#online = navigator.onLine;
79
+ this.element.setAttribute("data-state", this.#online ? "online" : "offline");
80
+ if (!this.#online) this.#showOffline();
81
+ window.addEventListener("online", this.#handleOnline);
82
+ window.addEventListener("offline", this.#handleOffline);
83
+ }
84
+ disconnect() {
85
+ window.removeEventListener("online", this.#handleOnline);
86
+ window.removeEventListener("offline", this.#handleOffline);
87
+ this.#timers.clearAll();
88
+ }
89
+ /** Applies a connectivity transition, guarded against duplicate states. */
90
+ #update(online) {
91
+ if (online === this.#online) return;
92
+ this.#online = online;
93
+ this.element.setAttribute("data-state", online ? "online" : "offline");
94
+ if (online) {
95
+ this.#showOnline();
96
+ } else {
97
+ this.#showOffline();
98
+ }
99
+ this.dispatch("change", { detail: { online } });
100
+ }
101
+ /** Shows the offline banner and hides the recovery banner. */
102
+ #showOffline() {
103
+ this.#timers.clearAll();
104
+ if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
105
+ if (this.hasOfflineTarget) {
106
+ this.offlineTarget.textContent = this.#offlineMessage;
107
+ this.offlineTarget.hidden = false;
108
+ }
109
+ }
110
+ /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
111
+ #showOnline() {
112
+ if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
113
+ if (!this.hasOnlineTarget) return;
114
+ this.onlineTarget.textContent = this.#onlineMessage;
115
+ this.onlineTarget.hidden = false;
116
+ if (this.onlineAutoHideValue > 0) {
117
+ this.#timers.set(() => {
118
+ this.onlineTarget.hidden = true;
119
+ }, this.onlineAutoHideValue);
120
+ }
121
+ }
122
+ };
123
+
124
+ export { NetworkStatusController };
125
+ //# sourceMappingURL=network_status_controller.js.map
126
+ //# sourceMappingURL=network_status_controller.js.map