stimeo-ui 0.1.0.pre.beta.3 → 0.2.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +6 -6
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/auto_submit_controller.js +56 -21
  6. data/dist/controllers/carousel_controller.js +272 -0
  7. data/dist/controllers/character_counter_controller.js +52 -15
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +213 -0
  11. data/dist/controllers/combobox_controller.js +64 -3
  12. data/dist/controllers/command_palette_controller.js +243 -39
  13. data/dist/controllers/confirm_controller.js +94 -8
  14. data/dist/controllers/context_menu_controller.js +191 -13
  15. data/dist/controllers/count_up_controller.js +8 -1
  16. data/dist/controllers/countdown_controller.js +2 -2
  17. data/dist/controllers/currency_input_controller.js +147 -0
  18. data/dist/controllers/data_grid_controller.js +168 -0
  19. data/dist/controllers/date_range_picker_controller.js +417 -0
  20. data/dist/controllers/dialog_controller.js +94 -8
  21. data/dist/controllers/dismissible_controller.js +117 -0
  22. data/dist/controllers/drawer_controller.js +630 -0
  23. data/dist/controllers/dropdown_controller.js +139 -16
  24. data/dist/controllers/editable_controller.js +168 -0
  25. data/dist/controllers/file_dropzone_controller.js +165 -0
  26. data/dist/controllers/filter_controller.js +86 -0
  27. data/dist/controllers/flash_controller.js +36 -5
  28. data/dist/controllers/focus_controller.js +94 -8
  29. data/dist/controllers/form_validation_controller.js +2 -2
  30. data/dist/controllers/highlight_controller.js +6 -4
  31. data/dist/controllers/hover_card_controller.js +103 -27
  32. data/dist/controllers/intersection_controller.js +41 -18
  33. data/dist/controllers/lazy_frame_controller.js +33 -11
  34. data/dist/controllers/listbox_controller.js +1 -0
  35. data/dist/controllers/masonry_controller.js +142 -0
  36. data/dist/controllers/menu_controller.js +181 -8
  37. data/dist/controllers/menubar_controller.js +433 -0
  38. data/dist/controllers/multi_select_controller.js +472 -0
  39. data/dist/controllers/navigation_menu_controller.js +384 -0
  40. data/dist/controllers/otp_controller.js +62 -22
  41. data/dist/controllers/overflow_indicator_controller.js +178 -27
  42. data/dist/controllers/password_reveal_controller.js +117 -0
  43. data/dist/controllers/pointer_drag_controller.js +3 -3
  44. data/dist/controllers/popover_controller.js +118 -27
  45. data/dist/controllers/range_slider_controller.js +166 -0
  46. data/dist/controllers/read_more_controller.js +194 -0
  47. data/dist/controllers/scroll_area_controller.js +15 -2
  48. data/dist/controllers/scroll_restore_controller.js +93 -0
  49. data/dist/controllers/scroll_visibility_controller.js +8 -4
  50. data/dist/controllers/scrollspy_controller.js +33 -11
  51. data/dist/controllers/separator_controller.js +87 -0
  52. data/dist/controllers/sidebar_controller.js +761 -0
  53. data/dist/controllers/stepper_controller.js +28 -12
  54. data/dist/controllers/stick_to_bottom_controller.js +8 -4
  55. data/dist/controllers/sticky_observer_controller.js +88 -20
  56. data/dist/controllers/tags_input_controller.js +275 -0
  57. data/dist/controllers/theme_controller.js +20 -10
  58. data/dist/controllers/time_picker_controller.js +212 -0
  59. data/dist/controllers/toast_controller.js +144 -38
  60. data/dist/controllers/tooltip_controller.js +148 -42
  61. data/dist/controllers/transition_controller.js +153 -38
  62. data/dist/controllers/tree_view_controller.js +275 -0
  63. data/dist/index.js +1644 -643
  64. data/lib/stimeo/ui/version.rb +5 -4
  65. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 778527340281767df85850342901a668c8f54f451ed25526b4ca6a6f280c32f3
