stimeo-ui 0.3.0 → 0.4.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 (82) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/controllers/alert_dialog_controller.js +32 -5
  3. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  4. package/dist/controllers/announcer_controller.d.ts +32 -6
  5. package/dist/controllers/announcer_controller.js +255 -20
  6. package/dist/controllers/announcer_controller.js.map +1 -1
  7. package/dist/controllers/color_picker_controller.d.ts +2 -1
  8. package/dist/controllers/color_picker_controller.js +46 -0
  9. package/dist/controllers/color_picker_controller.js.map +1 -1
  10. package/dist/controllers/command_palette_controller.js +32 -5
  11. package/dist/controllers/command_palette_controller.js.map +1 -1
  12. package/dist/controllers/confirm_controller.js +32 -5
  13. package/dist/controllers/confirm_controller.js.map +1 -1
  14. package/dist/controllers/countdown_controller.d.ts +19 -5
  15. package/dist/controllers/countdown_controller.js +112 -12
  16. package/dist/controllers/countdown_controller.js.map +1 -1
  17. package/dist/controllers/date_range_picker_controller.d.ts +4 -1
  18. package/dist/controllers/date_range_picker_controller.js +50 -0
  19. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  20. package/dist/controllers/dialog_controller.js +32 -5
  21. package/dist/controllers/dialog_controller.js.map +1 -1
  22. package/dist/controllers/dismissible_controller.js.map +1 -1
  23. package/dist/controllers/drawer_controller.js +32 -5
  24. package/dist/controllers/drawer_controller.js.map +1 -1
  25. package/dist/controllers/empty_state_controller.d.ts +9 -4
  26. package/dist/controllers/empty_state_controller.js +24 -10
  27. package/dist/controllers/empty_state_controller.js.map +1 -1
  28. package/dist/controllers/focus_controller.js +32 -5
  29. package/dist/controllers/focus_controller.js.map +1 -1
  30. package/dist/controllers/form_validation_controller.js.map +1 -1
  31. package/dist/controllers/frame_loading_controller.d.ts +14 -3
  32. package/dist/controllers/frame_loading_controller.js +177 -15
  33. package/dist/controllers/frame_loading_controller.js.map +1 -1
  34. package/dist/controllers/local_time_controller.d.ts +19 -3
  35. package/dist/controllers/local_time_controller.js +100 -6
  36. package/dist/controllers/local_time_controller.js.map +1 -1
  37. package/dist/controllers/meter_controller.d.ts +22 -2
  38. package/dist/controllers/meter_controller.js +145 -26
  39. package/dist/controllers/meter_controller.js.map +1 -1
  40. package/dist/controllers/network_status_controller.d.ts +13 -1
  41. package/dist/controllers/network_status_controller.js +28 -8
  42. package/dist/controllers/network_status_controller.js.map +1 -1
  43. package/dist/controllers/overflow_menu_controller.js +33 -6
  44. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  45. package/dist/controllers/progress_controller.d.ts +16 -1
  46. package/dist/controllers/progress_controller.js +116 -9
  47. package/dist/controllers/progress_controller.js.map +1 -1
  48. package/dist/controllers/range_slider_controller.d.ts +4 -1
  49. package/dist/controllers/range_slider_controller.js +68 -3
  50. package/dist/controllers/range_slider_controller.js.map +1 -1
  51. package/dist/controllers/rating_controller.d.ts +4 -1
  52. package/dist/controllers/rating_controller.js +53 -0
  53. package/dist/controllers/rating_controller.js.map +1 -1
  54. package/dist/controllers/relative_time_controller.d.ts +13 -0
  55. package/dist/controllers/relative_time_controller.js +133 -12
  56. package/dist/controllers/relative_time_controller.js.map +1 -1
  57. package/dist/controllers/sidebar_controller.d.ts +1 -11
  58. package/dist/controllers/sidebar_controller.js +37 -8
  59. package/dist/controllers/sidebar_controller.js.map +1 -1
  60. package/dist/controllers/skeleton_controller.d.ts +7 -2
  61. package/dist/controllers/skeleton_controller.js +73 -20
  62. package/dist/controllers/skeleton_controller.js.map +1 -1
  63. package/dist/controllers/slider_controller.js +17 -2
  64. package/dist/controllers/slider_controller.js.map +1 -1
  65. package/dist/controllers/spinner_controller.d.ts +37 -4
  66. package/dist/controllers/spinner_controller.js +228 -27
  67. package/dist/controllers/spinner_controller.js.map +1 -1
  68. package/dist/controllers/step_indicator_controller.d.ts +12 -1
  69. package/dist/controllers/step_indicator_controller.js +82 -5
  70. package/dist/controllers/step_indicator_controller.js.map +1 -1
  71. package/dist/controllers/stick_to_bottom_controller.d.ts +26 -2
  72. package/dist/controllers/stick_to_bottom_controller.js +60 -10
  73. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  74. package/dist/index.js +1056 -281
  75. package/dist/index.js.map +1 -1
  76. package/dist/inspector/cli.js +785 -76
  77. package/dist/inspector/cli.js.map +1 -1
  78. package/dist/inspector/cli_bin.js +785 -76
  79. package/dist/inspector/cli_bin.js.map +1 -1
  80. package/dist/inspector/examples.json +5 -5
  81. package/dist/inspector/manifest.json +21 -8
  82. package/package.json +1 -1
@@ -34,13 +34,18 @@ type SetValueEvent = Event & {
34
34
  * @remarks
35
35
  * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),
36
36
  * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment
37
- * is also available as text. Threshold presence is read from the *attributes*
38
- * (an absent attribute means "no threshold"), not from a sentinel value.
37
+ * is also available as text; a consumer that authors `aria-valuetext` itself keeps
38
+ * it instead (see {@link OWNED_VALUE_TEXT}). Threshold presence is read from the
39
+ * *attributes* (an absent attribute means "no threshold"), not from a sentinel value.
39
40
  */
