jet_ui 0.2.11 → 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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +29 -20
  4. data/app/assets/javascripts/jet_ui/dialog_controller.js +95 -0
  5. data/app/assets/javascripts/jet_ui/dialogs_controller.js +248 -0
  6. data/app/assets/javascripts/jet_ui/drawer_controller.js +6 -0
  7. data/app/assets/javascripts/jet_ui/drawers_controller.js +5 -0
  8. data/app/assets/javascripts/jet_ui/modal_controller.js +6 -0
  9. data/app/assets/javascripts/jet_ui/modals_controller.js +5 -0
  10. data/app/assets/stylesheets/jet_ui/dialog.css +134 -0
  11. data/app/assets/stylesheets/jet_ui/theme.css +12 -0
  12. data/app/assets/stylesheets/jet_ui.css +1 -0
  13. data/app/components/jet_ui/dialog/body_component.rb +21 -0
  14. data/app/components/jet_ui/dialog/component.rb +86 -0
  15. data/app/components/jet_ui/dialog/footer_component.rb +45 -0
  16. data/app/components/jet_ui/dialog/header_component.rb +45 -0
  17. data/app/components/jet_ui/dialogs/component.rb +18 -0
  18. data/app/components/jet_ui/drawer/body_component.rb +2 -14
  19. data/app/components/jet_ui/drawer/component.rb +14 -44
  20. data/app/components/jet_ui/drawer/footer_component.rb +2 -38
  21. data/app/components/jet_ui/drawer/header_component.rb +6 -38
  22. data/app/components/jet_ui/modal/body_component.rb +2 -14
  23. data/app/components/jet_ui/modal/component.rb +14 -44
  24. data/app/components/jet_ui/modal/footer_component.rb +2 -38
  25. data/app/components/jet_ui/modal/header_component.rb +6 -38
  26. data/app/components/jet_ui/turbo_confirm/component.html.erb +13 -15
  27. data/lib/generators/jet_ui/eject/eject_generator.rb +19 -0
  28. data/lib/generators/jet_ui/install/install_generator.rb +3 -3
  29. data/lib/jet_ui/version.rb +1 -1
  30. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89ac34e7a4ba973ca0e253bd918a62aaf9397cb166b4a61841033024521c938b
4
- data.tar.gz: 6ec8cd6cd71014a3cf3d06684717cab4ea0e952c3ed4fba51916356b35525a7f
3
+ metadata.gz: aaa259d3afc97d0f95133cdb1f0f4a5ab4ecdb8e202cc3e2915adf675747e8e3
4
+ data.tar.gz: e67258fe470b32b455ff87ff3c3d2121eba46f5b549cdce377c105e8f9485d9a
5
5
  SHA512:
