stimeo-ui 0.4.0 → 0.6.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 (173) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/controllers/announcer_controller.js.map +1 -1
  3. package/dist/controllers/aspect_ratio_controller.d.ts +3 -5
  4. package/dist/controllers/aspect_ratio_controller.js +19 -11
  5. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  6. package/dist/controllers/avatar_controller.d.ts +36 -15
  7. package/dist/controllers/avatar_controller.js +195 -40
  8. package/dist/controllers/avatar_controller.js.map +1 -1
  9. package/dist/controllers/breadcrumb_controller.js +5 -1
  10. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  11. package/dist/controllers/carousel_controller.d.ts +12 -3
  12. package/dist/controllers/carousel_controller.js +90 -10
  13. package/dist/controllers/carousel_controller.js.map +1 -1
  14. package/dist/controllers/checkbox_controller.d.ts +31 -5
  15. package/dist/controllers/checkbox_controller.js +136 -25
  16. package/dist/controllers/checkbox_controller.js.map +1 -1
  17. package/dist/controllers/clipboard_controller.js +8 -3
  18. package/dist/controllers/clipboard_controller.js.map +1 -1
  19. package/dist/controllers/collapsible_controller.d.ts +1 -1
  20. package/dist/controllers/collapsible_controller.js +4 -1
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +9 -3
  23. package/dist/controllers/color_picker_controller.js +41 -11
  24. package/dist/controllers/color_picker_controller.js.map +1 -1
  25. package/dist/controllers/combobox_controller.js.map +1 -1
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/context_menu_controller.d.ts +1 -1
  28. package/dist/controllers/context_menu_controller.js +2 -2
  29. package/dist/controllers/context_menu_controller.js.map +1 -1
  30. package/dist/controllers/countdown_controller.d.ts +5 -3
  31. package/dist/controllers/countdown_controller.js +5 -1
  32. package/dist/controllers/countdown_controller.js.map +1 -1
  33. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  34. package/dist/controllers/date_range_picker_controller.js +162 -31
  35. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  36. package/dist/controllers/direct_upload_controller.d.ts +1 -1
  37. package/dist/controllers/direct_upload_controller.js +3 -3
  38. package/dist/controllers/direct_upload_controller.js.map +1 -1
  39. package/dist/controllers/empty_state_controller.d.ts +27 -7
  40. package/dist/controllers/empty_state_controller.js +107 -16
  41. package/dist/controllers/empty_state_controller.js.map +1 -1
  42. package/dist/controllers/file_dropzone_controller.d.ts +7 -1
  43. package/dist/controllers/file_dropzone_controller.js +26 -3
  44. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  45. package/dist/controllers/flash_controller.d.ts +25 -5
  46. package/dist/controllers/flash_controller.js +161 -21
  47. package/dist/controllers/flash_controller.js.map +1 -1
  48. package/dist/controllers/focus_controller.d.ts +2 -3
  49. package/dist/controllers/focus_controller.js.map +1 -1
  50. package/dist/controllers/form_validation_controller.js +8 -2
  51. package/dist/controllers/form_validation_controller.js.map +1 -1
  52. package/dist/controllers/frame_loading_controller.d.ts +19 -2
  53. package/dist/controllers/frame_loading_controller.js +94 -22
  54. package/dist/controllers/frame_loading_controller.js.map +1 -1
  55. package/dist/controllers/highlight_controller.d.ts +8 -4
  56. package/dist/controllers/highlight_controller.js +38 -1
  57. package/dist/controllers/highlight_controller.js.map +1 -1
  58. package/dist/controllers/hover_card_controller.d.ts +2 -2
  59. package/dist/controllers/hover_card_controller.js.map +1 -1
  60. package/dist/controllers/idle_controller.d.ts +5 -4
  61. package/dist/controllers/idle_controller.js +39 -6
  62. package/dist/controllers/idle_controller.js.map +1 -1
  63. package/dist/controllers/listbox_controller.js.map +1 -1
  64. package/dist/controllers/local_time_controller.js +2 -0
  65. package/dist/controllers/local_time_controller.js.map +1 -1
  66. package/dist/controllers/masonry_controller.d.ts +1 -1
  67. package/dist/controllers/masonry_controller.js +1 -1
  68. package/dist/controllers/masonry_controller.js.map +1 -1
  69. package/dist/controllers/menubar_controller.js +5 -3
  70. package/dist/controllers/menubar_controller.js.map +1 -1
  71. package/dist/controllers/meter_controller.js +3 -1
  72. package/dist/controllers/meter_controller.js.map +1 -1
  73. package/dist/controllers/multi_select_controller.d.ts +46 -12
  74. package/dist/controllers/multi_select_controller.js +460 -151
  75. package/dist/controllers/multi_select_controller.js.map +1 -1
  76. package/dist/controllers/network_status_controller.d.ts +15 -13
  77. package/dist/controllers/network_status_controller.js +1 -3
  78. package/dist/controllers/network_status_controller.js.map +1 -1
  79. package/dist/controllers/number_input_controller.d.ts +29 -3
  80. package/dist/controllers/number_input_controller.js +455 -64
  81. package/dist/controllers/number_input_controller.js.map +1 -1
  82. package/dist/controllers/overflow_menu_controller.js +5 -1
  83. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  84. package/dist/controllers/pagination_controller.js +38 -1
  85. package/dist/controllers/pagination_controller.js.map +1 -1
  86. package/dist/controllers/password_strength_controller.d.ts +4 -4
  87. package/dist/controllers/password_strength_controller.js +21 -3
  88. package/dist/controllers/password_strength_controller.js.map +1 -1
  89. package/dist/controllers/persist_controller.d.ts +4 -4
  90. package/dist/controllers/persist_controller.js +24 -5
  91. package/dist/controllers/persist_controller.js.map +1 -1
  92. package/dist/controllers/pointer_drag_controller.js +10 -0
  93. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  94. package/dist/controllers/popover_controller.d.ts +2 -2
  95. package/dist/controllers/popover_controller.js.map +1 -1
  96. package/dist/controllers/portal_controller.d.ts +1 -2
  97. package/dist/controllers/portal_controller.js +10 -0
  98. package/dist/controllers/portal_controller.js.map +1 -1
  99. package/dist/controllers/progress_controller.d.ts +1 -1
  100. package/dist/controllers/progress_controller.js +7 -3
  101. package/dist/controllers/progress_controller.js.map +1 -1
  102. package/dist/controllers/radio_group_controller.d.ts +41 -15
  103. package/dist/controllers/radio_group_controller.js +540 -56
  104. package/dist/controllers/radio_group_controller.js.map +1 -1
  105. package/dist/controllers/range_slider_controller.d.ts +23 -0
  106. package/dist/controllers/range_slider_controller.js +385 -94
  107. package/dist/controllers/range_slider_controller.js.map +1 -1
  108. package/dist/controllers/rating_controller.d.ts +36 -31
  109. package/dist/controllers/rating_controller.js +274 -89
  110. package/dist/controllers/rating_controller.js.map +1 -1
  111. package/dist/controllers/relative_time_controller.js +2 -0
  112. package/dist/controllers/relative_time_controller.js.map +1 -1
  113. package/dist/controllers/resizable_controller.js +33 -0
  114. package/dist/controllers/resizable_controller.js.map +1 -1
  115. package/dist/controllers/roving_controller.d.ts +4 -0
  116. package/dist/controllers/roving_controller.js +60 -5
  117. package/dist/controllers/roving_controller.js.map +1 -1
  118. package/dist/controllers/scroll_area_controller.d.ts +7 -1
  119. package/dist/controllers/scroll_area_controller.js +155 -23
  120. package/dist/controllers/scroll_area_controller.js.map +1 -1
  121. package/dist/controllers/scroll_visibility_controller.js +33 -0
  122. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  123. package/dist/controllers/separator_controller.js +13 -17
  124. package/dist/controllers/separator_controller.js.map +1 -1
  125. package/dist/controllers/skeleton_controller.d.ts +2 -2
  126. package/dist/controllers/skeleton_controller.js +71 -3
  127. package/dist/controllers/skeleton_controller.js.map +1 -1
  128. package/dist/controllers/slider_controller.d.ts +17 -1
  129. package/dist/controllers/slider_controller.js +325 -48
  130. package/dist/controllers/slider_controller.js.map +1 -1
  131. package/dist/controllers/spinner_controller.d.ts +15 -10
  132. package/dist/controllers/spinner_controller.js +18 -3
  133. package/dist/controllers/spinner_controller.js.map +1 -1
  134. package/dist/controllers/step_indicator_controller.d.ts +1 -1
  135. package/dist/controllers/step_indicator_controller.js +3 -1
  136. package/dist/controllers/step_indicator_controller.js.map +1 -1
  137. package/dist/controllers/stepper_controller.js +2 -0
  138. package/dist/controllers/stepper_controller.js.map +1 -1
  139. package/dist/controllers/switch_controller.d.ts +12 -8
  140. package/dist/controllers/switch_controller.js +162 -18
  141. package/dist/controllers/switch_controller.js.map +1 -1
  142. package/dist/controllers/tags_input_controller.d.ts +41 -11
  143. package/dist/controllers/tags_input_controller.js +356 -120
  144. package/dist/controllers/tags_input_controller.js.map +1 -1
  145. package/dist/controllers/textarea_autosize_controller.js +1 -1
  146. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  147. package/dist/controllers/time_picker_controller.d.ts +42 -10
  148. package/dist/controllers/time_picker_controller.js +296 -104
  149. package/dist/controllers/time_picker_controller.js.map +1 -1
  150. package/dist/controllers/toggle_group_controller.d.ts +39 -13
  151. package/dist/controllers/toggle_group_controller.js +378 -55
  152. package/dist/controllers/toggle_group_controller.js.map +1 -1
  153. package/dist/controllers/toolbar_controller.js +5 -3
  154. package/dist/controllers/toolbar_controller.js.map +1 -1
  155. package/dist/controllers/tooltip_controller.d.ts +2 -2
  156. package/dist/controllers/tooltip_controller.js.map +1 -1
  157. package/dist/controllers/transition_controller.d.ts +2 -3
  158. package/dist/controllers/transition_controller.js.map +1 -1
  159. package/dist/controllers/tree_view_controller.d.ts +1 -2
  160. package/dist/controllers/tree_view_controller.js +24 -4
  161. package/dist/controllers/tree_view_controller.js.map +1 -1
  162. package/dist/index.js +3811 -1048
  163. package/dist/index.js.map +1 -1
  164. package/dist/inspector/cli.d.ts +92 -4
  165. package/dist/inspector/cli.js +174 -6
  166. package/dist/inspector/cli.js.map +1 -1
  167. package/dist/inspector/cli_bin.js +234 -8
  168. package/dist/inspector/cli_bin.js.map +1 -1
  169. package/dist/inspector/examples.json +24 -24
  170. package/dist/inspector/manifest.json +573 -59
  171. package/dist/positioning/index.d.ts +2 -2
  172. package/dist/positioning/index.js.map +1 -1
  173. package/package.json +3 -3
