stimeo-ui 0.1.0-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.
- package/CHANGELOG.md +149 -0
- package/README.md +6 -6
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.js +56 -21
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +100 -0
- package/dist/controllers/carousel_controller.js +272 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.js +52 -15
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +76 -0
- package/dist/controllers/color_picker_controller.js +213 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +6 -2
- package/dist/controllers/combobox_controller.js +64 -3
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +13 -6
- package/dist/controllers/command_palette_controller.js +243 -39
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +94 -8
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +11 -6
- package/dist/controllers/context_menu_controller.js +191 -13
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +2 -2
- package/dist/controllers/countdown_controller.js +2 -2
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +71 -0
- package/dist/controllers/data_grid_controller.js +168 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +87 -0
- package/dist/controllers/date_range_picker_controller.js +417 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js +94 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +9 -6
- package/dist/controllers/dropdown_controller.js +139 -16
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +168 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +94 -8
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +2 -2
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +15 -12
- package/dist/controllers/hover_card_controller.js +103 -27
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +41 -18
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +33 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js +1 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +18 -11
- package/dist/controllers/menu_controller.js +181 -8
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +62 -0
- package/dist/controllers/menubar_controller.js +433 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/multi_select_controller.d.ts +115 -0
- package/dist/controllers/multi_select_controller.js +472 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +99 -0
- package/dist/controllers/navigation_menu_controller.js +384 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/otp_controller.js +62 -22
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
- package/dist/controllers/overflow_indicator_controller.js +178 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +3 -2
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/pointer_drag_controller.js +3 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +18 -10
- package/dist/controllers/popover_controller.js +118 -27
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +87 -0
- package/dist/controllers/range_slider_controller.js +166 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +194 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.js +15 -2
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.js +8 -4
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +33 -11
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -0
- package/dist/controllers/separator_controller.js +87 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.js +8 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +88 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +3 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +275 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.js +20 -10
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +212 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +8 -18
- package/dist/controllers/toast_controller.js +144 -38
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +15 -18
- package/dist/controllers/tooltip_controller.js +148 -42
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +54 -0
- package/dist/controllers/tree_view_controller.js +275 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1788
- package/dist/index.js +1644 -643
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +8 -8
- package/dist/inspector/manifest.json +30 -5
- package/package.json +7 -7
package/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
|
package/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
|
|
8
|
+
<p align="center"><a href="https://stimeo-labs.com"><strong>Live demo →</strong></a></p>
|
|
9
9
|
|
|
10
|
-
[](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [](https://github.com/taiyaky/stimeo-ui/actions/workflows/ci.yml) [](https://www.npmjs.com/package/stimeo-ui) [](https://rubygems.org/gems/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: **
|
|
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
|
|
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
|
|
53
|
+
npm install stimeo-ui @hotwired/stimulus
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
```js
|
|
@@ -6,13 +6,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
6
6
|
* Markup contract (identifier: `stimeo--accordion`):
|
|
7
7
|
* <div data-controller="stimeo--accordion">
|
|
8
8
|
* <h3>
|
|
9
|
-
* <button
|
|
9
|
+
* <button id="trigger-1"
|
|
10
|
+
* data-stimeo--accordion-target="trigger"
|
|
10
11
|
* data-action="stimeo--accordion#toggle
|
|
11
12
|
* keydown->stimeo--accordion#onKeydown"
|
|
12
13
|
* aria-expanded="false" aria-controls="panel-1">Section 1</button>
|
|
13
14
|
* </h3>
|
|
14
15
|
* <div id="panel-1" data-stimeo--accordion-target="panel"
|
|
15
|
-
* role="region" hidden>…</div>
|
|
16
|
+
* role="region" aria-labelledby="trigger-1" hidden>…</div>
|
|
16
17
|
* <!-- repeat header/panel pairs -->
|
|
17
18
|
* </div>
|
|
18
19
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/accordion_controller.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/accordion_controller.ts"],"names":[],"mappings":"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","file":"accordion_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible accordion behavior.\n *\n * Markup contract (identifier: `stimeo--accordion`):\n * <div data-controller=\"stimeo--accordion\">\n * <h3>\n * <button id=\"trigger-1\"\n * data-stimeo--accordion-target=\"trigger\"\n * data-action=\"stimeo--accordion#toggle\n * keydown->stimeo--accordion#onKeydown\"\n * aria-expanded=\"false\" aria-controls=\"panel-1\">Section 1</button>\n * </h3>\n * <div id=\"panel-1\" data-stimeo--accordion-target=\"panel\"\n * role=\"region\" aria-labelledby=\"trigger-1\" hidden>…</div>\n * <!-- repeat header/panel pairs -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is\n * associated with its panel through `aria-controls`; the panel's `id` is the\n * single source of truth for the pairing, so headers and panels need not be\n * adjacent siblings.\n *\n * @remarks\n * Multiple panels may be open at once (this is the APG-allowed default). State\n * is reflected through `aria-expanded` on the header and the `hidden` attribute\n * on the panel — never through visual styling, which the consumer owns.\n *\n * Behavior provided:\n * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).\n * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the\n * first/last header. Hidden headers (including those inside a hidden subtree,\n * e.g. a section a filter collapsed) are skipped so focus stays perceivable.\n * - {@link expandAll} / {@link collapseAll} open or close every panel at once,\n * for an optional \"expand all / collapse all\" control pair anywhere in scope.\n */\nexport class AccordionController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static actions = [\"collapseAll\", \"expandAll\", \"onKeydown\", \"toggle\"] as const;\n\n declare readonly triggerTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Toggles the panel controlled by the activated header. */\n toggle(event: Event): void {\n const trigger = event.currentTarget as HTMLButtonElement;\n const panel = this.#panelFor(trigger);\n if (!panel) return;\n\n this.#setExpanded(trigger, panel, trigger.getAttribute(\"aria-expanded\") !== \"true\");\n }\n\n /** Opens every panel. Bound via `data-action` on an \"expand all\" control. */\n expandAll(): void {\n this.#setAll(true);\n }\n\n /** Closes every panel. Bound via `data-action` on a \"collapse all\" control. */\n collapseAll(): void {\n this.#setAll(false);\n }\n\n /** Drives every header/panel pair to the same expanded state. */\n #setAll(open: boolean): void {\n for (const trigger of this.triggerTargets) {\n const panel = this.#panelFor(trigger);\n if (panel) this.#setExpanded(trigger, panel, open);\n }\n }\n\n /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */\n #setExpanded(trigger: HTMLButtonElement, panel: HTMLElement, open: boolean): void {\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n panel.hidden = !open;\n }\n\n /**\n * Moves focus between headers per the APG keyboard model, skipping any header\n * that is hidden or nested in a hidden subtree. A consumer may hide whole\n * sections (e.g. an `stimeo--filter` that collapses empty groups), and an\n * unperceivable header must never become an arrow-key target — otherwise\n * `.focus()` lands on nothing and navigation appears to stall.\n */\n onKeydown(event: KeyboardEvent): void {\n const current = event.currentTarget as HTMLButtonElement;\n if (this.triggerTargets.indexOf(current) === -1) return;\n\n // `closest(\"[hidden]\")` catches both a directly-hidden header and one inside a\n // hidden ancestor (the filter-group case); navigate over the visible set only.\n const navigable = this.triggerTargets.filter((trigger) => trigger.closest(\"[hidden]\") === null);\n const here = navigable.indexOf(current);\n if (here === -1) return;\n\n let next: HTMLButtonElement | undefined;\n switch (event.key) {\n case \"ArrowDown\":\n next = navigable[(here + 1) % navigable.length];\n break;\n case \"ArrowUp\":\n next = navigable[(here - 1 + navigable.length) % navigable.length];\n break;\n case \"Home\":\n next = navigable[0];\n break;\n case \"End\":\n next = navigable[navigable.length - 1];\n break;\n default:\n return;\n }\n\n event.preventDefault();\n next?.focus();\n }\n\n /** Resolves the panel a header controls via its `aria-controls` reference. */\n #panelFor(trigger: HTMLButtonElement): HTMLElement | null {\n const id = trigger.getAttribute(\"aria-controls\");\n return id ? (this.panelTargets.find((panel) => panel.id === id) ?? null) : null;\n }\n}\n"]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible **alert dialog** behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--alert-dialog`):
|
|
7
|
+
* <div data-controller="stimeo--alert-dialog">
|
|
8
|
+
* <button data-stimeo--alert-dialog-target="trigger"
|
|
9
|
+
* data-action="click->stimeo--alert-dialog#open">Delete…</button>
|
|
10
|
+
* <div data-stimeo--alert-dialog-target="dialog" role="alertdialog"
|
|
11
|
+
* aria-modal="true" aria-labelledby="t" aria-describedby="d" hidden>
|
|
12
|
+
* <h2 id="t">…</h2><p id="d">…</p>
|
|
13
|
+
* <button data-stimeo--alert-dialog-target="initialFocus"
|
|
14
|
+
* data-action="click->stimeo--alert-dialog#cancel">Cancel</button>
|
|
15
|
+
* <button data-action="click->stimeo--alert-dialog#confirm">Delete</button>
|
|
16
|
+
* </div>
|
|
17
|
+
* </div>
|
|
18
|
+
*
|
|
19
|
+
* Implements the WAI-ARIA APG **Alert Dialog** pattern. It is the same modal as
|
|
20
|
+
* `stimeo--dialog` with two deliberate differences that suit an *interrupting
|
|
21
|
+
* confirmation*: it never closes on a backdrop click (an accidental dismissal
|
|
22
|
+
* would be dangerous), and it exposes explicit {@link confirm}/{@link cancel}
|
|
23
|
+
* actions that emit events so the consumer only writes the message and "what to
|
|
24
|
+
* do on confirm".
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Behavior only. The modal lifecycle (focus trap, scroll lock, background
|
|
28
|
+
* `inert`, focus restore, teardown reversal) is delegated to the shared
|
|
29
|
+
* {@link FocusTrap}. The only closing affordances are the confirm/cancel actions
|
|
30
|
+
* and `Escape` (which cancels) — there is intentionally no backdrop close.
|
|
31
|
+
*
|
|
32
|
+
* Behavior provided:
|
|
33
|
+
* - {@link open} shows the dialog and moves focus to the `initialFocus` target
|
|
34
|
+
* (the least destructive action, by convention), else the first focusable
|
|
35
|
+
* element.
|
|
36
|
+
* - `Tab`/`Shift+Tab` cycle focus within the dialog (focus trap).
|
|
37
|
+
* - {@link confirm} closes and dispatches `stimeo--alert-dialog:confirm`.
|
|
38
|
+
* - {@link cancel} and `Escape` close and dispatch `stimeo--alert-dialog:cancel`
|
|
39
|
+
* with a `reason` of `"user"` / `"escape"`. Focus returns to the opener.
|
|
40
|
+
*/
|
|
41
|
+
declare class AlertDialogController extends Controller<HTMLElement> {
|
|
42
|
+
#private;
|
|
43
|
+
static targets: string[];
|
|
44
|
+
static actions: readonly ["cancel", "confirm", "open"];
|
|
45
|
+
static events: readonly ["cancel", "confirm"];
|
|
46
|
+
readonly triggerTarget: HTMLElement;
|
|
47
|
+
readonly dialogTarget: HTMLElement;
|
|
48
|
+
readonly initialFocusTarget: HTMLElement;
|
|
49
|
+
readonly hasTriggerTarget: boolean;
|
|
50
|
+
readonly hasDialogTarget: boolean;
|
|
51
|
+
readonly hasInitialFocusTarget: boolean;
|
|
52
|
+
/** Starts closed (idempotently reflects the closed state on the markup). */
|
|
53
|
+
connect(): void;
|
|
54
|
+
/** Reverts the modal side effects if torn down while open (Turbo navigation). */
|
|
55
|
+
disconnect(): void;
|
|
56
|
+
/** Opens the dialog, traps focus, and locks background scroll. */
|
|
57
|
+
open(): void;
|
|
58
|
+
/** Confirms: closes and dispatches `confirm`. Bound via `data-action`. */
|
|
59
|
+
confirm(): void;
|
|
60
|
+
/** Cancels (user action): closes and dispatches `cancel` with `reason: "user"`. */
|
|
61
|
+
cancel(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { AlertDialogController };
|
|
@@ -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
|