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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34dbcfca1f64a125bc86e2d946b7c2c62753f02e67c7444b4b25823f9ed3f3a3
4
- data.tar.gz: fb0661197fe81c6559960f2b74d0dfffd5d126f3b285edda5123bf2fe37db6e3
3
+ metadata.gz: d5958af6bffb81de09834ec57bed72292dccba82e858e62c87b99a43f769cc94
4
+ data.tar.gz: 4a35a8a27e890e26652fa67bf44739e8a9e28993df7bfd7b912ca33b39fef11b
5
5
  SHA512:
6
- metadata.gz: 6f1578a41271afa0633a19c627200e4abe957ce7b6fb7e2d9013db876d75fd84140af7e3b98a8c0e37ffa931c159f9c952b444105b099882cc61bbfde220085b
7
- data.tar.gz: 2037a7c5dfb426bdc8f19bc6d14e689d488695eb545b59b58572bfbccd8db9d6dd6d9102d129a79d6baef3371ca1bd2301a0a6d353877584601689460c03e2e4
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
- # Stimeo UI
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
+ [![CI](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/stimeo-ui)](https://www.npmjs.com/package/stimeo-ui) [![gem](https://img.shields.io/gem/v/stimeo-ui)](https://rubygems.org/gems/stimeo-ui) [![License: MIT](https://img.shields.io/github/license/taiyaky/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: **alpha** (`0.x`). The `stimeo--*` attribute API may still change before
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.validationMessage);
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` listener so nothing outlives the element. */
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
- /** Enters the busy state for the submission started by `event`. */
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.submitter;
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` listener so nothing outlives the element. */
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.validationMessage);
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` listener so nothing outlives the element. */
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 = ["input", "list", "option", "tags", "tag", "tagTemplate", "status"];
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
- /** Enters the busy state for the submission started by `event`. */
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.submitter;
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` listener so nothing outlives the element. */
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) => {
@@ -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-alpha.1` ⇔ gem
6
- # `0.1.0.pre.alpha.1` (RubyGems forbids dashes). Bump both together —
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.alpha.1"
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.alpha.1
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-20 00:00:00.000000000 Z
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://github.com/taiyaky/stimeo-ui
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: []