6
- metadata.gz: 6c11d7a76915590603cd836206086772518c1712c1af743d2cda78ddcf03cd584b27a80bfcbe2a6c67c48b7f0f7429ea37cca18f2d329d1692241da316a4aad7
7
- data.tar.gz: ec4adf80d2294c2cd792efa1f49994f064d1f27119938aa8d79dec006f31a71692641ad0546b12e1e543c3e82f41f8f10206a05aa057e8120a9db8d96b9f338e
6
+ metadata.gz: a4eeb23778ec35b7df6d67d73185851f34bd261d930251159abf6977ed09a2c6deb19adc3112f8f506266d460fea28353a2974db2fb575e33179f109ad10e521
7
+ data.tar.gz: d6a945c029554f92578e4f32e9cd7c834d8a2d72d1f43497a179d0b92f4a2b7694f8538f093cce1c381f3d93a9ca4ff032f291a9059cb9b5c9d3ad77fb0fe670
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-11
11
+
12
+ ### Added
13
+ - `Dialog` component — unifies `Modal` and `Drawer` into a single component built on the native `<dialog>` element, with five positions (`:center`, `:left`, `:right`, `:top`, `:bottom`) and unlimited nesting of stacked dialogs
14
+ - `Dialogs` mount point component — renders the dialog stack root and the sentinel Turbo Frame that async dialog opens adopt
15
+ - ViewComponent previews for `Alert`, `Badge`, `Group`, and `Stat`
16
+ - `docs/vite.md` — setup and troubleshooting guide for the Vite/npm install path
17
+
18
+ ### Changed
19
+ - README `Installation` section reorganized with a comparison table so Importmap and Vite get equal visual weight, instead of Vite being a subordinate "Alternative:" section
20
+ - Dropped the `Ruby >=`/`Rails >=` badges from the README — duplicated info already in `## Requirements`, and not a pattern followed by comparable Ruby gems
21
+
22
+ ### Deprecated
23
+ - `Modal` and `Drawer` — now thin shims delegating to `Dialog` with `position: :center`/`:right`; emit an `ActiveSupport::Deprecation` warning and will be removed in the next major version. Existing `data: { turbo_frame: :modal }`/`:drawer` links keep working automatically. See [docs/components/dialog.md](docs/components/dialog.md#migrating-from-modaldrawer) for the full migration path
24
+
10
25
  ## [0.2.11] - 2026-09-07
11
26
 
12
27
  ### Fixed
data/README.md CHANGED
@@ -4,8 +4,7 @@
4
4
  [![Checks](https://img.shields.io/github/actions/workflow/status/jetrockets/jet_ui/ci.yml?label=checks&logo=github)](https://github.com/jetrockets/jet_ui/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE.md)
6
6
  [![Downloads](https://img.shields.io/gem/dt/jet_ui)](https://rubygems.org/gems/jet_ui)
7
- [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-CC342D)](https://rubygems.org/gems/jet_ui)
8
- [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-CC0000)](https://rubygems.org/gems/jet_ui)
7
+ [![Stars](https://img.shields.io/github/stars/jetrockets/jet_ui?style=social)](https://github.com/jetrockets/jet_ui/stargazers)
9
8
 
10
9
  ViewComponent-based UI library for Rails applications, matching the design system at [ui.jetrockets.com](https://ui.jetrockets.com).
11
10
 
@@ -18,40 +17,47 @@ ViewComponent-based UI library for Rails applications, matching the design syste
18
17
 
19
18
  ## Installation
20
19
 
21
- Add to your Gemfile:
20
+ Add the gem to your Gemfile, then run the install generator — it detects how your app manages
21
+ JavaScript and wires jet_ui up accordingly, no flags needed:
22
22
 
23
23
  ```ruby
24
24
  gem "jet_ui"
25
25
  ```
26
26
 
27
- Run the install generator:
28
-
29
27
  ```bash
30
28
  bundle install
31
29
  rails generate jet_ui:install
32
30
  ```
33
31
 
34
- The generator:
35
- - Detects your Tailwind CSS source file and injects a single import covering all component stylesheets
36
- - Registers JetUi Stimulus controllers in your app's controllers index
32
+ | | Importmap | Vite |
33
+ |--------------|-------------------------------------------------------------------|---------------------------------------------------------------------------------|
34
+ | **Detected by** | `config/importmap.rb` present | a Vite config, or `vite` in `package.json` |
35
+ | **JS wiring** | Automatic — controllers auto-register | Manual — you `import` and `application.register(...)` the ones you use |
36
+ | **CSS wiring** | Automatic — injected into your Tailwind source | Manual — `@import "@jetrockets/jet_ui/css"` in your CSS entry point |
37
+ | **npm install** | None needed — the gem ships the controllers itself | Done for you by the generator (`yarn add @jetrockets/jet_ui`, or your package manager's equivalent) |
37
38
 
38
- When the gem is updated, both CSS and JS are picked up automatically — no further changes needed. Safe to re-run after upgrades.
39
+ Full walkthrough and troubleshooting for the Vite path: [docs/vite.md](docs/vite.md).
39
40
 
40
- ### Alternative: npm package (Vite)
41
+ ### Importmap
41
42
 
42
- The above (importmap) setup needs no npm install — the gem ships the controllers itself. If your app bundles JavaScript with Vite instead, run the install generator: it detects Vite, **installs the `@jetrockets/jet_ui` npm package** for you (running `yarn add @jetrockets/jet_ui`, or the npm/pnpm/bun equivalent it detects), and prints the wiring steps:
43
+ The generator detects your Tailwind CSS source file and injects a single import covering all
44
+ component stylesheets, and registers JetUi Stimulus controllers in your app's controllers index.
45
+ When the gem is updated, both CSS and JS are picked up automatically — no further changes needed.
46
+ Safe to re-run after upgrades.
43
47
 
44
- ```bash
45
- rails generate jet_ui:install
46
- # detects Vite runs: yarn add @jetrockets/jet_ui
47
- ```
48
+ ### Vite
49
+
50
+ The generator detects Vite and installs the `@jetrockets/jet_ui` npm package for you. Vite apps
51
+ own their JS/CSS entry points, so those are not modified automatically — wire up the two pieces
52
+ yourself:
48
53
 
49
- Then register the controllers you use (`@hotwired/stimulus` is a peer dependency):
54
+ Register the controllers you use (`@hotwired/stimulus` is a peer dependency):
50
55
 
51
56
  ```javascript
52
- import { ModalController } from "@jetrockets/jet_ui"
57
+ import { DialogController, DialogsController } from "@jetrockets/jet_ui"
53
58
 
54
- application.register("modal", ModalController)
59
+ application.register("dialog", DialogController)
60
+ application.register("dialogs", DialogsController)
55
61
  ```
56
62
 
57
63
  Import the component styles in your Tailwind/CSS entry point:
@@ -67,6 +73,8 @@ import "@jetrockets/jet_ui/css" // all component styles
67
73
  import "@jetrockets/jet_ui/css/btn.css" // a single component
68
74
  ```
69
75
 
76
+ See [docs/vite.md](docs/vite.md) for detection details and troubleshooting.
77
+
70
78
  ## Usage
71
79
 
72
80
  The `jet_ui` helper is available in all views:
@@ -112,8 +120,9 @@ Subcomponents follow the `namespace_subcomponent` naming convention (`card_heade
112
120
  | Sidebar | [docs/components/sidebar.md](docs/components/sidebar.md) |
113
121
  | Header | [docs/components/header.md](docs/components/header.md) |
114
122
  | Navbar | [docs/components/navbar.md](docs/components/navbar.md) |
115
- | Modal ⚡ | [docs/components/modal.md](docs/components/modal.md) |
116
- | Drawer ⚡ | [docs/components/drawer.md](docs/components/drawer.md) |
123
+ | Dialog ⚡ | [docs/components/dialog.md](docs/components/dialog.md) |
124
+ | Modal(deprecated, use Dialog) | [docs/components/modal.md](docs/components/modal.md) |
125
+ | Drawer ⚡ (deprecated, use Dialog) | [docs/components/drawer.md](docs/components/drawer.md) |
117
126
  | Dropdown ⚡ | [docs/components/dropdown.md](docs/components/dropdown.md) |
118
127
  | Tooltip ⚡ | [docs/components/tooltip.md](docs/components/tooltip.md) |
119
128
  | Popover ⚡ | [docs/components/popover.md](docs/components/popover.md) |
@@ -0,0 +1,95 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Per-dialog behaviour: backdrop click / swipe-to-dismiss / Esc handling for a single
4
+ // <dialog>. Opening, stacking and DOM cleanup are owned by DialogsController — this
5
+ // controller only reacts to interactions on an already-mounted dialog.
6
+ export default class DialogController extends Controller {
7
+ static targets = ["panel"]
8
+
9
+ static values = {
10
+ position: { type: String, default: "center" },
11
+ dismissible: { type: Boolean, default: true },
12
+ swipe: { type: Boolean, default: false },
13
+ swipeThreshold: { type: Number, default: 100 }
14
+ }
15
+
16
+ #touchStart = 0
17
+
18
+ connect() {
19
+ this.element.addEventListener("click", this.#handleBackdropClick)
20
+ this.element.addEventListener("cancel", this.#handleCancel)
21
+
22
+ if (this.swipeValue) {
23
+ this.element.addEventListener("touchstart", this.#handleTouchStart, { passive: true })
24
+ this.element.addEventListener("touchmove", this.#handleTouchMove, { passive: true })
25
+ this.element.addEventListener("touchend", this.#handleTouchEnd, { passive: true })
26
+ }
27
+ }
28
+
29
+ disconnect() {
30
+ this.element.removeEventListener("click", this.#handleBackdropClick)
31
+ this.element.removeEventListener("cancel", this.#handleCancel)
32
+ this.element.removeEventListener("touchstart", this.#handleTouchStart)
33
+ this.element.removeEventListener("touchmove", this.#handleTouchMove)
34
+ this.element.removeEventListener("touchend", this.#handleTouchEnd)
35
+ }
36
+
37
+ close() {
38
+ try {
39
+ this.element.close()
40
+ } catch (e) {}
41
+ }
42
+
43
+ #handleBackdropClick = (event) => {
44
+ if (event.target === this.element && this.dismissibleValue) {
45
+ this.close()
46
+ }
47
+ }
48
+
49
+ #handleCancel = (event) => {
50
+ if (!this.dismissibleValue) event.preventDefault()
51
+ }
52
+
53
+ #axis() {
54
+ return this.positionValue === "top" || this.positionValue === "bottom" ? "y" : "x"
55
+ }
56
+
57
+ // Sign of the direction that counts as "swiping the panel away".
58
+ #sign() {
59
+ return { left: -1, right: 1, top: -1, bottom: 1 }[this.positionValue] ?? 1
60
+ }
61
+
62
+ #coordinate(touch) {
63
+ return this.#axis() === "x" ? touch.clientX : touch.clientY
64
+ }
65
+
66
+ #translate(value, unit) {
67
+ return this.#axis() === "x" ? `translateX(${value}${unit})` : `translateY(${value}${unit})`
68
+ }
69
+
70
+ #handleTouchStart = (event) => {
71
+ if (!this.hasPanelTarget) return
72
+ this.#touchStart = this.#coordinate(event.touches[0])
73
+ this.panelTarget.style.transition = "none"
74
+ }
75
+
76
+ #handleTouchMove = (event) => {
77
+ if (!this.hasPanelTarget) return
78
+ const delta = (this.#coordinate(event.touches[0]) - this.#touchStart) * this.#sign()
79
+ const clamped = Math.max(0, delta)
80
+ this.panelTarget.style.transform = this.#translate(clamped * this.#sign(), "px")
81
+ }
82
+
83
+ #handleTouchEnd = (event) => {
84
+ if (!this.hasPanelTarget) return
85
+ const delta = (this.#coordinate(event.changedTouches[0]) - this.#touchStart) * this.#sign()
86
+ this.panelTarget.style.transition = "transform 0.2s ease-out"
87
+
88
+ if (delta > this.swipeThresholdValue) {
89
+ this.panelTarget.style.transform = this.#translate(this.#sign() * 100, "%")
90
+ this.panelTarget.addEventListener("transitionend", () => this.close(), { once: true })
91
+ } else {
92
+ this.panelTarget.style.transform = this.#translate(0, "px")
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,248 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const FRAME_ID = "dialog"
4
+ const DEFAULT_POSITION = "center"
5
+ const DEFAULT_SIZE = "2xl"
6
+ const HORIZONTAL_POSITIONS = ["top", "bottom"]
7
+
8
+ // Legacy `data-turbo-frame` values kept working after the Modal/Drawer → Dialog unification.
9
+ // Each alias presets a position and is rewritten to the real frame id ("dialog") before Turbo
10
+ // resolves it, so existing async links need no edits — see docs/components/dialog.md.
11
+ const FRAME_ALIASES = {
12
+ dialog: DEFAULT_POSITION,
13
+ modal: "center",
14
+ drawer: "right"
15
+ }
16
+
17
+ // Stack manager for every <dialog> on the page — sync (declared inline with an `id`) and
18
+ // remote (opened via a link/form with data-turbo-frame="dialog", or the deprecated "modal"/
19
+ // "drawer" aliases). Unlimited nesting works because the manager, not any single dialog, owns
20
+ // depth tracking, the flash portal and the "sentinel" <turbo-frame id="dialog"> that every
21
+ // async open adopts.
22
+ export default class DialogsController extends Controller {
23
+ static targets = ["root", "sentinel", "dialog"]
24
+
25
+ #stack = []
26
+ #frameCounter = 0
27
+
28
+ connect() {
29
+ window.addEventListener("click", this.#interceptTrigger, { capture: true })
30
+ window.addEventListener("submit", this.#interceptTrigger, { capture: true })
31
+ document.addEventListener("turbo:frame-load", this.#handleFrameLoad)
32
+ document.addEventListener("turbo:frame-missing", this.#handleFrameMissing)
33
+ document.addEventListener("turbo:fetch-request-error", this.#handleFrameMissing)
34
+ document.addEventListener("turbo:before-visit", this.closeAll)
35
+ document.addEventListener("turbo:before-cache", this.closeAll)
36
+ document.addEventListener("turbo:before-render", this.#handleBeforeRender)
37
+ document.addEventListener("turbo:render", this.#handleRender)
38
+ }
39
+
40
+ disconnect() {
41
+ window.removeEventListener("click", this.#interceptTrigger, { capture: true })
42
+ window.removeEventListener("submit", this.#interceptTrigger, { capture: true })
43
+ document.removeEventListener("turbo:frame-load", this.#handleFrameLoad)
44
+ document.removeEventListener("turbo:frame-missing", this.#handleFrameMissing)
45
+ document.removeEventListener("turbo:fetch-request-error", this.#handleFrameMissing)
46
+ document.removeEventListener("turbo:before-visit", this.closeAll)
47
+ document.removeEventListener("turbo:before-cache", this.closeAll)
48
+ document.removeEventListener("turbo:before-render", this.#handleBeforeRender)
49
+ document.removeEventListener("turbo:render", this.#handleRender)
50
+ }
51
+
52
+ // Sync open — data-action="click->dialogs#open" data-id="myDialog" on the trigger.
53
+ open(event) {
54
+ const id = event.currentTarget.dataset.id
55
+ const dialog = this.dialogTargets.find((d) => d.id === id)
56
+ if (!dialog || this.#stack.includes(dialog)) return
57
+
58
+ this.#push(dialog, { remote: false })
59
+ }
60
+
61
+ closeAll = () => {
62
+ // Copy first: closing dispatches 'close' synchronously, which mutates #stack.
63
+ ;[...this.#stack].reverse().forEach((dialog) => dialog.close())
64
+ }
65
+
66
+ // --- async open: adopt the sentinel frame before Turbo navigates it ---------------------
67
+
68
+ #interceptTrigger = (event) => {
69
+ const frameName = this.#resolveFrameName(event)
70
+ if (!(frameName in FRAME_ALIASES)) return
71
+ if (!this.hasSentinelTarget) return
72
+ if (this.#opensInNewTab(event)) return
73
+
74
+ const sentinel = this.sentinelTarget
75
+ if (sentinel.dataset.pending === "true") return // already adopted, let Turbo re-navigate it
76
+
77
+ const trigger = this.#resolveTrigger(event)
78
+ const position = trigger?.dataset.dialogPosition || FRAME_ALIASES[frameName]
79
+ const size = trigger?.dataset.dialogSize || DEFAULT_SIZE
80
+ const dismissible = trigger?.dataset.dialogDismissible !== "false"
81
+
82
+ // Deprecated aliases ("modal"/"drawer") point at a frame id that no longer exists in the
83
+ // DOM — only the "dialog" sentinel does. Rewrite the trigger before Turbo reads it (we run
84
+ // first, same capture phase) so its own getElementById(frameName) lookup still finds it.
85
+ // Persist the resolved position on the trigger too: once data-turbo-frame becomes "dialog"
86
+ // it no longer carries the alias, so without this a second click on the same trigger would
87
+ // resolve to the "dialog" default position instead of the alias's preset.
88
+ if (frameName !== FRAME_ID) {
89
+ this.#rewriteFrameName(event, trigger)
90
+ if (trigger && !trigger.dataset.dialogPosition) trigger.dataset.dialogPosition = position
91
+ }
92
+
93
+ sentinel.dataset.pending = "true"
94
+
95
+ const shell = this.#buildShell({ position, size, dismissible })
96
+ shell.append(sentinel)
97
+ this.rootTarget.append(shell)
98
+
99
+ this.#push(shell, { remote: true })
100
+ }
101
+
102
+ // A modified click (Cmd/Ctrl/Shift/Alt, middle-click) or a link with target/download makes
103
+ // the browser open a new tab/window instead of navigating in place — Turbo itself ignores
104
+ // these clicks and lets the browser handle them, so we must too, or we'd adopt the sentinel
105
+ // and show an overlay for a navigation that never actually happens on this page.
106
+ #opensInNewTab(event) {
107
+ if (event.type !== "click") return false
108
+ if (event.button !== 0) return true
109
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return true
110
+
111
+ const trigger = this.#resolveTrigger(event)
112
+ const target = trigger?.getAttribute?.("target")
113
+ if (target && target !== "_self") return true
114
+
115
+ return trigger?.hasAttribute?.("download") ?? false
116
+ }
117
+
118
+ #resolveFrameName(event) {
119
+ if (event.type === "submit") {
120
+ const form = event.target
121
+ return event.submitter?.dataset.turboFrame || form?.dataset.turboFrame || null
122
+ }
123
+
124
+ const trigger = event.target?.closest?.("[data-turbo-frame]")
125
+ return trigger?.dataset.turboFrame || null
126
+ }
127
+
128
+ #resolveTrigger(event) {
129
+ if (event.type === "submit") {
130
+ return event.submitter?.dataset.turboFrame ? event.submitter : event.target
131
+ }
132
+
133
+ return event.target?.closest?.("[data-turbo-frame]") || null
134
+ }
135
+
136
+ #rewriteFrameName(event, trigger) {
137
+ if (!trigger) return
138
+ trigger.dataset.turboFrame = FRAME_ID
139
+ }
140
+
141
+ #buildShell({ position, size, dismissible }) {
142
+ const horizontal = HORIZONTAL_POSITIONS.includes(position)
143
+ const edge = position !== DEFAULT_POSITION
144
+
145
+ const dialog = document.createElement("dialog")
146
+ dialog.tabIndex = -1
147
+ dialog.className = `dialog dialog-${position} ${horizontal ? "h" : "w"}-${size}`
148
+ dialog.dataset.controller = "dialog"
149
+ dialog.dataset.dialogPositionValue = position
150
+ dialog.dataset.dialogDismissibleValue = String(dismissible)
151
+ dialog.dataset.dialogSwipeValue = String(edge)
152
+ return dialog
153
+ }
154
+
155
+ #handleFrameLoad = (event) => {
156
+ const frame = event.target
157
+ if (frame?.tagName !== "TURBO-FRAME") return
158
+ if (frame.id !== FRAME_ID) return
159
+
160
+ delete frame.dataset.pending
161
+ delete frame.dataset.dialogsTarget
162
+ frame.id = `${FRAME_ID}-${++this.#frameCounter}`
163
+
164
+ this.#respawnSentinel()
165
+ this.#portalFlash()
166
+ }
167
+
168
+ #handleFrameMissing = (event) => {
169
+ const frame = event.target
170
+ if (frame?.tagName !== "TURBO-FRAME") return
171
+ if (!new RegExp(`^${FRAME_ID}(-\\d+)?$`).test(frame.id)) return
172
+
173
+ const shell = frame.closest("dialog.dialog")
174
+ if (!shell) return
175
+
176
+ if (frame.id === FRAME_ID) this.#respawnSentinel()
177
+ shell.close()
178
+ }
179
+
180
+ #respawnSentinel() {
181
+ const sentinel = document.createElement("turbo-frame")
182
+ sentinel.id = FRAME_ID
183
+ sentinel.dataset.dialogsTarget = "sentinel"
184
+ this.rootTarget.append(sentinel)
185
+ }
186
+
187
+ // --- stack bookkeeping -------------------------------------------------------------------
188
+
189
+ #push(dialog, { remote }) {
190
+ dialog.dataset.remote = String(remote)
191
+ this.#stack.push(dialog)
192
+ this.#renumber()
193
+
194
+ dialog.addEventListener("close", this.#handleDialogClosed, { once: true })
195
+ dialog.showModal()
196
+
197
+ this.#portalFlash()
198
+ dialog.dispatchEvent(new CustomEvent("jet-ui:dialog:opened", {
199
+ bubbles: true,
200
+ detail: { depth: this.#stack.indexOf(dialog), position: dialog.dataset.dialogPositionValue }
201
+ }))
202
+ }
203
+
204
+ #handleDialogClosed = (event) => {
205
+ const dialog = event.target
206
+ const index = this.#stack.indexOf(dialog)
207
+ if (index === -1) return
208
+
209
+ this.#stack.splice(index, 1)
210
+ this.#renumber()
211
+
212
+ dialog.dispatchEvent(new CustomEvent("jet-ui:dialog:closed", { bubbles: true }))
213
+
214
+ if (dialog.dataset.remote === "true") dialog.remove()
215
+
216
+ this.#portalFlash()
217
+ }
218
+
219
+ #renumber() {
220
+ this.#stack.forEach((dialog, index) => { dialog.dataset.depth = String(index) })
221
+ }
222
+
223
+ // --- flash portal --------------------------------------------------------------------------
224
+ // The topmost open dialog is promoted to the browser's top layer, which makes everything
225
+ // outside it inert — a flash frame left in <body> would be invisible and unclickable.
226
+ // We move #flash into the topmost dialog's .dialog__flash-slot instead (see dialog.css).
227
+
228
+ #portalFlash = () => {
229
+ const flashFrame = document.getElementById("flash")
230
+ if (!flashFrame) return
231
+
232
+ const top = this.#stack.at(-1)
233
+ const target = top ? top.querySelector(".dialog__flash-slot") : document.body
234
+ if (target && flashFrame.parentElement !== target) target.append(flashFrame)
235
+ }
236
+
237
+ // #dialogs is data-turbo-permanent so open dialogs survive a morph refresh — but that also
238
+ // means Turbo won't merge a fresh #flash into a copy that is currently portaled inside it.
239
+ // Pull it back out to <body> before every render, then re-portal it after.
240
+ #handleBeforeRender = () => {
241
+ const flashFrame = document.getElementById("flash")
242
+ if (flashFrame && flashFrame.parentElement !== document.body) document.body.append(flashFrame)
243
+ }
244
+
245
+ #handleRender = () => {
246
+ this.#portalFlash()
247
+ }
248
+ }
@@ -1,5 +1,11 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // @deprecated Only the async (Turbo Frame) dialog markup rendered by JetUi::Dialog::Component
4
+ // still uses "modal"/"drawer" as data-turbo-frame aliases — those keep working automatically,
5
+ // handled by DialogsController. This standalone controller matches the pre-Dialog async drawer
6
+ // markup and is no longer wired up by JetUi::Drawer::Component. Kept only for apps with custom
7
+ // markup still registering it directly. Migrate to the "dialog" controller. Removed in the
8
+ // next major version.
3
9
  export default class DrawerController extends Controller {
4
10
  static values = {
5
11
  swipeThreshold: { type: Number, default: 100 }
@@ -1,5 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // @deprecated JetUi::Drawer::Component's sync-mode markup now targets
4
+ // data-dialogs-target="dialog" (see JetUi::Dialog::Component), not
5
+ // data-drawers-target="dialog" — this controller no longer matches it. Migrate your
6
+ // data-controller="drawers" wrapper and click->drawers#open actions to "dialogs" /
7
+ // click->dialogs#open. Removed in the next major version.
3
8
  export default class DrawersController extends Controller {
4
9
  static targets = ["dialog"]
5
10
  static values = {
@@ -1,5 +1,11 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // @deprecated Only the async (Turbo Frame) dialog markup rendered by JetUi::Dialog::Component
4
+ // still uses "modal"/"drawer" as data-turbo-frame aliases — those keep working automatically,
5
+ // handled by DialogsController. This standalone controller matches the pre-Dialog async modal
6
+ // markup and is no longer wired up by JetUi::Modal::Component. Kept only for apps with custom
7
+ // markup still registering it directly. Migrate to the "dialog" controller. Removed in the
8
+ // next major version.
3
9
  export default class ModalController extends Controller {
4
10
  connect() {
5
11
  this.element.addEventListener("click", this.#closeOnBackdropClick.bind(this))
@@ -1,5 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // @deprecated JetUi::Modal::Component's sync-mode markup now targets
4
+ // data-dialogs-target="dialog" (see JetUi::Dialog::Component), not
5
+ // data-modals-target="dialog" — this controller no longer matches it. Migrate your
6
+ // data-controller="modals" wrapper and click->modals#open actions to "dialogs" /
7
+ // click->dialogs#open. Removed in the next major version.
3
8
  export default class ModalsController extends Controller {
4
9
  static targets = ["dialog"]
5
10
 
@@ -0,0 +1,134 @@
1
+ @layer theme, base, components, utilities;
2
+
3
+ @layer components {
4
+ /*
5
+ * .dialog is a pure, invisible positioning shell: fixed, sized, anchored to an edge (or
6
+ * centered). It must never carry a background, shadow, rounded corners, overflow-hidden, or
7
+ * — most importantly — transform/filter/perspective/contain/will-change, because any of
8
+ * those would make it a containing block for position: fixed descendants, breaking the
9
+ * flash portal (see dialogs_controller.js's #portalFlash — .dialog__flash-slot is a sibling
10
+ * of .dialog__panel for exactly this reason, not a descendant of it).
11
+ *
12
+ * All visible chrome (background, shadow, rounded corners, clipping) and the enter animation
13
+ * live on .dialog__panel instead, so the whole visible card animates as one piece instead of
14
+ * the chrome popping in instantly while only the content inside it slides/fades.
15
+ */
16
+ .dialog {
17
+ @apply fixed hidden p-0 m-0 border-0 bg-transparent;
18
+ @apply animate-slide-up;
19
+ }
20
+
21
+ .dialog[open] {
22
+ @apply flex flex-col;
23
+ }
24
+
25
+ .dialog::backdrop {
26
+ @apply bg-overlay;
27
+ }
28
+
29
+ .dialog-center {
30
+ @apply inset-0 m-auto h-fit min-h-24 max-w-11/12 max-h-11/12;
31
+ }
32
+
33
+ .dialog-left {
34
+ @apply animate-slide-right;
35
+ @apply inset-y-0 left-0 right-auto h-full max-h-full;
36
+ max-width: min(90%, 1280px);
37
+ }
38
+
39
+ .dialog-right {
40
+ @apply animate-slide-left;
41
+ @apply inset-y-0 left-auto right-0 h-full max-h-full;
42
+ max-width: min(90%, 1280px);
43
+ }
44
+
45
+ .dialog-top {
46
+ @apply animate-slide-down;
47
+ @apply inset-x-0 bottom-auto top-0 w-full max-h-11/12 max-w-full;
48
+ }
49
+
50
+ .dialog-bottom {
51
+ @apply animate-slide-up;
52
+ @apply inset-x-0 bottom-0 top-auto w-full max-h-11/12 max-w-full;
53
+ }
54
+
55
+ /*
56
+ * A remote dialog's content arrives inside a <turbo-frame id="dialog">, which sits between
57
+ * .dialog and its children — make it transparent to layout so .dialog__panel and
58
+ * .dialog__flash-slot still behave as .dialog's direct flex children.
59
+ */
60
+ .dialog > turbo-frame {
61
+ display: contents;
62
+ }
63
+
64
+ .dialog__panel {
65
+ @apply relative flex flex-col flex-1 overflow-hidden;
66
+ @apply shadow-sm bg-card rounded-modal;
67
+ }
68
+
69
+ .dialog-left .dialog__panel {
70
+ @apply rounded-l-none;
71
+ }
72
+
73
+ .dialog-right .dialog__panel {
74
+ @apply rounded-r-none;
75
+ }
76
+
77
+ .dialog-top .dialog__panel {
78
+ @apply rounded-t-none;
79
+ }
80
+
81
+ .dialog-bottom .dialog__panel {
82
+ @apply rounded-b-none;
83
+ }
84
+
85
+ .dialog__header {
86
+ @apply flex items-start justify-between flex-shrink-0 p-4 px-6 backdrop-blur-sm bg-card/70;
87
+ }
88
+
89
+ .dialog__header-bordered {
90
+ @apply border-b border-border;
91
+ }
92
+
93
+ .dialog__title {
94
+ @apply text-xl font-semibold leading-tight text-foreground;
95
+ }
96
+
97
+ .dialog__subtitle {
98
+ @apply mt-1 text-sm text-muted-foreground;
99
+ }
100
+
101
+ .dialog__close {
102
+ @apply inline-flex items-center p-1 ml-auto text-sm transition-all duration-300 bg-transparent rounded-full cursor-pointer outline-0;
103
+ @apply text-muted-foreground hover:bg-accent hover:text-accent-foreground;
104
+ }
105
+
106
+ .dialog__body {
107
+ @apply flex-1 min-h-0 px-6 py-6 overflow-auto;
108
+ }
109
+
110
+ .dialog__footer {
111
+ @apply flex-shrink-0 w-full p-4 backdrop-blur-sm bg-card/70;
112
+ }
113
+
114
+ .dialog__footer-bordered {
115
+ @apply border-t border-border;
116
+ }
117
+
118
+ /*
119
+ * Zero-size anchor the flash frame is portaled into (see dialogs_controller.js) so it is
120
+ * painted inside the topmost dialog's top-layer subtree. Deliberately a *sibling* of
121
+ * .dialog__panel, not nested inside it — .dialog__panel carries the enter animation
122
+ * (transform), which would otherwise turn it into a containing block for the portaled
123
+ * flash frame's position: fixed. display:contents keeps this slot itself out of the
124
+ * flex flow entirely.
125
+ */
126
+ .dialog__flash-slot {
127
+ display: contents;
128
+ }
129
+
130
+ /* Direct (non-Turbo-Frame, non-dialog) page render — e.g. a dialog URL visited directly. */
131
+ .dialog-page {
132
+ @apply mt-4 mx-auto;
133
+ }
134
+ }