stimeo-ui 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/dist/controllers/aspect_ratio_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +5 -1
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +6 -2
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +5 -1
- data/dist/controllers/date_range_picker_controller.js +5 -1
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/empty_state_controller.js +107 -16
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +94 -22
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +13 -2
- data/dist/controllers/local_time_controller.js +2 -0
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/meter_controller.js +3 -1
- data/dist/controllers/network_status_controller.js +1 -3
- data/dist/controllers/number_input_controller.js +191 -24
- data/dist/controllers/overflow_menu_controller.js +1 -1
- data/dist/controllers/pagination_controller.js +5 -1
- data/dist/controllers/password_strength_controller.js +1 -1
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +7 -3
- data/dist/controllers/range_slider_controller.js +385 -94
- data/dist/controllers/rating_controller.js +2 -0
- data/dist/controllers/relative_time_controller.js +2 -0
- data/dist/controllers/scroll_area_controller.js +1 -1
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/skeleton_controller.js +71 -3
- data/dist/controllers/slider_controller.js +325 -48
- data/dist/controllers/spinner_controller.js +18 -3
- data/dist/controllers/step_indicator_controller.js +3 -1
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +6 -3
- data/dist/controllers/tree_view_controller.js +19 -1
- data/dist/index.js +1214 -307
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a78d7192424e66f85cd4fbc901f75f260178f890fbcf040724fd5e518314dda7
|
|
4
|
+
data.tar.gz: d14ba0559fd6659b4b1edc860c653815bb949439768f26e3540cb758dabee2f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e02be89ae227eec783f0eb7f21c492a00ff75174c5285de3d8cf8418b3a826b6d9fb42d779b63a47845b77679d4b61adcade253746cf39ea83d7c924651f8a8e
|
|
7
|
+
data.tar.gz: 7661666a77da707d43ad07bf9e7bb4a8b0cb63caea75f2d0d8abf9f43fd6b43421bfe8d24203f814e70042a506177995df72e14487c4c11aa49016129bd44f02
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-08-13
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Breaking** — every CSS custom property the library writes now carries the
|
|
15
|
+
`--stimeo--` namespace (two hyphens). Rename them in your stylesheets: a name
|
|
16
|
+
you miss raises no error, it just falls back to `var()`'s default and the bar
|
|
17
|
+
or thumb stops moving.
|
|
18
|
+
- `--stimeo-range-start` / `--stimeo-range-end` →
|
|
19
|
+
`--stimeo--range-slider-start` / `--stimeo--range-slider-end`
|
|
20
|
+
- `--stimeo-<name>` → `--stimeo--<name>` for `aspect-ratio`,
|
|
21
|
+
`collapsible-content-height`, `color`, `context-menu-x`, `context-menu-y`,
|
|
22
|
+
`masonry-columns`, `meter-ratio`, `password-strength`, `progress-ratio`,
|
|
23
|
+
`scroll-progress`, `step-indicator-ratio`, `textarea-rows`,
|
|
24
|
+
`upload-progress`
|
|
25
|
+
- **Breaking** — spinner's `indicator`, the network-status banners, and
|
|
26
|
+
countdown's `status` slot are no longer live regions. Drop `role="status"` /
|
|
27
|
+
`role="alert"` / `aria-live` from them and set `announceText` (and its
|
|
28
|
+
siblings) with a `stimeo--announcer` on the page; keeping the role reads the
|
|
29
|
+
change twice.
|
|
30
|
+
- time-picker: committing a field change dispatches a native `change` from the
|
|
31
|
+
`field` target, and `stimeo--time-picker:change` reports the composed value
|
|
32
|
+
even when no `field` target is present.
|
|
33
|
+
- tree-view: more native elements count as nested-interactive (`label`,
|
|
34
|
+
`summary`, `details`, `area[href]`, media with `controls`, `iframe`, `object`,
|
|
35
|
+
`embed`), so a click or key inside one is left alone instead of moving the
|
|
36
|
+
tree selection.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Inspector: the manifest advances from schema v8 to **v11** — tools that read it
|
|
41
|
+
raw must accept the new `valueConstraints`, `hosts`, and `valueRelations`
|
|
42
|
+
fields. Two diagnostics are new, so **re-run `stimeo check` before deploying;
|
|
43
|
+
it can report errors on views that passed under 0.4.0**:
|
|
44
|
+
- `invalid-value` — slider, range-slider, and number-input need a finite,
|
|
45
|
+
positive `step`; range-slider needs finite `min`, `max`, `start`, and `end`
|
|
46
|
+
with `min` at or below `max`.
|
|
47
|
+
- `invalid-host` — switch, time-picker, and tree-view report an unsupported
|
|
48
|
+
host element before it reaches the browser.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- range-slider: two thumbs resting on the same value can be pulled apart with
|
|
53
|
+
the pointer again, and invalid or reversed bounds fall back to a finite
|
|
54
|
+
ordered range.
|
|
55
|
+
- slider, range-slider, number-input: an endpoint that does not sit on the step
|
|
56
|
+
grid is reachable, and an invalid `step` falls back to `1`.
|
|
57
|
+
- slider, range-slider: a Turbo morph that swaps a value repaints instead of
|
|
58
|
+
freezing, a replaced thumb or track picks up the current state, and a drag
|
|
59
|
+
stays with the pointer that started it.
|
|
60
|
+
- switch: holding Space no longer scrolls a non-`<button>` host, and an ancestor
|
|
61
|
+
`fieldset[disabled]` or `aria-disabled` blocks activation.
|
|
62
|
+
- flash: hover and focus pause the auto-dismiss independently, a flash paused
|
|
63
|
+
after its deadline dismisses instead of staying forever, a second dismiss on a
|
|
64
|
+
leaving flash is ignored, and a replaced `region` target is picked up.
|
|
65
|
+
- frame-loading: `aria-busy`, the skeleton, and `inert` are rolled back when the
|
|
66
|
+
controller is detached, instead of burning in with nothing left to finish the
|
|
67
|
+
load.
|
|
68
|
+
- empty-state: a replaced `list` or `empty` element and a runtime `itemSelector`
|
|
69
|
+
change are followed, so the placeholder no longer sticks beside a filled list.
|
|
70
|
+
- highlight: a highlight that outlives a Turbo navigation is cleaned up on the
|
|
71
|
+
way back instead of staying marked.
|
|
72
|
+
- idle: `data-idle` is cleared when a new measurement cycle connects, and
|
|
73
|
+
`disconnect()` removes the listeners it actually registered even when `events`
|
|
74
|
+
changed while connected.
|
|
75
|
+
- skeleton: a skeleton moved within the page keeps its ready intent.
|
|
76
|
+
|
|
10
77
|
## [0.4.0] - 2026-08-11
|
|
11
78
|
|
|
12
79
|
The feedback and status components announce through the shared
|
|
@@ -391,6 +458,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
391
458
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
392
459
|
RubyGems.
|
|
393
460
|
|
|
461
|
+
[0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
|
|
394
462
|
[0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
|
|
395
463
|
[0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
|
|
396
464
|
[0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
|
|
@@ -8,7 +8,7 @@ var AspectRatioController = class extends Controller {
|
|
|
8
8
|
};
|
|
9
9
|
/** Applies the ratio on connect and whenever the value changes. */
|
|
10
10
|
ratioValueChanged() {
|
|
11
|
-
this.element.style.setProperty("--stimeo
|
|
11
|
+
this.element.style.setProperty("--stimeo--aspect-ratio", this.#normalizeRatio(this.ratioValue));
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
14
|
* Normalizes a ratio string to a valid CSS `<ratio>`:
|
|
@@ -287,7 +287,11 @@ var BreadcrumbController = class extends Controller {
|
|
|
287
287
|
this.ellipsisTarget.hidden = true;
|
|
288
288
|
return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
|
|
289
289
|
}
|
|
290
|
-
/**
|
|
290
|
+
/**
|
|
291
|
+
* Applies the collapsed/expanded state to the items, ellipsis, and trigger.
|
|
292
|
+
*
|
|
293
|
+
* @stimeoRenderRoot
|
|
294
|
+
*/
|
|
291
295
|
#render() {
|
|
292
296
|
const collapsed = this.#overflowing && !this.#expanded;
|
|
293
297
|
const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
|
|
@@ -271,7 +271,11 @@ var CarouselController = class extends Controller {
|
|
|
271
271
|
this.#syncTimer();
|
|
272
272
|
if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
|
|
273
273
|
}
|
|
274
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* Reflects `this.#index` onto slides and pickers (state hooks + roving).
|
|
276
|
+
*
|
|
277
|
+
* @stimeoRenderRoot
|
|
278
|
+
*/
|
|
275
279
|
#render({ focus }) {
|
|
276
280
|
this.slideTargets.forEach((slide, i) => {
|
|
277
281
|
const active = i === this.#index;
|
|
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/clipboard_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/default_attribute.ts
|
|
6
|
+
function setDefaultAttribute(element, name, value) {
|
|
7
|
+
if (element.hasAttribute(name)) return false;
|
|
8
|
+
element.setAttribute(name, value);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
// src/utils/safe_timeout.ts
|
|
6
13
|
var TimerRegistry = class {
|
|
7
14
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -75,9 +82,7 @@ var ClipboardController = class extends Controller {
|
|
|
75
82
|
*/
|
|
76
83
|
#resetTimerId = null;
|
|
77
84
|
connect() {
|
|
78
|
-
|
|
79
|
-
this.element.setAttribute("data-state", "idle");
|
|
80
|
-
}
|
|
85
|
+
setDefaultAttribute(this.element, "data-state", "idle");
|
|
81
86
|
}
|
|
82
87
|
disconnect() {
|
|
83
88
|
this.#timers.clearAll();
|
|
@@ -297,7 +297,10 @@ var CollapsibleController = class extends Controller {
|
|
|
297
297
|
#applyContent(content, open, waitForCloseTransition) {
|
|
298
298
|
if (open) {
|
|
299
299
|
content.hidden = false;
|
|
300
|
-
content.style.setProperty(
|
|
300
|
+
content.style.setProperty(
|
|
301
|
+
"--stimeo--collapsible-content-height",
|
|
302
|
+
`${content.scrollHeight}px`
|
|
303
|
+
);
|
|
301
304
|
content.setAttribute("data-state", "open");
|
|
302
305
|
return;
|
|
303
306
|
}
|
|
@@ -59,7 +59,7 @@ var MicrotaskCoalescer = class {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
// src/controllers/color_picker_controller.ts
|
|
62
|
-
var COLOR_PROPERTY = "--stimeo
|
|
62
|
+
var COLOR_PROPERTY = "--stimeo--color";
|
|
63
63
|
var CHANNEL_RANGE = {
|
|
64
64
|
hue: [0, 360],
|
|
65
65
|
saturation: [0, 100],
|
|
@@ -188,7 +188,11 @@ var ColorPickerController = class extends Controller {
|
|
|
188
188
|
this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
|
|
189
189
|
this.#render();
|
|
190
190
|
}
|
|
191
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Reflects the model onto sliders, the hex input, preview, and form field.
|
|
193
|
+
*
|
|
194
|
+
* @stimeoRenderRoot
|
|
195
|
+
*/
|
|
192
196
|
#render() {
|
|
193
197
|
for (const slider of this.sliderTargets) {
|
|
194
198
|
const channel = this.#channelOf(slider);
|
|
@@ -237,8 +237,8 @@ var ContextMenuController = class extends Controller {
|
|
|
237
237
|
onDismiss: () => this.#closeAndRestore(),
|
|
238
238
|
claims: claimsWhileFocusWithin(this.element)
|
|
239
239
|
});
|
|
240
|
-
this.menuTarget.style.setProperty("--stimeo
|
|
241
|
-
this.menuTarget.style.setProperty("--stimeo
|
|
240
|
+
this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
|
|
241
|
+
this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
|
|
242
242
|
this.menuTarget.hidden = false;
|
|
243
243
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
|
|
244
244
|
this.#navigableItems[0]?.focus();
|
|
@@ -268,7 +268,11 @@ var CountdownController = class extends Controller {
|
|
|
268
268
|
const raw = this.#isDown ? this.#reference - now : now - this.#reference;
|
|
269
269
|
return Math.max(0, raw);
|
|
270
270
|
}
|
|
271
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Writes the amount into the day/hour/minute/second slots.
|
|
273
|
+
*
|
|
274
|
+
* @stimeoRenderRoot
|
|
275
|
+
*/
|
|
272
276
|
#render(amount) {
|
|
273
277
|
const totalSeconds = Math.floor(amount / SECOND_MS);
|
|
274
278
|
this.#renderedAmount = totalSeconds * SECOND_MS;
|
|
@@ -355,7 +355,11 @@ var DateRangePickerController = class extends Controller {
|
|
|
355
355
|
this.#focusedDate = target;
|
|
356
356
|
this.#render();
|
|
357
357
|
}
|
|
358
|
-
/**
|
|
358
|
+
/**
|
|
359
|
+
* Builds the six-week grid and binds range/roving/disabled state per cell.
|
|
360
|
+
*
|
|
361
|
+
* @stimeoRenderRoot
|
|
362
|
+
*/
|
|
359
363
|
#render() {
|
|
360
364
|
const info = parseISOMonthString(this.#viewMonth);
|
|
361
365
|
if (!info) return;
|
|
@@ -110,7 +110,7 @@ var DirectUploadController = class extends Controller {
|
|
|
110
110
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
111
111
|
row.setAttribute("aria-valuenow", String(clamped));
|
|
112
112
|
row.setAttribute("aria-valuetext", `${clamped}%`);
|
|
113
|
-
row.style.setProperty("--stimeo
|
|
113
|
+
row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
|
|
114
114
|
this.#setField(row, "percent", `${clamped}%`);
|
|
115
115
|
this.#syncAggregate();
|
|
116
116
|
this.dispatch("progress", { detail: { id, percent: clamped } });
|
|
@@ -148,7 +148,7 @@ var DirectUploadController = class extends Controller {
|
|
|
148
148
|
}
|
|
149
149
|
clone.setAttribute("aria-valuenow", "0");
|
|
150
150
|
clone.setAttribute("data-upload-state", "uploading");
|
|
151
|
-
clone.style.setProperty("--stimeo
|
|
151
|
+
clone.style.setProperty("--stimeo--upload-progress", "0%");
|
|
152
152
|
this.listTarget.appendChild(clone);
|
|
153
153
|
this.#rows.set(key, clone);
|
|
154
154
|
return clone;
|
|
@@ -172,7 +172,7 @@ var DirectUploadController = class extends Controller {
|
|
|
172
172
|
}
|
|
173
173
|
const overall = Math.round(total / this.#rows.size);
|
|
174
174
|
this.element.setAttribute("data-upload-progress", String(overall));
|
|
175
|
-
this.element.style.setProperty("--stimeo
|
|
175
|
+
this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
|
|
176
176
|
}
|
|
177
177
|
/** Writes a consumer label (with `%{name}` substituted) to the status region. */
|
|
178
178
|
#announce(label, row) {
|
|
@@ -29,23 +29,120 @@ var EmptyStateController = class extends Controller {
|
|
|
29
29
|
};
|
|
30
30
|
static events = ["change"];
|
|
31
31
|
#observer = null;
|
|
32
|
+
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
33
|
+
#connected = false;
|
|
32
34
|
/** Last applied empty state; `null` until the first sync so connect emits nothing. */
|
|
33
35
|
#empty = null;
|
|
34
36
|
connect() {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.#observer.observe(this.listTarget, { childList: true });
|
|
39
|
-
}
|
|
40
|
-
this.#apply();
|
|
37
|
+
this.#connected = true;
|
|
38
|
+
this.#syncObservation();
|
|
39
|
+
this.#update();
|
|
41
40
|
}
|
|
42
41
|
disconnect() {
|
|
42
|
+
this.#connected = false;
|
|
43
|
+
this.#stopObserving();
|
|
44
|
+
}
|
|
45
|
+
/** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
|
|
46
|
+
listTargetConnected() {
|
|
47
|
+
this.#resync();
|
|
48
|
+
}
|
|
49
|
+
/** Releases the observation when the `list` element leaves the target set. */
|
|
50
|
+
listTargetDisconnected() {
|
|
51
|
+
this.#resync();
|
|
52
|
+
}
|
|
53
|
+
/** Syncs an `empty` element that arrives — or is replaced — at runtime. */
|
|
54
|
+
emptyTargetConnected() {
|
|
55
|
+
this.#resync();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Syncs the `empty` element that remains when one leaves the target set. A
|
|
59
|
+
* single-target getter resolves to the first `empty` element in document order,
|
|
60
|
+
* so a swap that inserts the replacement *before* removing the original (Turbo
|
|
61
|
+
* Stream `after` / `before` / `append` followed by `remove`) leaves the
|
|
62
|
+
* replacement untouched until the original goes — this callback is that moment.
|
|
63
|
+
*/
|
|
64
|
+
emptyTargetDisconnected() {
|
|
65
|
+
this.#resync();
|
|
66
|
+
}
|
|
67
|
+
/** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
|
|
68
|
+
itemSelectorValueChanged() {
|
|
69
|
+
this.#resync();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Re-points the observation and re-renders after a target or selector change.
|
|
73
|
+
* The `#connected` guard is load-bearing: Stimulus runs value and target
|
|
74
|
+
* callbacks for the initial markup *before* `connect()` and runs target
|
|
75
|
+
* callbacks during teardown *after* `disconnect()`, and re-observing there
|
|
76
|
+
* would outlive the controller.
|
|
77
|
+
*/
|
|
78
|
+
#resync() {
|
|
79
|
+
if (!this.#connected) return;
|
|
80
|
+
this.#syncObservation();
|
|
81
|
+
this.#update();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Points the mutation observation at the current `list` target — re-resolved on
|
|
85
|
+
* every sync rather than captured at connect, so an element swapped in at
|
|
86
|
+
* runtime is observed instead of the detached original.
|
|
87
|
+
*
|
|
88
|
+
* The observation covers exactly what the count predicate reads. With no
|
|
89
|
+
* `itemSelector` the count is the child element count, which only `childList`
|
|
90
|
+
* can change. With one, the predicate reads the children themselves, so
|
|
91
|
+
* attribute and descendant mutations are watched too — and the controller's own
|
|
92
|
+
* writes are filtered back out, or toggling `hidden` would re-enter the render.
|
|
93
|
+
*/
|
|
94
|
+
#syncObservation() {
|
|
95
|
+
this.#stopObserving();
|
|
96
|
+
if (!this.hasListTarget || typeof MutationObserver === "undefined") return;
|
|
97
|
+
const watchesItems = this.itemSelectorValue.length > 0;
|
|
98
|
+
this.#observer = new MutationObserver((records) => {
|
|
99
|
+
if (records.some((record) => this.#affectsCount(record))) this.#update();
|
|
100
|
+
});
|
|
101
|
+
this.#observer.observe(this.listTarget, {
|
|
102
|
+
childList: true,
|
|
103
|
+
subtree: watchesItems,
|
|
104
|
+
attributes: watchesItems
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
#stopObserving() {
|
|
43
108
|
this.#observer?.disconnect();
|
|
44
109
|
this.#observer = null;
|
|
45
110
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Whether a mutation can change the item count. An attribute written on a
|
|
113
|
+
* target this controller owns is its own echo — `hidden` on `list` / `empty`,
|
|
114
|
+
* and the hooks on the controller element when the list *is* that element.
|
|
115
|
+
*/
|
|
116
|
+
#affectsCount(record) {
|
|
117
|
+
if (record.type !== "attributes") return true;
|
|
118
|
+
const own = record.target === this.listTarget || this.hasEmptyTarget && record.target === this.emptyTarget;
|
|
119
|
+
return !own;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Renders the count, then reports a crossed boundary. The announcement copy is
|
|
123
|
+
* read here rather than while rendering: it is the wording of the report, not
|
|
124
|
+
* an input to what is displayed.
|
|
125
|
+
*/
|
|
126
|
+
#update() {
|
|
127
|
+
const count = this.#render();
|
|
128
|
+
if (count === null) return;
|
|
129
|
+
const empty = count === 0;
|
|
130
|
+
const crossed = this.#empty !== null && empty !== this.#empty;
|
|
131
|
+
this.#empty = empty;
|
|
132
|
+
if (!crossed) return;
|
|
133
|
+
this.dispatch("change", { detail: { count, empty } });
|
|
134
|
+
announce(
|
|
135
|
+
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Syncs visibility and the state hooks to the current item count, and returns
|
|
140
|
+
* it. `null` when there is no `list` target to count.
|
|
141
|
+
*
|
|
142
|
+
* @stimeoRenderRoot
|
|
143
|
+
*/
|
|
144
|
+
#render() {
|
|
145
|
+
if (!this.hasListTarget) return null;
|
|
49
146
|
const count = this.#count();
|
|
50
147
|
const empty = count === 0;
|
|
51
148
|
this.element.setAttribute("data-count", String(count));
|
|
@@ -56,13 +153,7 @@ var EmptyStateController = class extends Controller {
|
|
|
56
153
|
}
|
|
57
154
|
this.listTarget.hidden = empty;
|
|
58
155
|
if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
|
|
59
|
-
|
|
60
|
-
this.dispatch("change", { detail: { count, empty } });
|
|
61
|
-
announce(
|
|
62
|
-
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
this.#empty = empty;
|
|
156
|
+
return count;
|
|
66
157
|
}
|
|
67
158
|
/** Item count: element children matching `itemSelector`, or all element children. */
|
|
68
159
|
#count() {
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/flash_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/safe_timeout.ts
|
|
6
35
|
var TimerRegistry = class {
|
|
7
36
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -103,29 +132,119 @@ var FlashController = class extends Controller {
|
|
|
103
132
|
static events = ["show", "dismiss"];
|
|
104
133
|
#timers = new SafeTimeout();
|
|
105
134
|
#observer = null;
|
|
135
|
+
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
136
|
+
#connected = false;
|
|
106
137
|
/** Auto-dismiss timer state keyed by message element. */
|
|
107
138
|
#state = /* @__PURE__ */ new Map();
|
|
108
139
|
/** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
|
|
109
140
|
#order = [];
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
|
|
143
|
+
* releases the bookkeeping above *before* the transition wait, so for that window the
|
|
144
|
+
* element is in the DOM but in neither collection — without this set a re-scan would
|
|
145
|
+
* read it as a brand-new flash and show it a second time.
|
|
146
|
+
*/
|
|
147
|
+
#leaving = /* @__PURE__ */ new Set();
|
|
148
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
149
|
+
#onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
|
|
150
|
+
#onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
|
|
112
151
|
connect() {
|
|
113
|
-
|
|
152
|
+
this.#connected = true;
|
|
114
153
|
for (const message of this.messageTargets) {
|
|
115
|
-
this.#process(message, true);
|
|
116
|
-
}
|
|
117
|
-
if (typeof MutationObserver !== "undefined") {
|
|
118
|
-
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
119
|
-
this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
|
|
154
|
+
if (this.#owns(message)) this.#process(message, true);
|
|
120
155
|
}
|
|
156
|
+
this.#syncObservation();
|
|
157
|
+
this.#beforeCache.activate();
|
|
121
158
|
}
|
|
122
159
|
disconnect() {
|
|
123
|
-
this.#
|
|
124
|
-
this.#
|
|
160
|
+
this.#connected = false;
|
|
161
|
+
this.#beforeCache.deactivate();
|
|
162
|
+
this.#stopObserving();
|
|
125
163
|
this.#timers.clearAll();
|
|
126
164
|
for (const message of this.#order) this.#unbindPause(message);
|
|
127
165
|
this.#state.clear();
|
|
128
166
|
this.#order.length = 0;
|
|
167
|
+
this.#leaving.clear();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Takes the managed flashes out of the page just before Turbo freezes it, so a
|
|
171
|
+
* restored snapshot carries no notification the visitor has already received: the
|
|
172
|
+
* fresh `connect()` there reads a leftover flash as a brand-new one and announces it
|
|
173
|
+
* a second time. A message that never auto-dismisses (`duration: 0`) is one of these
|
|
174
|
+
* too — that value governs the timer, not what belongs in a cached page. Removal
|
|
175
|
+
* only: `dismiss` reports a dismissal, and freezing the page is not one.
|
|
176
|
+
*/
|
|
177
|
+
#rewindForCache() {
|
|
178
|
+
for (const message of [...this.#order]) {
|
|
179
|
+
message.remove();
|
|
180
|
+
this.#forget(message);
|
|
181
|
+
}
|
|
182
|
+
for (const message of this.#leaving) message.remove();
|
|
183
|
+
this.#leaving.clear();
|
|
184
|
+
}
|
|
185
|
+
/** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
|
|
186
|
+
regionTargetConnected() {
|
|
187
|
+
this.#resync();
|
|
188
|
+
}
|
|
189
|
+
/** Releases the observation when the `region` element leaves the target set. */
|
|
190
|
+
regionTargetDisconnected() {
|
|
191
|
+
this.#resync();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether this controller owns `message`. Ownership is the current `region`'s
|
|
195
|
+
* subtree: a message target anywhere else in the controller's scope is the
|
|
196
|
+
* consumer's, and so is one in a region that has gone away. The initial scan, a
|
|
197
|
+
* re-scan after a `region` swap, and a departure from the target set all resolve
|
|
198
|
+
* ownership through this one test; the observation gets it structurally, by watching
|
|
199
|
+
* that subtree and nothing else.
|
|
200
|
+
*/
|
|
201
|
+
#owns(message) {
|
|
202
|
+
return this.hasRegionTarget && this.regionTarget.contains(message);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Releases a message that left the target set (a Turbo Stream `remove`, the consumer
|
|
206
|
+
* detaching the node, or a morph that rewrote the target attribute in place): it
|
|
207
|
+
* stops occupying a `max` slot, and both its pending auto-dismiss and an already
|
|
208
|
+
* scheduled removal are cancelled. A move *within* the region keeps all of them —
|
|
209
|
+
* which is why the element must still be a message to be treated as one: ownership
|
|
210
|
+
* alone reads an in-place attribute rewrite as a move, and a node outside the target
|
|
211
|
+
* set belongs to the consumer, so nothing here may dismiss it.
|
|
212
|
+
*/
|
|
213
|
+
messageTargetDisconnected(message) {
|
|
214
|
+
if (!this.#connected) return;
|
|
215
|
+
const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
|
|
216
|
+
if (moved) return;
|
|
217
|
+
this.#forget(message);
|
|
218
|
+
this.#leaving.delete(message);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Re-points the observation after a `region` swap and picks up the messages the
|
|
222
|
+
* new element brought with it (dynamic inserts, so their own `role` announces
|
|
223
|
+
* them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
|
|
224
|
+
* for the initial markup *before* `connect()` and again during teardown *after*
|
|
225
|
+
* `disconnect()`, and re-observing there would outlive the controller.
|
|
226
|
+
*/
|
|
227
|
+
#resync() {
|
|
228
|
+
if (!this.#connected) return;
|
|
229
|
+
this.#syncObservation();
|
|
230
|
+
for (const message of this.messageTargets) {
|
|
231
|
+
if (this.#owns(message)) this.#process(message, false);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Points the mutation observation at the current `region` target, re-resolved on
|
|
236
|
+
* every sync rather than captured at connect, so an element swapped in at runtime
|
|
237
|
+
* is observed instead of the detached original.
|
|
238
|
+
*/
|
|
239
|
+
#syncObservation() {
|
|
240
|
+
this.#stopObserving();
|
|
241
|
+
if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
|
|
242
|
+
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
243
|
+
this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
|
|
244
|
+
}
|
|
245
|
+
#stopObserving() {
|
|
246
|
+
this.#observer?.disconnect();
|
|
247
|
+
this.#observer = null;
|
|
129
248
|
}
|
|
130
249
|
/**
|
|
131
250
|
* Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
|
|
@@ -171,6 +290,7 @@ var FlashController = class extends Controller {
|
|
|
171
290
|
*/
|
|
172
291
|
#process(message, bridge) {
|
|
173
292
|
if (this.#state.has(message) || this.#order.includes(message)) return;
|
|
293
|
+
if (this.#leaving.has(message)) return;
|
|
174
294
|
const type = message.getAttribute("data-flash-type") ?? "";
|
|
175
295
|
const assertive = ASSERTIVE_TYPES.has(type);
|
|
176
296
|
if (!message.hasAttribute("role")) {
|
|
@@ -203,33 +323,53 @@ var FlashController = class extends Controller {
|
|
|
203
323
|
const existing = this.#state.get(message);
|
|
204
324
|
if (existing?.id) this.#timers.clear(existing.id);
|
|
205
325
|
const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
|
|
206
|
-
this.#state.set(message, {
|
|
326
|
+
this.#state.set(message, {
|
|
327
|
+
id,
|
|
328
|
+
startedAt: Date.now(),
|
|
329
|
+
remaining: duration,
|
|
330
|
+
paused: existing?.paused ?? /* @__PURE__ */ new Set()
|
|
331
|
+
});
|
|
207
332
|
}
|
|
208
|
-
/**
|
|
209
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
|
|
335
|
+
* Hover and focus are independent reasons: the remaining time is banked on the
|
|
336
|
+
* first of them, and {@link FlashController.#resume} waits for the last one.
|
|
337
|
+
*/
|
|
338
|
+
#pause(message, reason) {
|
|
210
339
|
const timer = this.#state.get(message);
|
|
211
|
-
if (!timer
|
|
340
|
+
if (!timer) return;
|
|
341
|
+
timer.paused.add(reason);
|
|
342
|
+
if (timer.id === 0) return;
|
|
212
343
|
this.#timers.clear(timer.id);
|
|
213
|
-
const remaining = Math.max(
|
|
214
|
-
this.#state.set(message, { id: 0, startedAt: 0, remaining });
|
|
344
|
+
const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
|
|
345
|
+
this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
|
|
215
346
|
}
|
|
216
347
|
/** Resumes a paused message's auto-dismiss with the banked time. */
|
|
217
|
-
#resume(message) {
|
|
348
|
+
#resume(message, reason) {
|
|
218
349
|
const timer = this.#state.get(message);
|
|
219
350
|
if (!timer) return;
|
|
220
|
-
|
|
351
|
+
timer.paused.delete(reason);
|
|
352
|
+
if (timer.paused.size > 0) return;
|
|
353
|
+
if (timer.id !== 0) return;
|
|
221
354
|
this.#startTimer(message, timer.remaining);
|
|
222
355
|
}
|
|
223
|
-
/**
|
|
224
|
-
#
|
|
356
|
+
/** Releases every per-message resource: timer, stacking slot, pause listeners. */
|
|
357
|
+
#forget(message) {
|
|
225
358
|
const timer = this.#state.get(message);
|
|
226
359
|
if (timer?.id) this.#timers.clear(timer.id);
|
|
227
360
|
this.#state.delete(message);
|
|
228
361
|
const index = this.#order.indexOf(message);
|
|
229
362
|
if (index !== -1) this.#order.splice(index, 1);
|
|
363
|
+
this.#unbindPause(message);
|
|
364
|
+
}
|
|
365
|
+
/** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
|
|
366
|
+
#beginDismiss(message, reason) {
|
|
367
|
+
if (!this.#state.has(message) && !this.#order.includes(message)) return;
|
|
368
|
+
this.#forget(message);
|
|
369
|
+
this.#leaving.add(message);
|
|
230
370
|
message.setAttribute("data-flash-state", "leaving");
|
|
231
371
|
const finalize = () => {
|
|
232
|
-
this.#
|
|
372
|
+
if (!this.#leaving.delete(message)) return;
|
|
233
373
|
message.remove();
|
|
234
374
|
this.dispatch("dismiss", { detail: { element: message, reason } });
|
|
235
375
|
};
|