40
41
  declare class MeterController extends Controller<HTMLElement> {
41
42
  #private;
42
43
  static targets: string[];
43
44
  static values: {
45
+ announceText: {
46
+ type: StringConstructor;
47
+ default: string;
48
+ };
44
49
  value: {
45
50
  type: NumberConstructor;
46
51
  default: number;
@@ -81,13 +86,28 @@ declare class MeterController extends Controller<HTMLElement> {
81
86
  highValue: number;
82
87
  optimumValue: number;
83
88
  valueTextValue: string;
89
+ announceTextValue: string;
84
90
  connect(): void;
91
+ /** Closes the window in which a queued repaint may still run. */
92
+ disconnect(): void;
85
93
  /**
86
94
  * Updates the measured value from an action param (`amount`) or a
87
95
  * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
88
96
  * `change` with the value, ratio, and computed segment.
89
97
  */
90
98
  setValue(event: SetValueEvent): void;
99
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
100
+ valueValueChanged(): void;
101
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
102
+ minValueChanged(): void;
103
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
104
+ maxValueChanged(): void;
105
+ /** Repaints when application code (or a Turbo morph) changes `low` at runtime. */
106
+ lowValueChanged(): void;
107
+ /** Repaints when application code (or a Turbo morph) changes `high` at runtime. */
108
+ highValueChanged(): void;
109
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
110
+ valueTextValueChanged(): void;
91
111
  }
92
112
 
93
113
  export { MeterController };
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/meter_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
5
22
  // src/utils/coerce.ts
6
23
  function toFiniteNumber(raw) {
7
24
  if (raw === null || raw === void 0 || raw === "") return null;
@@ -9,10 +26,61 @@ function toFiniteNumber(raw) {
9
26
  return Number.isFinite(value) ? value : null;
10
27
  }
11
28
 
29
+ // src/utils/microtask_coalescer.ts
30
+ var MicrotaskCoalescer = class {
31
+ #run;
32
+ #queued = false;
33
+ #active = false;
34
+ #generation = 0;
35
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
36
+ constructor(run) {
37
+ this.#run = run;
38
+ }
39
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
40
+ activate() {
41
+ this.#active = true;
42
+ }
43
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
44
+ cancel() {
45
+ this.#active = false;
46
+ this.#queued = false;
47
+ this.#generation += 1;
48
+ }
49
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
50
+ schedule() {
51
+ if (!this.#active || this.#queued) return;
52
+ this.#queued = true;
53
+ const generation = this.#generation;
54
+ queueMicrotask(() => {
55
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
56
+ this.#queued = false;
57
+ this.#run();
58
+ });
59
+ }
60
+ };
61
+
62
+ // src/utils/range.ts
63
+ function rangeFraction(value, min, max) {
64
+ const span = max - min;
65
+ if (!(span > 0)) return 0;
66
+ const clamped = Math.min(max, Math.max(min, value));
67
+ let fraction;
68
+ if (Number.isFinite(span)) {
69
+ fraction = (clamped - min) / span;
70
+ } else {
71
+ const scale = Math.max(Math.abs(min), Math.abs(max));
72
+ fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
73
+ }
74
+ if (!Number.isFinite(fraction)) return 0;
75
+ return Math.min(1, Math.max(0, fraction));
76
+ }
77
+
12
78
  // src/controllers/meter_controller.ts
79
+ var OWNED_VALUE_TEXT = "data-stimeo--meter-owns-valuetext";
13
80
  var MeterController = class extends Controller {
14
81
  static targets = ["bar"];
15
82
  static values = {
83
+ announceText: { type: String, default: "" },
16
84
  value: { type: Number, default: 0 },
17
85
  min: { type: Number, default: 0 },
18
86
  max: { type: Number, default: 100 },
@@ -23,9 +91,24 @@ var MeterController = class extends Controller {
23
91
  };
24
92
  static actions = ["setValue"];
25
93
  static events = ["change"];
94
+ /**
95
+ * Collapses a morph that swaps several render inputs at once into one repaint.
96
+ * A single update usually rewrites the whole set, and each Value would otherwise
97
+ * repaint on its own.
98
+ */
99
+ #repaint = new MicrotaskCoalescer(() => {
100
+ this.#render();
101
+ });
102
+ /** The segment last announced, so only a change is read out. */
103
+ #announcedState = null;
26
104
  connect() {
105
+ this.#repaint.activate();
27
106
  this.#render();
28
107
  }
108
+ /** Closes the window in which a queued repaint may still run. */
109
+ disconnect() {
110
+ this.#repaint.cancel();
111
+ }
29
112
  /**
30
113
  * Updates the measured value from an action param (`amount`) or a
31
114
  * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
@@ -35,59 +118,95 @@ var MeterController = class extends Controller {
35
118
  const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
36
119
  if (next === null) return;
37
120
  this.valueValue = this.#clamp(next);
38
- this.#render();
39
- this.dispatch("change", {
40
- detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state }
41
- });
121
+ const reading = this.#render();
122
+ this.dispatch("change", { detail: reading });
123
+ if (reading.state !== this.#announcedState) {
124
+ this.#announcedState = reading.state;
125
+ announce(
126
+ fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value })
127
+ );
128
+ }
129
+ }
130
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
131
+ valueValueChanged() {
132
+ this.#repaint.schedule();
133
+ }
134
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
135
+ minValueChanged() {
136
+ this.#repaint.schedule();
137
+ }
138
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
139
+ maxValueChanged() {
140
+ this.#repaint.schedule();
141
+ }
142
+ /** Repaints when application code (or a Turbo morph) changes `low` at runtime. */
143
+ lowValueChanged() {
144
+ this.#repaint.schedule();
145
+ }
146
+ /** Repaints when application code (or a Turbo morph) changes `high` at runtime. */
147
+ highValueChanged() {
148
+ this.#repaint.schedule();
149
+ }
150
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
151
+ valueTextValueChanged() {
152
+ this.#repaint.schedule();
42
153
  }
43
154
  /** Clamps `raw` into the configured `[min, max]` range. */
44
155
  #clamp(raw) {
45
156
  return Math.min(this.maxValue, Math.max(this.minValue, raw));
46
157
  }
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
158
  /** Whether a threshold attribute is present (absent = no threshold). */
54
159
  #hasThreshold(name) {
55
160
  return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
56
161
  }
57
162
  /**
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`.
163
+ * Classifies `value` into a `low`/`medium`/`high` segment. Values at or below
164
+ * `low` are `low`; at or above `high` are `high`; otherwise `medium`. With
165
+ * neither threshold present, everything is `medium`.
61
166
  */
62
- get #state() {
63
- const value = this.#clamp(this.valueValue);
167
+ #stateOf(value) {
64
168
  if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
65
169
  if (this.#hasThreshold("high") && value >= this.highValue) return "high";
66
170
  return "medium";
67
171
  }
68
- /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */
172
+ /**
173
+ * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
174
+ * The reading is derived once and returned, so the `change` detail reports the
175
+ * same numbers the DOM just received.
176
+ */
69
177
  #render() {
70
178
  const value = this.#clamp(this.valueValue);
179
+ const reading = {
180
+ value,
181
+ ratio: rangeFraction(value, this.minValue, this.maxValue),
182
+ state: this.#stateOf(value)
183
+ };
71
184
  this.element.setAttribute("aria-valuemin", String(this.minValue));
72
185
  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);
186
+ this.element.setAttribute("aria-valuenow", String(reading.value));
187
+ this.element.style.setProperty("--stimeo-meter-ratio", String(reading.ratio));
188
+ this.element.setAttribute("data-state", reading.state);
189
+ this.#applyValueText(reading);
190
+ return reading;
77
191
  }
78
192
  /**
79
193
  * 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.
194
+ * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.
195
+ * With no template the attribute belongs to the consumer, so only a text this
196
+ * controller wrote is taken back ({@link OWNED_VALUE_TEXT}).
82
197
  */
83
- #applyValueText(value) {
198
+ #applyValueText({ value, ratio, state }) {
84
199
  if (this.valueTextValue.length === 0) {
85
- this.element.removeAttribute("aria-valuetext");
200
+ if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {
201
+ this.element.removeAttribute("aria-valuetext");
202
+ this.element.removeAttribute(OWNED_VALUE_TEXT);
203
+ }
86
204
  return;
87
205
  }
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);
206
+ const percent = Math.round(ratio * 100);
207
+ const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", state);
90
208
  this.element.setAttribute("aria-valuetext", text);