4
- data.tar.gz: b343d8b09c9827d9ee379fe7604424183086826d81593a712549a6af6ca46ee2
3
+ metadata.gz: a4f40e999f04533e49b382f7a5165c17b2338e8d520c702c34523843c12eff4c
4
+ data.tar.gz: 2bd4c18004fd4f92998513dd6a0d51975c7f9fcbe452fcef89477eb3f87edd7b
5
5
  SHA512:
6
- metadata.gz: 62a2f1c1dd34706ace247fc1cd9d1f581458cda076f533532c064d2c4fec40d42037e1e70aba19e6bb8a7ed11eba08565ba8ab61bd325c8ce89c3ac710036f17
7
- data.tar.gz: a862d0e7f53905d014c0938f26484047626adb34e8ea96272d33eda4532ce6fc36819554119698b9989d9cf456f95aab52627841cd620a2de2f08c569e10762e
6
+ metadata.gz: 06ed197e53937fa086971d86c22d66efa4aed0f2accf14000046711be6aa73c3f486671c3912264a4ce5ca7c6da97a9836d7fe79c1d3ce106e1b368e5013f0d1
7
+ data.tar.gz: e49c3e2ccbc075def36be9d4e926d167fa4714cb66710389ad983ad7725f86e91d525fddfbb809090b4af09bc0cdaed44541466cb57391db53566119613f8863
data/CHANGELOG.md CHANGED
@@ -7,6 +7,154 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.2.1] - 2026-07-26
11
+
12
+ Patch release from a review of the disclosure and layout components, plus a
13
+ packaging fix. No breaking changes, but `stimeo check` now reports two markup
14
+ requirements that existing pages may need to satisfy.
15
+
16
+ ### Added
17
+
18
+ - sidebar: `beforeCache` action for Turbo page caching — wire
19
+ `data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"` to close
20
+ an open overlay and release its modal side effects before the snapshot is taken.
21
+ - sidebar, stepper: the `breakpoint` and `index` values now take effect when
22
+ changed at runtime. An invalid `breakpoint` falls back to 768px.
23
+ - Inspector: `stimeo check` verifies that the tabs `role="tablist"` element has an
24
+ accessible name.
25
+
26
+ ### Changed
27
+
28
+ - tabs: the tablist container is now a required `list` target. Add
29
+ `data-stimeo--tabs-target="list"` to it, or `stimeo check` reports an error.
30
+ Runtime behavior is unchanged.
31
+ - Inspector: `stimeo check` reports an error when `stimeo--sidebar` markup has no
32
+ `panel` target.
33
+
34
+ ### Fixed
35
+
36
+ - package exports: 26 controllers — including sidebar, drawer, collapsible,
37
+ read-more, alert-dialog, carousel and tree-view — had no build output behind the
38
+ `./controllers/*` subpath their `package.json` `exports` declares, so
39
+ `import … from "stimeo-ui/controllers/<name>_controller"` failed to resolve in
40
+ 0.2.0. Every controller in the catalog now ships one.
41
+ - transition, flash, toast, sidebar, drawer, collapsible: animations now wait for
42
+ every declared transition property and its `transition-delay`, not just the
43
+ first duration. Multi-property and delayed animations are no longer cut short, a
44
+ cancelled transition settles immediately, and pseudo-element transitions no
45
+ longer end the wait early — so `hidden`, events, focus restore and scroll unlock
46
+ land at the right moment.
47
+ - sidebar, drawer, collapsible: an interrupted close animation no longer leaves the
48
+ panel stuck open with the focus trap, background `inert` or scroll lock in place.
49
+ - sidebar, drawer, collapsible, read-more, sticky-observer, overflow-indicator:
50
+ targets swapped in or added after connect (Turbo morph, lazily loaded Frames,
51
+ Streams) are now reconciled with the current state instead of keeping the value
52
+ they were rendered with.
53
+ - overflow-indicator, scroll-area: horizontal position is read logically, so the
54
+ overflow attributes, the `scrollByPage` action, `data-scroll` and
55
+ `--stimeo-scroll-progress` behave correctly inside `dir="rtl"` containers.
56
+ - overflow-indicator: reaching an edge while a page button has focus no longer
57
+ drops focus to the page body — the button stays focused and inert via
58
+ `aria-disabled`, becoming natively `disabled` only once blurred.
59
+ - read-more: the toggle is no longer hidden while it holds keyboard focus when the
60
+ text starts to fit; overflow is re-checked on blur, on content changes, and when
61
+ media inside it finishes loading.
62
+ - sticky-observer: `data-stuck="true"` is set only once the sentinel has actually
63
+ scrolled past the top edge — one that is merely out of view, or inside a hidden
64
+ container, no longer marks the element as stuck. Negative `offset` values are now
65
+ accepted.
66
+ - intersection: an element with no layout box (hidden ancestor, collapsed
67
+ `<details>`) is no longer reported as scrolled past.
68
+
69
+ ## [0.2.0] - 2026-07-22
70
+
71
+ First release off the beta channel: `npm install stimeo-ui` now resolves
72
+ this version as `latest`, and the gem no longer needs a prerelease pin. The
73
+ library is still `0.x`, so the `stimeo--*` attribute API may change before
74
+ 1.0. The changes below come from a component-by-component hardening review
75
+ of the catalog, centered on layered Escape handling, IME-safe input, and
76
+ focus/dismissal correctness in the overlay controllers.
77
+
78
+ ### Added
79
+
80
+ - dropdown: the trigger is now associated with its menu — on connect the
81
+ controller sets `aria-controls`, minting a menu id when needed. Authored
82
+ markup is never overwritten.
83
+ - toast: item interaction (dismiss click, Escape, hover/focus pause) is now
84
+ delegated from the list container, so dynamically appended toasts work
85
+ without per-item `data-action` attributes; the dismiss button is the
86
+ element carrying the new `data-toast-dismiss` attribute. Existing per-item
87
+ markup keeps working.
88
+ - Inspector: new checks — the menu target of `stimeo--menu` must carry an
89
+ accessible name, and every command-palette option needs a unique `id`.
90
+
91
+ ### Changed
92
+
93
+ - tooltip, hover-card: Escape dismissal is now owned by the shared layer
94
+ stack, so the redundant `onKeydown` action was removed from the
95
+ controllers and the documented trigger markup. Markup still binding it
96
+ logs a Stimulus missing-action warning; dismissal keeps working.
97
+ - toast: the live-region role (`status`/`alert`) now applies to the body
98
+ slot instead of the toast item, so screen readers announce only the
99
+ message text.
100
+ - menu, overflow-menu: the documented markup contract now requires an
101
+ accessible name on the `role="menu"` element (`aria-labelledby` or
102
+ `aria-label`). Existing markup keeps working; the new Inspector check
103
+ flags the missing name.
104
+ - command-palette: option ARIA is now controller-managed — `data-disabled`
105
+ and authored `aria-disabled` stay in sync, and every option gets a
106
+ baseline `aria-selected="false"`.
107
+
108
+ ### Fixed
109
+
110
+ - Escape now dismisses exactly one overlay layer per press — the most
111
+ recently opened one — when overlays are stacked: nested dialogs no longer
112
+ close together, a tooltip or hover card shown above an open menu or dialog
113
+ no longer closes the layer beneath it, a press already handled by a nested
114
+ component (an inline edit, a listbox, a toast) is never acted on twice,
115
+ and an Escape aimed at another layer no longer closes an overlay
116
+ underneath or yanks focus back to its trigger. This covers dropdown,
117
+ popover, menu, menubar, context-menu, navigation-menu, hover-card,
118
+ tooltip, and the modal overlays; a combobox with its list closed now lets
119
+ Escape reach the enclosing dialog.
120
+ - Escape or Enter pressed during an IME composition now steers the
121
+ composition only: cancelling a Japanese/Chinese/Korean conversion no
122
+ longer closes overlays, dismisses a toast or dismissible, or cancels an
123
+ inline edit, a drag, or a pending date-range start — and a
124
+ conversion-confirming Enter no longer saves an edit, commits a tag, or
125
+ selects a command. Filtering inputs (combobox, multi-select,
126
+ command-palette) apply the confirmed text once on `compositionend` instead
127
+ of filtering intermediate text. The guards track the composition lifecycle
128
+ instead of the deprecated `keyCode === 229` heuristic, and otp ignores
129
+ events flagged `isComposing` even without a preceding `compositionstart`.
130
+ - command-palette: the open/close hotkey no longer misfires when Shift or
131
+ Alt is held or when both Cmd and Ctrl are down, an unsupported hotkey
132
+ value now does nothing, and stale `aria-activedescendant`/active-option
133
+ state is cleaned up correctly.
134
+ - menu, context-menu: `aria-disabled="true"` items are truly inert —
135
+ activating one runs no consumer handlers and the menu stays open — and
136
+ Tab now lets the browser move focus before the menu closes. context-menu
137
+ also closes on an outside right-click without stealing focus from the
138
+ new target.
139
+ - popover, navigation-menu, dropdown: outside-interaction handling
140
+ corrected — an outside click closes without refocusing the trigger,
141
+ clicking non-focusable panel content or deactivating the browser window
142
+ no longer dismisses an open panel, and dropdown's outside-click listener
143
+ runs in the capture phase so a widget that stops click propagation can no
144
+ longer leave a stuck menu.
145
+ - tooltip, hover-card: keyboard focus and pointer hover are now tracked as
146
+ separate reasons to stay open (per WCAG 1.4.13), and a pending show/hide
147
+ delay no longer leaves the element permanently stuck after a
148
+ disconnect/reconnect cycle (e.g. a Turbo restore). The documented
149
+ hover-card markup now also closes the card after keyboard focus leaves it.
150
+ - toast: runtime `duration` changes now take effect (0 or below makes
151
+ toasts persistent), a toast paused after its time fully elapsed dismisses
152
+ immediately, and dismissal is idempotent — the dismiss event fires exactly
153
+ once per removal.
154
+ - dismissible: the `closeOnEscape` value is live after connect, and focus
155
+ retreat now skips unfocusable candidates instead of silently dropping
156
+ focus to `<body>`.
157
+
10
158
  ## [0.1.0-beta.3] - 2026-07-18
