stimeo-ui 0.3.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.
- package/CHANGELOG.md +124 -0
- package/dist/controllers/alert_dialog_controller.js +32 -5
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +32 -6
- package/dist/controllers/announcer_controller.js +255 -20
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/aspect_ratio_controller.d.ts +2 -2
- package/dist/controllers/aspect_ratio_controller.js +1 -1
- package/dist/controllers/aspect_ratio_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.js +5 -1
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.js +5 -1
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.js +8 -3
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +1 -1
- package/dist/controllers/collapsible_controller.js +4 -1
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +4 -3
- package/dist/controllers/color_picker_controller.js +52 -2
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.js +32 -5
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +32 -5
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +1 -1
- package/dist/controllers/context_menu_controller.js +2 -2
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +24 -8
- package/dist/controllers/countdown_controller.js +117 -13
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +4 -1
- package/dist/controllers/date_range_picker_controller.js +55 -1
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +32 -5
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +1 -1
- package/dist/controllers/direct_upload_controller.js +3 -3
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +32 -5
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +36 -11
- package/dist/controllers/empty_state_controller.js +128 -23
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +25 -5
- package/dist/controllers/flash_controller.js +161 -21
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +32 -5
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +8 -2
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +31 -3
- package/dist/controllers/frame_loading_controller.js +261 -27
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.d.ts +8 -4
- package/dist/controllers/highlight_controller.js +38 -1
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +2 -1
- package/dist/controllers/idle_controller.js +13 -2
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +19 -3
- package/dist/controllers/local_time_controller.js +102 -6
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +1 -1
- package/dist/controllers/masonry_controller.js +1 -1
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +22 -2
- package/dist/controllers/meter_controller.js +147 -26
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +25 -11
- package/dist/controllers/network_status_controller.js +29 -11
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +8 -0
- package/dist/controllers/number_input_controller.js +191 -24
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.js +34 -7
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.js +5 -1
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +1 -1
- package/dist/controllers/password_strength_controller.js +1 -1
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +10 -0
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/portal_controller.js +10 -0
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +17 -2
- package/dist/controllers/progress_controller.js +123 -12
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +27 -1
- package/dist/controllers/range_slider_controller.js +449 -93
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +4 -1
- package/dist/controllers/rating_controller.js +55 -0
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +13 -0
- package/dist/controllers/relative_time_controller.js +135 -12
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +1 -1
- package/dist/controllers/scroll_area_controller.js +1 -1
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/separator_controller.js +13 -17
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +1 -11
- package/dist/controllers/sidebar_controller.js +37 -8
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +7 -2
- package/dist/controllers/skeleton_controller.js +143 -22
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +17 -1
- package/dist/controllers/slider_controller.js +342 -50
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +50 -12
- package/dist/controllers/spinner_controller.js +244 -28
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +13 -2
- package/dist/controllers/step_indicator_controller.js +85 -6
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.js +2 -0
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +26 -2
- package/dist/controllers/stick_to_bottom_controller.js +60 -10
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +12 -8
- package/dist/controllers/switch_controller.js +162 -18
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.js +1 -1
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +3 -0
- package/dist/controllers/time_picker_controller.js +6 -3
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +1 -2
- package/dist/controllers/tree_view_controller.js +19 -1
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +2278 -596
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +76 -1
- package/dist/inspector/cli.js +937 -76
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +997 -78
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +20 -20
- package/dist/inspector/manifest.json +428 -33
- package/package.json +3 -3
|
@@ -13,17 +13,21 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
13
13
|
* </div>
|
|
14
14
|
*
|
|
15
15
|
* Counts `list`'s child items (all element children, or those matching
|
|
16
|
-
* `itemSelector`) on connect and on every
|
|
17
|
-
* `list` / `empty` at the 0 ↔ 1+ boundary, and reflects
|
|
18
|
-
* on the controller element. Crossing the boundary
|
|
16
|
+
* `itemSelector`) on connect and on every mutation that can change that count,
|
|
17
|
+
* toggles `hidden` on `list` / `empty` at the 0 ↔ 1+ boundary, and reflects
|
|
18
|
+
* `data-empty` / `data-count` on the controller element. Crossing the boundary
|
|
19
|
+
* dispatches `change` after the display is updated, so a listener reads the
|
|
20
|
+
* state the crossing produced.
|
|
19
21
|
*
|
|
20
22
|
* @remarks
|
|
21
23
|
* Behavior only — the placeholder's look/copy is the consumer's. State is derived
|
|
22
24
|
* from the DOM (no module-scope state), so `connect()` re-syncs after a Turbo
|
|
23
|
-
* Stream insertion.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Stream insertion. Both targets are re-resolved at runtime: a `list` element
|
|
26
|
+
* swapped in re-points the observation, and a swapped-in `empty` element is
|
|
27
|
+
* re-synced — including when the replacement is inserted while the original is
|
|
28
|
+
* still in the document. The `MutationObserver` is severed on `disconnect()`
|
|
29
|
+
* (Turbo navigation included). `announceText` / `announceFilledText` are read at
|
|
30
|
+
* the crossing and handed to the page's announcer.
|
|
27
31
|
*
|
|
28
32
|
* Ownership note: this controller deliberately owns `hidden` on the `list` / `empty`
|
|
29
33
|
* targets as its single source of truth for which one is shown, rather than only
|
|
@@ -41,9 +45,13 @@ declare class EmptyStateController extends Controller<HTMLElement> {
|
|
|
41
45
|
type: StringConstructor;
|
|
42
46
|
default: string;
|
|
43
47
|
};
|
|
44
|
-
|
|
45
|
-
type:
|
|
46
|
-
default:
|
|
48
|
+
announceText: {
|
|
49
|
+
type: StringConstructor;
|
|
50
|
+
default: string;
|
|
51
|
+
};
|
|
52
|
+
announceFilledText: {
|
|
53
|
+
type: StringConstructor;
|
|
54
|
+
default: string;
|
|
47
55
|
};
|
|
48
56
|
};
|
|
49
57
|
static events: readonly ["change"];
|
|
@@ -52,9 +60,26 @@ declare class EmptyStateController extends Controller<HTMLElement> {
|
|
|
52
60
|
readonly hasListTarget: boolean;
|
|
53
61
|
readonly hasEmptyTarget: boolean;
|
|
54
62
|
itemSelectorValue: string;
|
|
55
|
-
|
|
63
|
+
announceTextValue: string;
|
|
64
|
+
announceFilledTextValue: string;
|
|
56
65
|
connect(): void;
|
|
57
66
|
disconnect(): void;
|
|
67
|
+
/** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
|
|
68
|
+
listTargetConnected(): void;
|
|
69
|
+
/** Releases the observation when the `list` element leaves the target set. */
|
|
70
|
+
listTargetDisconnected(): void;
|
|
71
|
+
/** Syncs an `empty` element that arrives — or is replaced — at runtime. */
|
|
72
|
+
emptyTargetConnected(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Syncs the `empty` element that remains when one leaves the target set. A
|
|
75
|
+
* single-target getter resolves to the first `empty` element in document order,
|
|
76
|
+
* so a swap that inserts the replacement *before* removing the original (Turbo
|
|
77
|
+
* Stream `after` / `before` / `append` followed by `remove`) leaves the
|
|
78
|
+
* replacement untouched until the original goes — this callback is that moment.
|
|
79
|
+
*/
|
|
80
|
+
emptyTargetDisconnected(): void;
|
|
81
|
+
/** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
|
|
82
|
+
itemSelectorValueChanged(): void;
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
export { EmptyStateController };
|
|
@@ -1,35 +1,148 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/empty_state_controller.ts
|
|
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
|
+
|
|
3
22
|
// src/controllers/empty_state_controller.ts
|
|
4
23
|
var EmptyStateController = class extends Controller {
|
|
5
24
|
static targets = ["list", "empty"];
|
|
6
25
|
static values = {
|
|
7
26
|
itemSelector: { type: String, default: "" },
|
|
8
|
-
|
|
27
|
+
announceText: { type: String, default: "" },
|
|
28
|
+
announceFilledText: { type: String, default: "" }
|
|
9
29
|
};
|
|
10
30
|
static events = ["change"];
|
|
11
31
|
#observer = null;
|
|
32
|
+
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
33
|
+
#connected = false;
|
|
12
34
|
/** Last applied empty state; `null` until the first sync so connect emits nothing. */
|
|
13
35
|
#empty = null;
|
|
14
36
|
connect() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
this.emptyTarget.setAttribute("aria-live", "polite");
|
|
19
|
-
}
|
|
20
|
-
if (typeof MutationObserver !== "undefined") {
|
|
21
|
-
this.#observer = new MutationObserver(() => this.#apply());
|
|
22
|
-
this.#observer.observe(this.listTarget, { childList: true });
|
|
23
|
-
}
|
|
24
|
-
this.#apply();
|
|
37
|
+
this.#connected = true;
|
|
38
|
+
this.#syncObservation();
|
|
39
|
+
this.#update();
|
|
25
40
|
}
|
|
26
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() {
|
|
27
108
|
this.#observer?.disconnect();
|
|
28
109
|
this.#observer = null;
|
|
29
110
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
33
146
|
const count = this.#count();
|
|
34
147
|
const empty = count === 0;
|
|
35
148
|
this.element.setAttribute("data-count", String(count));
|
|
@@ -40,10 +153,7 @@ var EmptyStateController = class extends Controller {
|
|
|
40
153
|
}
|
|
41
154
|
this.listTarget.hidden = empty;
|
|
42
155
|
if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
|
|
43
|
-
|
|
44
|
-
this.dispatch("change", { detail: { count, empty } });
|
|
45
|
-
}
|
|
46
|
-
this.#empty = empty;
|
|
156
|
+
return count;
|
|
47
157
|
}
|
|
48
158
|
/** Item count: element children matching `itemSelector`, or all element children. */
|
|
49
159
|
#count() {
|
|
@@ -55,11 +165,6 @@ var EmptyStateController = class extends Controller {
|
|
|
55
165
|
return this.listTarget.childElementCount;
|
|
56
166
|
}
|
|
57
167
|
}
|
|
58
|
-
#isLiveRegion(el) {
|
|
59
|
-
if (el.hasAttribute("aria-live")) return true;
|
|
60
|
-
const role = el.getAttribute("role");
|
|
61
|
-
return role === "status" || role === "alert";
|
|
62
|
-
}
|
|
63
168
|
};
|
|
64
169
|
|
|
65
170
|
export { EmptyStateController };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/empty_state_controller.ts"],"names":[],"mappings":"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","file":"empty_state_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless empty-state behavior: shows an \"empty\" placeholder when a list has no\n * items and hides it once one arrives (and vice-versa), watching the list with a\n * `MutationObserver` (no dedicated APG pattern; follows the WCAG \"status messages\"\n * practice when announcing).\n *\n * Markup contract (identifier: `stimeo--empty-state`):\n * <div data-controller=\"stimeo--empty-state\">\n * <ul data-stimeo--empty-state-target=\"list\"><!-- Turbo Stream rows --></ul>\n * <p data-stimeo--empty-state-target=\"empty\" hidden>No items</p>\n * </div>\n *\n * Counts `list`'s child items (all element children, or those matching\n * `itemSelector`) on connect and on every childList mutation, toggles `hidden` on\n * `list` / `empty` at the 0 ↔ 1+ boundary, and reflects `data-empty` / `data-count`\n * on the controller element. Crossing the boundary dispatches `change`.\n *\n * @remarks\n * Behavior only — the placeholder's look/copy is the consumer's. State is derived\n * from the DOM (no module-scope state), so `connect()` re-syncs after a Turbo\n * Stream insertion. The `MutationObserver` is severed on `disconnect()` (Turbo\n * navigation included). With `announce`, the `empty` target is made a polite live\n * region (only if the author hasn't already), so showing it is announced — SR\n * support for unhiding a live region varies; pair with Announcer for a guarantee.\n *\n * Ownership note: this controller deliberately owns `hidden` on the `list` / `empty`\n * targets as its single source of truth for which one is shown, rather than only\n * emitting `data-empty` and delegating visibility to consumer CSS. The toggle is\n * unconditional (set every sync), so there is nothing to save/restore and no\n * authored `hidden` to preserve — the displayed half is always a pure function of\n * the item count. Consumers wanting CSS-driven visibility should not also set\n * `hidden` on these targets themselves.\n */\nexport class EmptyStateController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"empty\"];\n static override values = {\n itemSelector: { type: String, default: \"\" },\n announce: { type: Boolean, default: false },\n };\n static events = [\"change\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly emptyTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasEmptyTarget: boolean;\n\n declare itemSelectorValue: string;\n declare announceValue: boolean;\n\n #observer: MutationObserver | null = null;\n /** Last applied empty state; `null` until the first sync so connect emits nothing. */\n #empty: boolean | null = null;\n\n override connect(): void {\n if (!this.hasListTarget) return;\n if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {\n this.emptyTarget.setAttribute(\"role\", \"status\");\n this.emptyTarget.setAttribute(\"aria-live\", \"polite\");\n }\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#apply());\n this.#observer.observe(this.listTarget, { childList: true });\n }\n this.#apply();\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /** Recomputes the count and syncs visibility, hooks, and the change event. */\n #apply(): void {\n if (!this.hasListTarget) return;\n const count = this.#count();\n const empty = count === 0;\n\n this.element.setAttribute(\"data-count\", String(count));\n if (empty) {\n this.element.setAttribute(\"data-empty\", \"true\");\n } else {\n this.element.removeAttribute(\"data-empty\");\n }\n this.listTarget.hidden = empty;\n if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;\n\n // Emit only when the 0 ↔ 1+ boundary is crossed (not on the initial sync, and\n // not for count changes that stay non-empty, e.g. 2 → 3).\n if (this.#empty !== null && empty !== this.#empty) {\n this.dispatch(\"change\", { detail: { count, empty } });\n }\n this.#empty = empty;\n }\n\n /** Item count: element children matching `itemSelector`, or all element children. */\n #count(): number {\n const selector = this.itemSelectorValue;\n if (selector.length === 0) return this.listTarget.childElementCount;\n try {\n return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;\n } catch {\n // An invalid selector (author typo) must not crash the controller — count all.\n return this.listTarget.childElementCount;\n }\n }\n\n #isLiveRegion(el: HTMLElement): boolean {\n if (el.hasAttribute(\"aria-live\")) return true;\n const role = el.getAttribute(\"role\");\n return role === \"status\" || role === \"alert\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/announce.ts","../../src/controllers/empty_state_controller.ts"],"names":[],"mappings":"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","file":"empty_state_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","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\n\n/**\n * Headless empty-state behavior: shows an \"empty\" placeholder when a list has no\n * items and hides it once one arrives (and vice-versa), watching the list with a\n * `MutationObserver` (no dedicated APG pattern; follows the WCAG \"status messages\"\n * practice when announcing).\n *\n * Markup contract (identifier: `stimeo--empty-state`):\n * <div data-controller=\"stimeo--empty-state\">\n * <ul data-stimeo--empty-state-target=\"list\"><!-- Turbo Stream rows --></ul>\n * <p data-stimeo--empty-state-target=\"empty\" hidden>No items</p>\n * </div>\n *\n * Counts `list`'s child items (all element children, or those matching\n * `itemSelector`) on connect and on every mutation that can change that count,\n * toggles `hidden` on `list` / `empty` at the 0 ↔ 1+ boundary, and reflects\n * `data-empty` / `data-count` on the controller element. Crossing the boundary\n * dispatches `change` after the display is updated, so a listener reads the\n * state the crossing produced.\n *\n * @remarks\n * Behavior only — the placeholder's look/copy is the consumer's. State is derived\n * from the DOM (no module-scope state), so `connect()` re-syncs after a Turbo\n * Stream insertion. Both targets are re-resolved at runtime: a `list` element\n * swapped in re-points the observation, and a swapped-in `empty` element is\n * re-synced — including when the replacement is inserted while the original is\n * still in the document. The `MutationObserver` is severed on `disconnect()`\n * (Turbo navigation included). `announceText` / `announceFilledText` are read at\n * the crossing and handed to the page's announcer.\n *\n * Ownership note: this controller deliberately owns `hidden` on the `list` / `empty`\n * targets as its single source of truth for which one is shown, rather than only\n * emitting `data-empty` and delegating visibility to consumer CSS. The toggle is\n * unconditional (set every sync), so there is nothing to save/restore and no\n * authored `hidden` to preserve — the displayed half is always a pure function of\n * the item count. Consumers wanting CSS-driven visibility should not also set\n * `hidden` on these targets themselves.\n */\nexport class EmptyStateController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"empty\"];\n static override values = {\n itemSelector: { type: String, default: \"\" },\n announceText: { type: String, default: \"\" },\n announceFilledText: { type: String, default: \"\" },\n };\n static events = [\"change\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly emptyTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasEmptyTarget: boolean;\n\n declare itemSelectorValue: string;\n declare announceTextValue: string;\n declare announceFilledTextValue: string;\n\n #observer: MutationObserver | null = null;\n /** Whether the controller is between `connect()` and `disconnect()`. */\n #connected = false;\n /** Last applied empty state; `null` until the first sync so connect emits nothing. */\n #empty: boolean | null = null;\n\n override connect(): void {\n this.#connected = true;\n this.#syncObservation();\n this.#update();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#stopObserving();\n }\n\n /** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */\n listTargetConnected(): void {\n this.#resync();\n }\n\n /** Releases the observation when the `list` element leaves the target set. */\n listTargetDisconnected(): void {\n this.#resync();\n }\n\n /** Syncs an `empty` element that arrives — or is replaced — at runtime. */\n emptyTargetConnected(): void {\n this.#resync();\n }\n\n /**\n * Syncs the `empty` element that remains when one leaves the target set. A\n * single-target getter resolves to the first `empty` element in document order,\n * so a swap that inserts the replacement *before* removing the original (Turbo\n * Stream `after` / `before` / `append` followed by `remove`) leaves the\n * replacement untouched until the original goes — this callback is that moment.\n */\n emptyTargetDisconnected(): void {\n this.#resync();\n }\n\n /** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */\n itemSelectorValueChanged(): void {\n this.#resync();\n }\n\n /**\n * Re-points the observation and re-renders after a target or selector change.\n * The `#connected` guard is load-bearing: Stimulus runs value and target\n * callbacks for the initial markup *before* `connect()` and runs target\n * callbacks during teardown *after* `disconnect()`, and re-observing there\n * would outlive the controller.\n */\n #resync(): void {\n if (!this.#connected) return;\n this.#syncObservation();\n this.#update();\n }\n\n /**\n * Points the mutation observation at the current `list` target — re-resolved on\n * every sync rather than captured at connect, so an element swapped in at\n * runtime is observed instead of the detached original.\n *\n * The observation covers exactly what the count predicate reads. With no\n * `itemSelector` the count is the child element count, which only `childList`\n * can change. With one, the predicate reads the children themselves, so\n * attribute and descendant mutations are watched too — and the controller's own\n * writes are filtered back out, or toggling `hidden` would re-enter the render.\n */\n #syncObservation(): void {\n this.#stopObserving();\n if (!this.hasListTarget || typeof MutationObserver === \"undefined\") return;\n const watchesItems = this.itemSelectorValue.length > 0;\n this.#observer = new MutationObserver((records) => {\n if (records.some((record) => this.#affectsCount(record))) this.#update();\n });\n this.#observer.observe(this.listTarget, {\n childList: true,\n subtree: watchesItems,\n attributes: watchesItems,\n });\n }\n\n #stopObserving(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Whether a mutation can change the item count. An attribute written on a\n * target this controller owns is its own echo — `hidden` on `list` / `empty`,\n * and the hooks on the controller element when the list *is* that element.\n */\n #affectsCount(record: MutationRecord): boolean {\n if (record.type !== \"attributes\") return true;\n const own =\n record.target === this.listTarget ||\n (this.hasEmptyTarget && record.target === this.emptyTarget);\n return !own;\n }\n\n /**\n * Renders the count, then reports a crossed boundary. The announcement copy is\n * read here rather than while rendering: it is the wording of the report, not\n * an input to what is displayed.\n */\n #update(): void {\n const count = this.#render();\n if (count === null) return;\n const empty = count === 0;\n // Report only when the 0 ↔ 1+ boundary is crossed (not on the initial sync,\n // and not for count changes that stay non-empty, e.g. 2 → 3).\n const crossed = this.#empty !== null && empty !== this.#empty;\n this.#empty = empty;\n if (!crossed) return;\n\n this.dispatch(\"change\", { detail: { count, empty } });\n // The crossing is the news, and the page's announcer reads it: a region that\n // only becomes live when the empty state appears is not reliably announced.\n announce(\n fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count }),\n );\n }\n\n /**\n * Syncs visibility and the state hooks to the current item count, and returns\n * it. `null` when there is no `list` target to count.\n *\n * @stimeoRenderRoot\n */\n #render(): number | null {\n if (!this.hasListTarget) return null;\n const count = this.#count();\n const empty = count === 0;\n\n this.element.setAttribute(\"data-count\", String(count));\n if (empty) {\n this.element.setAttribute(\"data-empty\", \"true\");\n } else {\n this.element.removeAttribute(\"data-empty\");\n }\n this.listTarget.hidden = empty;\n if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;\n return count;\n }\n\n /** Item count: element children matching `itemSelector`, or all element children. */\n #count(): number {\n const selector = this.itemSelectorValue;\n if (selector.length === 0) return this.listTarget.childElementCount;\n try {\n return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;\n } catch {\n // An invalid selector (author typo) must not crash the controller — count all.\n return this.listTarget.childElementCount;\n }\n }\n}\n"]}
|
|
@@ -15,9 +15,9 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
15
15
|
*
|
|
16
16
|
* Each message is mapped by `data-flash-type` to `role="status"` (notice) or
|
|
17
17
|
* `role="alert"` (alert/error), flagged `data-flash-state="visible"`, auto-dismissed
|
|
18
|
-
* after `duration` (paused while hovered
|
|
19
|
-
*
|
|
20
|
-
* one manually.
|
|
18
|
+
* after `duration` (paused while hovered *or* focused when `pauseOnHover`, and
|
|
19
|
+
* resumed only once both are released), and capped at `max` simultaneous messages.
|
|
20
|
+
* A close button wired to the `dismiss` action removes one manually.
|
|
21
21
|
*
|
|
22
22
|
* @remarks
|
|
23
23
|
* Reading is **delegated to the shared Announcer** — but only for the *initial*,
|
|
@@ -26,8 +26,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
26
26
|
* event. Messages inserted *later* (Turbo Stream) are announced by their own freshly
|
|
27
27
|
* inserted `role`, exactly like Toast, so they are not bridged again (no double
|
|
28
28
|
* announcement). Behavior only — no styling; `data-flash-state="leaving"` lets CSS
|
|
29
|
-
* animate removal. Focus is never moved (WCAG 2.2 4.1.3)
|
|
30
|
-
*
|
|
29
|
+
* animate removal. Focus is never moved (WCAG 2.2 4.1.3): pausing never removes a
|
|
30
|
+
* message, so the control under the pointer or holding focus stays put. The
|
|
31
|
+
* observation follows a `region` element replaced at runtime; the managed set is the
|
|
32
|
+
* current region's subtree, and a message that leaves it gives up its stacking slot,
|
|
33
|
+
* its pending auto-dismiss, and any removal already scheduled. The observer, timers,
|
|
34
|
+
* and per-message listeners are torn down on `disconnect()` (Turbo navigation
|
|
35
|
+
* included), and the managed flashes leave the page before Turbo caches it so a
|
|
36
|
+
* restored snapshot does not replay a notification the visitor already received.
|
|
31
37
|
*/
|
|
32
38
|
declare class FlashController extends Controller<HTMLElement> {
|
|
33
39
|
#private;
|
|
@@ -56,6 +62,20 @@ declare class FlashController extends Controller<HTMLElement> {
|
|
|
56
62
|
maxValue: number;
|
|
57
63
|
connect(): void;
|
|
58
64
|
disconnect(): void;
|
|
65
|
+
/** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
|
|
66
|
+
regionTargetConnected(): void;
|
|
67
|
+
/** Releases the observation when the `region` element leaves the target set. */
|
|
68
|
+
regionTargetDisconnected(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Releases a message that left the target set (a Turbo Stream `remove`, the consumer
|
|
71
|
+
* detaching the node, or a morph that rewrote the target attribute in place): it
|
|
72
|
+
* stops occupying a `max` slot, and both its pending auto-dismiss and an already
|
|
73
|
+
* scheduled removal are cancelled. A move *within* the region keeps all of them —
|
|
74
|
+
* which is why the element must still be a message to be treated as one: ownership
|
|
75
|
+
* alone reads an in-place attribute rewrite as a move, and a node outside the target
|
|
76
|
+
* set belongs to the consumer, so nothing here may dismiss it.
|
|
77
|
+
*/
|
|
78
|
+
messageTargetDisconnected(message: HTMLElement): void;
|
|
59
79
|
/** Dismisses the flash whose close control fired the event. */
|
|
60
80
|
dismiss(event: Event): void;
|
|
61
81
|
}
|
|
@@ -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
|
};
|