stimeo-ui 0.1.0.pre.alpha.1 → 0.1.0.pre.beta.1
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 +36 -0
- data/README.md +56 -3
- data/dist/controllers/combobox_controller.js +1 -0
- data/dist/controllers/form_validation_controller.js +62 -1
- data/dist/controllers/hover_card_controller.js +40 -5
- data/dist/controllers/popover_controller.js +39 -0
- data/dist/controllers/submit_once_controller.js +23 -2
- data/dist/controllers/tooltip_controller.js +44 -5
- data/dist/index.js +197 -15
- data/lib/stimeo/ui/version.rb +3 -3
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5958af6bffb81de09834ec57bed72292dccba82e858e62c87b99a43f769cc94
|
|
4
|
+
data.tar.gz: 4a35a8a27e890e26652fa67bf44739e8a9e28993df7bfd7b912ca33b39fef11b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b288d136c73d80180990c922f83999d9eabc8b03cf2e1c685ee0c9ebcbbb3f46aecb336888200570ff8caf481049a147f3bdeb70b53611be76a14ae4a7ab7e1f
|
|
7
|
+
data.tar.gz: 8155446be40a4afbff0d10608797da863cd5264efc03507c018478aa3d0121029083fd8f6d28ed3d704604e9d2f4eb02dfc23641aeaeb57bf04961c5d573c1b3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
|
+
change between releases.
|
|
9
|
+
|
|
10
|
+
## [0.1.0-beta.1] - 2026-06-30
|
|
11
|
+
|
|
12
|
+
First beta. The 101 core components meet the accessibility quality bar, so the
|
|
13
|
+
library graduates from the `alpha` channel to `beta`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- multi-select: emits named hidden fields so the current selection submits with
|
|
18
|
+
the form, no application JavaScript required.
|
|
19
|
+
- form-validation: declarative per-constraint messages and a `disallow=whitespace`
|
|
20
|
+
rule.
|
|
21
|
+
- hover-card, tooltip, and popover: opt-in dismiss when the page scrolls.
|
|
22
|
+
- submit-once: auto-subscribes to `turbo:submit-start` on connect.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Ignore keydown events fired during IME composition in tags-input, multi-select,
|
|
27
|
+
and combobox, so selecting a candidate no longer triggers shortcuts.
|
|
28
|
+
|
|
29
|
+
## [0.1.0-alpha.1] - 2026-06-20
|
|
30
|
+
|
|
31
|
+
Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
32
|
+
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
33
|
+
RubyGems.
|
|
34
|
+
|
|
35
|
+
[0.1.0-beta.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.1
|
|
36
|
+
[0.1.0-alpha.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-alpha.1
|
data/README.md
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/taiyaky/stimeo-ui/main/assets/logo-wordmark-dark.png">
|
|
4
|
+
<img alt="Stimeo UI" src="https://raw.githubusercontent.com/taiyaky/stimeo-ui/main/assets/logo-wordmark.png" width="240">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<p align="center"><a href="https://stimeo-labs.com"><strong>Live demo (beta) →</strong></a></p>
|
|
9
|
+
|
|
10
|
+
[](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [](https://www.npmjs.com/package/stimeo-ui) [](https://rubygems.org/gems/stimeo-ui) [](LICENSE)
|
|
2
11
|
|
|
3
12
|
**Headless Stimulus UI framework for Ruby on Rails.** Stimeo UI ships *behavior*
|
|
4
13
|
— ARIA state, keyboard interaction, focus management, Turbo resilience — as
|
|
@@ -14,7 +23,7 @@ owns the look entirely.
|
|
|
14
23
|
- Public controller identifiers use the `stimeo--` namespace (e.g.
|
|
15
24
|
`stimeo--dropdown`).
|
|
16
25
|
|
|
17
|
-
> Status: **
|
|
26
|
+
> Status: **beta** (`0.x`). The `stimeo--*` attribute API may still change before
|
|
18
27
|
> 1.0 — pin your version.
|
|
19
28
|
|
|
20
29
|
## Install
|
|
@@ -22,7 +31,7 @@ owns the look entirely.
|
|
|
22
31
|
### Rails with importmap (recommended)
|
|
23
32
|
|
|
24
33
|
```bash
|
|
25
|
-
bundle add stimeo-ui
|
|
34
|
+
bundle add stimeo-ui --version "0.1.0.pre.beta.1"
|
|
26
35
|
bin/rails generate stimeo:install
|
|
27
36
|
```
|
|
28
37
|
|
|
@@ -61,6 +70,50 @@ Need only a few controllers? Import them individually from
|
|
|
61
70
|
- **No CSS is shipped.** Style the components yourself; controllers only toggle
|
|
62
71
|
ARIA state and `data-*` hooks.
|
|
63
72
|
|
|
73
|
+
## Linting
|
|
74
|
+
|
|
75
|
+
Stimeo UI is headless, so **you** author the WAI-ARIA roles, states, and
|
|
76
|
+
properties — and some controllers use explicit roles as selector contracts (the
|
|
77
|
+
data-grid finds its rows via `[role="row"]`). Your markup therefore contains
|
|
78
|
+
valid custom-widget ARIA such as `<ul role="menu">`, `<div role="radio">`, and
|
|
79
|
+
`<table role="grid">…<td role="gridcell">`.
|
|
80
|
+
|
|
81
|
+
Strict static a11y linters — Biome's `recommended` preset (≥ 2.5) and
|
|
82
|
+
`eslint-plugin-jsx-a11y` — report these valid
|
|
83
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/) patterns as errors, because their
|
|
84
|
+
heuristics assume native semantic elements (there is no native equivalent for a
|
|
85
|
+
custom, fully-stylable radio). Relax the conflicting rules **only for the paths
|
|
86
|
+
where you author Stimeo UI markup** — set `includes` to your own component
|
|
87
|
+
directories (the value below is a placeholder; adjust it to your layout) and
|
|
88
|
+
keep the rules on everywhere else. For Biome:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"overrides": [
|
|
93
|
+
{
|
|
94
|
+
"includes": ["app/components/**"],
|
|
95
|
+
"linter": {
|
|
96
|
+
"rules": {
|
|
97
|
+
"a11y": {
|
|
98
|
+
"noNoninteractiveElementToInteractiveRole": "off",
|
|
99
|
+
"noRedundantRoles": "off",
|
|
100
|
+
"useSemanticElements": "off",
|
|
101
|
+
"useFocusableInteractive": "off",
|
|
102
|
+
"noNoninteractiveTabindex": "off"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The `eslint-plugin-jsx-a11y` equivalents are
|
|
112
|
+
`no-noninteractive-element-to-interactive-role`, `no-redundant-roles`,
|
|
113
|
+
`prefer-tag-over-role`, `interactive-supports-focus`, and
|
|
114
|
+
`no-noninteractive-tabindex`. These components' real accessibility is exercised
|
|
115
|
+
with axe-core and real screen readers in this project's own test suite.
|
|
116
|
+
|
|
64
117
|
## Contributing
|
|
65
118
|
|
|
66
119
|
Bug reports and feature requests are very welcome — please open a GitHub issue.
|
|
@@ -60,6 +60,7 @@ var ComboboxController = class extends Controller {
|
|
|
60
60
|
}
|
|
61
61
|
/** Routes keyboard interaction per the APG combobox model. */
|
|
62
62
|
onKeydown(event) {
|
|
63
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
63
64
|
switch (event.key) {
|
|
64
65
|
case "ArrowDown": {
|
|
65
66
|
event.preventDefault();
|
|
@@ -6,6 +6,21 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
6
6
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
7
|
|
|
8
8
|
// src/controllers/form_validation_controller.ts
|
|
9
|
+
var CONSTRAINT_MESSAGE_KEYS = [
|
|
10
|
+
["valueMissing", "value-missing"],
|
|
11
|
+
["typeMismatch", "type-mismatch"],
|
|
12
|
+
["patternMismatch", "pattern-mismatch"],
|
|
13
|
+
["tooShort", "too-short"],
|
|
14
|
+
["tooLong", "too-long"],
|
|
15
|
+
["rangeUnderflow", "range-underflow"],
|
|
16
|
+
["rangeOverflow", "range-overflow"],
|
|
17
|
+
["stepMismatch", "step-mismatch"],
|
|
18
|
+
["badInput", "bad-input"]
|
|
19
|
+
];
|
|
20
|
+
var MESSAGE_ATTR_PREFIX = "data-stimeo--form-field-message-";
|
|
21
|
+
var MESSAGE_ATTR_GENERIC = "data-stimeo--form-field-message";
|
|
22
|
+
var DISALLOW_ATTR = "data-stimeo--form-field-disallow";
|
|
23
|
+
var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
|
|
9
24
|
var FormValidationController = class _FormValidationController extends Controller {
|
|
10
25
|
static outlets = ["stimeo--form-field"];
|
|
11
26
|
static values = {
|
|
@@ -20,6 +35,12 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
20
35
|
static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
|
|
21
36
|
/** Controls already interacted with — the gate for blur / input (re)validation. */
|
|
22
37
|
#touched = /* @__PURE__ */ new WeakSet();
|
|
38
|
+
/**
|
|
39
|
+
* Controls whose `customError` *we* set via the `disallow` rule. Tracked so we
|
|
40
|
+
* only ever clear our own custom validity — a consumer's `setCustomValidity` on
|
|
41
|
+
* the same control survives once our rule passes (don't-clobber-authored-state).
|
|
42
|
+
*/
|
|
43
|
+
#ownedCustomError = /* @__PURE__ */ new WeakSet();
|
|
23
44
|
#onSubmit = (event) => {
|
|
24
45
|
if (event.target !== this.element) return;
|
|
25
46
|
const invalid = this.#validateAll();
|
|
@@ -131,16 +152,56 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
131
152
|
* Routing goes through the outlet, so the ARIA wiring is never duplicated here.
|
|
132
153
|
*/
|
|
133
154
|
#applyGroup(group) {
|
|
155
|
+
for (const control of group.controls) this.#syncCustomValidity(control);
|
|
134
156
|
const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
|
|
135
157
|
if (group.field) {
|
|
136
158
|
if (firstInvalid) {
|
|
137
|
-
group.field.setError(firstInvalid
|
|
159
|
+
group.field.setError(this.#messageFor(firstInvalid));
|
|
138
160
|
} else {
|
|
139
161
|
group.field.clearError();
|
|
140
162
|
}
|
|
141
163
|
}
|
|
142
164
|
return firstInvalid;
|
|
143
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Resolves the message to show for an invalid control: a per-constraint
|
|
168
|
+
* override (`data-stimeo--form-field-message-<constraint>`) for the first failing
|
|
169
|
+
* `ValidityState` flag, then a generic `data-stimeo--form-field-message`
|
|
170
|
+
* override, then the browser's native `validationMessage`. Authoring an override
|
|
171
|
+
* gives controlled, localizable, theme-able wording with **no consumer JS** —
|
|
172
|
+
* and sidesteps headless browsers that return an empty native message.
|
|
173
|
+
*/
|
|
174
|
+
#messageFor(control) {
|
|
175
|
+
for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
|
|
176
|
+
if (control.validity[flag]) {
|
|
177
|
+
return control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || "";
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
184
|
+
* for controls that opt in with `data-stimeo--form-field-disallow`. The one
|
|
185
|
+
* supported rule today is `"whitespace"` — a value that is non-empty but blank
|
|
186
|
+
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
187
|
+
* the per-constraint (`value-missing`) → generic → default chain.
|
|
188
|
+
*
|
|
189
|
+
* Don't-clobber-authored-state: an unknown/absent rule is never touched, and a
|
|
190
|
+
* custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),
|
|
191
|
+
* so a consumer's own `setCustomValidity` on the same control survives.
|
|
192
|
+
*/
|
|
193
|
+
#syncCustomValidity(control) {
|
|
194
|
+
const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
|
|
195
|
+
if (violates) {
|
|
196
|
+
control.setCustomValidity(
|
|
197
|
+
control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT
|
|
198
|
+
);
|
|
199
|
+
this.#ownedCustomError.add(control);
|
|
200
|
+
} else if (this.#ownedCustomError.has(control)) {
|
|
201
|
+
this.#ownedCustomError.delete(control);
|
|
202
|
+
control.setCustomValidity("");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
144
205
|
/**
|
|
145
206
|
* A grouping key that collects controls belonging to the same field: the owning
|
|
146
207
|
* `stimeo--form-field` when present, else a radio group's shared `name`, else
|
|
@@ -55,26 +55,56 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// src/utils/scroll_dismiss.ts
|
|
59
|
+
function observeScrollDismiss(element, onScroll) {
|
|
60
|
+
const targets = [...scrollParents(element), window];
|
|
61
|
+
const handler = () => onScroll();
|
|
62
|
+
for (const target of targets) {
|
|
63
|
+
target.addEventListener("scroll", handler, { passive: true });
|
|
64
|
+
}
|
|
65
|
+
return () => {
|
|
66
|
+
for (const target of targets) target.removeEventListener("scroll", handler);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function scrollParents(element) {
|
|
70
|
+
const parents = [];
|
|
71
|
+
let node = element.parentElement;
|
|
72
|
+
while (node) {
|
|
73
|
+
if (isScrollable(node)) parents.push(node);
|
|
74
|
+
node = node.parentElement;
|
|
75
|
+
}
|
|
76
|
+
return parents;
|
|
77
|
+
}
|
|
78
|
+
function isScrollable(element) {
|
|
79
|
+
const style = getComputedStyle(element);
|
|
80
|
+
return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
// src/controllers/hover_card_controller.ts
|
|
59
84
|
var HoverCardController = class extends Controller {
|
|
60
85
|
static targets = ["trigger", "card"];
|
|
61
86
|
static values = {
|
|
62
87
|
openDelay: { type: Number, default: 300 },
|
|
63
|
-
closeDelay: { type: Number, default: 200 }
|
|
88
|
+
closeDelay: { type: Number, default: 200 },
|
|
89
|
+
closeOnScroll: { type: Boolean, default: false }
|
|
64
90
|
};
|
|
65
91
|
static actions = ["close", "onKeydown", "open"];
|
|
66
92
|
/** Pending open/close timers, torn down together on disconnect. */
|
|
67
93
|
#timers = new SafeTimeout();
|
|
68
94
|
#pendingOpen = null;
|
|
69
95
|
#pendingClose = null;
|
|
96
|
+
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
97
|
+
#stopScrollDismiss = null;
|
|
70
98
|
/** Starts closed. */
|
|
71
99
|
connect() {
|
|
72
100
|
this.#conceal();
|
|
73
101
|
}
|
|
74
|
-
/** Clears timers and the document `Escape`
|
|
102
|
+
/** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
|
|
75
103
|
disconnect() {
|
|
76
104
|
this.#timers.clearAll();
|
|
77
105
|
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
106
|
+
this.#stopScrollDismiss?.();
|
|
107
|
+
this.#stopScrollDismiss = null;
|
|
78
108
|
}
|
|
79
109
|
/** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
|
|
80
110
|
open() {
|
|
@@ -111,21 +141,26 @@ var HoverCardController = class extends Controller {
|
|
|
111
141
|
this.#dismiss();
|
|
112
142
|
}
|
|
113
143
|
}
|
|
114
|
-
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape
|
|
144
|
+
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
|
|
115
145
|
#reveal() {
|
|
116
146
|
if (!this.hasCardTarget) return;
|
|
117
147
|
this.cardTarget.hidden = false;
|
|
118
148
|
this.cardTarget.setAttribute("data-state", "open");
|
|
119
149
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
120
150
|
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
151
|
+
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
152
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
153
|
+
}
|
|
121
154
|
}
|
|
122
|
-
/** Hides the card, reflects state, and stops watching for `Escape
|
|
155
|
+
/** Hides the card, reflects state, and stops watching for `Escape`/scroll. */
|
|
123
156
|
#conceal() {
|
|
157
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
158
|
+
this.#stopScrollDismiss?.();
|
|
159
|
+
this.#stopScrollDismiss = null;
|
|
124
160
|
if (!this.hasCardTarget) return;
|
|
125
161
|
this.cardTarget.hidden = true;
|
|
126
162
|
this.cardTarget.setAttribute("data-state", "closed");
|
|
127
163
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
128
|
-
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
129
164
|
}
|
|
130
165
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
131
166
|
#dismiss() {
|
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/popover_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/scroll_dismiss.ts
|
|
6
|
+
function observeScrollDismiss(element, onScroll) {
|
|
7
|
+
const targets = [...scrollParents(element), window];
|
|
8
|
+
const handler = () => onScroll();
|
|
9
|
+
for (const target of targets) {
|
|
10
|
+
target.addEventListener("scroll", handler, { passive: true });
|
|
11
|
+
}
|
|
12
|
+
return () => {
|
|
13
|
+
for (const target of targets) target.removeEventListener("scroll", handler);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function scrollParents(element) {
|
|
17
|
+
const parents = [];
|
|
18
|
+
let node = element.parentElement;
|
|
19
|
+
while (node) {
|
|
20
|
+
if (isScrollable(node)) parents.push(node);
|
|
21
|
+
node = node.parentElement;
|
|
22
|
+
}
|
|
23
|
+
return parents;
|
|
24
|
+
}
|
|
25
|
+
function isScrollable(element) {
|
|
26
|
+
const style = getComputedStyle(element);
|
|
27
|
+
return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
|
|
28
|
+
}
|
|
29
|
+
|
|
3
30
|
// src/controllers/popover_controller.ts
|
|
4
31
|
var PopoverController = class _PopoverController extends Controller {
|
|
5
32
|
static targets = ["trigger", "panel"];
|
|
33
|
+
static values = {
|
|
34
|
+
closeOnScroll: { type: Boolean, default: false }
|
|
35
|
+
};
|
|
6
36
|
static actions = ["close", "open", "toggle"];
|
|
37
|
+
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
38
|
+
#stopScrollDismiss = null;
|
|
7
39
|
/** Selector for natively focusable elements used to find the first one. */
|
|
8
40
|
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
9
41
|
/** Starts closed and registers the document-level dismissal listeners. */
|
|
@@ -22,6 +54,8 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
22
54
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
23
55
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
24
56
|
if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
57
|
+
this.#stopScrollDismiss?.();
|
|
58
|
+
this.#stopScrollDismiss = null;
|
|
25
59
|
}
|
|
26
60
|
/** Toggles the popover. Bound via `data-action` (click on the trigger). */
|
|
27
61
|
toggle() {
|
|
@@ -37,12 +71,17 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
37
71
|
this.panelTarget.hidden = false;
|
|
38
72
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
39
73
|
this.panelTarget.addEventListener("focusout", this.#onFocusOut);
|
|
74
|
+
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
75
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
|
|
76
|
+
}
|
|
40
77
|
this.#focusFirst();
|
|
41
78
|
}
|
|
42
79
|
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
43
80
|
close() {
|
|
44
81
|
if (!this.hasPanelTarget) return;
|
|
45
82
|
this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
83
|
+
this.#stopScrollDismiss?.();
|
|
84
|
+
this.#stopScrollDismiss = null;
|
|
46
85
|
this.panelTarget.hidden = true;
|
|
47
86
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
48
87
|
}
|
|
@@ -72,19 +72,28 @@ var SubmitOnceController = class extends Controller {
|
|
|
72
72
|
#timeoutId = null;
|
|
73
73
|
#busy = false;
|
|
74
74
|
#submitter = null;
|
|
75
|
+
#onSubmitStart = (event) => {
|
|
76
|
+
this.start(event);
|
|
77
|
+
};
|
|
75
78
|
#onSubmitEnd = (event) => {
|
|
76
79
|
const success = event.detail?.success;
|
|
77
80
|
this.#restore(success);
|
|
78
81
|
};
|
|
79
82
|
connect() {
|
|
83
|
+
this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
80
84
|
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
81
85
|
this.#clearStaleBusy();
|
|
82
86
|
}
|
|
83
87
|
disconnect() {
|
|
88
|
+
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
84
89
|
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
85
90
|
this.#clearTimeout();
|
|
86
91
|
}
|
|
87
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Enters the busy state for the submission started by `event`. Accepts both a
|
|
94
|
+
* native `SubmitEvent` (manual `submit->#start` on non-Turbo forms) and Turbo's
|
|
95
|
+
* `turbo:submit-start` CustomEvent (auto-subscribed in `connect`).
|
|
96
|
+
*/
|
|
88
97
|
start(event) {
|
|
89
98
|
if (this.#busy) return;
|
|
90
99
|
this.#busy = true;
|
|
@@ -158,12 +167,24 @@ var SubmitOnceController = class extends Controller {
|
|
|
158
167
|
}
|
|
159
168
|
/** The triggering button: the event's submitter when ours, else the first button. */
|
|
160
169
|
#resolveSubmitter(event, buttons) {
|
|
161
|
-
const submitter = event
|
|
170
|
+
const submitter = this.#eventSubmitter(event);
|
|
162
171
|
if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {
|
|
163
172
|
return submitter;
|
|
164
173
|
}
|
|
165
174
|
return buttons[0] ?? null;
|
|
166
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Reads the submitter from a native `SubmitEvent` (`event.submitter`) or from
|
|
178
|
+
* Turbo's `turbo:submit-start` detail (`detail.formSubmission.submitter`), so
|
|
179
|
+
* the busy label swaps onto the right button under either path.
|
|
180
|
+
*/
|
|
181
|
+
#eventSubmitter(event) {
|
|
182
|
+
if (typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent) {
|
|
183
|
+
return event.submitter;
|
|
184
|
+
}
|
|
185
|
+
const detail = event.detail;
|
|
186
|
+
return detail?.formSubmission?.submitter ?? null;
|
|
187
|
+
}
|
|
167
188
|
/** The controlled buttons: `submit` targets, or the form's native submit controls. */
|
|
168
189
|
get #buttons() {
|
|
169
190
|
if (this.hasSubmitTarget) return this.submitTargets;
|
|
@@ -55,12 +55,38 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// src/utils/scroll_dismiss.ts
|
|
59
|
+
function observeScrollDismiss(element, onScroll) {
|
|
60
|
+
const targets = [...scrollParents(element), window];
|
|
61
|
+
const handler = () => onScroll();
|
|
62
|
+
for (const target of targets) {
|
|
63
|
+
target.addEventListener("scroll", handler, { passive: true });
|
|
64
|
+
}
|
|
65
|
+
return () => {
|
|
66
|
+
for (const target of targets) target.removeEventListener("scroll", handler);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function scrollParents(element) {
|
|
70
|
+
const parents = [];
|
|
71
|
+
let node = element.parentElement;
|
|
72
|
+
while (node) {
|
|
73
|
+
if (isScrollable(node)) parents.push(node);
|
|
74
|
+
node = node.parentElement;
|
|
75
|
+
}
|
|
76
|
+
return parents;
|
|
77
|
+
}
|
|
78
|
+
function isScrollable(element) {
|
|
79
|
+
const style = getComputedStyle(element);
|
|
80
|
+
return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
// src/controllers/tooltip_controller.ts
|
|
59
84
|
var TooltipController = class extends Controller {
|
|
60
85
|
static targets = ["trigger", "content"];
|
|
61
86
|
static values = {
|
|
62
87
|
showDelay: { type: Number, default: 0 },
|
|
63
|
-
hideDelay: { type: Number, default: 0 }
|
|
88
|
+
hideDelay: { type: Number, default: 0 },
|
|
89
|
+
closeOnScroll: { type: Boolean, default: false }
|
|
64
90
|
};
|
|
65
91
|
static actions = ["hide", "onKeydown", "show"];
|
|
66
92
|
/** Pending show/hide timers, torn down together on disconnect. */
|
|
@@ -68,14 +94,18 @@ var TooltipController = class extends Controller {
|
|
|
68
94
|
/** The id of the currently pending show or hide timer, if any. */
|
|
69
95
|
#pendingShow = null;
|
|
70
96
|
#pendingHide = null;
|
|
97
|
+
/** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
|
|
98
|
+
#stopScrollDismiss = null;
|
|
71
99
|
/** Starts hidden. */
|
|
72
100
|
connect() {
|
|
73
101
|
this.#conceal();
|
|
74
102
|
}
|
|
75
|
-
/** Clears timers and the document `Escape`
|
|
103
|
+
/** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
|
|
76
104
|
disconnect() {
|
|
77
105
|
this.#timers.clearAll();
|
|
78
106
|
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
107
|
+
this.#stopScrollDismiss?.();
|
|
108
|
+
this.#stopScrollDismiss = null;
|
|
79
109
|
}
|
|
80
110
|
/** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */
|
|
81
111
|
show() {
|
|
@@ -117,19 +147,28 @@ var TooltipController = class extends Controller {
|
|
|
117
147
|
this.#conceal();
|
|
118
148
|
}
|
|
119
149
|
}
|
|
120
|
-
/** Reveals the content and starts watching for a dismissing `Escape
|
|
150
|
+
/** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
|
|
121
151
|
#reveal() {
|
|
122
152
|
if (!this.hasContentTarget) return;
|
|
123
153
|
this.contentTarget.hidden = false;
|
|
124
154
|
this.contentTarget.setAttribute("data-state", "open");
|
|
125
155
|
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
156
|
+
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
157
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {
|
|
158
|
+
this.#cancelShow();
|
|
159
|
+
this.#cancelHide();
|
|
160
|
+
this.#conceal();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
126
163
|
}
|
|
127
|
-
/** Hides the content and stops watching for `Escape
|
|
164
|
+
/** Hides the content and stops watching for `Escape`/scroll. */
|
|
128
165
|
#conceal() {
|
|
166
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
167
|
+
this.#stopScrollDismiss?.();
|
|
168
|
+
this.#stopScrollDismiss = null;
|
|
129
169
|
if (!this.hasContentTarget) return;
|
|
130
170
|
this.contentTarget.hidden = true;
|
|
131
171
|
this.contentTarget.setAttribute("data-state", "closed");
|
|
132
|
-
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
133
172
|
}
|
|
134
173
|
/** Document-level `Escape` watcher (active only while shown). */
|
|
135
174
|
#onDocumentKeydown = (event) => {
|
data/dist/index.js
CHANGED
|
@@ -2065,6 +2065,7 @@ var ComboboxController = class extends Controller {
|
|
|
2065
2065
|
}
|
|
2066
2066
|
/** Routes keyboard interaction per the APG combobox model. */
|
|
2067
2067
|
onKeydown(event) {
|
|
2068
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
2068
2069
|
switch (event.key) {
|
|
2069
2070
|
case "ArrowDown": {
|
|
2070
2071
|
event.preventDefault();
|
|
@@ -4833,6 +4834,21 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
4833
4834
|
return existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token));
|
|
4834
4835
|
}
|
|
4835
4836
|
};
|
|
4837
|
+
var CONSTRAINT_MESSAGE_KEYS = [
|
|
4838
|
+
["valueMissing", "value-missing"],
|
|
4839
|
+
["typeMismatch", "type-mismatch"],
|
|
4840
|
+
["patternMismatch", "pattern-mismatch"],
|
|
4841
|
+
["tooShort", "too-short"],
|
|
4842
|
+
["tooLong", "too-long"],
|
|
4843
|
+
["rangeUnderflow", "range-underflow"],
|
|
4844
|
+
["rangeOverflow", "range-overflow"],
|
|
4845
|
+
["stepMismatch", "step-mismatch"],
|
|
4846
|
+
["badInput", "bad-input"]
|
|
4847
|
+
];
|
|
4848
|
+
var MESSAGE_ATTR_PREFIX = "data-stimeo--form-field-message-";
|
|
4849
|
+
var MESSAGE_ATTR_GENERIC = "data-stimeo--form-field-message";
|
|
4850
|
+
var DISALLOW_ATTR = "data-stimeo--form-field-disallow";
|
|
4851
|
+
var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
|
|
4836
4852
|
var FormValidationController = class _FormValidationController extends Controller {
|
|
4837
4853
|
static outlets = ["stimeo--form-field"];
|
|
4838
4854
|
static values = {
|
|
@@ -4847,6 +4863,12 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
4847
4863
|
static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
|
|
4848
4864
|
/** Controls already interacted with — the gate for blur / input (re)validation. */
|
|
4849
4865
|
#touched = /* @__PURE__ */ new WeakSet();
|
|
4866
|
+
/**
|
|
4867
|
+
* Controls whose `customError` *we* set via the `disallow` rule. Tracked so we
|
|
4868
|
+
* only ever clear our own custom validity — a consumer's `setCustomValidity` on
|
|
4869
|
+
* the same control survives once our rule passes (don't-clobber-authored-state).
|
|
4870
|
+
*/
|
|
4871
|
+
#ownedCustomError = /* @__PURE__ */ new WeakSet();
|
|
4850
4872
|
#onSubmit = (event) => {
|
|
4851
4873
|
if (event.target !== this.element) return;
|
|
4852
4874
|
const invalid = this.#validateAll();
|
|
@@ -4958,16 +4980,56 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
4958
4980
|
* Routing goes through the outlet, so the ARIA wiring is never duplicated here.
|
|
4959
4981
|
*/
|
|
4960
4982
|
#applyGroup(group) {
|
|
4983
|
+
for (const control of group.controls) this.#syncCustomValidity(control);
|
|
4961
4984
|
const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
|
|
4962
4985
|
if (group.field) {
|
|
4963
4986
|
if (firstInvalid) {
|
|
4964
|
-
group.field.setError(firstInvalid
|
|
4987
|
+
group.field.setError(this.#messageFor(firstInvalid));
|
|
4965
4988
|
} else {
|
|
4966
4989
|
group.field.clearError();
|
|
4967
4990
|
}
|
|
4968
4991
|
}
|
|
4969
4992
|
return firstInvalid;
|
|
4970
4993
|
}
|
|
4994
|
+
/**
|
|
4995
|
+
* Resolves the message to show for an invalid control: a per-constraint
|
|
4996
|
+
* override (`data-stimeo--form-field-message-<constraint>`) for the first failing
|
|
4997
|
+
* `ValidityState` flag, then a generic `data-stimeo--form-field-message`
|
|
4998
|
+
* override, then the browser's native `validationMessage`. Authoring an override
|
|
4999
|
+
* gives controlled, localizable, theme-able wording with **no consumer JS** —
|
|
5000
|
+
* and sidesteps headless browsers that return an empty native message.
|
|
5001
|
+
*/
|
|
5002
|
+
#messageFor(control) {
|
|
5003
|
+
for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
|
|
5004
|
+
if (control.validity[flag]) {
|
|
5005
|
+
return control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || "";
|
|
5009
|
+
}
|
|
5010
|
+
/**
|
|
5011
|
+
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
5012
|
+
* for controls that opt in with `data-stimeo--form-field-disallow`. The one
|
|
5013
|
+
* supported rule today is `"whitespace"` — a value that is non-empty but blank
|
|
5014
|
+
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
5015
|
+
* the per-constraint (`value-missing`) → generic → default chain.
|
|
5016
|
+
*
|
|
5017
|
+
* Don't-clobber-authored-state: an unknown/absent rule is never touched, and a
|
|
5018
|
+
* custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),
|
|
5019
|
+
* so a consumer's own `setCustomValidity` on the same control survives.
|
|
5020
|
+
*/
|
|
5021
|
+
#syncCustomValidity(control) {
|
|
5022
|
+
const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
|
|
5023
|
+
if (violates) {
|
|
5024
|
+
control.setCustomValidity(
|
|
5025
|
+
control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT
|
|
5026
|
+
);
|
|
5027
|
+
this.#ownedCustomError.add(control);
|
|
5028
|
+
} else if (this.#ownedCustomError.has(control)) {
|
|
5029
|
+
this.#ownedCustomError.delete(control);
|
|
5030
|
+
control.setCustomValidity("");
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
4971
5033
|
/**
|
|
4972
5034
|
* A grouping key that collects controls belonging to the same field: the owning
|
|
4973
5035
|
* `stimeo--form-field` when present, else a radio group's shared `name`, else
|
|
@@ -5183,25 +5245,57 @@ var HighlightController = class extends Controller {
|
|
|
5183
5245
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
5184
5246
|
}
|
|
5185
5247
|
};
|
|
5248
|
+
|
|
5249
|
+
// src/utils/scroll_dismiss.ts
|
|
5250
|
+
function observeScrollDismiss(element, onScroll) {
|
|
5251
|
+
const targets = [...scrollParents(element), window];
|
|
5252
|
+
const handler = () => onScroll();
|
|
5253
|
+
for (const target of targets) {
|
|
5254
|
+
target.addEventListener("scroll", handler, { passive: true });
|
|
5255
|
+
}
|
|
5256
|
+
return () => {
|
|
5257
|
+
for (const target of targets) target.removeEventListener("scroll", handler);
|
|
5258
|
+
};
|
|
5259
|
+
}
|
|
5260
|
+
function scrollParents(element) {
|
|
5261
|
+
const parents = [];
|
|
5262
|
+
let node = element.parentElement;
|
|
5263
|
+
while (node) {
|
|
5264
|
+
if (isScrollable(node)) parents.push(node);
|
|
5265
|
+
node = node.parentElement;
|
|
5266
|
+
}
|
|
5267
|
+
return parents;
|
|
5268
|
+
}
|
|
5269
|
+
function isScrollable(element) {
|
|
5270
|
+
const style = getComputedStyle(element);
|
|
5271
|
+
return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
|
|
5272
|
+
}
|
|
5273
|
+
|
|
5274
|
+
// src/controllers/hover_card_controller.ts
|
|
5186
5275
|
var HoverCardController = class extends Controller {
|
|
5187
5276
|
static targets = ["trigger", "card"];
|
|
5188
5277
|
static values = {
|
|
5189
5278
|
openDelay: { type: Number, default: 300 },
|
|
5190
|
-
closeDelay: { type: Number, default: 200 }
|
|
5279
|
+
closeDelay: { type: Number, default: 200 },
|
|
5280
|
+
closeOnScroll: { type: Boolean, default: false }
|
|
5191
5281
|
};
|
|
5192
5282
|
static actions = ["close", "onKeydown", "open"];
|
|
5193
5283
|
/** Pending open/close timers, torn down together on disconnect. */
|
|
5194
5284
|
#timers = new SafeTimeout();
|
|
5195
5285
|
#pendingOpen = null;
|
|
5196
5286
|
#pendingClose = null;
|
|
5287
|
+
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
5288
|
+
#stopScrollDismiss = null;
|
|
5197
5289
|
/** Starts closed. */
|
|
5198
5290
|
connect() {
|
|
5199
5291
|
this.#conceal();
|
|
5200
5292
|
}
|
|
5201
|
-
/** Clears timers and the document `Escape`
|
|
5293
|
+
/** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
|
|
5202
5294
|
disconnect() {
|
|
5203
5295
|
this.#timers.clearAll();
|
|
5204
5296
|
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
5297
|
+
this.#stopScrollDismiss?.();
|
|
5298
|
+
this.#stopScrollDismiss = null;
|
|
5205
5299
|
}
|
|
5206
5300
|
/** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
|
|
5207
5301
|
open() {
|
|
@@ -5238,21 +5332,26 @@ var HoverCardController = class extends Controller {
|
|
|
5238
5332
|
this.#dismiss();
|
|
5239
5333
|
}
|
|
5240
5334
|
}
|
|
5241
|
-
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape
|
|
5335
|
+
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
|
|
5242
5336
|
#reveal() {
|
|
5243
5337
|
if (!this.hasCardTarget) return;
|
|
5244
5338
|
this.cardTarget.hidden = false;
|
|
5245
5339
|
this.cardTarget.setAttribute("data-state", "open");
|
|
5246
5340
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
5247
5341
|
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
5342
|
+
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
5343
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
5344
|
+
}
|
|
5248
5345
|
}
|
|
5249
|
-
/** Hides the card, reflects state, and stops watching for `Escape
|
|
5346
|
+
/** Hides the card, reflects state, and stops watching for `Escape`/scroll. */
|
|
5250
5347
|
#conceal() {
|
|
5348
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
5349
|
+
this.#stopScrollDismiss?.();
|
|
5350
|
+
this.#stopScrollDismiss = null;
|
|
5251
5351
|
if (!this.hasCardTarget) return;
|
|
5252
5352
|
this.cardTarget.hidden = true;
|
|
5253
5353
|
this.cardTarget.setAttribute("data-state", "closed");
|
|
5254
5354
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
5255
|
-
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
5256
5355
|
}
|
|
5257
5356
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
5258
5357
|
#dismiss() {
|
|
@@ -6381,9 +6480,20 @@ var MeterController = class extends Controller {
|
|
|
6381
6480
|
}
|
|
6382
6481
|
};
|
|
6383
6482
|
var MultiSelectController = class extends Controller {
|
|
6384
|
-
static targets = [
|
|
6483
|
+
static targets = [
|
|
6484
|
+
"input",
|
|
6485
|
+
"list",
|
|
6486
|
+
"option",
|
|
6487
|
+
"tags",
|
|
6488
|
+
"tag",
|
|
6489
|
+
"tagTemplate",
|
|
6490
|
+
"status",
|
|
6491
|
+
"fields"
|
|
6492
|
+
];
|
|
6385
6493
|
static values = {
|
|
6386
|
-
max: { type: Number, default: 0 }
|
|
6494
|
+
max: { type: Number, default: 0 },
|
|
6495
|
+
name: { type: String, default: "options[]" },
|
|
6496
|
+
form: { type: String, default: "" }
|
|
6387
6497
|
};
|
|
6388
6498
|
static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
6389
6499
|
static events = ["change", "filter"];
|
|
@@ -6400,6 +6510,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6400
6510
|
for (const option of this.#selectedOptions) this.#appendTag(option);
|
|
6401
6511
|
if (this.#removeButtons.length > 0) this.#roving.setActive(0);
|
|
6402
6512
|
}
|
|
6513
|
+
this.#syncFields();
|
|
6403
6514
|
document.addEventListener("click", this.#onOutsideClick);
|
|
6404
6515
|
}
|
|
6405
6516
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
@@ -6439,6 +6550,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6439
6550
|
}
|
|
6440
6551
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
6441
6552
|
onKeydown(event) {
|
|
6553
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
6442
6554
|
switch (event.key) {
|
|
6443
6555
|
case "ArrowDown":
|
|
6444
6556
|
event.preventDefault();
|
|
@@ -6533,6 +6645,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6533
6645
|
}
|
|
6534
6646
|
this.#announce(this.#optionLabel(option));
|
|
6535
6647
|
this.#refreshRoving();
|
|
6648
|
+
this.#syncFields();
|
|
6536
6649
|
this.dispatch("change", { detail: { values: this.#values } });
|
|
6537
6650
|
}
|
|
6538
6651
|
/** Builds one chip from the template for `option`. */
|
|
@@ -6566,6 +6679,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6566
6679
|
tag.remove();
|
|
6567
6680
|
this.#announce(option ? this.#optionLabel(option) : value);
|
|
6568
6681
|
this.#refreshRoving();
|
|
6682
|
+
this.#syncFields();
|
|
6569
6683
|
this.dispatch("change", { detail: { values: this.#values } });
|
|
6570
6684
|
const remaining = this.#removeButtons;
|
|
6571
6685
|
if (remaining.length === 0) {
|
|
@@ -6615,6 +6729,26 @@ var MultiSelectController = class extends Controller {
|
|
|
6615
6729
|
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
6616
6730
|
}
|
|
6617
6731
|
}
|
|
6732
|
+
/**
|
|
6733
|
+
* Mirrors the selected values into named hidden inputs under the `fields`
|
|
6734
|
+
* target so the selection submits with a normal form (parity with tags-input).
|
|
6735
|
+
* No-ops without a `fields` target, keeping the control back-compat. When the
|
|
6736
|
+
* `form` value is set, each input gets a matching `form` attribute so the picker
|
|
6737
|
+
* can submit with a `<form>` it lives outside of.
|
|
6738
|
+
*/
|
|
6739
|
+
#syncFields() {
|
|
6740
|
+
if (!this.hasFieldsTarget) return;
|
|
6741
|
+
this.fieldsTarget.replaceChildren(
|
|
6742
|
+
...this.#values.map((value) => {
|
|
6743
|
+
const input = document.createElement("input");
|
|
6744
|
+
input.type = "hidden";
|
|
6745
|
+
input.name = this.nameValue;
|
|
6746
|
+
input.value = value;
|
|
6747
|
+
if (this.hasFormValue && this.formValue !== "") input.setAttribute("form", this.formValue);
|
|
6748
|
+
return input;
|
|
6749
|
+
})
|
|
6750
|
+
);
|
|
6751
|
+
}
|
|
6618
6752
|
/** Keeps exactly one chip remove button tabbable after the set changes. */
|
|
6619
6753
|
#refreshRoving() {
|
|
6620
6754
|
if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1)
|
|
@@ -8124,7 +8258,12 @@ var PersistController = class extends Controller {
|
|
|
8124
8258
|
};
|
|
8125
8259
|
var PopoverController = class _PopoverController extends Controller {
|
|
8126
8260
|
static targets = ["trigger", "panel"];
|
|
8261
|
+
static values = {
|
|
8262
|
+
closeOnScroll: { type: Boolean, default: false }
|
|
8263
|
+
};
|
|
8127
8264
|
static actions = ["close", "open", "toggle"];
|
|
8265
|
+
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
8266
|
+
#stopScrollDismiss = null;
|
|
8128
8267
|
/** Selector for natively focusable elements used to find the first one. */
|
|
8129
8268
|
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
8130
8269
|
/** Starts closed and registers the document-level dismissal listeners. */
|
|
@@ -8143,6 +8282,8 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
8143
8282
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
8144
8283
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
8145
8284
|
if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
8285
|
+
this.#stopScrollDismiss?.();
|
|
8286
|
+
this.#stopScrollDismiss = null;
|
|
8146
8287
|
}
|
|
8147
8288
|
/** Toggles the popover. Bound via `data-action` (click on the trigger). */
|
|
8148
8289
|
toggle() {
|
|
@@ -8158,12 +8299,17 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
8158
8299
|
this.panelTarget.hidden = false;
|
|
8159
8300
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
8160
8301
|
this.panelTarget.addEventListener("focusout", this.#onFocusOut);
|
|
8302
|
+
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
8303
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
|
|
8304
|
+
}
|
|
8161
8305
|
this.#focusFirst();
|
|
8162
8306
|
}
|
|
8163
8307
|
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
8164
8308
|
close() {
|
|
8165
8309
|
if (!this.hasPanelTarget) return;
|
|
8166
8310
|
this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
8311
|
+
this.#stopScrollDismiss?.();
|
|
8312
|
+
this.#stopScrollDismiss = null;
|
|
8167
8313
|
this.panelTarget.hidden = true;
|
|
8168
8314
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
8169
8315
|
}
|
|
@@ -10489,19 +10635,28 @@ var SubmitOnceController = class extends Controller {
|
|
|
10489
10635
|
#timeoutId = null;
|
|
10490
10636
|
#busy = false;
|
|
10491
10637
|
#submitter = null;
|
|
10638
|
+
#onSubmitStart = (event) => {
|
|
10639
|
+
this.start(event);
|
|
10640
|
+
};
|
|
10492
10641
|
#onSubmitEnd = (event) => {
|
|
10493
10642
|
const success = event.detail?.success;
|
|
10494
10643
|
this.#restore(success);
|
|
10495
10644
|
};
|
|
10496
10645
|
connect() {
|
|
10646
|
+
this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
10497
10647
|
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
10498
10648
|
this.#clearStaleBusy();
|
|
10499
10649
|
}
|
|
10500
10650
|
disconnect() {
|
|
10651
|
+
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
10501
10652
|
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
10502
10653
|
this.#clearTimeout();
|
|
10503
10654
|
}
|
|
10504
|
-
/**
|
|
10655
|
+
/**
|
|
10656
|
+
* Enters the busy state for the submission started by `event`. Accepts both a
|
|
10657
|
+
* native `SubmitEvent` (manual `submit->#start` on non-Turbo forms) and Turbo's
|
|
10658
|
+
* `turbo:submit-start` CustomEvent (auto-subscribed in `connect`).
|
|
10659
|
+
*/
|
|
10505
10660
|
start(event) {
|
|
10506
10661
|
if (this.#busy) return;
|
|
10507
10662
|
this.#busy = true;
|
|
@@ -10575,12 +10730,24 @@ var SubmitOnceController = class extends Controller {
|
|
|
10575
10730
|
}
|
|
10576
10731
|
/** The triggering button: the event's submitter when ours, else the first button. */
|
|
10577
10732
|
#resolveSubmitter(event, buttons) {
|
|
10578
|
-
const submitter = event
|
|
10733
|
+
const submitter = this.#eventSubmitter(event);
|
|
10579
10734
|
if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {
|
|
10580
10735
|
return submitter;
|
|
10581
10736
|
}
|
|
10582
10737
|
return buttons[0] ?? null;
|
|
10583
10738
|
}
|
|
10739
|
+
/**
|
|
10740
|
+
* Reads the submitter from a native `SubmitEvent` (`event.submitter`) or from
|
|
10741
|
+
* Turbo's `turbo:submit-start` detail (`detail.formSubmission.submitter`), so
|
|
10742
|
+
* the busy label swaps onto the right button under either path.
|
|
10743
|
+
*/
|
|
10744
|
+
#eventSubmitter(event) {
|
|
10745
|
+
if (typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent) {
|
|
10746
|
+
return event.submitter;
|
|
10747
|
+
}
|
|
10748
|
+
const detail = event.detail;
|
|
10749
|
+
return detail?.formSubmission?.submitter ?? null;
|
|
10750
|
+
}
|
|
10584
10751
|
/** The controlled buttons: `submit` targets, or the form's native submit controls. */
|
|
10585
10752
|
get #buttons() {
|
|
10586
10753
|
if (this.hasSubmitTarget) return this.submitTargets;
|
|
@@ -10740,6 +10907,7 @@ var TagsInputController = class extends Controller {
|
|
|
10740
10907
|
}
|
|
10741
10908
|
/** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
|
|
10742
10909
|
onKeydown(event) {
|
|
10910
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
10743
10911
|
if (event.key === "Enter" || event.key === this.delimiterValue) {
|
|
10744
10912
|
event.preventDefault();
|
|
10745
10913
|
this.#commitInput();
|
|
@@ -11706,7 +11874,8 @@ var TooltipController = class extends Controller {
|
|
|
11706
11874
|
static targets = ["trigger", "content"];
|
|
11707
11875
|
static values = {
|
|
11708
11876
|
showDelay: { type: Number, default: 0 },
|
|
11709
|
-
hideDelay: { type: Number, default: 0 }
|
|
11877
|
+
hideDelay: { type: Number, default: 0 },
|
|
11878
|
+
closeOnScroll: { type: Boolean, default: false }
|
|
11710
11879
|
};
|
|
11711
11880
|
static actions = ["hide", "onKeydown", "show"];
|
|
11712
11881
|
/** Pending show/hide timers, torn down together on disconnect. */
|
|
@@ -11714,14 +11883,18 @@ var TooltipController = class extends Controller {
|
|
|
11714
11883
|
/** The id of the currently pending show or hide timer, if any. */
|
|
11715
11884
|
#pendingShow = null;
|
|
11716
11885
|
#pendingHide = null;
|
|
11886
|
+
/** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
|
|
11887
|
+
#stopScrollDismiss = null;
|
|
11717
11888
|
/** Starts hidden. */
|
|
11718
11889
|
connect() {
|
|
11719
11890
|
this.#conceal();
|
|
11720
11891
|
}
|
|
11721
|
-
/** Clears timers and the document `Escape`
|
|
11892
|
+
/** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
|
|
11722
11893
|
disconnect() {
|
|
11723
11894
|
this.#timers.clearAll();
|
|
11724
11895
|
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
11896
|
+
this.#stopScrollDismiss?.();
|
|
11897
|
+
this.#stopScrollDismiss = null;
|
|
11725
11898
|
}
|
|
11726
11899
|
/** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */
|
|
11727
11900
|
show() {
|
|
@@ -11763,19 +11936,28 @@ var TooltipController = class extends Controller {
|
|
|
11763
11936
|
this.#conceal();
|
|
11764
11937
|
}
|
|
11765
11938
|
}
|
|
11766
|
-
/** Reveals the content and starts watching for a dismissing `Escape
|
|
11939
|
+
/** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
|
|
11767
11940
|
#reveal() {
|
|
11768
11941
|
if (!this.hasContentTarget) return;
|
|
11769
11942
|
this.contentTarget.hidden = false;
|
|
11770
11943
|
this.contentTarget.setAttribute("data-state", "open");
|
|
11771
11944
|
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
11945
|
+
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
11946
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {
|
|
11947
|
+
this.#cancelShow();
|
|
11948
|
+
this.#cancelHide();
|
|
11949
|
+
this.#conceal();
|
|
11950
|
+
});
|
|
11951
|
+
}
|
|
11772
11952
|
}
|
|
11773
|
-
/** Hides the content and stops watching for `Escape
|
|
11953
|
+
/** Hides the content and stops watching for `Escape`/scroll. */
|
|
11774
11954
|
#conceal() {
|
|
11955
|
+
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
11956
|
+
this.#stopScrollDismiss?.();
|
|
11957
|
+
this.#stopScrollDismiss = null;
|
|
11775
11958
|
if (!this.hasContentTarget) return;
|
|
11776
11959
|
this.contentTarget.hidden = true;
|
|
11777
11960
|
this.contentTarget.setAttribute("data-state", "closed");
|
|
11778
|
-
document.removeEventListener("keydown", this.#onDocumentKeydown);
|
|
11779
11961
|
}
|
|
11780
11962
|
/** Document-level `Escape` watcher (active only while shown). */
|
|
11781
11963
|
#onDocumentKeydown = (event) => {
|
data/lib/stimeo/ui/version.rb
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Stimeo
|
|
4
4
|
module UI
|
|
5
|
-
# Kept in lockstep with package.json: npm `0.1.0-
|
|
6
|
-
# `0.1.0.pre.
|
|
5
|
+
# Kept in lockstep with package.json: npm `0.1.0-beta.1` ⇔ gem
|
|
6
|
+
# `0.1.0.pre.beta.1` (RubyGems forbids dashes). Bump both together —
|
|
7
7
|
# see RELEASING.md for the synchronized release procedure.
|
|
8
|
-
VERSION = "0.1.0.pre.
|
|
8
|
+
VERSION = "0.1.0.pre.beta.1"
|
|
9
9
|
end
|
|
10
10
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stimeo-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.pre.
|
|
4
|
+
version: 0.1.0.pre.beta.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stimeo Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|
|
@@ -39,6 +39,7 @@ executables: []
|
|
|
39
39
|
extensions: []
|
|
40
40
|
extra_rdoc_files: []
|
|
41
41
|
files:
|
|
42
|
+
- CHANGELOG.md
|
|
42
43
|
- LICENSE
|
|
43
44
|
- README.md
|
|
44
45
|
- dist/controllers/accordion_controller.js
|
|
@@ -122,13 +123,14 @@ files:
|
|
|
122
123
|
- lib/stimeo-ui.rb
|
|
123
124
|
- lib/stimeo/ui.rb
|
|
124
125
|
- lib/stimeo/ui/version.rb
|
|
125
|
-
homepage: https://
|
|
126
|
+
homepage: https://stimeo-labs.com
|
|
126
127
|
licenses:
|
|
127
128
|
- MIT
|
|
128
129
|
metadata:
|
|
129
130
|
source_code_uri: https://github.com/taiyaky/stimeo-ui
|
|
130
131
|
bug_tracker_uri: https://github.com/taiyaky/stimeo-ui/issues
|
|
131
132
|
changelog_uri: https://github.com/taiyaky/stimeo-ui/releases
|
|
133
|
+
documentation_uri: https://stimeo-labs.com/components
|
|
132
134
|
rubygems_mfa_required: 'true'
|
|
133
135
|
post_install_message:
|
|
134
136
|
rdoc_options: []
|