@@ -18,9 +18,46 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/microtask_coalescer.ts
22
+ var MicrotaskCoalescer = class {
23
+ #run;
24
+ #queued = false;
25
+ #active = false;
26
+ #generation = 0;
27
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
28
+ constructor(run) {
29
+ this.#run = run;
30
+ }
31
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
32
+ activate() {
33
+ this.#active = true;
34
+ }
35
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
36
+ cancel() {
37
+ this.#active = false;
38
+ this.#queued = false;
39
+ this.#generation += 1;
40
+ }
41
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
42
+ schedule() {
43
+ if (!this.#active || this.#queued) return;
44
+ this.#queued = true;
45
+ const generation = this.#generation;
46
+ queueMicrotask(() => {
47
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
48
+ this.#queued = false;
49
+ this.#run();
50
+ });
51
+ }
52
+ };
53
+
21
54
  // src/controllers/time_picker_controller.ts
22
55
  var AM = 0;
23
56
  var PM = 1;
57
+ var SECONDS_PER_MINUTE = 60;
58
+ var SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
59
+ var SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
60
+ var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
24
61
  var TimePickerController = class extends Controller {
25
62
  static targets = ["segment", "field"];
26
63
  static values = {
@@ -30,35 +67,80 @@ var TimePickerController = class extends Controller {
30
67
  wrap: { type: Boolean, default: true }
31
68
  };
32
69
  static actions = ["onKeydown"];
33
- static events = ["change"];
34
- /** Current numeric value per segment kind (hours are the *displayed* hours). */
35
- #state = { hour: 0, minute: 0, second: 0, meridiem: AM };
70
+ static events = ["change", "reconcile"];
71
+ /** Collapses target, Value, and retained-attribute morphs into one silent render. */
72
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
73
+ /** Canonical state; the displayed hour and meridiem are derived from this value. */
74
+ #state = { hour: 0, minute: 0, second: 0 };
36
75
  /** Direct-entry digit buffer and the segment it belongs to. */
37
76
  #typeBuffer = "";
38
77
  #typeSegment = null;
39
- /** Last composed field value, to suppress duplicate `change` dispatches. */
78
+ /** Last composed value, used to suppress duplicate user notifications. */
40
79
  #lastValue = "";
41
- /** Seeds each segment from its initial `aria-valuenow` and syncs the field. */
80
+ #observer = null;
81
+ #connected = false;
82
+ #domDirty = false;
83
+ /** Seeds state from the DOM, renders canonical ARIA, and starts morph observation. */
42
84
  connect() {
43
- for (const segment of this.segmentTargets) {
44
- const kind = this.#kindOf(segment);
45
- if (!kind) continue;
46
- const now = Number(segment.getAttribute("aria-valuenow"));
47
- const { min, max } = this.#bounds(kind);
48
- this.#state[kind] = Number.isFinite(now) ? Math.min(max, Math.max(min, now)) : min;
49
- }
50
- for (const segment of this.segmentTargets) this.#renderSegment(segment);
51
- this.#syncField(false);
85
+ this.#connected = true;
86
+ this.#reconcile.activate();
87
+ this.element.addEventListener("focusout", this.#onFocusOut);
88
+ this.#domDirty = false;
89
+ this.#adoptDomState();
90
+ this.#render();
91
+ this.#observeMutations();
92
+ }
93
+ /** Releases the observer, listener, queued reconciliation, and transient input state. */
94
+ disconnect() {
95
+ this.#connected = false;
96
+ this.#reconcile.cancel();
97
+ this.element.removeEventListener("focusout", this.#onFocusOut);
98
+ this.#observer?.disconnect();
99
+ this.#observer = null;
100
+ this.#domDirty = false;
101
+ this.#clearTypeBuffer();
102
+ this.#lastValue = "";
103
+ }
104
+ /** Re-renders the canonical instant when 12/24-hour presentation changes at runtime. */
105
+ hourCycleValueChanged() {
106
+ this.#reconcile.schedule();
107
+ }
108
+ /** Recomposes the optional seconds portion after a runtime Value change. */
109
+ secondsValueChanged() {
110
+ this.#reconcile.schedule();
111
+ }
112
+ /** Adopts a segment inserted or replaced by a Turbo morph. */
113
+ segmentTargetConnected() {
114
+ this.#domDirty = true;
115
+ this.#clearTypeBuffer();
116
+ this.#reconcile.schedule();
117
+ }
118
+ /** Rebuilds state after a segment leaves the retained controller element. */
119
+ segmentTargetDisconnected() {
120
+ this.#domDirty = true;
121
+ this.#clearTypeBuffer();
122
+ this.#reconcile.schedule();
123
+ }
124
+ /** Reflects the current composed value into a field added or replaced at runtime. */
125
+ fieldTargetConnected() {
126
+ this.#reconcile.schedule();
127
+ }
128
+ /** Reconciles silently when the optional field leaves at runtime. */
129
+ fieldTargetDisconnected() {
130
+ this.#reconcile.schedule();
52
131
  }
53
- /** Handles stepping, inter-segment focus moves, jumps, and direct entry. */
132
+ /** Handles APG stepping, inter-segment focus moves, jumps, and direct entry. */
54
133
  onKeydown(event) {
55
134
  if (isReservedArrowChord(event)) return;
56
135
  const segment = event.target?.closest(
57
- "[data-stimeo--time-picker-target='segment']"
136
+ "[data-stimeo--time-picker-target~='segment']"
58
137
  );
59
- const kind = segment ? this.#kindOf(segment) : null;
60
- if (!segment || !kind) return;
61
- switch (logicalArrowKey(event.key, this.element)) {
138
+ if (!segment || !this.segmentTargets.includes(segment)) return;
139
+ const kind = this.#kindOf(segment);
140
+ if (!kind) return;
141
+ const key = logicalArrowKey(event.key, this.element);
142
+ if ((key === "Home" || key === "End") && hasModifier(event)) return;
143
+ switch (key) {
62
144
  case "ArrowUp":
63
145
  event.preventDefault();
64
146
  this.#step(kind, this.#delta(kind));
@@ -77,131 +159,202 @@ var TimePickerController = class extends Controller {
77
159
  break;
78
160
  case "Home":
79
161
  event.preventDefault();
80
- this.#set(kind, this.#bounds(kind).min);
162
+ this.#setDisplayed(kind, this.#bounds(kind).min);
163
+ this.#commitRender();
81
164
  break;
82
165
  case "End":
83
166
  event.preventDefault();
84
- this.#set(kind, this.#bounds(kind).max);
167
+ this.#setDisplayed(kind, this.#bounds(kind).max);
168
+ this.#commitRender();
85
169
  break;
86
170
  default:
87
- if (/^[0-9]$/.test(event.key)) {
171
+ if (kind !== "meridiem" && /^[0-9]$/.test(event.key) && !hasModifier(event)) {
88
172
  event.preventDefault();
89
173
  this.#typeDigit(segment, kind, event.key);
174
+ return;
90
175
  }
91
176
  return;
92
177
  }
93
- this.#typeBuffer = "";
94
- this.#typeSegment = null;
178
+ this.#clearTypeBuffer();
95
179
  }
96
- /** The per-step delta: minutes step by `step`, others by 1, meridiem toggles. */
180
+ /** Clears direct-entry state when Tab, Shift+Tab, or pointer focus leaves a segment. */
181
+ #onFocusOut = (event) => {
182
+ const segment = event.target?.closest(
183
+ "[data-stimeo--time-picker-target~='segment']"
184
+ );
185
+ if (!segment || !this.segmentTargets.includes(segment)) return;
186
+ this.#clearTypeBuffer();
187
+ };
188
+ /** Uses the normalized positive-integer minute step; other segments step by one. */
97
189
  #delta(kind) {
98
- return kind === "minute" ? this.stepValue : 1;
190
+ if (kind !== "minute") return 1;
191
+ const step = Math.trunc(this.stepValue);
192
+ return Number.isFinite(step) && step > 0 ? step : 1;
99
193
  }
100
- /** Steps a segment, wrapping at its bounds and carrying over when enabled. */
194
+ /** Mutates one complete action and then renders/notifies its final value once. */
101
195
  #step(kind, delta) {
102
- if (kind === "meridiem") {
103
- this.#set("meridiem", this.#state.meridiem === AM ? PM : AM);
104
- return;
105
- }
106
- const { min, max } = this.#bounds(kind);
107
- const span = max - min + 1;
108
- const raw = this.#state[kind] + delta;
109
- if (raw > max || raw < min) {
110
- if (!this.wrapValue) {
111
- this.#set(kind, Math.min(max, Math.max(min, raw)));
112
- return;
113
- }
114
- const wrapped = ((raw - min) % span + span) % span + min;
115
- const carry = Math.floor((raw - min) / span);
116
- this.#set(kind, wrapped);
117
- this.#carry(kind, carry);
118
- return;
196
+ if (kind === "meridiem" && this.wrapValue) {
197
+ this.#state.hour = modulo(this.#state.hour + 12, 24);
198
+ } else if (this.wrapValue) {
199
+ const scale = kind === "hour" ? SECONDS_PER_HOUR : kind === "minute" ? SECONDS_PER_MINUTE : 1;
200
+ this.#setFromSeconds(this.#secondsOfDay + delta * scale);
201
+ } else if (kind === "hour") {
202
+ this.#state.hour = Math.min(23, Math.max(0, this.#state.hour + delta));
203
+ } else {
204
+ const { min, max } = this.#bounds(kind);
205
+ const displayed = this.#displayValue(kind);
206
+ this.#setDisplayed(kind, Math.min(max, Math.max(min, displayed + delta)));
119
207
  }
120
- this.#set(kind, raw);
121
- }
122
- /** Propagates a wrap carry from `kind` into the next larger segment. */
123
- #carry(kind, amount) {
124
- if (amount === 0) return;
125
- if (kind === "second") this.#step("minute", amount);
126
- else if (kind === "minute") this.#step("hour", amount);
127
- }
128
- /**
129
- * Sets a segment's value (clamped to its `[min, max]` bounds), re-renders it,
130
- * and resyncs the composed field. Clamping here guards the direct-entry path:
131
- * typing `0` into a 12-hour hour (min 1) must not commit an out-of-range
132
- * `aria-valuenow="0"`. The stepping path already passes in-bounds values, so
133
- * the clamp is a no-op there.
134
- */
135
- #set(kind, value) {
136
- const { min, max } = this.#bounds(kind);
137
- this.#state[kind] = Math.min(max, Math.max(min, value));
138
- const segment = this.segmentTargets.find((s) => this.#kindOf(s) === kind);
139
- if (segment) this.#renderSegment(segment);
140
- this.#syncField(true);
208
+ this.#commitRender();
141
209
  }
142
- /** Accumulates a typed digit, committing and advancing after two digits. */
210
+ /** Accumulates a typed digit, rendering once and advancing after completion. */
143
211
  #typeDigit(segment, kind, digit) {
144
- if (kind === "meridiem") return;
145
212
  if (this.#typeSegment !== kind) this.#typeBuffer = "";
146
213
  this.#typeSegment = kind;
147
214
  const { max } = this.#bounds(kind);
148
215
  const candidate = Number(`${this.#typeBuffer}${digit}`);
149
- if (candidate <= max) this.#typeBuffer = `${this.#typeBuffer}${digit}`;
150
- else this.#typeBuffer = digit;
151
- this.#set(kind, Number(this.#typeBuffer));
216
+ this.#typeBuffer = candidate <= max ? `${this.#typeBuffer}${digit}` : digit;
217
+ this.#setDisplayed(kind, Number(this.#typeBuffer));
218
+ this.#commitRender();
152
219
  if (this.#typeBuffer.length >= 2 || Number(this.#typeBuffer) * 10 > max) {
153
- this.#typeBuffer = "";
154
- this.#typeSegment = null;
220
+ this.#clearTypeBuffer();
155
221
  this.#focusSibling(segment, 1);
156
222
  }
157
223
  }
158
- /** Moves focus to the previous/next segment, if one exists. */
224
+ /** Moves focus to the previous/next valid segment, if one exists. */
159
225
  #focusSibling(segment, direction) {
160
- const index = this.segmentTargets.indexOf(segment);
161
- const next = this.segmentTargets[index + direction];
162
- next?.focus();
226
+ const segments = this.segmentTargets.filter((candidate) => this.#kindOf(candidate) !== null);
227
+ const index = segments.indexOf(segment);
228
+ segments[index + direction]?.focus();
163
229
  }
164
- /** Reflects a segment's current value onto its ARIA/text representation. */
230
+ /** Clears the direct-entry buffer and its owning segment together. */
231
+ #clearTypeBuffer() {
232
+ this.#typeBuffer = "";
233
+ this.#typeSegment = null;
234
+ }
235
+ /** Reconciles one coalesced target, Value, or retained-attribute mutation batch. */
236
+ #reconcileDom() {
237
+ if (this.#domDirty) {
238
+ this.#domDirty = false;
239
+ this.#clearTypeBuffer();
240
+ this.#adoptDomState();
241
+ }
242
+ this.#render();
243
+ }
244
+ /** Rebuilds canonical state from the targets; an absent hour represents midnight. */
245
+ #adoptDomState() {
246
+ let displayedHour = 0;
247
+ let meridiem = AM;
248
+ let minute = 0;
249
+ let second = 0;
250
+ for (const segment of this.segmentTargets) {
251
+ const kind = this.#kindOf(segment);
252
+ if (!kind) continue;
253
+ const value = this.#authoredValue(segment, kind);
254
+ if (kind === "hour") displayedHour = value;
255
+ else if (kind === "minute") minute = value;
256
+ else if (kind === "second") second = value;
257
+ else meridiem = value;
258
+ }
259
+ this.#state = {
260
+ hour: this.hourCycleValue === 12 ? displayedHour % 12 + (meridiem === PM ? 12 : 0) : displayedHour,
261
+ minute,
262
+ second
263
+ };
264
+ }
265
+ /** Reads and integer-clamps one authored `aria-valuenow`. */
266
+ #authoredValue(segment, kind) {
267
+ const raw = Number(segment.getAttribute("aria-valuenow"));
268
+ const { min, max } = this.#bounds(kind);
269
+ if (!Number.isFinite(raw)) return min;
270
+ return Math.min(max, Math.max(min, Math.trunc(raw)));
271
+ }
272
+ /** Writes a displayed segment value back into canonical state without rendering. */
273
+ #setDisplayed(kind, raw) {
274
+ const { min, max } = this.#bounds(kind);
275
+ const value = Math.min(max, Math.max(min, Math.trunc(raw)));
276
+ if (kind === "hour") {
277
+ this.#state.hour = this.hourCycleValue === 12 ? value % 12 + (this.#state.hour >= 12 ? 12 : 0) : value;
278
+ } else if (kind === "minute" || kind === "second") {
279
+ this.#state[kind] = value;
280
+ } else {
281
+ this.#state.hour = this.#state.hour % 12 + (value === PM ? 12 : 0);
282
+ }
283
+ }
284
+ /** Converts seconds with day wrapping into the canonical three-unit state. */
285
+ #setFromSeconds(raw) {
286
+ const seconds = modulo(raw, SECONDS_PER_DAY);
287
+ this.#state.hour = Math.floor(seconds / SECONDS_PER_HOUR);
288
+ this.#state.minute = Math.floor(seconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
289
+ this.#state.second = seconds % SECONDS_PER_MINUTE;
290
+ }
291
+ /** Reflects canonical state to every segment without dispatching user events. */
292
+ #renderSegments() {
293
+ this.#withoutObservation(() => {
294
+ for (const segment of this.segmentTargets) this.#renderSegment(segment);
295
+ });
296
+ }
297
+ /**
298
+ * Reflects canonical state during connection or DOM reconciliation.
299
+ *
300
+ * No user edit reaches here, so `change` never fires from this path. A pass
301
+ * that moves the committed value reports `reconcile` instead.
302
+ *
303
+ * @stimeoRenderRoot
304
+ */
305
+ #render() {
306
+ this.#renderSegments();
307
+ const value = this.#composedValue;
308
+ const previous = this.#lastValue;
309
+ this.#writeField(value);
310
+ this.#lastValue = value;
311
+ if (previous !== "" && value !== previous) {
312
+ this.dispatch("reconcile", { detail: { value } });
313
+ }
314
+ }
315
+ /** Reflects one completed user action and dispatches its final change exactly once. */
316
+ #commitRender() {
317
+ this.#renderSegments();
318
+ const value = this.#composedValue;
319
+ const fieldChanged = this.#writeField(value);
320
+ if (fieldChanged) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
321
+ if (value !== this.#lastValue) this.dispatch("change", { detail: { value } });
322
+ this.#lastValue = value;
323
+ }
324
+ /** Reflects one segment's value and controller-owned ARIA bounds/text. */
165
325
  #renderSegment(segment) {
