stimeo-ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/dist/controllers/aspect_ratio_controller.js +1 -1
  4. data/dist/controllers/breadcrumb_controller.js +5 -1
  5. data/dist/controllers/carousel_controller.js +5 -1
  6. data/dist/controllers/clipboard_controller.js +8 -3
  7. data/dist/controllers/collapsible_controller.js +4 -1
  8. data/dist/controllers/color_picker_controller.js +6 -2
  9. data/dist/controllers/context_menu_controller.js +2 -2
  10. data/dist/controllers/countdown_controller.js +5 -1
  11. data/dist/controllers/date_range_picker_controller.js +5 -1
  12. data/dist/controllers/direct_upload_controller.js +3 -3
  13. data/dist/controllers/empty_state_controller.js +107 -16
  14. data/dist/controllers/flash_controller.js +161 -21
  15. data/dist/controllers/form_validation_controller.js +8 -2
  16. data/dist/controllers/frame_loading_controller.js +94 -22
  17. data/dist/controllers/highlight_controller.js +38 -1
  18. data/dist/controllers/idle_controller.js +13 -2
  19. data/dist/controllers/local_time_controller.js +2 -0
  20. data/dist/controllers/masonry_controller.js +1 -1
  21. data/dist/controllers/meter_controller.js +3 -1
  22. data/dist/controllers/network_status_controller.js +1 -3
  23. data/dist/controllers/number_input_controller.js +191 -24
  24. data/dist/controllers/overflow_menu_controller.js +1 -1
  25. data/dist/controllers/pagination_controller.js +5 -1
  26. data/dist/controllers/password_strength_controller.js +1 -1
  27. data/dist/controllers/pointer_drag_controller.js +10 -0
  28. data/dist/controllers/portal_controller.js +10 -0
  29. data/dist/controllers/progress_controller.js +7 -3
  30. data/dist/controllers/range_slider_controller.js +385 -94
  31. data/dist/controllers/rating_controller.js +2 -0
  32. data/dist/controllers/relative_time_controller.js +2 -0
  33. data/dist/controllers/scroll_area_controller.js +1 -1
  34. data/dist/controllers/separator_controller.js +13 -17
  35. data/dist/controllers/skeleton_controller.js +71 -3
  36. data/dist/controllers/slider_controller.js +325 -48
  37. data/dist/controllers/spinner_controller.js +18 -3
  38. data/dist/controllers/step_indicator_controller.js +3 -1
  39. data/dist/controllers/stepper_controller.js +2 -0
  40. data/dist/controllers/switch_controller.js +162 -18
  41. data/dist/controllers/textarea_autosize_controller.js +1 -1
  42. data/dist/controllers/time_picker_controller.js +6 -3
  43. data/dist/controllers/tree_view_controller.js +19 -1
  44. data/dist/index.js +1214 -307
  45. data/lib/stimeo/ui/version.rb +1 -1
  46. metadata +2 -2