209
+ this.element.setAttribute(OWNED_VALUE_TEXT, "");
91
210
  }
92
211
  };
93
212
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/coerce.ts","../../src/controllers/meter_controller.ts"],"names":[],"mappings":"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","file":"meter_controller.js","sourcesContent":["/**\n * Numeric coercion shared by the value-bearing controllers (progress, meter,\n * color-picker).\n *\n * Stimulus already coerces numeric action params to numbers, but a value can also\n * arrive as a string — via a `*:set` CustomEvent `detail`, or an action param\n * whose attribute does not look numeric. Centralizing the parse keeps `setValue`\n * tolerant of either form while rejecting anything that is not a finite number.\n */\n\n/**\n * Coerces `raw` to a finite number, or returns `null` when it is absent, empty,\n * or not parseable. Empty strings are treated as \"no value\" rather than `0`, so a\n * stray blank param cannot silently reset the value.\n */\nexport function toFiniteNumber(raw: number | string | null | undefined): number | null {\n if (raw === null || raw === undefined || raw === \"\") return null;\n const value = typeof raw === \"number\" ? raw : Number(raw);\n return Number.isFinite(value) ? value : null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { toFiniteNumber } from \"../utils/coerce\";\n\n/**\n * Event shape `setValue` accepts: an action param `amount` or a `detail.value`.\n * Both are typed `number | string` because, while Stimulus coerces numeric action\n * params to numbers, a `meter:set` CustomEvent (or a non-numeric-looking param)\n * may carry a string; {@link MeterController.setValue} normalizes either form.\n */\ntype SetValueEvent = Event & {\n params?: { amount?: number | string };\n detail?: { value?: number | string };\n};\n\n/** Threshold segment a value falls into, reflected on `data-state`. */\ntype MeterState = \"low\" | \"medium\" | \"high\";\n\n/**\n * Headless meter behavior backed by the WAI-ARIA `meter` role.\n *\n * Markup contract (identifier: `stimeo--meter`):\n * <div data-controller=\"stimeo--meter\" role=\"meter\" aria-label=\"Disk usage\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"72\"\n * data-stimeo--meter-value-value=\"72\"\n * data-stimeo--meter-low-value=\"50\" data-stimeo--meter-high-value=\"80\">\n * <div data-stimeo--meter-target=\"bar\"></div>\n * </div>\n *\n * A `meter` is a *point-in-time* scalar within a known range (disk usage,\n * battery, score) — distinct from {@link ProgressController}'s task progress.\n * The controller syncs the ARIA value attributes and, when `low`/`high`\n * thresholds are present, classifies the value into a `low`/`medium`/`high`\n * segment on `data-state` so the consumer can color the bar.\n *\n * @remarks\n * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),\n * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment\n * is also available as text. Threshold presence is read from the *attributes*\n * (an absent attribute means \"no threshold\"), not from a sentinel value.\n */\nexport class MeterController extends Controller<HTMLElement> {\n static override targets = [\"bar\"];\n static override values = {\n value: { type: Number, default: 0 },\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n low: { type: Number, default: 0 },\n high: { type: Number, default: 0 },\n optimum: { type: Number, default: 0 },\n valueText: { type: String, default: \"\" },\n };\n static actions = [\"setValue\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly barTarget: HTMLElement;\n declare readonly hasBarTarget: boolean;\n\n declare valueValue: number;\n declare minValue: number;\n declare maxValue: number;\n declare lowValue: number;\n declare highValue: number;\n declare optimumValue: number;\n declare valueTextValue: string;\n\n override connect(): void {\n this.#render();\n }\n\n /**\n * Updates the measured value from an action param (`amount`) or a\n * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches\n * `change` with the value, ratio, and computed segment.\n */\n setValue(event: SetValueEvent): void {\n const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);\n if (next === null) return;\n this.valueValue = this.#clamp(next);\n this.#render();\n this.dispatch(\"change\", {\n detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state },\n });\n }\n\n /** Clamps `raw` into the configured `[min, max]` range. */\n #clamp(raw: number): number {\n return Math.min(this.maxValue, Math.max(this.minValue, raw));\n }\n\n /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */\n get #ratio(): number {\n const span = this.maxValue - this.minValue;\n if (span <= 0) return 0;\n return (this.#clamp(this.valueValue) - this.minValue) / span;\n }\n\n /** Whether a threshold attribute is present (absent = no threshold). */\n #hasThreshold(name: \"low\" | \"high\"): boolean {\n return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);\n }\n\n /**\n * Classifies the value into a `low`/`medium`/`high` segment. Values at or\n * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.\n * With neither threshold present, everything is `medium`.\n */\n get #state(): MeterState {\n const value = this.#clamp(this.valueValue);\n if (this.#hasThreshold(\"low\") && value <= this.lowValue) return \"low\";\n if (this.#hasThreshold(\"high\") && value >= this.highValue) return \"high\";\n return \"medium\";\n }\n\n /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */\n #render(): void {\n const value = this.#clamp(this.valueValue);\n this.element.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.element.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.element.setAttribute(\"aria-valuenow\", String(value));\n this.element.style.setProperty(\"--stimeo-meter-ratio\", String(this.#ratio));\n this.element.setAttribute(\"data-state\", this.#state);\n this.#applyValueText(value);\n }\n\n /**\n * Sets `aria-valuetext` from the consumer-provided template, substituting\n * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library;\n * cleared when no template is given.\n */\n #applyValueText(value: number): void {\n if (this.valueTextValue.length === 0) {\n this.element.removeAttribute(\"aria-valuetext\");\n return;\n }\n const percent = Math.round(this.#ratio * 100);\n const text = this.valueTextValue\n .replaceAll(\"{value}\", String(value))\n .replaceAll(\"{percent}\", String(percent))\n .replaceAll(\"{state}\", this.#state);\n this.element.setAttribute(\"aria-valuetext\", text);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/coerce.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/range.ts","../../src/controllers/meter_controller.ts"],"names":[],"mappings":"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","file":"meter_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","/**\n * Numeric coercion shared by the value-bearing controllers (progress, meter,\n * color-picker).\n *\n * Stimulus already coerces numeric action params to numbers, but a value can also\n * arrive as a string — via a `*:set` CustomEvent `detail`, or an action param\n * whose attribute does not look numeric. Centralizing the parse keeps `setValue`\n * tolerant of either form while rejecting anything that is not a finite number.\n */\n\n/**\n * Coerces `raw` to a finite number, or returns `null` when it is absent, empty,\n * or not parseable. Empty strings are treated as \"no value\" rather than `0`, so a\n * stray blank param cannot silently reset the value.\n */\nexport function toFiniteNumber(raw: number | string | null | undefined): number | null {\n if (raw === null || raw === undefined || raw === \"\") return null;\n const value = typeof raw === \"number\" ? raw : Number(raw);\n return Number.isFinite(value) ? value : null;\n}\n","/**\n * Collapses many target callbacks from one DOM mutation into a single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element, so\n * replacing a list of N options delivers N callbacks — but the useful unit of\n * work is \"reconcile against the DOM that resulted\", once, after the batch has\n * settled. Every controller that owns a reconcilable target set needs the same\n * shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers the *initial* target callbacks\n * ahead of `connect()`. Reconciling there would compute a fallback against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","/**\n * Range normalization shared by the value-bearing controllers (progress, meter,\n * slider, range-slider).\n *\n * Each of them publishes \"where the value sits inside `[min, max]`\" as a CSS\n * custom property the consumer multiplies a track by, and each has to answer the\n * same degenerate cases: empty and inverted ranges cannot express progress, and\n * malformed Number Values can produce `NaN`. Keeping the rule in one place is\n * what makes those answers identical across the four.\n */\n\n/**\n * Fraction of `[min, max]` that `value` occupies, always within `[0, 1]`.\n *\n * `value` is clamped into the range first, so a value outside it reports a full\n * or empty track rather than pushing the fraction past the ends.\n *\n * An empty, inverted, or non-numeric range yields `0`. That is a deliberate\n * floor rather than a computed result: `min === max` would produce `NaN`, and\n * `min > max` would report a full track for a value that is really out of range.\n * Non-finite results are floored for the same reason. These results reach\n * assistive tech, because the same fraction drives the percentage substituted\n * into `aria-valuetext`.\n */\nexport function rangeFraction(value: number, min: number, max: number): number {\n const span = max - min;\n if (!(span > 0)) return 0;\n\n const clamped = Math.min(max, Math.max(min, value));\n let fraction: number;\n if (Number.isFinite(span)) {\n fraction = (clamped - min) / span;\n } else {\n // Scaling preserves the ratio when two finite endpoints straddle zero so\n // widely that their subtraction overflows to Infinity.\n const scale = Math.max(Math.abs(min), Math.abs(max));\n fraction = (clamped / scale - min / scale) / (max / scale - min / scale);\n }\n\n if (!Number.isFinite(fraction)) return 0;\n return Math.min(1, Math.max(0, fraction));\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { toFiniteNumber } from \"../utils/coerce\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { rangeFraction } from \"../utils/range\";\n\n/**\n * Event shape `setValue` accepts: an action param `amount` or a `detail.value`.\n * Both are typed `number | string` because, while Stimulus coerces numeric action\n * params to numbers, a `meter:set` CustomEvent (or a non-numeric-looking param)\n * may carry a string; {@link MeterController.setValue} normalizes either form.\n */\ntype SetValueEvent = Event & {\n params?: { amount?: number | string };\n detail?: { value?: number | string };\n};\n\n/** Threshold segment a value falls into, reflected on `data-state`. */\ntype MeterState = \"low\" | \"medium\" | \"high\";\n\n/** One point-in-time reading: the clamped value with the ratio and segment it implies. */\ntype MeterReading = { value: number; ratio: number; state: MeterState };\n\n/**\n * Marks an `aria-valuetext` this controller wrote, so a render takes back only\n * its own text. `aria-valuetext` is shared: a consumer may author it instead of\n * supplying a template, and that text is what carries the threshold segment to\n * readers who cannot see the colour — clearing it would take the segment with it.\n */\nconst OWNED_VALUE_TEXT = \"data-stimeo--meter-owns-valuetext\";\n\n/**\n * Headless meter behavior backed by the WAI-ARIA `meter` role.\n *\n * Markup contract (identifier: `stimeo--meter`):\n * <div data-controller=\"stimeo--meter\" role=\"meter\" aria-label=\"Disk usage\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"72\"\n * data-stimeo--meter-value-value=\"72\"\n * data-stimeo--meter-low-value=\"50\" data-stimeo--meter-high-value=\"80\">\n * <div data-stimeo--meter-target=\"bar\"></div>\n * </div>\n *\n * A `meter` is a *point-in-time* scalar within a known range (disk usage,\n * battery, score) — distinct from {@link ProgressController}'s task progress.\n * The controller syncs the ARIA value attributes and, when `low`/`high`\n * thresholds are present, classifies the value into a `low`/`medium`/`high`\n * segment on `data-state` so the consumer can color the bar.\n *\n * @remarks\n * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),\n * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment\n * is also available as text; a consumer that authors `aria-valuetext` itself keeps\n * it instead (see {@link OWNED_VALUE_TEXT}). Threshold presence is read from the\n * *attributes* (an absent attribute means \"no threshold\"), not from a sentinel value.\n */\nexport class MeterController extends Controller<HTMLElement> {\n static override targets = [\"bar\"];\n static override values = {\n announceText: { type: String, default: \"\" },\n value: { type: Number, default: 0 },\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n low: { type: Number, default: 0 },\n high: { type: Number, default: 0 },\n optimum: { type: Number, default: 0 },\n valueText: { type: String, default: \"\" },\n };\n static actions = [\"setValue\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly barTarget: HTMLElement;\n declare readonly hasBarTarget: boolean;\n\n declare valueValue: number;\n declare minValue: number;\n declare maxValue: number;\n declare lowValue: number;\n declare highValue: number;\n declare optimumValue: number;\n declare valueTextValue: string;\n declare announceTextValue: string;\n\n /**\n * Collapses a morph that swaps several render inputs at once into one repaint.\n * A single update usually rewrites the whole set, and each Value would otherwise\n * repaint on its own.\n */\n readonly #repaint = new MicrotaskCoalescer(() => {\n this.#render();\n });\n\n /** The segment last announced, so only a change is read out. */\n #announcedState: MeterState | null = null;\n\n override connect(): void {\n this.#repaint.activate();\n this.#render();\n }\n\n /** Closes the window in which a queued repaint may still run. */\n override disconnect(): void {\n this.#repaint.cancel();\n }\n\n /**\n * Updates the measured value from an action param (`amount`) or a\n * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches\n * `change` with the value, ratio, and computed segment.\n */\n setValue(event: SetValueEvent): void {\n const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);\n if (next === null) return;\n this.valueValue = this.#clamp(next);\n const reading = this.#render();\n this.dispatch(\"change\", { detail: reading });\n // Only the segment is news: reading every value would be unusable, and the\n // number itself is already exposed through `aria-valuenow`.\n if (reading.state !== this.#announcedState) {\n this.#announcedState = reading.state;\n announce(\n fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value }),\n );\n }\n }\n\n /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */\n valueValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */\n minValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */\n maxValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `low` at runtime. */\n lowValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `high` at runtime. */\n highValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */\n valueTextValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /** Clamps `raw` into the configured `[min, max]` range. */\n #clamp(raw: number): number {\n return Math.min(this.maxValue, Math.max(this.minValue, raw));\n }\n\n /** Whether a threshold attribute is present (absent = no threshold). */\n #hasThreshold(name: \"low\" | \"high\"): boolean {\n return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);\n }\n\n /**\n * Classifies `value` into a `low`/`medium`/`high` segment. Values at or below\n * `low` are `low`; at or above `high` are `high`; otherwise `medium`. With\n * neither threshold present, everything is `medium`.\n */\n #stateOf(value: number): MeterState {\n if (this.#hasThreshold(\"low\") && value <= this.lowValue) return \"low\";\n if (this.#hasThreshold(\"high\") && value >= this.highValue) return \"high\";\n return \"medium\";\n }\n\n /**\n * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.\n * The reading is derived once and returned, so the `change` detail reports the\n * same numbers the DOM just received.\n */\n #render(): MeterReading {\n const value = this.#clamp(this.valueValue);\n const reading: MeterReading = {\n value,\n ratio: rangeFraction(value, this.minValue, this.maxValue),\n state: this.#stateOf(value),\n };\n this.element.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.element.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.element.setAttribute(\"aria-valuenow\", String(reading.value));\n this.element.style.setProperty(\"--stimeo-meter-ratio\", String(reading.ratio));\n this.element.setAttribute(\"data-state\", reading.state);\n this.#applyValueText(reading);\n return reading;\n }\n\n /**\n * Sets `aria-valuetext` from the consumer-provided template, substituting\n * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.\n * With no template the attribute belongs to the consumer, so only a text this\n * controller wrote is taken back ({@link OWNED_VALUE_TEXT}).\n */\n #applyValueText({ value, ratio, state }: MeterReading): void {\n if (this.valueTextValue.length === 0) {\n if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {\n this.element.removeAttribute(\"aria-valuetext\");\n this.element.removeAttribute(OWNED_VALUE_TEXT);\n }\n return;\n }\n const percent = Math.round(ratio * 100);\n const text = this.valueTextValue\n .replaceAll(\"{value}\", String(value))\n .replaceAll(\"{percent}\", String(percent))\n .replaceAll(\"{state}\", state);\n this.element.setAttribute(\"aria-valuetext\", text);\n this.element.setAttribute(OWNED_VALUE_TEXT, \"\");\n }\n}\n"]}
@@ -23,7 +23,9 @@ import { Controller } from '@hotwired/stimulus';
23
23
  * Behavior only. `navigator.onLine` is the browser's *guess* — it does not
