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,206 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/submit_once_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/submit_once_controller.ts
59
+ var ORIGINAL_LABEL = "data-submit-once-original-label";
60
+ var DISABLED_MARKER = "data-submit-once-disabled";
61
+ var BUTTON_BUSY_LABEL = "data-submit-once-busy-label";
62
+ var SubmitOnceController = class extends Controller {
63
+ static targets = ["submit"];
64
+ static values = {
65
+ busyLabel: { type: String, default: "" },
66
+ timeout: { type: Number, default: 0 },
67
+ restoreFocus: { type: Boolean, default: false }
68
+ };
69
+ static actions = ["start"];
70
+ static events = ["start", "end"];
71
+ #timeouts = new SafeTimeout();
72
+ #timeoutId = null;
73
+ #busy = false;
74
+ #submitter = null;
75
+ #onSubmitEnd = (event) => {
76
+ const success = event.detail?.success;
77
+ this.#restore(success);
78
+ };
79
+ connect() {
80
+ this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
81
+ this.#clearStaleBusy();
82
+ }
83
+ disconnect() {
84
+ this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
85
+ this.#clearTimeout();
86
+ }
87
+ /** Enters the busy state for the submission started by `event`. */
88
+ start(event) {
89
+ if (this.#busy) return;
90
+ this.#busy = true;
91
+ const buttons = this.#buttons;
92
+ const submitter = this.#resolveSubmitter(event, buttons);
93
+ this.#submitter = submitter;
94
+ for (const button of buttons) {
95
+ this.#enterBusy(button, button === submitter);
96
+ }
97
+ this.element.setAttribute("data-submitting", "true");
98
+ this.element.setAttribute("aria-busy", "true");
99
+ this.dispatch("start", { detail: {} });
100
+ if (this.timeoutValue > 0) {
101
+ this.#timeoutId = this.#timeouts.set(() => this.#restore(false), this.timeoutValue);
102
+ }
103
+ }
104
+ /** Restores the non-busy state, re-enabling buttons and putting labels back. */
105
+ #restore(success) {
106
+ if (!this.#busy) return;
107
+ this.#busy = false;
108
+ this.#clearTimeout();
109
+ for (const button of this.#buttons) {
110
+ this.#exitBusy(button);
111
+ }
112
+ this.element.removeAttribute("data-submitting");
113
+ this.element.removeAttribute("aria-busy");
114
+ this.dispatch("end", { detail: { success } });
115
+ if (this.restoreFocusValue && this.#submitter) {
116
+ this.#submitter.focus();
117
+ }
118
+ this.#submitter = null;
119
+ }
120
+ /** Disables a button, marks it busy, and swaps the trigger's label. */
121
+ #enterBusy(button, isTrigger) {
122
+ if (!button.disabled) {
123
+ button.disabled = true;
124
+ button.setAttribute(DISABLED_MARKER, "true");
125
+ button.setAttribute("aria-busy", "true");
126
+ }
127
+ if (!isTrigger) return;
128
+ const label = button.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;
129
+ if (label.length === 0) return;
130
+ if (!button.hasAttribute(ORIGINAL_LABEL)) {
131
+ button.setAttribute(ORIGINAL_LABEL, this.#getLabel(button));
132
+ }
133
+ this.#setLabel(button, label);
134
+ }
135
+ /** Re-enables a button we disabled and restores its parked label, if any. */
136
+ #exitBusy(button) {
137
+ if (button.hasAttribute(DISABLED_MARKER)) {
138
+ button.disabled = false;
139
+ button.removeAttribute(DISABLED_MARKER);
140
+ button.removeAttribute("aria-busy");
141
+ }
142
+ const original = button.getAttribute(ORIGINAL_LABEL);
143
+ if (original !== null) {
144
+ this.#setLabel(button, original);
145
+ button.removeAttribute(ORIGINAL_LABEL);
146
+ }
147
+ }
148
+ /** Resets busy state left in a restored cache snapshot without firing events. */
149
+ #clearStaleBusy() {
150
+ this.#busy = false;
151
+ if (this.element.hasAttribute("data-submitting")) {
152
+ this.element.removeAttribute("data-submitting");
153
+ this.element.removeAttribute("aria-busy");
154
+ }
155
+ for (const button of this.#buttons) {
156
+ this.#exitBusy(button);
157
+ }
158
+ }
159
+ /** The triggering button: the event's submitter when ours, else the first button. */
160
+ #resolveSubmitter(event, buttons) {
161
+ const submitter = event.submitter;
162
+ if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {
163
+ return submitter;
164
+ }
165
+ return buttons[0] ?? null;
166
+ }
167
+ /** The controlled buttons: `submit` targets, or the form's native submit controls. */
168
+ get #buttons() {
169
+ if (this.hasSubmitTarget) return this.submitTargets;
170
+ return Array.from(
171
+ this.element.querySelectorAll('button[type="submit"], input[type="submit"]')
172
+ );
173
+ }
174
+ #isSubmitButton(el) {
175
+ return el instanceof HTMLButtonElement || el instanceof HTMLInputElement;
176
+ }
177
+ /** Reads a button's visible label (input value, aria-label, or text). */
178
+ #getLabel(button) {
179
+ if (button instanceof HTMLInputElement) return button.value;
180
+ const aria = button.getAttribute("aria-label");
181
+ if (aria !== null) return aria;
182
+ return button.textContent ?? "";
183
+ }
184
+ /** Writes a button's visible label through the same channel it was read from. */
185
+ #setLabel(button, text) {
186
+ if (button instanceof HTMLInputElement) {
187
+ button.value = text;
188
+ return;
189
+ }
190
+ if (button.hasAttribute("aria-label")) {
191
+ button.setAttribute("aria-label", text);
192
+ return;
193
+ }
194
+ button.textContent = text;
195
+ }
196
+ #clearTimeout() {
197
+ if (this.#timeoutId !== null) {
198
+ this.#timeouts.clear(this.#timeoutId);
199
+ this.#timeoutId = null;
200
+ }
201
+ }
202
+ };
203
+
204
+ export { SubmitOnceController };
205
+ //# sourceMappingURL=submit_once_controller.js.map
206
+ //# sourceMappingURL=submit_once_controller.js.map
@@ -0,0 +1,50 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/switch_controller.ts
4
+ var SwitchController = class extends Controller {
5
+ static actions = ["onKeydown", "toggle"];
6
+ static events = ["changed"];
7
+ /** Ensures the switch exposes a role and is keyboard-reachable. */
8
+ connect() {
9
+ if (!this.element.hasAttribute("role")) {
10
+ this.element.setAttribute("role", "switch");
11
+ }
12
+ if (!this.element.hasAttribute("aria-checked")) {
13
+ this.element.setAttribute("aria-checked", "false");
14
+ }
15
+ if (!(this.element instanceof HTMLButtonElement) && !this.element.hasAttribute("tabindex")) {
16
+ this.element.setAttribute("tabindex", "0");
17
+ }
18
+ }
19
+ /** Toggles the checked state. Bound via `data-action` (click). */
20
+ toggle() {
21
+ this.#checked = !this.#checked;
22
+ }
23
+ /**
24
+ * Activates the switch on Space/Enter for non-native hosts and prevents the
25
+ * default Space scroll. Bound via `data-action` (keydown). Native `<button>`
26
+ * hosts are skipped because the browser already turns Space/Enter into a click,
27
+ * which would otherwise toggle the switch twice.
28
+ */
29
+ onKeydown(event) {
30
+ if (this.element instanceof HTMLButtonElement) return;
31
+ if (event.repeat) return;
32
+ if (event.key === " " || event.key === "Enter") {
33
+ event.preventDefault();
34
+ this.toggle();
35
+ }
36
+ }
37
+ /** Whether the switch is currently on. */
38
+ get #checked() {
39
+ return this.element.getAttribute("aria-checked") === "true";
40
+ }
41
+ /** Reflects the new state on `aria-checked` and notifies listeners. */
42
+ set #checked(value) {
43
+ this.element.setAttribute("aria-checked", value ? "true" : "false");
44
+ this.dispatch("changed", { detail: { checked: value } });
45
+ }
46
+ };
47
+
48
+ export { SwitchController };
49
+ //# sourceMappingURL=switch_controller.js.map
50
+ //# sourceMappingURL=switch_controller.js.map
@@ -0,0 +1,63 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/tabs_controller.ts
4
+ var TabsController = class extends Controller {
5
+ static targets = ["tab", "panel"];
6
+ static actions = ["onKeydown", "select"];
7
+ /** Selects the initially active tab (the pre-selected one, else the first). */
8
+ connect() {
9
+ const preselected = this.tabTargets.findIndex(
10
+ (tab) => tab.getAttribute("aria-selected") === "true"
11
+ );
12
+ this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });
13
+ }
14
+ /** Selects the clicked tab. Bound via `data-action` (click). */
15
+ select(event) {
16
+ const index = this.tabTargets.indexOf(event.currentTarget);
17
+ if (index !== -1) this.#selectIndex(index, { focus: false });
18
+ }
19
+ /** Implements arrow/Home/End navigation with automatic activation. */
20
+ onKeydown(event) {
21
+ const tabs = this.tabTargets;
22
+ const currentIndex = tabs.indexOf(event.currentTarget);
23
+ if (currentIndex === -1) return;
24
+ let nextIndex = null;
25
+ switch (event.key) {
26
+ case "ArrowRight":
27
+ nextIndex = (currentIndex + 1) % tabs.length;
28
+ break;
29
+ case "ArrowLeft":
30
+ nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
31
+ break;
32
+ case "Home":
33
+ nextIndex = 0;
34
+ break;
35
+ case "End":
36
+ nextIndex = tabs.length - 1;
37
+ break;
38
+ default:
39
+ return;
40
+ }
41
+ event.preventDefault();
42
+ this.#selectIndex(nextIndex, { focus: true });
43
+ }
44
+ /**
45
+ * Activates the tab/panel pair at `index`: updates `aria-selected`, the roving
46
+ * `tabindex`, and panel visibility. Optionally moves focus to the new tab.
47
+ */
48
+ #selectIndex(index, { focus }) {
49
+ this.tabTargets.forEach((tab, i) => {
50
+ const selected = i === index;
51
+ tab.setAttribute("aria-selected", selected ? "true" : "false");
52
+ tab.tabIndex = selected ? 0 : -1;
53
+ });
54
+ this.panelTargets.forEach((panel, i) => {
55
+ panel.hidden = i !== index;
56
+ });
57
+ if (focus) this.tabTargets[index]?.focus();
58
+ }
59
+ };
60
+
61
+ export { TabsController };
62
+ //# sourceMappingURL=tabs_controller.js.map
63
+ //# sourceMappingURL=tabs_controller.js.map
@@ -0,0 +1,72 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/textarea_autosize_controller.ts
4
+ function px(value) {
5
+ const n = Number.parseFloat(value);
6
+ return Number.isNaN(n) ? 0 : n;
7
+ }
8
+ var TextareaAutosizeController = class extends Controller {
9
+ static values = {
10
+ minRows: { type: Number, default: 1 },
11
+ maxRows: { type: Number, default: 0 }
12
+ };
13
+ static actions = ["resize"];
14
+ static events = ["resize"];
15
+ #lastHeight = -1;
16
+ #onInput = () => {
17
+ this.resize();
18
+ };
19
+ connect() {
20
+ this.element.addEventListener("input", this.#onInput);
21
+ this.resize();
22
+ }
23
+ disconnect() {
24
+ this.element.removeEventListener("input", this.#onInput);
25
+ }
26
+ /** Re-measures the content and applies the clamped height. */
27
+ resize() {
28
+ const el = this.element;
29
+ const style = window.getComputedStyle(el);
30
+ const lineHeight = this.#lineHeight(style);
31
+ const paddingV = px(style.paddingTop) + px(style.paddingBottom);
32
+ const borderV = px(style.borderTopWidth) + px(style.borderBottomWidth);
33
+ const borderBox = style.boxSizing === "border-box";
34
+ el.style.height = "auto";
35
+ const contentHeight = Math.max(0, el.scrollHeight - paddingV);
36
+ const rows = Math.max(1, Math.round(contentHeight / lineHeight));
37
+ let targetContent = Math.max(contentHeight, this.minRowsValue * lineHeight);
38
+ let atMax = false;
39
+ if (this.maxRowsValue > 0) {
40
+ const maxContent = this.maxRowsValue * lineHeight;
41
+ if (targetContent > maxContent) {
42
+ targetContent = maxContent;
43
+ atMax = true;
44
+ }
45
+ }
46
+ const boxExtra = borderBox ? paddingV + borderV : 0;
47
+ const height = Math.round(targetContent + boxExtra);
48
+ el.style.height = `${height}px`;
49
+ el.style.overflowY = atMax ? "auto" : "hidden";
50
+ if (atMax) {
51
+ el.setAttribute("data-at-max-rows", "true");
52
+ } else {
53
+ el.removeAttribute("data-at-max-rows");
54
+ }
55
+ el.style.setProperty("--stimeo-textarea-rows", String(rows));
56
+ if (height !== this.#lastHeight) {
57
+ this.#lastHeight = height;
58
+ this.dispatch("resize", { detail: { height, rows } });
59
+ }
60
+ }
61
+ /** Resolved line height, falling back to ~1.2× font-size when `normal`. */
62
+ #lineHeight(style) {
63
+ const lh = px(style.lineHeight);
64
+ if (lh > 0) return lh;
65
+ const fontSize = px(style.fontSize);
66
+ return fontSize > 0 ? fontSize * 1.2 : 16;
67
+ }
68
+ };
69
+
70
+ export { TextareaAutosizeController };
71
+ //# sourceMappingURL=textarea_autosize_controller.js.map
72
+ //# sourceMappingURL=textarea_autosize_controller.js.map
@@ -0,0 +1,154 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/theme_controller.ts
4
+ var MODES = ["light", "dark", "system"];
5
+ var isMode = (value) => typeof value === "string" && MODES.includes(value);
6
+ var ThemeController = class extends Controller {
7
+ static targets = ["option"];
8
+ static values = {
9
+ mode: { type: String, default: "system" },
10
+ storageKey: { type: String, default: "stimeo-theme" },
11
+ target: { type: String, default: "html" }
12
+ };
13
+ static actions = ["set", "toggle"];
14
+ static events = ["change"];
15
+ /** The OS dark-mode query, watched so `system` tracks live changes. */
16
+ #media = null;
17
+ /** Re-resolves while in `system` mode when the OS preference flips. */
18
+ #onMediaChange = () => {
19
+ if (this.modeValue === "system") {
20
+ this.#applyTheme();
21
+ this.#syncControls();
22
+ this.#dispatchChange();
23
+ }
24
+ };
25
+ /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
26
+ #onKeydown = (event) => {
27
+ const options = this.optionTargets;
28
+ if (options.length === 0) return;
29
+ const target = event.target;
30
+ const current = options.indexOf(target);
31
+ if (current === -1) return;
32
+ const last = options.length - 1;
33
+ let next = current;
34
+ switch (event.key) {
35
+ case "ArrowDown":
36
+ case "ArrowRight":
37
+ next = current === last ? 0 : current + 1;
38
+ break;
39
+ case "ArrowUp":
40
+ case "ArrowLeft":
41
+ next = current === 0 ? last : current - 1;
42
+ break;
43
+ case "Home":
44
+ next = 0;
45
+ break;
46
+ case "End":
47
+ next = last;
48
+ break;
49
+ default:
50
+ return;
51
+ }
52
+ event.preventDefault();
53
+ const option = options[next];
54
+ if (!option) return;
55
+ option.focus();
56
+ this.#setMode(this.#optionMode(option));
57
+ };
58
+ connect() {
59
+ const stored = this.#readStored();
60
+ if (stored) this.modeValue = stored;
61
+ this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
62
+ this.#media?.addEventListener("change", this.#onMediaChange);
63
+ if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
64
+ this.#applyTheme();
65
+ this.#syncControls();
66
+ }
67
+ disconnect() {
68
+ this.#media?.removeEventListener("change", this.#onMediaChange);
69
+ this.element.removeEventListener("keydown", this.#onKeydown);
70
+ }
71
+ /** Selects an explicit mode from the `mode` action param (radiogroup option). */
72
+ set(event) {
73
+ const mode = event.params?.mode;
74
+ if (isMode(mode)) this.#setMode(mode);
75
+ }
76
+ /** Toggles light↔dark for the 2-value single-button contract. */
77
+ toggle() {
78
+ this.#setMode(this.#resolved() === "dark" ? "light" : "dark");
79
+ }
80
+ /** Central mode change: persist, apply to the root, resync controls, announce. */
81
+ #setMode(mode) {
82
+ this.modeValue = mode;
83
+ this.#writeStored(mode);
84
+ this.#applyTheme();
85
+ this.#syncControls();
86
+ this.#dispatchChange();
87
+ }
88
+ /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
89
+ #applyTheme() {
90
+ const root = this.#targetElement();
91
+ if (!root) return;
92
+ const resolved = this.#resolved();
93
+ root.setAttribute("data-theme", resolved);
94
+ root.style.setProperty("color-scheme", resolved);
95
+ }
96
+ /** Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync. */
97
+ #syncControls() {
98
+ const options = this.optionTargets;
99
+ if (options.length > 0) {
100
+ let hasTabbable = false;
101
+ for (const option of options) {
102
+ const selected = this.#optionMode(option) === this.modeValue;
103
+ option.setAttribute("aria-checked", String(selected));
104
+ option.tabIndex = selected ? 0 : -1;
105
+ hasTabbable ||= selected;
106
+ }
107
+ const first = options[0];
108
+ if (!hasTabbable && first) first.tabIndex = 0;
109
+ return;
110
+ }
111
+ this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
112
+ }
113
+ /** Emits `change` with the selected mode and the resolved theme. */
114
+ #dispatchChange() {
115
+ this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
116
+ }
117
+ /** The effective theme: the OS preference when `system`, else the mode itself. */
118
+ #resolved() {
119
+ if (this.modeValue === "dark") return "dark";
120
+ if (this.modeValue === "light") return "light";
121
+ return this.#media?.matches ? "dark" : "light";
122
+ }
123
+ /** Reads an option's mode from its action param attribute. */
124
+ #optionMode(option) {
125
+ const mode = option.getAttribute("data-stimeo--theme-mode-param");
126
+ return isMode(mode) ? mode : "system";
127
+ }
128
+ /** Resolves the state-hook target (`<html>` by default). */
129
+ #targetElement() {
130
+ if (this.targetValue === "html" || this.targetValue === ":root")
131
+ return document.documentElement;
132
+ return document.querySelector(this.targetValue);
133
+ }
134
+ /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
135
+ #readStored() {
136
+ try {
137
+ const value = window.localStorage.getItem(this.storageKeyValue);
138
+ return isMode(value) ? value : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+ /** Persists the mode, swallowing storage errors (private mode / quota). */
144
+ #writeStored(mode) {
145
+ try {
146
+ window.localStorage.setItem(this.storageKeyValue, mode);
147
+ } catch {
148
+ }
149
+ }
150
+ };
151
+
152
+ export { ThemeController };
153
+ //# sourceMappingURL=theme_controller.js.map
154
+ //# sourceMappingURL=theme_controller.js.map