@@ -1,39 +1,181 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/switch_controller.ts
4
+
5
+ // src/utils/default_attribute.ts
6
+ function setDefaultAttribute(element, name, value) {
7
+ if (element.hasAttribute(name)) return false;
8
+ element.setAttribute(name, value);
9
+ return true;
10
+ }
11
+
12
+ // src/utils/focus_candidate.ts
13
+ function inheritsFieldsetDisabled(control) {
14
+ let fieldset = control.closest("fieldset[disabled]");
15
+ while (fieldset) {
16
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
17
+ if (!legend?.contains(control)) return true;
18
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ // src/utils/interactive_host.ts
24
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
25
+ function isInteractiveHost(element) {
26
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
27
+ let current = element;
28
+ while (current) {
29
+ const raw = current.getAttribute("contenteditable");
30
+ if (raw !== null) {
31
+ const value = raw.trim().toLowerCase();
32
+ if (value === "false") return false;
33
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
34
+ }
35
+ current = current.parentElement;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ // src/controllers/switch_controller.ts
41
+ var OBSERVED_ATTRIBUTES = [
42
+ "role",
43
+ "aria-checked",
44
+ "tabindex",
45
+ "type",
46
+ "href",
47
+ "contenteditable",
48
+ "controls"
49
+ ];
50
+ var OBSERVED_ANCESTOR_ATTRIBUTES = ["contenteditable"];
4
51
  var SwitchController = class extends Controller {
5
52
  static actions = ["onKeydown", "toggle"];
6
53
  static events = ["changed"];
54
+ /** Defaults this instance introduced and may therefore remove safely. */
55
+ #ownedDefaults = /* @__PURE__ */ new Set();
56
+ /** Controller writes that must not be mistaken for authored morph changes. */
57
+ #internalAttributeValues = /* @__PURE__ */ new Map();
58
+ #attributeObserver = new MutationObserver((records) => {
59
+ this.#releaseAuthoredDefaults(records);
60
+ this.#attributeObserver.disconnect();
61
+ this.#reconcileDefaults();
62
+ this.#observeAttributes();
63
+ });
64
+ /** Blocks disabled pointer/native-key activation before consumer click handlers. */
65
+ #onClickCapture = (event) => {
66
+ if (!this.#isSupportedHost || !this.#isActivationDisabled) return;
67
+ event.preventDefault();
68
+ event.stopImmediatePropagation();
69
+ };
7
70
  /** Ensures the switch exposes a role and is keyboard-reachable. */
8
71
  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
- }
72
+ this.#reconcileDefaults();
73
+ this.element.addEventListener("click", this.#onClickCapture, true);
74
+ this.#observeAttributes();
75
+ }
76
+ /** Releases the explicit click guard and retained-element attribute observer. */
77
+ disconnect() {
78
+ this.element.removeEventListener("click", this.#onClickCapture, true);
79
+ this.#attributeObserver.disconnect();
80
+ this.#internalAttributeValues.clear();
18
81
  }
19
82
  /** Toggles the checked state. Bound via `data-action` (click). */
20
83
  toggle() {
84
+ if (!this.#isSupportedHost || this.#isActivationDisabled) return;
21
85
  this.#checked = !this.#checked;
22
86
  }
23
87
  /**
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.
88
+ * Activates the switch on Space/Enter for non-native hosts and suppresses key
89
+ * repeat. Bound via `data-action` (keydown). A native `<button type="button">`
90
+ * owns the initial key-to-click synthesis; repeated keydowns are canceled before
91
+ * the browser can synthesize additional clicks.
28
92
  */
29
93
  onKeydown(event) {
30
94
  if (event.defaultPrevented) return;
31
- if (this.element instanceof HTMLButtonElement) return;
32
- if (event.repeat) return;
33
- if (event.key === " " || event.key === "Enter") {
95
+ if (!this.#isSupportedHost) return;
96
+ if (event.key !== " " && event.key !== "Enter") return;
97
+ if (this.#isActivationDisabled) {
34
98
  event.preventDefault();
35
- this.toggle();
99
+ event.stopImmediatePropagation();
100
+ return;
101
+ }
102
+ if (this.element instanceof HTMLButtonElement) {
103
+ if (event.repeat) event.preventDefault();
104
+ return;
105
+ }
106
+ event.preventDefault();
107
+ if (event.repeat) return;
108
+ this.toggle();
109
+ }
110
+ /** Re-adds only missing defaults, preserving every authored attribute value. */
111
+ #reconcileDefaults() {
112
+ if (!this.#isSupportedHost) {
113
+ this.#removeOwnedDefaults();
114
+ return;
115
+ }
116
+ this.#setOwnedDefault("role", "switch");
117
+ this.#setOwnedDefault("aria-checked", "false");
118
+ if (!(this.element instanceof HTMLButtonElement)) {
119
+ this.#setOwnedDefault("tabindex", "0");
120
+ }
121
+ }
122
+ /** Records a newly introduced default without claiming authored markup. */
123
+ #setOwnedDefault(name, value) {
124
+ if (setDefaultAttribute(this.element, name, value)) this.#ownedDefaults.add(name);
125
+ }
126
+ /**
127
+ * Gives ownership back when a retained-element morph authors a value. Missing
128
+ * owned attributes stay owned so reconciliation can restore them.
129
+ */
130
+ #releaseAuthoredDefaults(records) {
131
+ for (const record of records) {
132
+ if (record.target === this.element) {
133
+ const name = record.attributeName;
134
+ if (name && this.#ownedDefaults.has(name)) {
135
+ const value = this.element.getAttribute(name);
136
+ if (value !== null && this.#internalAttributeValues.get(name) !== value) {
137
+ this.#ownedDefaults.delete(name);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ this.#internalAttributeValues.clear();
143
+ }
144
+ /** Removes only defaults introduced by this instance when a host becomes invalid. */
145
+ #removeOwnedDefaults() {
146
+ for (const name of this.#ownedDefaults) this.element.removeAttribute(name);
147
+ this.#ownedDefaults.clear();
148
+ this.#internalAttributeValues.clear();
149
+ }
150
+ /** Watches retained host attributes that Turbo can morph without reconnecting. */
151
+ #observeAttributes() {
152
+ this.#attributeObserver.observe(this.element, {
153
+ attributes: true,
154
+ attributeFilter: OBSERVED_ATTRIBUTES
155
+ });
156
+ let ancestor = this.element.parentElement;
157
+ while (ancestor) {
158
+ this.#attributeObserver.observe(ancestor, {
159
+ attributes: true,
160
+ attributeFilter: OBSERVED_ANCESTOR_ATTRIBUTES
161
+ });
162
+ ancestor = ancestor.parentElement;
163
+ }
164
+ }
165
+ /** Whether the host has one activation model that this controller can own. */
166
+ get #isSupportedHost() {
167
+ if (this.element instanceof HTMLButtonElement) return this.element.type === "button";
168
+ return !isInteractiveHost(this.element);
169
+ }
170
+ /** Whether ARIA or native HTML semantics make the supported switch inoperable. */
171
+ get #isActivationDisabled() {
172
+ let current = this.element;
173
+ while (current) {
174
+ if (current.getAttribute("aria-disabled") === "true") return true;
175
+ current = current.parentElement;
36
176
  }
177
+ if (!(this.element instanceof HTMLButtonElement)) return false;
178
+ return this.element.disabled || inheritsFieldsetDisabled(this.element);
37
179
  }