11
159
 
12
160
  Maintenance release: one bug fix in the focus-trap primitive shared by the
@@ -86,6 +234,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
86
234
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
87
235
  RubyGems.
88
236
 
237
+ [0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
89
238
  [0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
90
239
  [0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
91
240
  [0.1.0-beta.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.1
data/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  </picture>
6
6
  </h1>
7
7
 
8
- <p align="center"><a href="https://stimeo-labs.com"><strong>Live demo (beta) →</strong></a></p>
8
+ <p align="center"><a href="https://stimeo-labs.com"><strong>Live demo →</strong></a></p>
9
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/beta)](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)
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)
11
11
 
12
12
  **Headless Stimulus UI framework for Ruby on Rails.** Stimeo UI ships *behavior*
13
13
  — ARIA state, keyboard interaction, focus management, Turbo resilience — as
@@ -23,15 +23,15 @@ owns the look entirely.
23
23
  - Public controller identifiers use the `stimeo--` namespace (e.g.
24
24
  `stimeo--dropdown`).
25
25
 
26
- > Status: **beta** (`0.x`). The `stimeo--*` attribute API may still change before
27
- > 1.0 — pin your version.
26
+ > Status: **pre-release** (`0.x`). The `stimeo--*` attribute API may still
27
+ > change before 1.0 — pin your version.
28
28
 
29
29
  ## Install
30
30
 
31
31
  ### Rails with importmap (recommended)
32
32
 
33
33
  ```bash
34
- bundle add stimeo-ui --version "0.1.0.pre.beta.3"
34
+ bundle add stimeo-ui
35
35
  bin/rails generate stimeo:install
36
36
  ```
37
37
 
@@ -50,7 +50,7 @@ Stimulus application. Then drive components from HTML alone:
50
50
  ### npm (jsbundling or any bundler)
51
51
 
52
52
  ```bash
53
- npm install stimeo-ui@beta @hotwired/stimulus
53
+ npm install stimeo-ui @hotwired/stimulus
54
54
  ```
55
55
 
56
56
  ```js
@@ -0,0 +1,318 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/alert_dialog_controller.ts
4
+
5
+ // src/utils/escape_layer.ts
6
+ var EscapeLayer = class _EscapeLayer {
7
+ static #registries = /* @__PURE__ */ new WeakMap();
8
+ #ownerDocument = null;
9
+ /** Dismissal callback while active; `null` when inactive. */
10
+ #onDismiss = null;
11
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
12
+ #claims = null;
13
+ /**
14
+ * Activates this layer at the top of its document's Escape stack, installing
15
+ * the document's shared resolver listener if this is its first layer.
16
+ * Re-activating an already-active layer moves it to the top.
17
+ */
18
+ activate(ownerDocument = document, options) {
19
+ this.deactivate();
20
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
21
+ if (!registry) {
22
+ registry = _EscapeLayer.#createRegistry();
23
+ _EscapeLayer.#registries.set(ownerDocument, registry);
24
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
25
+ }
26
+ registry.stack.push(this);
27
+ this.#ownerDocument = ownerDocument;
28
+ this.#onDismiss = options.onDismiss;
29
+ this.#claims = options.claims ?? null;
30
+ }
31
+ /**
32
+ * Removes this layer from its document's Escape stack, uninstalling the
33
+ * shared listener when the stack empties. Safe to call when inactive.
34
+ */
35
+ deactivate() {
36
+ const ownerDocument = this.#ownerDocument;
37
+ if (!ownerDocument) return;
38
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
39
+ if (registry) {
40
+ const index = registry.stack.lastIndexOf(this);
41
+ if (index >= 0) registry.stack.splice(index, 1);
42
+ if (registry.stack.length === 0) {
43
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
44
+ _EscapeLayer.#registries.delete(ownerDocument);
45
+ }
46
+ }
47
+ this.#ownerDocument = null;
48
+ this.#onDismiss = null;
49
+ this.#claims = null;
50
+ }
51
+ /**
52
+ * Whether this active layer would own a press right now: it is the topmost
53
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
54
+ * and diagnostics — production dismissal goes through the shared listener.
55
+ */
56
+ get ownsEscape() {
57
+ const ownerDocument = this.#ownerDocument;
58
+ if (!ownerDocument) return false;
59
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
60
+ if (!registry) return false;
61
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
62
+ }
63
+ /** Builds a document's registry with its shared resolver listener. */
64
+ static #createRegistry() {
65
+ const registry = {
66
+ stack: [],
67
+ onKeydown: (event) => {
68
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
69
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
70
+ if (!owner) return;
71
+ event.preventDefault();
72
+ owner.#onDismiss?.();
73
+ }
74
+ };
75
+ return registry;
76
+ }
77
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
78
+ static #resolveOwner(stack) {
79
+ for (let index = stack.length - 1; index >= 0; index--) {
80
+ const layer = stack[index];
81
+ if (!layer) continue;
82
+ if (layer.#claims && !layer.#claims()) continue;
83
+ return layer;
84
+ }
85
+ return null;
86
+ }
87
+ };
88
+
89
+ // src/utils/focus_trap.ts
90
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
91
+ var FocusTrap = class {
92
+ /** The element focused before activation, restored on deactivation. */
93
+ #previouslyFocused = null;
94
+ /** The body's inline `overflow` before locking, restored on deactivation. */
95
+ #previousBodyOverflow = "";
96
+ /** Whether scroll was locked this activation (so it is only restored if applied). */
97
+ #scrollLocked = false;
98
+ /** Background siblings made `inert` while active, restored on deactivation. */
99
+ #inertedSiblings = [];
100
+ /** Whether the modal side effects are currently applied. */
101
+ #activeState = false;
102
+ /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
103
+ #escapeLayer = new EscapeLayer();
104
+ /** Returns the trapped element; called on every operation for the live target. */
105
+ #getContainer;
106
+ /** Closing/focus hooks; see {@link FocusTrapOptions}. */
107
+ #options;
108
+ /**
109
+ * @param getContainer - Returns the trapped element. Called on every operation
110
+ * so the live target is always used.
111
+ * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
112
+ */
113
+ constructor(getContainer, options = {}) {
114
+ this.#getContainer = getContainer;
115
+ this.#options = options;
116
+ }
117
+ /** Whether the trap is currently active. */
118
+ get active() {
119
+ return this.#activeState;
120
+ }
121
+ /**
122
+ * Applies the trap: records the current focus, optionally locks background scroll
123
+ * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
124
+ * `autoFocus` is off) moves focus inside. No-ops if already active.
125
+ */
126
+ activate() {
127
+ if (this.#activeState) return;
128
+ this.#activeState = true;
129
+ const active = document.activeElement;
130
+ this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
131
+ if (this.#flag(this.#options.lockScroll, true)) {
132
+ this.#previousBodyOverflow = document.body.style.overflow;
133
+ document.body.style.overflow = "hidden";
134
+ this.#scrollLocked = true;
135
+ }
136
+ if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
+ document.addEventListener("keydown", this.#onKeydown);
138
+ document.addEventListener("turbo:before-cache", this.#onBeforeCache);
139
+ const onEscape = this.#options.onEscape;
140
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
+ if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
142
+ }
143
+ /**
144
+ * Reverts every side effect applied by {@link activate}. No-ops if inactive, so
145
+ * a controller can call it defensively from both `close()` and `disconnect()`.
146
+ *
147
+ * @param restoreFocus - Move focus back to the opener (default `true`). Pass
148
+ * `false` on teardown (`disconnect`), where yanking focus is undesirable.
149
+ */
150
+ deactivate({ restoreFocus = true } = {}) {
151
+ if (!this.#activeState) return;
152
+ this.#activeState = false;
153
+ this.#escapeLayer.deactivate();
154
+ document.removeEventListener("keydown", this.#onKeydown);
155
+ document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
156
+ if (this.#scrollLocked) {
157
+ document.body.style.overflow = this.#previousBodyOverflow;
158
+ this.#scrollLocked = false;
159
+ }
160
+ this.#releaseBackground();
161
+ if (restoreFocus) {
162
+ const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
163
+ target?.focus();
164
+ }
165
+ }
166
+ /** Resolves a boolean-or-getter option, defaulting when it was not provided. */
167
+ #flag(option, fallback) {
168
+ if (option === void 0) return fallback;
169
+ return typeof option === "function" ? option() : option;
170
+ }
171
+ /**
172
+ * Reverts the side effects just before Turbo caches the page snapshot, so an
173
+ * overlay left open does not bake the scroll lock into `body[style]` — a
174
+ * restored page would feed that locked value back into {@link activate} as the
175
+ * baseline, and closing would then never unlock the page. Markup state stays
176
+ * untouched (restore-open designs reopen against a clean baseline), and focus
177
+ * is left alone mid-navigation. The listener lives only while active.
178
+ */
179
+ #onBeforeCache = () => {
180
+ this.deactivate({ restoreFocus: false });
181
+ };
182
+ /**
183
+ * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
+ * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
185
+ * which layer currently owns Escape.
186
+ */
187
+ #onKeydown = (event) => {
188
+ if (event.key === "Tab") this.#trapTab(event);
189
+ };
190
+ /** Keeps `Tab` focus cycling within the container's focusable elements. */
191
+ #trapTab(event) {
192
+ const focusable = this.#focusableElements();
193
+ if (focusable.length === 0) {
194
+ event.preventDefault();
195
+ return;
196
+ }
197
+ const first = focusable[0];
198
+ const last = focusable[focusable.length - 1];
199
+ const active = document.activeElement;
200
+ if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
201
+ event.preventDefault();
202
+ first?.focus();
203
+ return;
204
+ }
205
+ if (event.shiftKey && active === first) {
206
+ event.preventDefault();
207
+ last?.focus();
208
+ } else if (!event.shiftKey && active === last) {
209
+ event.preventDefault();
210
+ first?.focus();
211
+ }
212
+ }
213
+ /**
214
+ * Marks every element outside the container's subtree as `inert` so background
215
+ * content cannot be focused or reached by assistive technology, honoring the
216
+ * `aria-modal="true"` contract. An element that was *already* `inert` is left
217
+ * untracked so `#releaseBackground` does not wrongly clear it.
218
+ */
219
+ #isolateBackground() {
220
+ const container = this.#getContainer();
221
+ this.#inertedSiblings = [];
222
+ for (const sibling of Array.from(document.body.children)) {
223
+ if (!(sibling instanceof HTMLElement)) continue;
224
+ if (sibling.contains(container) || sibling.inert) continue;
225
+ sibling.inert = true;
226
+ this.#inertedSiblings.push(sibling);
227
+ }
228
+ }
229
+ /** Reverts the `inert` flags applied by `#isolateBackground`. */
230
+ #releaseBackground() {
231
+ for (const sibling of this.#inertedSiblings) {
232
+ sibling.inert = false;
233
+ }
234
+ this.#inertedSiblings = [];
235
+ }
236
+ /** Moves focus to the initial target, the first focusable, or the container. */
237
+ #focusInitial() {
238
+ const preferred = this.#options.initialFocus?.();
239
+ if (preferred) {
240
+ preferred.focus();
241
+ return;
242
+ }
243
+ const focusable = this.#focusableElements();
244
+ if (focusable[0]) {
245
+ focusable[0].focus();
246
+ return;
247
+ }
248
+ const container = this.#getContainer();
249
+ container.tabIndex = -1;
250
+ container.focus();
251
+ }
252
+ /** Collects the container's currently focusable descendants in DOM order. */
253
+ #focusableElements() {
254
+ return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
255
+ (el) => !el.hidden
256
+ );
257
+ }
258
+ };
259
+
260
+ // src/controllers/alert_dialog_controller.ts
261
+ var AlertDialogController = class extends Controller {
262
+ static targets = ["trigger", "dialog", "initialFocus"];
263
+ static actions = ["cancel", "confirm", "open"];
264
+ static events = ["cancel", "confirm"];
265
+ /**
266
+ * Owns the modal side effects. Escape is routed through {@link cancel} so it
267
+ * emits the same event as the cancel button (tagged `"escape"`); focus falls
268
+ * back to the trigger when nothing was focused before opening.
269
+ */
270
+ #trap = new FocusTrap(() => this.dialogTarget, {
271
+ onEscape: () => this.#requestCancel("escape"),
272
+ initialFocus: () => this.hasInitialFocusTarget ? this.initialFocusTarget : null,
273
+ fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
274
+ });
275
+ /** Starts closed (idempotently reflects the closed state on the markup). */
276
+ connect() {
277
+ if (this.hasDialogTarget) this.dialogTarget.hidden = true;
278
+ }
279
+ /** Reverts the modal side effects if torn down while open (Turbo navigation). */
280
+ disconnect() {
281
+ this.#trap.deactivate({ restoreFocus: false });
282
+ }
283
+ /** Opens the dialog, traps focus, and locks background scroll. */
284
+ open() {
285
+ if (!this.hasDialogTarget || this.#isOpen) return;
286
+ this.dialogTarget.hidden = false;
287
+ this.#trap.activate();
288
+ }
289
+ /** Confirms: closes and dispatches `confirm`. Bound via `data-action`. */
290
+ confirm() {
291
+ if (!this.#isOpen) return;
292
+ this.dispatch("confirm");
293
+ this.#closeDialog();
294
+ }
295
+ /** Cancels (user action): closes and dispatches `cancel` with `reason: "user"`. */
296
+ cancel() {
297
+ this.#requestCancel("user");
298
+ }
299
+ /** Shared cancel path used by both the cancel action and the Escape key. */
300
+ #requestCancel(reason) {
301
+ if (!this.#isOpen) return;
302
+ this.dispatch("cancel", { detail: { reason } });
303
+ this.#closeDialog();
304
+ }
305
+ /** Hides the dialog and reverts the modal side effects (restoring focus). */
306
+ #closeDialog() {
307
+ this.dialogTarget.hidden = true;
308
+ this.#trap.deactivate();
309
+ }
310
+ /** Whether the dialog is currently visible. */
311
+ get #isOpen() {
312
+ return this.hasDialogTarget && !this.dialogTarget.hidden;
313
+ }
314
+ };
315
+
316
+ export { AlertDialogController };
317
+ //# sourceMappingURL=alert_dialog_controller.js.map
318
+ //# sourceMappingURL=alert_dialog_controller.js.map
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/auto_submit_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
5
52
  // src/utils/safe_timeout.ts