24
24
  * guarantee server reachability, which stays the consumer's job. To make the
25
25
  * announcement reliable across assistive tech (merely un-hiding a static banner
26
- * is flaky), the controller re-writes the banner's text on each transition,
26
+ * is flaky), the controller reveals the banner and *then* re-writes its text on
27
+ * each transition — a region revealed after its content changed is outside the
28
+ * accessibility tree while the text changes, so the write has to come second —
27
29
  * guarded so an unchanged state never re-announces. The event listeners and the
28
30
  * auto-hide timer are removed/cleared on `disconnect()` (Turbo included).
29
31
  */
@@ -31,6 +33,14 @@ declare class NetworkStatusController extends Controller<HTMLElement> {
31
33
  #private;
32
34
  static targets: string[];
33
35
  static values: {
36
+ announceText: {
37
+ type: StringConstructor;
38
+ default: string;
39
+ };
40
+ announceOnlineText: {
41
+ type: StringConstructor;
42
+ default: string;
43
+ };
34
44
  onlineAutoHide: {
35
45
  type: NumberConstructor;
36
46
  default: number;
@@ -42,6 +52,8 @@ declare class NetworkStatusController extends Controller<HTMLElement> {
42
52
  readonly hasOfflineTarget: boolean;
43
53
  readonly hasOnlineTarget: boolean;
44
54
  onlineAutoHideValue: number;
55
+ announceTextValue: string;
56
+ announceOnlineTextValue: string;
45
57
  connect(): void;
46
58
  disconnect(): void;
47
59
  }
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/network_status_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
5
22
  // src/utils/safe_timeout.ts
6
23
  var TimerRegistry = class {
7
24
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -59,20 +76,17 @@ var SafeTimeout = class extends TimerRegistry {
59
76
  var NetworkStatusController = class extends Controller {
60
77
  static targets = ["offline", "online"];
61
78
  static values = {
79
+ announceText: { type: String, default: "" },
80
+ announceOnlineText: { type: String, default: "" },
62
81
  onlineAutoHide: { type: Number, default: 0 }
63
82
  };
64
83
  static events = ["change"];
65
84
  #timers = new SafeTimeout();
66
85
  /** Last known connectivity; guards against duplicate-state re-announcements. */
67
86
  #online = true;
68
- /** Banner text captured from the markup so transitions can re-write it. */
69
- #offlineMessage = "";
70
- #onlineMessage = "";
71
87
  #handleOnline = () => this.#update(true);
72
88
  #handleOffline = () => this.#update(false);
73
89
  connect() {
74
- this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
75
- this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
76
90
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
77
91
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
78
92
  this.#online = navigator.onLine;
@@ -86,7 +100,12 @@ var NetworkStatusController = class extends Controller {
86
100
  window.removeEventListener("offline", this.#handleOffline);
87
101
  this.#timers.clearAll();
88
102
  }
89
- /** Applies a connectivity transition, guarded against duplicate states. */
103
+ /**
104
+ * Applies a connectivity transition, guarded against duplicate states.
105
+ *
106
+ * The event goes out last, so a listener reading `data-state` or a banner's
107
+ * visibility sees the state the transition landed on rather than the previous one.
108
+ */
90
109
  #update(online) {
91
110
  if (online === this.#online) return;
92
111
  this.#online = online;
@@ -96,6 +115,9 @@ var NetworkStatusController = class extends Controller {
96
115
  } else {
97
116
  this.#showOffline();
98
117
  }
118
+ announce(fillTemplate(online ? this.announceOnlineTextValue : this.announceTextValue, {}), {
119
+ assertive: !online
120
+ });
99
121
  this.dispatch("change", { detail: { online } });
100
122
  }
101
123
  /** Shows the offline banner and hides the recovery banner. */
@@ -103,7 +125,6 @@ var NetworkStatusController = class extends Controller {
103
125
  this.#timers.clearAll();
104
126
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
105
127
  if (this.hasOfflineTarget) {
106
- this.offlineTarget.textContent = this.#offlineMessage;
107
128
  this.offlineTarget.hidden = false;
108
129
  }
109
130
  }
@@ -111,7 +132,6 @@ var NetworkStatusController = class extends Controller {
111
132
  #showOnline() {
112
133
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
113
134
  if (!this.hasOnlineTarget) return;
114
- this.onlineTarget.textContent = this.#onlineMessage;
115
135
  this.onlineTarget.hidden = false;
116
136
  if (this.onlineAutoHideValue > 0) {
117
137
  this.#timers.set(() => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/network_status_controller.ts"],"names":[],"mappings":"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","file":"network_status_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless online/offline banner behavior built on the live-region practice (no\n * dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--network-status`):\n * <div data-controller=\"stimeo--network-status\">\n * <div role=\"alert\" hidden data-stimeo--network-status-target=\"offline\">\n * You are offline.\n * </div>\n * <div role=\"status\" hidden data-stimeo--network-status-target=\"online\">\n * Back online.\n * </div>\n * </div>\n *\n * Reads `navigator.onLine` on connect and subscribes to the `window`\n * `online`/`offline` events, toggling the matching banner. The offline banner is\n * `role=\"alert\"` (assertive) because losing connectivity is urgent; the recovery\n * banner is `role=\"status\"` (polite).\n *\n * @remarks\n * Behavior only. `navigator.onLine` is the browser's *guess* — it does not\n * guarantee server reachability, which stays the consumer's job. To make the\n * announcement reliable across assistive tech (merely un-hiding a static banner\n * is flaky), the controller re-writes the banner's text on each transition,\n * guarded so an unchanged state never re-announces. The event listeners and the\n * auto-hide timer are removed/cleared on `disconnect()` (Turbo included).\n */\nexport class NetworkStatusController extends Controller<HTMLElement> {\n static override targets = [\"offline\", \"online\"];\n static override values = {\n onlineAutoHide: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly offlineTarget: HTMLElement;\n declare readonly onlineTarget: HTMLElement;\n declare readonly hasOfflineTarget: boolean;\n declare readonly hasOnlineTarget: boolean;\n\n declare onlineAutoHideValue: number;\n\n readonly #timers = new SafeTimeout();\n\n /** Last known connectivity; guards against duplicate-state re-announcements. */\n #online = true;\n /** Banner text captured from the markup so transitions can re-write it. */\n #offlineMessage = \"\";\n #onlineMessage = \"\";\n\n readonly #handleOnline = (): void => this.#update(true);\n readonly #handleOffline = (): void => this.#update(false);\n\n override connect(): void {\n this.#offlineMessage = this.hasOfflineTarget\n ? (this.offlineTarget.textContent ?? \"\").trim()\n : \"\";\n this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? \"\").trim() : \"\";\n\n // Normalize initial visibility so a missing `hidden` in the markup cannot\n // strand a stale banner (e.g. an offline notice showing while online).\n if (this.hasOfflineTarget) this.offlineTarget.hidden = true;\n if (this.hasOnlineTarget) this.onlineTarget.hidden = true;\n\n this.#online = navigator.onLine;\n // On connect, surface only the offline state; do not flash a \"back online\"\n // banner just because the page loaded while connected.\n this.element.setAttribute(\"data-state\", this.#online ? \"online\" : \"offline\");\n if (!this.#online) this.#showOffline();\n\n window.addEventListener(\"online\", this.#handleOnline);\n window.addEventListener(\"offline\", this.#handleOffline);\n }\n\n override disconnect(): void {\n window.removeEventListener(\"online\", this.#handleOnline);\n window.removeEventListener(\"offline\", this.#handleOffline);\n this.#timers.clearAll();\n }\n\n /** Applies a connectivity transition, guarded against duplicate states. */\n #update(online: boolean): void {\n if (online === this.#online) return;\n this.#online = online;\n this.element.setAttribute(\"data-state\", online ? \"online\" : \"offline\");\n if (online) {\n this.#showOnline();\n } else {\n this.#showOffline();\n }\n this.dispatch(\"change\", { detail: { online } });\n }\n\n /** Shows the offline banner and hides the recovery banner. */\n #showOffline(): void {\n this.#timers.clearAll();\n if (this.hasOnlineTarget) this.onlineTarget.hidden = true;\n if (this.hasOfflineTarget) {\n this.offlineTarget.textContent = this.#offlineMessage;\n this.offlineTarget.hidden = false;\n }\n }\n\n /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */\n #showOnline(): void {\n if (this.hasOfflineTarget) this.offlineTarget.hidden = true;\n if (!this.hasOnlineTarget) return;\n this.onlineTarget.textContent = this.#onlineMessage;\n this.onlineTarget.hidden = false;\n if (this.onlineAutoHideValue > 0) {\n this.#timers.set(() => {\n this.onlineTarget.hidden = true;\n }, this.onlineAutoHideValue);\n }\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/safe_timeout.ts","../../src/controllers/network_status_controller.ts"],"names":[],"mappings":"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","file":"network_status_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless online/offline banner behavior built on the live-region practice (no\n * dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--network-status`):\n * <div data-controller=\"stimeo--network-status\">\n * <div role=\"alert\" hidden data-stimeo--network-status-target=\"offline\">\n * You are offline.\n * </div>\n * <div role=\"status\" hidden data-stimeo--network-status-target=\"online\">\n * Back online.\n * </div>\n * </div>\n *\n * Reads `navigator.onLine` on connect and subscribes to the `window`\n * `online`/`offline` events, toggling the matching banner. The offline banner is\n * `role=\"alert\"` (assertive) because losing connectivity is urgent; the recovery\n * banner is `role=\"status\"` (polite).\n *\n * @remarks\n * Behavior only. `navigator.onLine` is the browser's *guess* — it does not\n * guarantee server reachability, which stays the consumer's job. To make the\n * announcement reliable across assistive tech (merely un-hiding a static banner\n * is flaky), the controller reveals the banner and *then* re-writes its text on\n * each transition — a region revealed after its content changed is outside the\n * accessibility tree while the text changes, so the write has to come second —\n * guarded so an unchanged state never re-announces. The event listeners and the\n * auto-hide timer are removed/cleared on `disconnect()` (Turbo included).\n */\nexport class NetworkStatusController extends Controller<HTMLElement> {\n static override targets = [\"offline\", \"online\"];\n static override values = {\n announceText: { type: String, default: \"\" },\n announceOnlineText: { type: String, default: \"\" },\n onlineAutoHide: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly offlineTarget: HTMLElement;\n declare readonly onlineTarget: HTMLElement;\n declare readonly hasOfflineTarget: boolean;\n declare readonly hasOnlineTarget: boolean;\n\n declare onlineAutoHideValue: number;\n declare announceTextValue: string;\n declare announceOnlineTextValue: string;\n\n readonly #timers = new SafeTimeout();\n\n /** Last known connectivity; guards against duplicate-state re-announcements. */\n #online = true;\n\n readonly #handleOnline = (): void => this.#update(true);\n readonly #handleOffline = (): void => this.#update(false);\n\n override connect(): void {\n // Normalize initial visibility so a missing `hidden` in the markup cannot\n // strand a stale banner (e.g. an offline notice showing while online).\n if (this.hasOfflineTarget) this.offlineTarget.hidden = true;\n if (this.hasOnlineTarget) this.onlineTarget.hidden = true;\n\n this.#online = navigator.onLine;\n // On connect, surface only the offline state; do not flash a \"back online\"\n // banner just because the page loaded while connected.\n this.element.setAttribute(\"data-state\", this.#online ? \"online\" : \"offline\");\n if (!this.#online) this.#showOffline();\n\n window.addEventListener(\"online\", this.#handleOnline);\n window.addEventListener(\"offline\", this.#handleOffline);\n }\n\n override disconnect(): void {\n window.removeEventListener(\"online\", this.#handleOnline);\n window.removeEventListener(\"offline\", this.#handleOffline);\n this.#timers.clearAll();\n }\n\n /**\n * Applies a connectivity transition, guarded against duplicate states.\n *\n * The event goes out last, so a listener reading `data-state` or a banner's\n * visibility sees the state the transition landed on rather than the previous one.\n */\n #update(online: boolean): void {\n if (online === this.#online) return;\n this.#online = online;\n this.element.setAttribute(\"data-state\", online ? \"online\" : \"offline\");\n if (online) {\n this.#showOnline();\n } else {\n this.#showOffline();\n }\n // The banner is the visual half; reading it out is the announcer's job, because\n // a region that is only revealed at the moment of the change is not reliably read.\n announce(fillTemplate(online ? this.announceOnlineTextValue : this.announceTextValue, {}), {\n assertive: !online,\n });\n this.dispatch(\"change\", { detail: { online } });\n }\n\n /** Shows the offline banner and hides the recovery banner. */\n #showOffline(): void {\n this.#timers.clearAll();\n if (this.hasOnlineTarget) this.onlineTarget.hidden = true;\n if (this.hasOfflineTarget) {\n // Reveal before writing: a hidden region is out of the accessibility tree,\n // so a text change made while it is still hidden is never observed.\n this.offlineTarget.hidden = false;\n }\n }\n\n /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */\n #showOnline(): void {\n if (this.hasOfflineTarget) this.offlineTarget.hidden = true;\n if (!this.hasOnlineTarget) return;\n // Reveal before writing, same as the offline banner.\n this.onlineTarget.hidden = false;\n if (this.onlineAutoHideValue > 0) {\n this.#timers.set(() => {\n this.onlineTarget.hidden = true;\n }, this.onlineAutoHideValue);\n }\n }\n}\n"]}
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/overflow_menu_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/focus_candidate.ts
6
35
  function inheritsFieldsetDisabled(control) {
7
36
  let fieldset = control.closest("fieldset[disabled]");
@@ -183,17 +212,15 @@ var OverflowMenuController = class extends Controller {
183
212
  #lastHidden = null;
184
213
  /** The `tabindex` this instance lends the root for the focus fallback. */
185
214
  #tabindex = new TabindexLoan();
186
- /** Hands Turbo a pristine snapshot: the cache is written after this event. */
187
- #onBeforeCache = () => {
188
- this.#restoreAll();
189
- };
215
+ /** Hands Turbo a pristine snapshot of the bar, with every item back in place. */
216
+ #beforeCache = new BeforeCacheReset(() => this.#restoreAll());
190
217
  connect() {
191
218
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
192
219
  const trigger = this.#trigger();
193
220
  if (trigger !== null && this.#isBareTrigger(trigger)) {
194
221
  trigger.textContent = this.moreLabelValue;
195
222
  }
196
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
223
+ this.#beforeCache.activate();
197
224
  this.#layout.observe(this.element);
198
225
  this.#layout.observeViewport();
199
226
  this.update();
@@ -201,7 +228,7 @@ var OverflowMenuController = class extends Controller {
201
228
  disconnect() {
202
229
  this.#layout.disconnect();
203
230
  this.#timers.clearAll();
204
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
231
+ this.#beforeCache.deactivate();
205
232
  this.#restoreAll();
206
233
  this.#lastHidden = null;
207
234
  }