38
180
  /** Whether the switch is currently on. */
39
181
  get #checked() {
@@ -41,7 +183,9 @@ var SwitchController = class extends Controller {
41
183
  }
42
184
  /** Reflects the new state on `aria-checked` and notifies listeners. */
43
185
  set #checked(value) {
44
- this.element.setAttribute("aria-checked", value ? "true" : "false");
186
+ const reflected = value ? "true" : "false";
187
+ this.#internalAttributeValues.set("aria-checked", reflected);
188
+ this.element.setAttribute("aria-checked", reflected);
45
189
  this.dispatch("changed", { detail: { checked: value } });
46
190
  }
47
191
  };
@@ -52,7 +52,7 @@ var TextareaAutosizeController = class extends Controller {
52
52
  } else {
53
53
  el.removeAttribute("data-at-max-rows");
54
54
  }
55
- el.style.setProperty("--stimeo-textarea-rows", String(rows));
55
+ el.style.setProperty("--stimeo--textarea-rows", String(rows));
56
56
  if (height !== this.#lastHeight) {
57
57
  this.#lastHeight = height;
58
58
  this.dispatch("resize", { detail: { height, rows } });
@@ -121,7 +121,6 @@ var TimePickerController = class extends Controller {
121
121
  }
122
122
  /** Propagates a wrap carry from `kind` into the next larger segment. */
123
123
  #carry(kind, amount) {
124
- if (amount === 0) return;
125
124
  if (kind === "second") this.#step("minute", amount);
126
125
  else if (kind === "minute") this.#step("hour", amount);
127
126
  }
@@ -183,15 +182,19 @@ var TimePickerController = class extends Controller {
183
182
  segment.setAttribute("aria-valuemax", String(max));
184
183
  segment.textContent = text;
185
184
  }
186
- /** Composes `HH:MM[:SS]` (24-hour) into the hidden field; dispatches `change`. */
185
+ /** Composes `HH:MM[:SS]` and notifies both native-form and widget consumers. */
187
186
  #syncField(notify) {
188
187
  const h24 = this.#hours24();
189
188
  const parts = [pad(h24), pad(this.#state.minute)];
190
189
  if (this.secondsValue) parts.push(pad(this.#state.second));
191
190
  const value = parts.join(":");
192
- if (this.hasFieldTarget && this.fieldTarget.value !== value) {
191
+ const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
192
+ if (fieldChanged) {
193
193
  this.fieldTarget.value = value;
194
194
  }
195
+ if (notify && fieldChanged) {
196
+ this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
197
+ }
195
198
  if (notify && value !== this.#lastValue) this.dispatch("change", { detail: { value } });
196
199
  this.#lastValue = value;
197
200
  }
@@ -30,6 +30,23 @@ function canTakeFocus(element) {
30
30
  return !inheritsFieldsetDisabled(element);
31
31
  }
32
32
 
33
+ // src/utils/interactive_host.ts
34
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
35
+ function isInteractiveHost(element) {
36
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
37
+ let current = element;
38
+ while (current) {
39
+ const raw = current.getAttribute("contenteditable");
40
+ if (raw !== null) {
41
+ const value = raw.trim().toLowerCase();
42
+ if (value === "false") return false;
43
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
44
+ }
45
+ current = current.parentElement;
46
+ }
47
+ return false;
48
+ }
49
+
33
50
  // src/utils/roving_tabindex.ts
34
51
  var RovingTabindex = class {
35
52
  /** Returns the current ordered item elements; called on every operation. */
@@ -175,7 +192,7 @@ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel
175
192
  }
176
193
 
177
194
  // src/controllers/tree_view_controller.ts
178
- var NESTED_INTERACTIVE = 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"])';
195
+ var NESTED_INTERACTIVE = INTERACTIVE_HOST_SELECTOR;
179
196
  var TreeViewController = class extends Controller {
180
197
  static targets = ["item", "group"];
181
198
  static actions = ["onClick", "onKeydown", "toggle"];
@@ -436,6 +453,7 @@ var TreeViewController = class extends Controller {
436
453
  if (target.closest('[role="treeitem"]') !== item) return null;
437
454
  const control = target.closest(NESTED_INTERACTIVE);
438
455
  if (control && item.contains(control)) return null;
456
+ if (isInteractiveHost(target)) return null;
439
457
  return item;
440
458
  }
441
459
  /**