166
326
  const kind = this.#kindOf(segment);
167
327
  if (!kind) return;
168
- const value = this.#state[kind];
169
- if (kind === "meridiem") {
170
- const text2 = value === PM ? "PM" : "AM";
171
- segment.setAttribute("aria-valuenow", String(value));
172
- segment.setAttribute("aria-valuetext", text2);
173
- segment.setAttribute("aria-valuemin", String(AM));
174
- segment.setAttribute("aria-valuemax", String(PM));
175
- segment.textContent = text2;
176
- return;
177
- }
328
+ const value = this.#displayValue(kind);
329
+ const text = kind === "meridiem" ? value === PM ? "PM" : "AM" : pad(value);
178
330
  const { min, max } = this.#bounds(kind);
179
- const text = String(value).padStart(2, "0");
180
331
  segment.setAttribute("aria-valuenow", String(value));
181
332
  segment.setAttribute("aria-valuetext", text);
182
333
  segment.setAttribute("aria-valuemin", String(min));
183
334
  segment.setAttribute("aria-valuemax", String(max));
184
335
  segment.textContent = text;
185
336
  }
186
- /** Composes `HH:MM[:SS]` (24-hour) into the hidden field; dispatches `change`. */
187
- #syncField(notify) {
188
- const h24 = this.#hours24();
189
- const parts = [pad(h24), pad(this.#state.minute)];
337
+ /** Writes a composed value to the optional form field and reports whether it changed. */
338
+ #writeField(value) {
339
+ if (!this.hasFieldTarget || this.fieldTarget.value === value) return false;
340
+ this.fieldTarget.value = value;
341
+ return true;
342
+ }
343
+ /** The canonical form value composed as `HH:MM[:SS]`. */
344
+ get #composedValue() {
345
+ const parts = [pad(this.#state.hour), pad(this.#state.minute)];
190
346
  if (this.secondsValue) parts.push(pad(this.#state.second));
191
- const value = parts.join(":");
192
- if (this.hasFieldTarget && this.fieldTarget.value !== value) {
193
- this.fieldTarget.value = value;
194
- }
195
- if (notify && value !== this.#lastValue) this.dispatch("change", { detail: { value } });
196
- this.#lastValue = value;
347
+ return parts.join(":");
197
348
  }
198
- /** Converts the displayed hour (+ meridiem in 12-hour mode) to 24-hour. */
199
- #hours24() {
200
- if (this.hourCycleValue !== 12) return this.#state.hour;
201
- const base = this.#state.hour % 12;
202
- return base + (this.#state.meridiem === PM ? 12 : 0);
349
+ /** Returns a canonical unit in the presentation form exposed by its segment. */
350
+ #displayValue(kind) {
351
+ if (kind === "hour") {
352
+ return this.hourCycleValue === 12 ? this.#state.hour % 12 || 12 : this.#state.hour;
353
+ }
354
+ if (kind === "meridiem") return this.#state.hour >= 12 ? PM : AM;
355
+ return this.#state[kind];
203
356
  }
204
- /** The inclusive `[min, max]` bounds for a segment kind. */
357
+ /** The inclusive controller-owned bounds for a segment kind. */
205
358
  #bounds(kind) {
206
359
  switch (kind) {
207
360
  case "hour":
@@ -213,6 +366,38 @@ var TimePickerController = class extends Controller {
213
366
  return { min: AM, max: PM };
214
367
  }
215
368
  }
369
+ /** Watches retained segment state while excluding this controller's own reflections. */
370
+ #observeMutations() {
371
+ const observer = new MutationObserver((records) => {
372
+ const changed = records.some(
373
+ ({ target }) => target instanceof HTMLElement && this.segmentTargets.includes(target)
374
+ );
375
+ if (!changed) return;
376
+ this.#domDirty = true;
377
+ this.#clearTypeBuffer();
378
+ this.#reconcile.schedule();
379
+ });
380
+ this.#observer = observer;
381
+ this.#observeWith(observer);
382
+ }
383
+ /** Registers retained attribute observation on the controller subtree. */
384
+ #observeWith(observer) {
385
+ observer.observe(this.element, {
386
+ subtree: true,
387
+ attributes: true,
388
+ attributeFilter: ["aria-valuenow", "data-segment"]
389
+ });
390
+ }
391
+ /** Temporarily pauses observation so derived ARIA writes cannot become DOM input. */
392
+ #withoutObservation(run) {
393
+ const observer = this.#observer;
394
+ observer?.disconnect();
395
+ try {
396
+ run();
397
+ } finally {
398
+ if (observer && this.#connected) this.#observeWith(observer);
399
+ }
400
+ }
216
401
  /** Reads a segment's declared kind, or null when absent/invalid. */
217
402
  #kindOf(segment) {
218
403
  const kind = segment.getAttribute("data-segment");
@@ -221,7 +406,14 @@ var TimePickerController = class extends Controller {
221
406
  }
222
407
  return null;
223
408
  }
409
+ /** Canonical state as seconds since the start of its nominal day. */
410
+ get #secondsOfDay() {
411
+ return this.#state.hour * SECONDS_PER_HOUR + this.#state.minute * SECONDS_PER_MINUTE + this.#state.second;
412
+ }
224
413
  };
414
+ function modulo(value, modulus) {
415
+ return (value % modulus + modulus) % modulus;
416
+ }
225
417
  function pad(value) {
226
418
  return String(value).padStart(2, "0");
227
419
  }