stimeo-ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  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 +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10b597acf4a7ae2ea8c06755f65219ebcd6cf8620dada3700e196d75991c5544
4
- data.tar.gz: d0cabdded21420aa90c4eb4eb439588ab7a0bfeff3d4f9fe83b179197de9acb2
3
+ metadata.gz: dc8f7abf12a14555f6b1182d81844594d3bf86c772f115338d931e15eb35a43e
4
+ data.tar.gz: fee179d507f73e693b718e8563e3239d7a3fcad20d42d6db4624bdcbdf2a353c
5
5
  SHA512:
6
- metadata.gz: 66afe27aa72dcbd893b2e2f76525bfddc6bd55788b15ee531cbad6357342ec0bf4d3c2ada2e3cd3dad7dfa37ff9306356b866c1a6e7bdde2445cb103ef944119
7
- data.tar.gz: 616de9b15f081a503d907cb468a3c6ad77a74a6773e9677f3e1b9fb28baad505d5bfff6d588c66b48ed965a5acd275f5d8cc1209d69bac2aa7a89153e143be54
6
+ metadata.gz: e68c6fd65748f2e637ddd8bd77364f74f4336549fdd2bb47803d5bd0e300c7838c0037850154d040f32aadb7ccab53879dc754acf8bbe99430ddedeb3b102b8f
7
+ data.tar.gz: 833febb4eb631e61d0d700e56a13cfb38fa59465757a4d0c5f9430a8f87967ef9a8cc993e3272922b4794ec88b9ab38c8244a1c7614a904cc1d539bc40cd3e81
data/CHANGELOG.md CHANGED
@@ -7,6 +7,167 @@ 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.3.0] - 2026-08-09
11
+
12
+ Minor release focused on consistent keyboard and selection behavior, resilient
13
+ runtime DOM changes, and substantially stronger Inspector diagnostics. No
14
+ controller, target, value, action, event, or package export was removed. The
15
+ Inspector manifest advances from schema v5 to v8, so tools that read the raw
16
+ manifest must accept the new schema and fields.
17
+
18
+ ### Upgrade notes
19
+
20
+ - Run `stimeo check` against existing views before deploying. New structural
21
+ checks can report errors for incomplete optional target groups, missing
22
+ companion controllers, undeclared role-bearing targets, and invalid
23
+ cardinality. New ARIA checks can also add warnings without failing the command.
24
+ - In RTL containers, horizontal arrow keys now follow logical inline direction
25
+ across composite widgets. LTR behavior is unchanged.
26
+ - Slider, range-slider, and color-picker pointer/arrow mirroring remains opt-in:
27
+ set their new `logicalTrack` value only when the consuming CSS mirrors the
28
+ track in RTL.
29
+ - calendar's `stimeo--calendar:monthchange` no longer fires while the grid
30
+ settles on its initial month, so code that ran from that first event must now
31
+ run once at initialization. Listeners that re-apply per-date `aria-disabled`
32
+ marks are the common case.
33
+
34
+ ### Added
35
+
36
+ - breadcrumb: a public `update` action for reconciling collapsible state after
37
+ application-driven DOM changes.
38
+ - tree-view: a public `toggle` action for explicitly expanding or collapsing an
39
+ item from application markup.
40
+ - scrollspy: a `focusSection` value for opting into focus movement when a link
41
+ activates a section.
42
+ - slider, range-slider, color-picker: the `logicalTrack` value described above.
43
+ - Inspector schema v6-v8: conditional target requirements and the new
44
+ `missing-companion`, `undeclared-target`, `cardinality-violation`,
45
+ `forbidden-aria`, and `missing-conditional-target` diagnostics. Rules can now
46
+ depend on controller values, element/document conditions, contained targets,
47
+ and ARIA requirement severity.
48
+
49
+ ### Changed
50
+
51
+ - accordion, carousel, calendar, combobox, command-palette, context-menu,
52
+ data-grid, date-range-picker, listbox, menu, menubar, multi-select,
53
+ navigation-menu, OTP, radio-group, roving, tabs, tags-input, time-picker,
54
+ toggle-group, toolbar, tree-view, and related horizontal controls now resolve
55
+ Left/Right behavior from the effective text direction. Sortable geometry also
56
+ follows logical inline direction.
57
+ - Arrow-key handlers yield modified browser shortcuts, and composite handlers
58
+ yield events already consumed by a nested widget. IME composition continues to
59
+ take precedence over component shortcuts.
60
+ - Selection widgets normalize controller-owned `aria-selected` state, preserve a
61
+ single active/selected item where the pattern requires one, and reconcile that
62
+ state when options are inserted, removed, or replaced at runtime.
63
+ - menu and overflow-menu delegate item interaction from their containers, so
64
+ dynamically added items work without per-item actions. Existing action markup
65
+ remains compatible.
66
+ - `aria-disabled="true"` items remain discoverable by composite keyboard
67
+ navigation while activation, expansion, and consumer actions are suppressed.
68
+ - Outside pointer listeners for dismissible composites — click and context-menu
69
+ alike — run in the capture phase so nested application code cannot leave an
70
+ open layer stuck by stopping bubbling.
71
+ - Inspector accessible-name diagnostics now follow ARIA's Required, Recommended,
72
+ and conditional levels instead of treating every role alike.
73
+
74
+ ### Fixed
75
+
76
+ - combobox, command-palette, listbox, and multi-select no longer retain stale
77
+ active IDs, selection, chips, or hidden fields after Turbo morphs and runtime
78
+ target replacement. A disconnect/reconnect in the same task can no longer let
79
+ an old reconciliation microtask consume the fresh generation's pass.
80
+ - breadcrumb fails open when its optional disclosure targets are incomplete and
81
+ restores items that leave the collapsible set instead of leaving them hidden.
82
+ - menu, menubar, navigation-menu, overflow-menu, pagination, and tree-view now
83
+ recover focus consistently across empty lists, disabled fieldsets, hidden
84
+ ancestors, item banking, and runtime item removal.
85
+ - toolbar restores its single tab stop when an enclosing `<fieldset>` is
86
+ re-enabled, so unlocking a form brings the group back into the Tab sequence.
87
+ - multi-select keeps chip focus when the selection was made in an order other
88
+ than the option order. The chips are reconciled against the selected value set
89
+ rather than position by position, and their labels are paired by value.
90
+ - overflow-menu measures items adopted from server-rendered overflow in the bar
91
+ rather than inside the closed menu, so an already-overflowed bar no longer
92
+ returns every item to the bar on first paint.
93
+ - combobox commits a clicked option after its input target is removed instead of
94
+ throwing, matching the tolerance the popup already had for that state.
95
+ - color-picker falls back to the per-channel range when a slider omits
96
+ `aria-valuemin` / `aria-valuemax`, instead of pinning the channel at zero.
97
+ - calendar maintains exactly one roving tab stop while selection and available
98
+ days change.
99
+ - calendar reports `monthchange` from the month it paints, so a malformed
100
+ `month` value can no longer put a raw non-`YYYY-MM` string in `detail.month`.
101
+ The report is driven by the repaint rather than by the `month` value, so a
102
+ fallback month moved by `selected` or by selecting a neighbouring month's cell
103
+ is announced as it happens instead of arriving on a later repaint. Re-applying
104
+ the same month, as a Turbo cache restore does, stays silent.
105
+ - scrollspy re-evaluates links and sections after every supported entry path;
106
+ intersection-based controllers align fallback thresholds, hidden-layout
107
+ handling, and reduced-motion behavior.
108
+ - `stimeo catalog --json` and large `stimeo check --json` reports are no longer
109
+ truncated when stdout is piped; the CLI now lets buffered output flush before
110
+ exiting, and the build audit exercises the real piped catalog.
111
+
112
+ ## [0.2.1] - 2026-07-26
113
+
114
+ Patch release from a review of the disclosure and layout components, plus a
115
+ packaging fix. No breaking changes, but `stimeo check` now reports two markup
116
+ requirements that existing pages may need to satisfy.
117
+
118
+ ### Added
119
+
120
+ - sidebar: `beforeCache` action for Turbo page caching — wire
121
+ `data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"` to close
122
+ an open overlay and release its modal side effects before the snapshot is taken.
123
+ - sidebar, stepper: the `breakpoint` and `index` values now take effect when
124
+ changed at runtime. An invalid `breakpoint` falls back to 768px.
125
+ - Inspector: `stimeo check` verifies that the tabs `role="tablist"` element has an
126
+ accessible name.
127
+
128
+ ### Changed
129
+
130
+ - tabs: the tablist container is now a required `list` target. Add
131
+ `data-stimeo--tabs-target="list"` to it, or `stimeo check` reports an error.
132
+ Runtime behavior is unchanged.
133
+ - Inspector: `stimeo check` reports an error when `stimeo--sidebar` markup has no
134
+ `panel` target.
135
+
136
+ ### Fixed
137
+
138
+ - package exports: 26 controllers — including sidebar, drawer, collapsible,
139
+ read-more, alert-dialog, carousel and tree-view — had no build output behind the
140
+ `./controllers/*` subpath their `package.json` `exports` declares, so
141
+ `import … from "stimeo-ui/controllers/<name>_controller"` failed to resolve in
142
+ 0.2.0. Every controller in the catalog now ships one.
143
+ - transition, flash, toast, sidebar, drawer, collapsible: animations now wait for
144
+ every declared transition property and its `transition-delay`, not just the
145
+ first duration. Multi-property and delayed animations are no longer cut short, a
146
+ cancelled transition settles immediately, and pseudo-element transitions no
147
+ longer end the wait early — so `hidden`, events, focus restore and scroll unlock
148
+ land at the right moment.
149
+ - sidebar, drawer, collapsible: an interrupted close animation no longer leaves the
150
+ panel stuck open with the focus trap, background `inert` or scroll lock in place.
151
+ - sidebar, drawer, collapsible, read-more, sticky-observer, overflow-indicator:
152
+ targets swapped in or added after connect (Turbo morph, lazily loaded Frames,
153
+ Streams) are now reconciled with the current state instead of keeping the value
154
+ they were rendered with.
155
+ - overflow-indicator, scroll-area: horizontal position is read logically, so the
156
+ overflow attributes, the `scrollByPage` action, `data-scroll` and
157
+ `--stimeo-scroll-progress` behave correctly inside `dir="rtl"` containers.
158
+ - overflow-indicator: reaching an edge while a page button has focus no longer
159
+ drops focus to the page body — the button stays focused and inert via
160
+ `aria-disabled`, becoming natively `disabled` only once blurred.
161
+ - read-more: the toggle is no longer hidden while it holds keyboard focus when the
162
+ text starts to fit; overflow is re-checked on blur, on content changes, and when
163
+ media inside it finishes loading.
164
+ - sticky-observer: `data-stuck="true"` is set only once the sentinel has actually
165
+ scrolled past the top edge — one that is merely out of view, or inside a hidden
166
+ container, no longer marks the element as stuck. Negative `offset` values are now
167
+ accepted.
168
+ - intersection: an element with no layout box (hidden ancestor, collapsed
169
+ `<details>`) is no longer reported as scrolled past.
170
+
10
171
  ## [0.2.0] - 2026-07-22
11
172
 
12
173
  First release off the beta channel: `npm install stimeo-ui` now resolves
@@ -175,6 +336,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
175
336
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
176
337
  RubyGems.
177
338
 
339
+ [0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
340
+ [0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
178
341
  [0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
179
342
  [0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
180
343
  [0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
@@ -1,5 +1,13 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/accordion_controller.ts
4
+
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
3
11
  // src/controllers/accordion_controller.ts
4
12
  var AccordionController = class extends Controller {
5
13
  static targets = ["trigger", "panel"];
@@ -39,6 +47,8 @@ var AccordionController = class extends Controller {
39
47
  * `.focus()` lands on nothing and navigation appears to stall.
40
48
  */
41
49
  onKeydown(event) {
50
+ if (event.defaultPrevented) return;
51
+ if (isReservedArrowChord(event)) return;
42
52
  const current = event.currentTarget;
43
53
  if (this.triggerTargets.indexOf(current) === -1) return;
44
54
  const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
@@ -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