6
53
  var TimerRegistry = class {
7
54
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -79,34 +126,22 @@ var AutoSubmitController = class extends Controller {
79
126
  window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
80
127
  }
81
128
  };
82
- /** True while an IME composition is in progress on one of the form's fields. */
83
- #composing = false;
84
- /** Marks composition active so `input` events mid-conversion don't submit. */
85
- #onCompositionStart = () => {
86
- this.#composing = true;
87
- };
88
- /**
89
- * Composition finished (the IME conversion is confirmed): clear the flag and
90
- * schedule a submit as if `input` fired, so the settled text triggers a submit
91
- * even on browsers whose post-composition `input` still reads `isComposing`.
92
- */
93
- #onCompositionEnd = (event) => {
94
- this.#composing = false;
95
- if (this.#triggers("input")) this.#schedule(event.target ?? null);
96
- };
129
+ /** Owns delegated IME lifecycle state and submits confirmed input text. */
130
+ #composition = new CompositionTracker({
131
+ onEnd: (event) => {
132
+ if (this.#triggers("input")) this.#schedule(event.target ?? null);
133
+ }
134
+ });
97
135
  connect() {
98
136
  this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
99
- this.#form.addEventListener("compositionstart", this.#onCompositionStart);
100
- this.#form.addEventListener("compositionend", this.#onCompositionEnd);
137
+ this.#composition.observe(this.#form);
101
138
  }
102
139
  disconnect() {
103
140
  this.#timers.clearAll();
104
141
  this.#pendingId = 0;
105
- this.#composing = false;
142
+ this.#composition.disconnect();
106
143
  this.#form.removeAttribute("data-auto-submit-pending");
107
144
  this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
108
- this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
109
- this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
110
145
  }
111
146
  /**
112
147
  * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
@@ -115,7 +150,7 @@ var AutoSubmitController = class extends Controller {
115
150
  */
116
151
  submit(event) {
117
152
  if (!this.#triggers(event.type)) return;
118
- if (event.type === "input" && (this.#composing || event.isComposing)) return;
153
+ if (event.type === "input" && this.#composition.isComposing(event)) return;
119
154
  this.#schedule(event.target ?? null);
120
155
  }
121
156
  /** Schedules (and coalesces) the debounced submit for the given trigger. */