stimeo-ui 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/carousel_controller.js +85 -9
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/color_picker_controller.js +35 -9
- data/dist/controllers/date_range_picker_controller.js +157 -30
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/idle_controller.js +27 -5
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/number_input_controller.js +275 -51
- data/dist/controllers/overflow_menu_controller.js +4 -0
- data/dist/controllers/pagination_controller.js +33 -0
- data/dist/controllers/password_strength_controller.js +20 -2
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/rating_controller.js +272 -89
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +154 -22
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/time_picker_controller.js +296 -107
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +5 -3
- data/dist/index.js +2637 -781
- 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: 2883d44061c4c7ec8e5cd1a3b021c1d7fa8bf34bf8f2f74df1a3f496fe99f578
|
|
4
|
+
data.tar.gz: 9024ef97e2802777390ebc1a18cbbc202df8643ccac875d59b49a06bf6ae7f3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6dd511725546fc75ffa9e150ff02640d22c09404238f9ac418b8042eac78fab90bf6c19e8266933a5b2c47178ccc13dea00793c76c7e4184f708e9619afb84c
|
|
7
|
+
data.tar.gz: 926ccd828054b1e374bc5e9039d94d0eb4b0c398a45b235090af702ce037993e2bff6b774f6932d39e8eabdb27e69ae80fd953e25b2a8ead7525c1ee6a59d585
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,121 @@ 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.6.0] - 2026-08-15
|
|
11
|
+
|
|
12
|
+
Minor release that separates reconciled state from user edits: `change` now
|
|
13
|
+
means the user committed a value, and a controller that re-derives its own state
|
|
14
|
+
reports `reconcile` instead. Several markup contracts tightened, and authored
|
|
15
|
+
prose — remove-button names, announcements — moved out of the library. The
|
|
16
|
+
Inspector manifest stays on schema v11, but **`stimeo check` can report errors on
|
|
17
|
+
views that passed under 0.5.0**, so run it before deploying.
|
|
18
|
+
|
|
19
|
+
### Upgrade notes
|
|
20
|
+
|
|
21
|
+
- Listening to `change` to observe controller-driven repair? Subscribe to
|
|
22
|
+
`reconcile` too: carousel, checkbox, color-picker, multi-select, number-input,
|
|
23
|
+
radio-group, rating, tags-input, time-picker. Neither fires on connect.
|
|
24
|
+
- rating: delete `max` and every `data-rating-value`. The DOM order of the
|
|
25
|
+
`symbol` targets is the scale.
|
|
26
|
+
- aspect-ratio: delete the `content` target.
|
|
27
|
+
- toggle-group, radio-group: items must be `<button type="button">` or a
|
|
28
|
+
non-interactive host with `role="button"` / `role="radio"`. Others are left
|
|
29
|
+
untouched at runtime.
|
|
30
|
+
- tags-input, multi-select: delete the `status` target, put a
|
|
31
|
+
`stimeo--announcer` on the page, and set `announceText` /
|
|
32
|
+
`announceRemovedText`. In the chip template add `label` and `remove` targets
|
|
33
|
+
and author the remove button's `aria-label` (`{label}` / `{value}` expand).
|
|
34
|
+
Drop `data-*-slot="label"` and tags-input's `removeTag` action.
|
|
35
|
+
- date-range-picker: declare unavailable days with `disabled-dates`; an
|
|
36
|
+
`aria-disabled` you write on a cell is overwritten on the next paint.
|
|
37
|
+
- time-picker: AM/PM is a `role="spinbutton"` segment, not an `aria-pressed`
|
|
38
|
+
toggle. `seconds` only decides whether the composed value carries seconds.
|
|
39
|
+
- List values are read as JSON strings — idle's `events`, password-strength's
|
|
40
|
+
`levels`, persist's `exclude`, date-range-picker's `disabled-dates`. The
|
|
41
|
+
markup is unchanged; the manifest reports `String` where it reported `Array`.
|
|
42
|
+
|
|
43
|
+
### Removed
|
|
44
|
+
|
|
45
|
+
- **Breaking** — rating: `max` and `data-rating-value`. aspect-ratio: the
|
|
46
|
+
`content` target. tags-input, multi-select: the `status` target, tags-input's
|
|
47
|
+
`removeTag` action, and the English `Remove {value}` label the library wrote
|
|
48
|
+
onto generated remove buttons.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- A `reconcile` event, carrying the same `detail` as that component's `change`,
|
|
53
|
+
on carousel, checkbox, color-picker, multi-select, number-input, radio-group,
|
|
54
|
+
rating, tags-input, and time-picker.
|
|
55
|
+
- checkbox: a group works without a `parent`; the root still reports
|
|
56
|
+
`all` / `partial` / `none`.
|
|
57
|
+
- rating: `Delete` and `Backspace` clear the rating when `clearable`.
|
|
58
|
+
- date-range-picker: `disabled-dates`, a `monthchange` event,
|
|
59
|
+
`Shift+PageUp` / `Shift+PageDown` for stepping a year, and month labels
|
|
60
|
+
formatted from `documentElement.lang`.
|
|
61
|
+
- multi-select: a `fields` target holding one hidden input per selected value,
|
|
62
|
+
with `name` and `form`, plus `announceText` / `announceRemovedText`.
|
|
63
|
+
tags-input: `label` and `remove` targets and the same two announce values.
|
|
64
|
+
- avatar: an `empty` state beside `loading`, `loaded`, and `error`.
|
|
65
|
+
- Inspector: `invalid-host` for radio-group and toggle-group items;
|
|
66
|
+
chip-template parts required *inside* the template; `aria-label` on remove
|
|
67
|
+
buttons and the group role on toggle groups; at most one checkbox `parent`
|
|
68
|
+
and one checked radio; a positive integer time-picker `step`. An empty
|
|
69
|
+
attribute value no longer satisfies a requirement that asks only for the
|
|
70
|
+
attribute's presence.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- **Breaking** — `change` is reserved for user-committed values. DOM
|
|
75
|
+
normalization, target churn, morphs, and runtime value changes report
|
|
76
|
+
`reconcile`, and a repair that leaves the committed state unchanged reports
|
|
77
|
+
nothing.
|
|
78
|
+
- **Breaking** — radio-group, time-picker: the hidden `field` fires a native
|
|
79
|
+
`change` only for user edits, so a server-rendered repaint cannot drive an
|
|
80
|
+
`auto-submit` companion into a resubmit loop.
|
|
81
|
+
- **Breaking** — multi-select: `max` is enforced at all times, not only on the
|
|
82
|
+
click path; surplus options normalize to `aria-selected="false"`.
|
|
83
|
+
- **Breaking** — avatar: a present `src` value wins over an authored `src`, and
|
|
84
|
+
an empty one means "no image". Remove the attribute to hand the element's own
|
|
85
|
+
`src` back.
|
|
86
|
+
- **Breaking** — checkbox: a parent toggle sets each child's `checked` without
|
|
87
|
+
synthesizing a native `change` per child; the root reports one event.
|
|
88
|
+
- toggle-group, radio-group, tags-input, multi-select: interaction is delegated
|
|
89
|
+
from the container, so items added at runtime work without per-item
|
|
90
|
+
`data-action`. Existing per-item actions stay compatible.
|
|
91
|
+
- toggle-group, radio-group: `aria-disabled` items stay reachable by arrow keys
|
|
92
|
+
with activation suppressed; natively disabled and `hidden` items are skipped.
|
|
93
|
+
- number-input, rating, scroll-area, time-picker: ARIA and `tabindex` the
|
|
94
|
+
controller writes are treated as loans and handed back to their authored
|
|
95
|
+
values, without overwriting a change you made in the meantime.
|
|
96
|
+
- date-range-picker: a preset that only partly overlaps `min` / `max` is
|
|
97
|
+
narrowed to the selectable span instead of committing an unavailable endpoint.
|
|
98
|
+
- multi-select, tags-input: `Backspace` in an empty input removes the last chip
|
|
99
|
+
and keeps focus in the input.
|
|
100
|
+
- time-picker: a unit that wraps past noon or midnight carries AM/PM with it,
|
|
101
|
+
and one operation reports one `change` with the final value.
|
|
102
|
+
|
|
103
|
+
### Fixed
|
|
104
|
+
|
|
105
|
+
- avatar, carousel, checkbox, radio-group, rating, roving, toggle-group: targets
|
|
106
|
+
added, removed, or morphed at runtime settle on one state — a single tab stop,
|
|
107
|
+
a single checked or pressed item, and focus on a surviving item.
|
|
108
|
+
- date-range-picker: an unavailable day cannot become a committed endpoint
|
|
109
|
+
through a preset, a preview, or Enter/Space; a reversed stored range is
|
|
110
|
+
normalized on connect; a pending selection is rewound before Turbo caches the
|
|
111
|
+
page.
|
|
112
|
+
- number-input: commits compare numbers rather than the displayed text, and IME
|
|
113
|
+
composition never commits a value.
|
|
114
|
+
- time-picker: with `wrap` disabled, 11 and 12 no longer step into each other,
|
|
115
|
+
and an unfinished direct-entry buffer is discarded when focus leaves.
|
|
116
|
+
- multi-select, tags-input: a chip is built only when the template can produce a
|
|
117
|
+
complete one, only the `remove` target inside a chip acts on it, and a
|
|
118
|
+
replaced `tags` container is re-wired.
|
|
119
|
+
- file-dropzone, scroll-area: a replaced `list` / `viewport` keeps its state and
|
|
120
|
+
takes over the listeners; the old element stops responding.
|
|
121
|
+
- idle, password-strength, persist, date-range-picker: a malformed list value
|
|
122
|
+
falls back to its default instead of stopping the controller from connecting
|
|
123
|
+
and leaving the whole element inert.
|
|
124
|
+
|
|
10
125
|
## [0.5.0] - 2026-08-13
|
|
11
126
|
|
|
12
127
|
### Changed
|
|
@@ -458,6 +573,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
458
573
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
459
574
|
RubyGems.
|
|
460
575
|
|
|
576
|
+
[0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
|
|
461
577
|
[0.5.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.5.0
|
|
462
578
|
[0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
|
|
463
579
|
[0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
|
|
@@ -1,33 +1,41 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/aspect_ratio_controller.ts
|
|
4
|
+
var DEFAULT_RATIO = "1 / 1";
|
|
5
|
+
var RATIO_PROPERTY = "--stimeo--aspect-ratio";
|
|
6
|
+
var CSS_NUMBER_PATTERN = /^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
4
7
|
var AspectRatioController = class extends Controller {
|
|
5
|
-
static targets = ["content"];
|
|
6
8
|
static values = {
|
|
7
9
|
ratio: { type: String, default: "1/1" }
|
|
8
10
|
};
|
|
9
11
|
/** Applies the ratio on connect and whenever the value changes. */
|
|
10
12
|
ratioValueChanged() {
|
|
11
|
-
this.element.style.setProperty(
|
|
13
|
+
this.element.style.setProperty(RATIO_PROPERTY, this.#normalizeRatio(this.ratioValue));
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
16
|
* Normalizes a ratio string to a valid CSS `<ratio>`:
|
|
15
|
-
* - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive numbers)
|
|
16
|
-
* - `"1.5"` → `"1.5"` (a bare positive number)
|
|
17
|
+
* - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive CSS numbers)
|
|
18
|
+
* - `"1.5"` → `"1.5"` (a bare positive CSS number)
|
|
17
19
|
* - anything else → `"1 / 1"` (the default), so the custom property is always valid.
|
|
18
20
|
*/
|
|
19
21
|
#normalizeRatio(raw) {
|
|
20
22
|
const value = raw.trim();
|
|
21
23
|
if (value.includes("/")) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
|
|
24
|
+
const parts = value.split("/");
|
|
25
|
+
if (parts.length !== 2) return DEFAULT_RATIO;
|
|
26
|
+
const width = this.#parsePositiveNumber(parts[0] ?? "");
|
|
27
|
+
const height = this.#parsePositiveNumber(parts[1] ?? "");
|
|
28
|
+
return width !== void 0 && height !== void 0 ? `${width} / ${height}` : DEFAULT_RATIO;
|
|
25
29
|
}
|
|
26
|
-
const single =
|
|
27
|
-
return
|
|
30
|
+
const single = this.#parsePositiveNumber(value);
|
|
31
|
+
return single === void 0 ? DEFAULT_RATIO : String(single);
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
/** Parses one complete CSS `<number>` token and rejects units or trailing syntax. */
|
|
34
|
+
#parsePositiveNumber(raw) {
|
|
35
|
+
const token = raw.trim();
|
|
36
|
+
if (!CSS_NUMBER_PATTERN.test(token)) return void 0;
|
|
37
|
+
const value = Number(token);
|
|
38
|
+
return Number.isFinite(value) && value > 0 ? value : void 0;
|
|
31
39
|
}
|
|
32
40
|
};
|
|
33
41
|
|
|
@@ -1,63 +1,218 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/avatar_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/attribute_lease.ts
|
|
6
|
+
var AttributeLease = class {
|
|
7
|
+
#attribute;
|
|
8
|
+
#records = /* @__PURE__ */ new Map();
|
|
9
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
10
|
+
constructor(attribute) {
|
|
11
|
+
this.#attribute = attribute;
|
|
12
|
+
}
|
|
13
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
14
|
+
write(element, value) {
|
|
15
|
+
const existing = this.#records.get(element);
|
|
16
|
+
if (existing) {
|
|
17
|
+
existing.written = value;
|
|
18
|
+
} else {
|
|
19
|
+
this.#records.set(element, {
|
|
20
|
+
original: element.getAttribute(this.#attribute),
|
|
21
|
+
written: value
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
25
|
+
else element.setAttribute(this.#attribute, value);
|
|
26
|
+
}
|
|
27
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
28
|
+
return(element) {
|
|
29
|
+
const record = this.#records.get(element);
|
|
30
|
+
if (!record) return;
|
|
31
|
+
this.#records.delete(element);
|
|
32
|
+
if (element.getAttribute(this.#attribute) !== record.written) return;
|
|
33
|
+
if (record.original === null) element.removeAttribute(this.#attribute);
|
|
34
|
+
else element.setAttribute(this.#attribute, record.original);
|
|
35
|
+
}
|
|
36
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
37
|
+
returnAll() {
|
|
38
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/utils/microtask_coalescer.ts
|
|
43
|
+
var MicrotaskCoalescer = class {
|
|
44
|
+
#run;
|
|
45
|
+
#queued = false;
|
|
46
|
+
#active = false;
|
|
47
|
+
#generation = 0;
|
|
48
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
49
|
+
constructor(run) {
|
|
50
|
+
this.#run = run;
|
|
51
|
+
}
|
|
52
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
53
|
+
activate() {
|
|
54
|
+
this.#active = true;
|
|
55
|
+
}
|
|
56
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
57
|
+
cancel() {
|
|
58
|
+
this.#active = false;
|
|
59
|
+
this.#queued = false;
|
|
60
|
+
this.#generation += 1;
|
|
61
|
+
}
|
|
62
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
63
|
+
schedule() {
|
|
64
|
+
if (!this.#active || this.#queued) return;
|
|
65
|
+
this.#queued = true;
|
|
66
|
+
const generation = this.#generation;
|
|
67
|
+
queueMicrotask(() => {
|
|
68
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
69
|
+
this.#queued = false;
|
|
70
|
+
this.#run();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
3
75
|
// src/controllers/avatar_controller.ts
|
|
4
76
|
var AvatarController = class extends Controller {
|
|
5
77
|
static targets = ["image", "fallback"];
|
|
6
|
-
static values = {
|
|
7
|
-
src: { type: String, default: "" }
|
|
8
|
-
};
|
|
78
|
+
static values = { src: String };
|
|
9
79
|
static actions = ["onError", "onLoad"];
|
|
10
80
|
static events = ["error"];
|
|
81
|
+
/** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
|
|
82
|
+
#reconcile = new MicrotaskCoalescer(() => this.#render());
|
|
83
|
+
/** Preserves directly-authored source and visibility while targets are controlled. */
|
|
84
|
+
#src = new AttributeLease("src");
|
|
85
|
+
#imageHidden = new AttributeLease("hidden");
|
|
86
|
+
#fallbackHidden = new AttributeLease("hidden");
|
|
87
|
+
/** True only while target departure means ownership was actually relinquished. */
|
|
88
|
+
#connected = false;
|
|
89
|
+
/** Watches the non-Value render input used by the direct `<img src>` form. */
|
|
90
|
+
#srcObserver = new MutationObserver((records) => {
|
|
91
|
+
if (records.some((record) => this.hasImageTarget && record.target === this.imageTarget)) {
|
|
92
|
+
this.#reconcile.schedule();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
/** Starts retained-DOM observation and silently derives the current phase. */
|
|
11
96
|
connect() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
97
|
+
this.#connected = true;
|
|
98
|
+
this.#reconcile.activate();
|
|
99
|
+
this.#srcObserver.observe(this.element, {
|
|
100
|
+
attributes: true,
|
|
101
|
+
attributeFilter: ["src"],
|
|
102
|
+
subtree: true
|
|
103
|
+
});
|
|
104
|
+
this.#render();
|
|
105
|
+
}
|
|
106
|
+
/** Cancels asynchronous work while retaining the last materialized visual state. */
|
|
107
|
+
disconnect() {
|
|
108
|
+
this.#connected = false;
|
|
109
|
+
this.#reconcile.cancel();
|
|
110
|
+
this.#srcObserver.disconnect();
|
|
111
|
+
}
|
|
112
|
+
/** Repaints when application code or a Turbo morph changes the source Value. */
|
|
113
|
+
srcValueChanged() {
|
|
114
|
+
this.#reconcile.schedule();
|
|
115
|
+
}
|
|
116
|
+
/** Applies the current source and phase to an image inserted or replaced at runtime. */
|
|
117
|
+
imageTargetConnected() {
|
|
118
|
+
this.#reconcile.schedule();
|
|
119
|
+
}
|
|
120
|
+
/** Returns attributes borrowed from an image that leaves this live avatar. */
|
|
121
|
+
imageTargetDisconnected(image) {
|
|
122
|
+
if (this.#connected) {
|
|
123
|
+
this.#src.return(image);
|
|
124
|
+
this.#imageHidden.return(image);
|
|
15
125
|
}
|
|
16
|
-
|
|
17
|
-
|
|
126
|
+
this.#reconcile.schedule();
|
|
127
|
+
}
|
|
128
|
+
/** Applies the current phase to a fallback inserted or replaced at runtime. */
|
|
129
|
+
fallbackTargetConnected() {
|
|
130
|
+
this.#reconcile.schedule();
|
|
131
|
+
}
|
|
132
|
+
/** Returns authored visibility when a fallback leaves this live avatar. */
|
|
133
|
+
fallbackTargetDisconnected(fallback) {
|
|
134
|
+
if (this.#connected) this.#fallbackHidden.return(fallback);
|
|
135
|
+
this.#reconcile.schedule();
|
|
136
|
+
}
|
|
137
|
+
/** Reveals the current image after its native `load` event. */
|
|
138
|
+
onLoad(event) {
|
|
139
|
+
const image = this.#eventImage(event);
|
|
140
|
+
if (!image) return;
|
|
141
|
+
if (!image.getAttribute("src")) {
|
|
142
|
+
this.#render();
|
|
143
|
+
return;
|
|
18
144
|
}
|
|
19
|
-
|
|
145
|
+
this.#reflect("loaded");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Reveals the fallback after the current image's native `error` event.
|
|
149
|
+
*
|
|
150
|
+
* Dispatches `stimeo--avatar:error` with `{ src: string }`, where `src` is
|
|
151
|
+
* the attempted attribute value. Empty/stale/detached image events are ignored.
|
|
152
|
+
*/
|
|
153
|
+
onError(event) {
|
|
154
|
+
const image = this.#eventImage(event);
|
|
155
|
+
if (!image) return;
|
|
156
|
+
const src = image.getAttribute("src");
|
|
20
157
|
if (!src) {
|
|
21
|
-
this.#
|
|
158
|
+
this.#render();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.#reflect("error");
|
|
162
|
+
this.dispatch("error", { detail: { src } });
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Materializes the phase from the final source/target DOM after one mutation batch.
|
|
166
|
+
*
|
|
167
|
+
* Cached success and failure are state reconstruction, not new load events, so
|
|
168
|
+
* this pass never dispatches the public `error` event.
|
|
169
|
+
*
|
|
170
|
+
* @stimeoRenderRoot
|
|
171
|
+
*/
|
|
172
|
+
#render() {
|
|
173
|
+
if (!this.hasImageTarget) {
|
|
174
|
+
this.#reflect("empty");
|
|
22
175
|
return;
|
|
23
176
|
}
|
|
24
|
-
|
|
25
|
-
|
|
177
|
+
const image = this.imageTarget;
|
|
178
|
+
const src = this.#applySource(image);
|
|
179
|
+
if (!src) {
|
|
180
|
+
this.#reflect("empty");
|
|
26
181
|
return;
|
|
27
182
|
}
|
|
28
|
-
if (
|
|
29
|
-
this
|
|
183
|
+
if (!image.complete) {
|
|
184
|
+
this.#reflect("loading");
|
|
30
185
|
return;
|
|
31
186
|
}
|
|
32
|
-
this.#
|
|
187
|
+
this.#reflect(image.naturalWidth > 0 ? "loaded" : "error");
|
|
33
188
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
this
|
|
189
|
+
/** Applies Value precedence and returns the effective raw `src` attribute. */
|
|
190
|
+
#applySource(image) {
|
|
191
|
+
if (!this.element.hasAttribute("data-stimeo--avatar-src-value")) {
|
|
192
|
+
this.#src.return(image);
|
|
193
|
+
return image.getAttribute("src");
|
|
194
|
+
}
|
|
195
|
+
const desired = this.srcValue.length > 0 ? this.srcValue : null;
|
|
196
|
+
if (image.getAttribute("src") !== desired) this.#src.write(image, desired);
|
|
197
|
+
return desired;
|
|
37
198
|
}
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
this.
|
|
199
|
+
/** Accepts an action only from the singular image target currently controlled. */
|
|
200
|
+
#eventImage(event) {
|
|
201
|
+
const candidate = event.currentTarget;
|
|
202
|
+
if (!(candidate instanceof HTMLImageElement)) return null;
|
|
203
|
+
if (!this.hasImageTarget || candidate !== this.imageTarget) return null;
|
|
204
|
+
return candidate;
|
|
43
205
|
}
|
|
44
|
-
/**
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
if (this.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.element.setAttribute("data-state", "loaded");
|
|
55
|
-
}
|
|
56
|
-
/** Error / no-src phase: fallback visible, image hidden. */
|
|
57
|
-
#showFallback() {
|
|
58
|
-
if (this.hasImageTarget) this.imageTarget.hidden = true;
|
|
59
|
-
if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;
|
|
60
|
-
this.element.setAttribute("data-state", "error");
|
|
206
|
+
/** Writes the visibility pair and public root state as one controller-owned output. */
|
|
207
|
+
#reflect(state) {
|
|
208
|
+
const showImage = state === "loading" || state === "loaded";
|
|
209
|
+
if (this.hasImageTarget) {
|
|
210
|
+
this.#imageHidden.write(this.imageTarget, showImage ? null : "");
|
|
211
|
+
}
|
|
212
|
+
if (this.hasFallbackTarget) {
|
|
213
|
+
this.#fallbackHidden.write(this.fallbackTarget, showImage ? "" : null);
|
|
214
|
+
}
|
|
215
|
+
this.element.setAttribute("data-state", state);
|
|
61
216
|
}
|
|
62
217
|
};
|
|
63
218
|
|
|
@@ -20,6 +20,39 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
20
20
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// src/utils/microtask_coalescer.ts
|
|
24
|
+
var MicrotaskCoalescer = class {
|
|
25
|
+
#run;
|
|
26
|
+
#queued = false;
|
|
27
|
+
#active = false;
|
|
28
|
+
#generation = 0;
|
|
29
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
30
|
+
constructor(run) {
|
|
31
|
+
this.#run = run;
|
|
32
|
+
}
|
|
33
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
34
|
+
activate() {
|
|
35
|
+
this.#active = true;
|
|
36
|
+
}
|
|
37
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
38
|
+
cancel() {
|
|
39
|
+
this.#active = false;
|
|
40
|
+
this.#queued = false;
|
|
41
|
+
this.#generation += 1;
|
|
42
|
+
}
|
|
43
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
44
|
+
schedule() {
|
|
45
|
+
if (!this.#active || this.#queued) return;
|
|
46
|
+
this.#queued = true;
|
|
47
|
+
const generation = this.#generation;
|
|
48
|
+
queueMicrotask(() => {
|
|
49
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
50
|
+
this.#queued = false;
|
|
51
|
+
this.#run();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
23
56
|
// src/utils/roving_tabindex.ts
|
|
24
57
|
var RovingTabindex = class {
|
|
25
58
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -42,10 +75,12 @@ var RovingTabindex = class {
|
|
|
42
75
|
* "nothing is currently tabbable".
|
|
43
76
|
*
|
|
44
77
|
* @param index - Position of the item to make tabbable.
|
|
45
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item
|
|
78
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
79
|
+
* and `items` to reuse an event-scoped collection snapshot.
|
|
46
80
|
*/
|
|
47
|
-
setActive(index,
|
|
48
|
-
const
|
|
81
|
+
setActive(index, options = {}) {
|
|
82
|
+
const { focus = false } = options;
|
|
83
|
+
const items = options.items ?? this.#getItems();
|
|
49
84
|
items.forEach((item, i) => {
|
|
50
85
|
item.tabIndex = i === index ? 0 : -1;
|
|
51
86
|
});
|
|
@@ -120,9 +155,14 @@ var CarouselController = class extends Controller {
|
|
|
120
155
|
"resume",
|
|
121
156
|
"togglePlay"
|
|
122
157
|
];
|
|
123
|
-
static events = ["change", "pause", "play"];
|
|
158
|
+
static events = ["change", "pause", "play", "reconcile"];
|
|
124
159
|
#roving = new RovingTabindex(() => this.pickerTargets);
|
|
125
|
-
|
|
160
|
+
#reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
|
|
161
|
+
/**
|
|
162
|
+
* Whether `connect()` has run. Scheduling is already inert outside that window
|
|
163
|
+
* ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
|
|
164
|
+
* present at mount authored for itself.
|
|
165
|
+
*/
|
|
126
166
|
#connected = false;
|
|
127
167
|
#intervals = new SafeInterval();
|
|
128
168
|
/** Index of the visible slide. */
|
|
@@ -148,6 +188,7 @@ var CarouselController = class extends Controller {
|
|
|
148
188
|
this.#render({ focus: false });
|
|
149
189
|
this.#syncTimer();
|
|
150
190
|
this.#connected = true;
|
|
191
|
+
this.#reconcileTargets.activate();
|
|
151
192
|
}
|
|
152
193
|
/**
|
|
153
194
|
* Re-establishes the single selected picker when one is added after connect.
|
|
@@ -158,9 +199,22 @@ var CarouselController = class extends Controller {
|
|
|
158
199
|
* repaint re-derives every picker from `#index`, so a late arrival never steals
|
|
159
200
|
* the selection.
|
|
160
201
|
*/
|
|
161
|
-
pickerTargetConnected() {
|
|
202
|
+
pickerTargetConnected(picker) {
|
|
162
203
|
if (!this.#connected) return;
|
|
163
|
-
|
|
204
|
+
picker.tabIndex = -1;
|
|
205
|
+
this.#reconcileTargets.schedule();
|
|
206
|
+
}
|
|
207
|
+
/** Repairs selection and roving after a picker leaves a retained carousel. */
|
|
208
|
+
pickerTargetDisconnected() {
|
|
209
|
+
this.#reconcileTargets.schedule();
|
|
210
|
+
}
|
|
211
|
+
/** Reconciles a slide added in the same DOM batch as its picker. */
|
|
212
|
+
slideTargetConnected() {
|
|
213
|
+
this.#reconcileTargets.schedule();
|
|
214
|
+
}
|
|
215
|
+
/** Re-clamps the active index after a slide is removed. */
|
|
216
|
+
slideTargetDisconnected() {
|
|
217
|
+
this.#reconcileTargets.schedule();
|
|
164
218
|
}
|
|
165
219
|
/**
|
|
166
220
|
* Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
|
|
@@ -178,6 +232,7 @@ var CarouselController = class extends Controller {
|
|
|
178
232
|
/** Clears the autoplay interval so it never fires after teardown. */
|
|
179
233
|
disconnect() {
|
|
180
234
|
this.#connected = false;
|
|
235
|
+
this.#reconcileTargets.cancel();
|
|
181
236
|
this.#intervals.clearAll();
|
|
182
237
|
this.#timerId = null;
|
|
183
238
|
}
|
|
@@ -282,10 +337,31 @@ var CarouselController = class extends Controller {
|
|
|
282
337
|
slide.setAttribute("data-state", active ? "active" : "inactive");
|
|
283
338
|
slide.hidden = !active;
|
|
284
339
|
});
|
|
340
|
+
const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
|
|
285
341
|
this.pickerTargets.forEach((picker, i) => {
|
|
286
|
-
picker.setAttribute("aria-selected", i ===
|
|
342
|
+
picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
|
|
287
343
|
});
|
|
288
|
-
this.#roving.setActive(
|
|
344
|
+
this.#roving.setActive(pickerIndex, { focus });
|
|
345
|
+
}
|
|
346
|
+
/** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
|
|
347
|
+
#reconcileTargetSet() {
|
|
348
|
+
const activeSlide = this.slideTargets.findIndex(
|
|
349
|
+
(slide) => slide.getAttribute("data-state") === "active"
|
|
350
|
+
);
|
|
351
|
+
const selectedPicker = this.pickerTargets.findIndex(
|
|
352
|
+
(picker) => picker.getAttribute("aria-selected") === "true"
|
|
353
|
+
);
|
|
354
|
+
const lastSlide = this.slideTargets.length - 1;
|
|
355
|
+
const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
|
|
356
|
+
const previous = this.#index;
|
|
357
|
+
this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
|
|
358
|
+
this.#render({ focus: false });
|
|
359
|
+
this.#syncTimer();
|
|
360
|
+
if (this.#index !== previous) {
|
|
361
|
+
this.dispatch("reconcile", {
|
|
362
|
+
detail: { index: this.#index, total: this.slideTargets.length }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
289
365
|
}
|
|
290
366
|
/**
|
|
291
367
|
* Drives the autoplay interval toward the desired state. Autoplay should run
|