stimeo-ui 0.9.0 → 0.11.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 +149 -0
- data/dist/cable/index.js +226 -43
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +2 -2
- data/dist/controllers/collapsible_controller.js +1 -1
- data/dist/controllers/command_palette_controller.js +15 -5
- data/dist/controllers/confirm_controller.js +15 -5
- data/dist/controllers/countdown_controller.js +1 -1
- data/dist/controllers/data_grid_controller.js +1 -1
- data/dist/controllers/dialog_controller.js +15 -5
- data/dist/controllers/direct_upload_controller.js +1 -5
- data/dist/controllers/drawer_controller.js +17 -7
- data/dist/controllers/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- data/dist/controllers/overflow_menu_controller.js +2 -2
- data/dist/controllers/password_reveal_controller.js +102 -3
- data/dist/controllers/password_strength_controller.js +287 -42
- data/dist/controllers/pointer_drag_controller.js +64 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/resizable_controller.js +1 -1
- data/dist/controllers/roving_controller.js +127 -14
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/stick_to_bottom_controller.js +149 -28
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +1188 -318
- data/dist/positioning/index.js +84 -25
- 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: dbc740b1497e7c760dce2eb0207e1e513a64e944aef2d666b8785c1244bd4618
|
|
4
|
+
data.tar.gz: e687fa19aae7ea8432130228a1bb0bfeb82868f56b18c8d823edb5d6dd1b77c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eeeef9df2dedeb5aa23da6de1c08bc6921f0c85554de9162999895d48ddd89950dfe5dfa32b895d3fd80a780470966a2ae9f9517e8743b6b7868c9a1e441b2da
|
|
7
|
+
data.tar.gz: 3a40f5f32596616f06fa52a93a446ddf73ed25421c610c54209d200eb8c631d5b334e1f014852e8331e4fd9b09ad0933929ad1de54d36aaa4d14361e05a2bcc0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,153 @@ 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.11.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
Minor release with no new components. Eight existing ones are reworked — the three
|
|
13
|
+
cable controllers (live-counter, presence, typing-indicator) plus pointer-drag,
|
|
14
|
+
portal, preview-guard, roving, and stick-to-bottom. Two of them changed a contract,
|
|
15
|
+
so read Removed before upgrading. The Inspector manifest stays on schema v12.
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
|
|
19
|
+
- preview-guard: the `mode` Value. `placeholder` alone decides the guard's form —
|
|
20
|
+
empty (the default) hides the element with `visibility`, and any other text takes
|
|
21
|
+
the place of its content.
|
|
22
|
+
- typing-indicator: the live region on the `status` target. It is a plain visible
|
|
23
|
+
slot now — drop its `aria-live`, seat a `stimeo--announcer` on the page, and set
|
|
24
|
+
`announceOneText` / `announceManyText`. `stimeo check` stops asking for the
|
|
25
|
+
live-region semantics too.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- typing-indicator: `announceOneText` / `announceManyText` (`{name}` / `{names}` /
|
|
30
|
+
`{count}`), debounced through the shared announcer.
|
|
31
|
+
- pointer-drag: a `reset` action that returns the element to its origin — dropping
|
|
32
|
+
the committed follow offset and cancelling an in-flight drag.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- The cable controllers share one wire subscription per identifier (channel +
|
|
37
|
+
params), so a second widget on the same stream sends instead of waiting on a
|
|
38
|
+
confirmation that never comes. A caller joining a confirmed or refused
|
|
39
|
+
identifier is told so on the next microtask, and the wire is released when the
|
|
40
|
+
last one leaves.
|
|
41
|
+
- Cable identifier parameters are read by the controller, so an unreadable
|
|
42
|
+
`params` declaration falls back to the channel alone instead of stopping the
|
|
43
|
+
subscription from being created at all.
|
|
44
|
+
- An element moved within the page keeps what it was carrying: pointer-drag's
|
|
45
|
+
session and the attributes it lends its handles, portal's teleport,
|
|
46
|
+
preview-guard's guard, and roving's tab stop.
|
|
47
|
+
- A declaration that cannot be read falls back to its default — typing-indicator's
|
|
48
|
+
`timeout` / `throttle`, stick-to-bottom's `threshold`, and portal's `to`, which
|
|
49
|
+
now lands the node at the default destination instead of leaving it in place.
|
|
50
|
+
- A key that steers an IME conversion is left to the composition (pointer-drag,
|
|
51
|
+
roving), roving passes a modified `Home` / `End` through to the browser, and
|
|
52
|
+
pointer-drag leaves a press to a native control or editing surface inside the
|
|
53
|
+
handle.
|
|
54
|
+
- roving: an item that cannot take focus — `hidden`, natively `disabled`,
|
|
55
|
+
including through a `fieldset` — is neither a move target nor a tab-stop
|
|
56
|
+
candidate, and the horizontal arrow pair follows the writing direction.
|
|
57
|
+
- pointer-drag: the `touch-action` and `tabindex` lent to a handle are given back
|
|
58
|
+
when an element stops being one, and a handle that leaves the controller ends
|
|
59
|
+
its session in `cancel`.
|
|
60
|
+
- preview-guard: the rewind Turbo's snapshot needs runs on `turbo:before-cache`,
|
|
61
|
+
the inline `visibility` is leased so an authored declaration survives, and the
|
|
62
|
+
child markup a placeholder displaced is put back intact.
|
|
63
|
+
- Transient state is re-derived on connect rather than trusted from a restored
|
|
64
|
+
snapshot: the cable controllers' rejection hooks, stick-to-bottom's
|
|
65
|
+
`data-pinned` / `data-has-new`, and preview-guard's `data-preview-hidden`.
|
|
66
|
+
- A swap at runtime is followed rather than left behind: typing-indicator repaints
|
|
67
|
+
a `status` slot swapped in mid-conversation, stick-to-bottom moves its append
|
|
68
|
+
watch onto a `content` target that arrives or leaves and re-derives pinned from a
|
|
69
|
+
`threshold` changed on the element, and preview-guard re-forms a guard that is
|
|
70
|
+
already up to match a changed `placeholder`.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- stick-to-bottom: a container connected without a box holds unpinned until the
|
|
75
|
+
layout arrives, so what arrives meanwhile is flagged rather than followed into a
|
|
76
|
+
box that cannot move.
|
|
77
|
+
- pointer-drag: an in-flight follow offset leaves the DOM on teardown, so a
|
|
78
|
+
re-inserted element does not read it back as a committed base.
|
|
79
|
+
- portal: an instance that has been replaced on the same element no longer rewinds
|
|
80
|
+
the teleport the live one holds.
|
|
81
|
+
- typing-indicator: a name carrying `$&`, `` $` ``, `$'` or `$$` is written into the
|
|
82
|
+
status copy literally instead of expanding into the template's own text.
|
|
83
|
+
|
|
84
|
+
## [0.10.0] - 2026-08-31
|
|
85
|
+
|
|
86
|
+
Minor release with no new components. Eight existing ones are reworked — anchored,
|
|
87
|
+
focus, password-reveal, password-strength, scroll-restore, scroll-visibility,
|
|
88
|
+
theme, and transition — and a shared change to the focus trap reaches the six
|
|
89
|
+
modal overlays too. Most of them changed a contract, so read Removed and Changed
|
|
90
|
+
before upgrading. The Inspector manifest stays on schema v12.
|
|
91
|
+
|
|
92
|
+
### Removed
|
|
93
|
+
|
|
94
|
+
- theme: the `mode` action param. Options declare their mode with
|
|
95
|
+
`data-value="light|dark|system"` — migrate every option or it can never become
|
|
96
|
+
the selection.
|
|
97
|
+
- password-strength: the live region on the `label` target. Drop its `aria-live`,
|
|
98
|
+
seat a `stimeo--announcer` on the page, and set `announceText`.
|
|
99
|
+
- focus: `[autofocus]` initial focus and the optional scroll lock, neither of
|
|
100
|
+
which was ever implemented. Initial focus is the `initial` target, else the
|
|
101
|
+
first focusable descendant, else the container.
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
|
|
105
|
+
- password-strength: `announceText` (`{level}` / `{score}` / `{max}` / `{band}`),
|
|
106
|
+
a `setScore` action for a score computed outside the built-in heuristic, and a
|
|
107
|
+
`reconcile` event.
|
|
108
|
+
- `stimeo check` warns when a page uses password-strength with no
|
|
109
|
+
`stimeo--announcer`.
|
|
110
|
+
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- alert-dialog, command-palette, confirm, dialog, drawer, sidebar, and focus:
|
|
114
|
+
background isolation walks from the element up to `<body>` and inerts each
|
|
115
|
+
ancestor's other children, so one nested below `<body>` isolates its own branch.
|
|
116
|
+
- transition: declare a token as a stage Value **or** author it as a standing
|
|
117
|
+
class, not both — a token already on the element is the consumer's and is never
|
|
118
|
+
applied or removed. `connect()` no longer strips half-applied stage classes.
|
|
119
|
+
- A half-applied state is rewound before Turbo caches the page, silently:
|
|
120
|
+
transition's stage classes, focus's `data-focus-trapped`, password-reveal's
|
|
121
|
+
revealed field, and everything password-strength derives from the field value.
|
|
122
|
+
- focus reads its declarations at fixed moments: only `trap` is watched, `auto`
|
|
123
|
+
and `inert` are read when the trap turns on, and `restore` when it turns off.
|
|
124
|
+
- A declaration that cannot be read falls back to its default instead of poisoning
|
|
125
|
+
the widget — anchored's `placement` / `offset` / `padding`, theme's `mode` /
|
|
126
|
+
`target`, scroll-visibility's `offset` / `root` / `focusSelector`,
|
|
127
|
+
password-strength's `minScore` / `levels`.
|
|
128
|
+
- Targets and declarations swapped in at runtime are followed: anchored's `anchor`
|
|
129
|
+
/ `floating`, password-reveal's `input` / `toggle`, password-strength's targets,
|
|
130
|
+
theme's options, scroll-visibility's `element` / `offset` / `mode`, and
|
|
131
|
+
scroll-restore's `key` / `axis`.
|
|
132
|
+
- Events report transitions only — theme's `change` when `mode` or `resolved`
|
|
133
|
+
actually moved, scroll-visibility's `change` on a real visibility change
|
|
134
|
+
(connecting is silent), password-strength's announcement when the level changes.
|
|
135
|
+
A scroll with no vertical movement leaves scroll-visibility alone.
|
|
136
|
+
- Smaller contract shifts: password-strength's `data-strength` bands anchor both
|
|
137
|
+
ends of the declared scale (a level count other than four moves the boundaries)
|
|
138
|
+
and its `meter` target needs an accessible name; scroll-restore always restores
|
|
139
|
+
instantly (so `scroll-behavior: smooth` no longer animates it) and writes
|
|
140
|
+
nothing for an element it never restored and the reader never scrolled; theme writes
|
|
141
|
+
`aria-pressed` only on a button and passes modified `Home` / `End` through.
|
|
142
|
+
|
|
143
|
+
### Fixed
|
|
144
|
+
|
|
145
|
+
- transition: `enter()` from `hidden` runs the CSS transition instead of popping
|
|
146
|
+
in, so `entered` arrives on the real terminal event rather than the safety
|
|
147
|
+
timeout.
|
|
148
|
+
- A control that owns focus is no longer hidden or stranded: scroll-visibility
|
|
149
|
+
holds the hide until it blurs and `toTop` focuses without scrolling, and focus's
|
|
150
|
+
state hook and events follow the controller's own record of trapping.
|
|
151
|
+
- Stored and measured state holds: scroll-restore keeps a saved offset that a
|
|
152
|
+
clamped restore would have overwritten and never republishes it under a new
|
|
153
|
+
`key`; password-reveal arms the `autoHide` it skipped on an already-revealed
|
|
154
|
+
field; anchored measures a target swapped in at runtime and writes no stale
|
|
155
|
+
placement after teardown.
|
|
156
|
+
|
|
10
157
|
## [0.9.0] - 2026-08-28
|
|
11
158
|
|
|
12
159
|
Minor release with no new components. Ten existing ones are reworked —
|
|
@@ -783,6 +930,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
783
930
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
784
931
|
RubyGems.
|
|
785
932
|
|
|
933
|
+
[0.11.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.11.0
|
|
934
|
+
[0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
|
|
786
935
|
[0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
|
|
787
936
|
[0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
|
|
788
937
|
[0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
|
data/dist/cable/index.js
CHANGED
|
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
import { createConsumer } from '@rails/actioncable';
|
|
3
3
|
|
|
4
4
|
// src/cable/live_counter_controller.ts
|
|
5
|
+
function parseSubscriptionParams(raw) {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
9
|
+
return parsed;
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
5
14
|
var sharedConsumer = null;
|
|
6
15
|
function setCableConsumer(consumer) {
|
|
7
16
|
sharedConsumer = consumer;
|
|
@@ -10,41 +19,93 @@ function getCableConsumer() {
|
|
|
10
19
|
if (!sharedConsumer) sharedConsumer = createConsumer();
|
|
11
20
|
return sharedConsumer;
|
|
12
21
|
}
|
|
22
|
+
var sharedSubscriptions = /* @__PURE__ */ new WeakMap();
|
|
23
|
+
function identifierOf(channel) {
|
|
24
|
+
return JSON.stringify(typeof channel === "string" ? { channel } : channel);
|
|
25
|
+
}
|
|
13
26
|
function createConfirmedSubscription(channel, mixin) {
|
|
14
|
-
|
|
15
|
-
let
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
rejected
|
|
33
|
-
mixin.
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
const consumer = getCableConsumer();
|
|
28
|
+
let registry = sharedSubscriptions.get(consumer);
|
|
29
|
+
if (!registry) {
|
|
30
|
+
registry = /* @__PURE__ */ new Map();
|
|
31
|
+
sharedSubscriptions.set(consumer, registry);
|
|
32
|
+
}
|
|
33
|
+
const identifier = identifierOf(channel);
|
|
34
|
+
const member = { mixin, catchUp: false };
|
|
35
|
+
let shared = registry.get(identifier);
|
|
36
|
+
if (!shared) {
|
|
37
|
+
shared = openSharedSubscription(consumer, channel);
|
|
38
|
+
registry.set(identifier, shared);
|
|
39
|
+
} else if (shared.rejected || shared.confirmed) {
|
|
40
|
+
const owed = shared;
|
|
41
|
+
member.catchUp = true;
|
|
42
|
+
queueMicrotask(() => {
|
|
43
|
+
if (!member.catchUp) return;
|
|
44
|
+
member.catchUp = false;
|
|
45
|
+
if (owed.rejected) mixin.rejected?.();
|
|
46
|
+
else mixin.connected?.();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
shared.members.add(member);
|
|
50
|
+
const owner = shared;
|
|
51
|
+
const registered = registry;
|
|
52
|
+
let active = true;
|
|
37
53
|
return {
|
|
38
|
-
perform: (action, data) => subscription.perform(action, data),
|
|
39
|
-
unsubscribe: () =>
|
|
54
|
+
perform: (action, data) => owner.subscription.perform(action, data),
|
|
55
|
+
unsubscribe: () => {
|
|
56
|
+
if (!active) return;
|
|
57
|
+
active = false;
|
|
58
|
+
member.catchUp = false;
|
|
59
|
+
owner.members.delete(member);
|
|
60
|
+
if (owner.members.size === 0) {
|
|
61
|
+
registered.delete(identifier);
|
|
62
|
+
owner.subscription.unsubscribe();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
40
65
|
get confirmed() {
|
|
41
|
-
return confirmed;
|
|
66
|
+
return owner.confirmed;
|
|
42
67
|
},
|
|
43
68
|
get rejected() {
|
|
44
|
-
return rejected;
|
|
69
|
+
return owner.rejected;
|
|
45
70
|
}
|
|
46
71
|
};
|
|
47
72
|
}
|
|
73
|
+
function openSharedSubscription(consumer, channel) {
|
|
74
|
+
const members = /* @__PURE__ */ new Set();
|
|
75
|
+
const fanOut = (deliver) => {
|
|
76
|
+
for (const member of [...members]) {
|
|
77
|
+
member.catchUp = false;
|
|
78
|
+
deliver(member.mixin);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const shared = {
|
|
82
|
+
members,
|
|
83
|
+
confirmed: false,
|
|
84
|
+
rejected: false,
|
|
85
|
+
subscription: consumer.subscriptions.create(channel, {
|
|
86
|
+
// A refusal is final: Action Cable never confirms a rejected subscription,
|
|
87
|
+
// so a late connected/disconnected (only possible from a misbehaving
|
|
88
|
+
// consumer double) must not reopen the gate `rejected` promised shut.
|
|
89
|
+
connected: () => {
|
|
90
|
+
if (shared.rejected) return;
|
|
91
|
+
shared.confirmed = true;
|
|
92
|
+
fanOut((mixin) => mixin.connected?.());
|
|
93
|
+
},
|
|
94
|
+
disconnected: () => {
|
|
95
|
+
if (shared.rejected) return;
|
|
96
|
+
shared.confirmed = false;
|
|
97
|
+
fanOut((mixin) => mixin.disconnected?.());
|
|
98
|
+
},
|
|
99
|
+
rejected: () => {
|
|
100
|
+
shared.confirmed = false;
|
|
101
|
+
shared.rejected = true;
|
|
102
|
+
fanOut((mixin) => mixin.rejected?.());
|
|
103
|
+
},
|
|
104
|
+
received: (data) => fanOut((mixin) => mixin.received?.(data))
|
|
105
|
+
})
|
|
106
|
+
};
|
|
107
|
+
return shared;
|
|
108
|
+
}
|
|
48
109
|
|
|
49
110
|
// src/cable/live_counter_controller.ts
|
|
50
111
|
var DISABLED_MARKER = "data-live-counter-disabled";
|
|
@@ -52,17 +113,28 @@ var LiveCounterController = class extends Controller {
|
|
|
52
113
|
static targets = ["value", "trigger"];
|
|
53
114
|
static values = {
|
|
54
115
|
channel: { type: String, default: "" },
|
|
55
|
-
params: { type:
|
|
116
|
+
params: { type: String, default: "" },
|
|
56
117
|
id: { type: String, default: "" }
|
|
57
118
|
};
|
|
58
119
|
static actions = ["increment"];
|
|
59
120
|
static events = ["change"];
|
|
121
|
+
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
122
|
+
#params = {};
|
|
60
123
|
#subscription = null;
|
|
124
|
+
/**
|
|
125
|
+
* Re-parses the identifier parameters when the declaration changes.
|
|
126
|
+
*
|
|
127
|
+
* A malformed declaration falls back to no parameters, so the identifier keeps
|
|
128
|
+
* naming the channel instead of the subscription never being created at all.
|
|
129
|
+
*/
|
|
130
|
+
paramsValueChanged() {
|
|
131
|
+
this.#params = parseSubscriptionParams(this.paramsValue);
|
|
132
|
+
}
|
|
61
133
|
connect() {
|
|
62
134
|
this.element.removeAttribute("data-live-counter-rejected");
|
|
63
135
|
if (this.channelValue) {
|
|
64
136
|
this.#subscription = createConfirmedSubscription(
|
|
65
|
-
{ channel: this.channelValue, ...this
|
|
137
|
+
{ channel: this.channelValue, ...this.#params },
|
|
66
138
|
{
|
|
67
139
|
connected: () => this.#syncTriggers(),
|
|
68
140
|
// A drop closes the send window (the shared subscription tracks it)
|
|
@@ -240,13 +312,15 @@ var PresenceController = class extends Controller {
|
|
|
240
312
|
static targets = ["count", "list", "template"];
|
|
241
313
|
static values = {
|
|
242
314
|
channel: { type: String, default: "" },
|
|
243
|
-
params: { type:
|
|
315
|
+
params: { type: String, default: "" },
|
|
244
316
|
id: { type: String, default: "" },
|
|
245
317
|
name: { type: String, default: "" },
|
|
246
318
|
heartbeat: { type: Number, default: 15e3 },
|
|
247
319
|
timeout: { type: Number, default: 4e4 }
|
|
248
320
|
};
|
|
249
321
|
static events = ["join", "leave", "change"];
|
|
322
|
+
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
323
|
+
#params = {};
|
|
250
324
|
#subscription = null;
|
|
251
325
|
/** Present peers keyed by id (insertion order = join order). */
|
|
252
326
|
#peers = /* @__PURE__ */ new Map();
|
|
@@ -256,13 +330,22 @@ var PresenceController = class extends Controller {
|
|
|
256
330
|
#lastBeaconAt = 0;
|
|
257
331
|
/** Pending trailing-edge convergence beacon (at most one queued). */
|
|
258
332
|
#pendingBeacon = null;
|
|
333
|
+
/**
|
|
334
|
+
* Re-parses the identifier parameters when the declaration changes.
|
|
335
|
+
*
|
|
336
|
+
* A malformed declaration falls back to no parameters, so the identifier keeps
|
|
337
|
+
* naming the channel instead of the subscription never being created at all.
|
|
338
|
+
*/
|
|
339
|
+
paramsValueChanged() {
|
|
340
|
+
this.#params = parseSubscriptionParams(this.paramsValue);
|
|
341
|
+
}
|
|
259
342
|
connect() {
|
|
260
343
|
this.#reset();
|
|
261
344
|
this.element.removeAttribute("data-presence-rejected");
|
|
262
345
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
|
|
263
346
|
if (!this.channelValue) return;
|
|
264
347
|
this.#subscription = createConfirmedSubscription(
|
|
265
|
-
{ channel: this.channelValue, ...this
|
|
348
|
+
{ channel: this.channelValue, ...this.#params },
|
|
266
349
|
{
|
|
267
350
|
// The first beacon must wait for the confirmed subscription — a
|
|
268
351
|
// perform() before that is silently dropped by Action Cable. Fires
|
|
@@ -441,25 +524,75 @@ var PresenceController = class extends Controller {
|
|
|
441
524
|
}
|
|
442
525
|
}
|
|
443
526
|
};
|
|
444
|
-
|
|
527
|
+
|
|
528
|
+
// src/utils/announce.ts
|
|
529
|
+
function announce(message, options = {}) {
|
|
530
|
+
const text = message.trim();
|
|
531
|
+
if (text.length === 0) return;
|
|
532
|
+
window.dispatchEvent(
|
|
533
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
534
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
535
|
+
})
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
function fillTemplate(template, values) {
|
|
539
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
540
|
+
const replacement = values[name];
|
|
541
|
+
return replacement === void 0 ? match : String(replacement);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/cable/typing_indicator_controller.ts
|
|
546
|
+
var DEFAULT_TIMEOUT = 3e3;
|
|
547
|
+
var DEFAULT_THROTTLE = 2e3;
|
|
548
|
+
var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
|
|
549
|
+
var TypingIndicatorController = class _TypingIndicatorController extends Controller {
|
|
445
550
|
static targets = ["input", "status"];
|
|
446
551
|
static values = {
|
|
447
552
|
channel: { type: String, default: "" },
|
|
448
|
-
params: { type:
|
|
553
|
+
params: { type: String, default: "" },
|
|
449
554
|
name: { type: String, default: "" },
|
|
450
|
-
timeout: { type: Number, default:
|
|
555
|
+
timeout: { type: Number, default: DEFAULT_TIMEOUT },
|
|
451
556
|
// Must stay below `timeout`: the throttle is leading-edge only (no trailing
|
|
452
557
|
// send), so a receiver's display survives continuous typing only while a
|
|
453
558
|
// fresh signal lands within its timeout window.
|
|
454
|
-
throttle: { type: Number, default:
|
|
559
|
+
throttle: { type: Number, default: DEFAULT_THROTTLE },
|
|
560
|
+
announceOneText: { type: String, default: "" },
|
|
561
|
+
announceManyText: { type: String, default: "" }
|
|
455
562
|
};
|
|
456
563
|
static events = ["change"];
|
|
564
|
+
/** Delay (ms) before one settled typer set is sent to the shared announcer. */
|
|
565
|
+
static #announceDelay = 200;
|
|
566
|
+
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
567
|
+
#params = {};
|
|
457
568
|
#subscription = null;
|
|
458
569
|
/** Names currently typing (other clients), each with its auto-clear timer id. */
|
|
459
570
|
#typers = /* @__PURE__ */ new Map();
|
|
460
571
|
#timers = new SafeTimeout();
|
|
461
572
|
/** Epoch ms of the last broadcast, for leading-edge throttling. */
|
|
462
573
|
#lastSentAt = 0;
|
|
574
|
+
/** The one outstanding announcement, so a newer set supersedes it. */
|
|
575
|
+
#announceId = null;
|
|
576
|
+
/**
|
|
577
|
+
* Re-parses the identifier parameters when the declaration changes.
|
|
578
|
+
*
|
|
579
|
+
* A malformed declaration falls back to no parameters, so the identifier keeps
|
|
580
|
+
* naming the channel instead of the subscription never being created at all.
|
|
581
|
+
*/
|
|
582
|
+
paramsValueChanged() {
|
|
583
|
+
this.#params = parseSubscriptionParams(this.paramsValue);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Paints the current copy into a `status` target that arrived at runtime.
|
|
587
|
+
*
|
|
588
|
+
* No event: the set of typers did not change, only the element that displays it.
|
|
589
|
+
* Without this a region swapped in mid-conversation stays empty while `data-typing`
|
|
590
|
+
* still says someone is typing, leaving the state in the visual hook alone. An empty
|
|
591
|
+
* set needs no paint — a fresh region already shows it.
|
|
592
|
+
*/
|
|
593
|
+
statusTargetConnected() {
|
|
594
|
+
if (this.#typers.size > 0) this.#paint();
|
|
595
|
+
}
|
|
463
596
|
connect() {
|
|
464
597
|
this.element.removeAttribute("data-typing");
|
|
465
598
|
this.element.removeAttribute("data-typing-indicator-rejected");
|
|
@@ -467,7 +600,7 @@ var TypingIndicatorController = class extends Controller {
|
|
|
467
600
|
this.element.addEventListener("input", this.#onInput);
|
|
468
601
|
if (this.channelValue) {
|
|
469
602
|
this.#subscription = createConfirmedSubscription(
|
|
470
|
-
{ channel: this.channelValue, ...this
|
|
603
|
+
{ channel: this.channelValue, ...this.#params },
|
|
471
604
|
{
|
|
472
605
|
// The server refused the subscription: the send gate stays shut for
|
|
473
606
|
// good, and the hook lets the consumer's CSS reflect the dead stream.
|
|
@@ -484,6 +617,7 @@ var TypingIndicatorController = class extends Controller {
|
|
|
484
617
|
this.#subscription?.unsubscribe();
|
|
485
618
|
this.#subscription = null;
|
|
486
619
|
this.#timers.clearAll();
|
|
620
|
+
this.#announceId = null;
|
|
487
621
|
this.#typers.clear();
|
|
488
622
|
this.element.removeAttribute("data-typing");
|
|
489
623
|
this.element.removeAttribute("data-typing-indicator-rejected");
|
|
@@ -494,7 +628,7 @@ var TypingIndicatorController = class extends Controller {
|
|
|
494
628
|
#onInput = () => {
|
|
495
629
|
if (!this.#subscription?.confirmed) return;
|
|
496
630
|
const now = Date.now();
|
|
497
|
-
if (now - this.#lastSentAt < this
|
|
631
|
+
if (now - this.#lastSentAt < this.#throttle) return;
|
|
498
632
|
this.#lastSentAt = now;
|
|
499
633
|
this.#subscription.perform("typing", { name: this.nameValue });
|
|
500
634
|
};
|
|
@@ -511,25 +645,74 @@ var TypingIndicatorController = class extends Controller {
|
|
|
511
645
|
const added = existing === void 0;
|
|
512
646
|
this.#typers.set(
|
|
513
647
|
name,
|
|
514
|
-
this.#timers.set(() => this.#untrack(name), this
|
|
648
|
+
this.#timers.set(() => this.#untrack(name), this.#timeout)
|
|
515
649
|
);
|
|
516
650
|
if (added) this.#render();
|
|
517
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* The silence after which a typer is dropped, in ms: a finite, non-negative number.
|
|
654
|
+
* Anything else names no delay — `setTimeout` reads `NaN`, a negative value and
|
|
655
|
+
* `Infinity` alike as "now", so the typer would vanish in the same task it appeared
|
|
656
|
+
* and the indicator could never be seen. Such a declaration falls back to the default.
|
|
657
|
+
*/
|
|
658
|
+
get #timeout() {
|
|
659
|
+
const declared = this.timeoutValue;
|
|
660
|
+
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_TIMEOUT;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* The minimum gap between outgoing signals, in ms: a finite, non-negative number.
|
|
664
|
+
* Anything else names no interval, and the gate then settles the same way at every
|
|
665
|
+
* keystroke — `NaN` and a negative gap leave it open, so every keystroke broadcasts,
|
|
666
|
+
* while `Infinity` is never exceeded, so nothing is ever sent. Such a declaration
|
|
667
|
+
* falls back to the default.
|
|
668
|
+
*/
|
|
669
|
+
get #throttle() {
|
|
670
|
+
const declared = this.throttleValue;
|
|
671
|
+
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THROTTLE;
|
|
672
|
+
}
|
|
518
673
|
#untrack(name) {
|
|
519
674
|
this.#typers.delete(name);
|
|
520
675
|
this.#render();
|
|
521
676
|
}
|
|
522
|
-
/** Reflects the typer set onto the
|
|
677
|
+
/** Reflects the typer set onto the display, announces it, and emits `change`. */
|
|
523
678
|
#render() {
|
|
679
|
+
const names = this.#paint();
|
|
680
|
+
this.#announce(names);
|
|
681
|
+
this.dispatch("change", { detail: { names } });
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Hands the settled typer set to the page's shared announcer.
|
|
685
|
+
*
|
|
686
|
+
* Debounced: a burst of arrivals is one announcement, not one per name. Only a
|
|
687
|
+
* non-empty set is announced — that typing stopped is not news worth interrupting
|
|
688
|
+
* a reader for, and the visible copy already clears. Wording comes from the
|
|
689
|
+
* consumer, and {@link announce} drops an empty message, so an undeclared template
|
|
690
|
+
* announces nothing at all.
|
|
691
|
+
*/
|
|
692
|
+
#announce(names) {
|
|
693
|
+
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
694
|
+
this.#announceId = null;
|
|
695
|
+
if (names.length === 0) return;
|
|
696
|
+
const message = names.length === 1 ? fillTemplate(this.announceOneTextValue, { name: names[0] ?? "" }) : fillTemplate(this.announceManyTextValue, {
|
|
697
|
+
names: names.join(", "),
|
|
698
|
+
count: names.length
|
|
699
|
+
});
|
|
700
|
+
this.#announceId = this.#timers.set(() => {
|
|
701
|
+
announce(message);
|
|
702
|
+
this.#announceId = null;
|
|
703
|
+
}, _TypingIndicatorController.#announceDelay);
|
|
704
|
+
}
|
|
705
|
+
/** Writes the current typer set onto the hook and the status slot. */
|
|
706
|
+
#paint() {
|
|
524
707
|
const names = [...this.#typers.keys()];
|
|
525
708
|
this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
|
|
526
709
|
if (this.hasStatusTarget) {
|
|
527
710
|
this.statusTarget.textContent = this.#message(names);
|
|
528
711
|
}
|
|
529
|
-
|
|
712
|
+
return names;
|
|
530
713
|
}
|
|
531
714
|
/**
|
|
532
|
-
* Builds the
|
|
715
|
+
* Builds the status copy. Localizable through `data-one` / `data-many`
|
|
533
716
|
* templates on the status target (`%{name}` / `%{names}` / `%{count}`); terse
|
|
534
717
|
* English is the fallback.
|
|
535
718
|
*/
|
|
@@ -539,10 +722,10 @@ var TypingIndicatorController = class extends Controller {
|
|
|
539
722
|
if (names.length === 1) {
|
|
540
723
|
const template2 = this.statusTarget.dataset.one;
|
|
541
724
|
const name = names[0] ?? "";
|
|
542
|
-
return template2 ? template2
|
|
725
|
+
return template2 ? fillTokens(template2, { name }) : `${name} is typing\u2026`;
|
|
543
726
|
}
|
|
544
727
|
const template = this.statusTarget.dataset.many;
|
|
545
|
-
return template ? template
|
|
728
|
+
return template ? fillTokens(template, { names: joined, count: String(names.length) }) : `${joined} are typing\u2026`;
|
|
546
729
|
}
|
|
547
730
|
};
|
|
548
731
|
|
|
@@ -315,15 +315,25 @@ var FocusTrap = class {
|
|
|
315
315
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
316
316
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
317
317
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
318
|
+
*
|
|
319
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
320
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
321
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
322
|
+
* nested container is the ordinary case.
|
|
318
323
|
*/
|
|
319
324
|
#isolateBackground() {
|
|
320
325
|
const container = this.#getContainer();
|
|
321
326
|
this.#inertedSiblings = [];
|
|
322
|
-
for (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
327
|
+
for (let node = container; node !== document.body; ) {
|
|
328
|
+
const parent = node.parentElement;
|
|
329
|
+
if (!parent) break;
|
|
330
|
+
for (const sibling of Array.from(parent.children)) {
|
|
331
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
332
|
+
if (sibling === node || sibling.inert) continue;
|
|
333
|
+
sibling.inert = true;
|
|
334
|
+
this.#inertedSiblings.push(sibling);
|
|
335
|
+
}
|
|
336
|
+
node = parent;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -361,8 +371,8 @@ var AlertDialogController = class extends Controller {
|
|
|
361
371
|
static actions = ["cancel", "confirm", "open"];
|
|
362
372
|
static events = ["cancel", "confirm"];
|
|
363
373
|
/**
|
|
364
|
-
* Owns the modal side effects. Escape
|
|
365
|
-
* emits the same event
|
|
374
|
+
* Owns the modal side effects. Escape takes the same cancel path as
|
|
375
|
+
* {@link cancel} and emits the same event, tagged `"escape"` instead of `"user"`; focus falls
|
|
366
376
|
* back to the trigger when nothing was focused before opening.
|
|
367
377
|
*/
|
|
368
378
|
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
@@ -338,7 +338,7 @@ var AnnouncerController = class extends Controller {
|
|
|
338
338
|
}
|
|
339
339
|
/**
|
|
340
340
|
* Arms `region`'s single pending timer. Callers reach here with the slot
|
|
341
|
-
* already free — `#
|
|
341
|
+
* already free — `#drain` releases it before writing, and a fired timer clears its own
|
|
342
342
|
* entry below — so this does not cancel again.
|
|
343
343
|
*/
|
|
344
344
|
#schedule(region, callback, delay) {
|
|
@@ -63,8 +63,8 @@ var BulkSelectController = class extends Controller {
|
|
|
63
63
|
static events = ["change", "reconcile"];
|
|
64
64
|
/** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
|
|
65
65
|
* `connect()` over markup that already carries the attribute rehydrates the
|
|
66
|
-
* mode — a morph, a Turbo Stream,
|
|
67
|
-
* visit
|
|
66
|
+
* mode — a morph, a Turbo Stream, a server that renders it back, or a restore
|
|
67
|
+
* visit, whose cached snapshot carries the attribute too. */
|
|
68
68
|
#allPagesMode = false;
|
|
69
69
|
/** Last emitted figures, so a recompute reports only on a real change. */
|
|
70
70
|
#lastCount = -1;
|
|
@@ -217,7 +217,7 @@ var CollapsibleController = class extends Controller {
|
|
|
217
217
|
* `open` Value disagrees. An already-open region remains open without a
|
|
218
218
|
* close/reopen cycle. The Value only seeds a genuinely fresh render where no
|
|
219
219
|
* state attribute is present yet; any opening animation in that case belongs to
|
|
220
|
-
* the consumer's CSS.
|
|
220
|
+
* the consumer's CSS.
|
|
221
221
|
*/
|
|
222
222
|
connect() {
|
|
223
223
|
this.#connected = true;
|