jet_ui 0.2.10 → 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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +30 -21
  4. data/app/assets/images/logo.svg +25 -0
  5. data/app/assets/javascripts/jet_ui/dialog_controller.js +95 -0
  6. data/app/assets/javascripts/jet_ui/dialogs_controller.js +248 -0
  7. data/app/assets/javascripts/jet_ui/drawer_controller.js +6 -0
  8. data/app/assets/javascripts/jet_ui/drawers_controller.js +5 -0
  9. data/app/assets/javascripts/jet_ui/modal_controller.js +6 -0
  10. data/app/assets/javascripts/jet_ui/modals_controller.js +5 -0
  11. data/app/assets/stylesheets/jet_ui/dialog.css +134 -0
  12. data/app/assets/stylesheets/jet_ui/theme.css +12 -0
  13. data/app/assets/stylesheets/jet_ui.css +1 -0
  14. data/app/components/jet_ui/dialog/body_component.rb +21 -0
  15. data/app/components/jet_ui/dialog/component.rb +86 -0
  16. data/app/components/jet_ui/dialog/footer_component.rb +45 -0
  17. data/app/components/jet_ui/dialog/header_component.rb +45 -0
  18. data/app/components/jet_ui/dialogs/component.rb +18 -0
  19. data/app/components/jet_ui/drawer/body_component.rb +2 -14
  20. data/app/components/jet_ui/drawer/component.rb +14 -44
  21. data/app/components/jet_ui/drawer/footer_component.rb +2 -38
  22. data/app/components/jet_ui/drawer/header_component.rb +6 -38
  23. data/app/components/jet_ui/flash/component.rb +2 -2
  24. data/app/components/jet_ui/icon/component.rb +1 -3
  25. data/app/components/jet_ui/modal/body_component.rb +2 -14
  26. data/app/components/jet_ui/modal/component.rb +14 -44
  27. data/app/components/jet_ui/modal/footer_component.rb +2 -38
  28. data/app/components/jet_ui/modal/header_component.rb +6 -38
  29. data/app/components/jet_ui/turbo_confirm/component.html.erb +13 -15
  30. data/lib/generators/jet_ui/eject/eject_generator.rb +19 -0
  31. data/lib/generators/jet_ui/install/install_generator.rb +3 -3
  32. data/lib/jet_ui/version.rb +1 -1
  33. metadata +33 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: debfc83f3c33c58716e876a43bab280d430a94fc4682de9b89b0cb605c8359d4
4
- data.tar.gz: 5ad38cb1116cb06dfd0b1516ba282213af3a0759e3a3947e548113cf5a24c2d4
3
+ metadata.gz: aaa259d3afc97d0f95133cdb1f0f4a5ab4ecdb8e202cc3e2915adf675747e8e3
4
+ data.tar.gz: e67258fe470b32b455ff87ff3c3d2121eba46f5b549cdce377c105e8f9485d9a
5
5
  SHA512:
6
- metadata.gz: 1cba15b76863db3147f4881a443ecbd2bbf69a4cf5b3f21e05be6987767ae2934e0dfe1700865f659de4b25d2c8c07bc412bf2092be1d63f980244d6670feab5
7
- data.tar.gz: 9f336006021e46d6209e1e36c233cf1d44750a893b99945bb7ea6a62bf5a874abfd85ab2fd215cd69a66cc81c068ec4a785f55f18e26accbfb489680fa75f1f1
6
+ metadata.gz: a4eeb23778ec35b7df6d67d73185851f34bd261d930251159abf6977ed09a2c6deb19adc3112f8f506266d460fea28353a2974db2fb575e33179f109ad10e521
7
+ data.tar.gz: d6a945c029554f92578e4f32e9cd7c834d8a2d72d1f43497a179d0b92f4a2b7694f8538f093cce1c381f3d93a9ca4ff032f291a9059cb9b5c9d3ad77fb0fe670
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ 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
+
25
+ ## [0.2.11] - 2026-09-07
26
+
27
+ ### Fixed
28
+ - Rails 7.x test suite broken by Capybara 3.40+ (`quirks_mode` keyword incompatibility) — pin Capybara to `~> 3.36` and JSON to `< 3.0` in development dependencies
29
+ - Gemspec no longer pins an exact Rails development version — the previous exact pin caused a hard `Bundler::GemfileError` against the CI matrix gemfiles on newer Bundler versions
30
+ - Flash component's Stimulus data attributes now use hyphenated keys for Rails 7.1 compatibility
31
+
32
+ ### Added
33
+ - JetUI logo to README
34
+
10
35
  ## [0.2.10] - 2026-08-22
11
36
 
12
37
  ### Changed
data/README.md CHANGED
@@ -1,11 +1,10 @@
1
- # JetUi
1
+ ![JetUI Logo](app/assets/images/logo.svg)
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/jet_ui)](https://rubygems.org/gems/jet_ui)
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,25 @@
1
+ <svg width="179" height="58" viewBox="0 0 179 58" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <style>
3
+ @media (prefers-color-scheme: dark) {
4
+ path { fill: white !important; stroke: white !important; }
5
+ path[fill="#FF5B1F"] { fill: #FF5B1F !important; }
6
+ }
7
+ @media (prefers-color-scheme: light) {
8
+ path { fill: #1A1A1A !important; stroke: #1A1A1A !important; }
9
+ path[fill="#FF5B1F"] { fill: #FF5B1F !important; }
10
+ }
11
+ </style>
12
+ <g clip-path="url(#clip0_18891_31745)">
13
+ <path d="M12 2H46C51.5228 2 56 6.47715 56 12V46C56 51.5228 51.5228 56 46 56H12C6.47715 56 2 51.5228 2 46V12C2 6.47715 6.47715 2 12 2Z" stroke="#1A1A1A" stroke-width="4"/>
14
+ <path d="M21 11H11C9.34315 11 8 12.3431 8 14V24C8 25.6569 9.34315 27 11 27H21C22.6569 27 24 25.6569 24 24V14C24 12.3431 22.6569 11 21 11Z" fill="#1A1A1A"/>
15
+ <path d="M47.375 11H31.625C30.1753 11 29 11.8954 29 13V15C29 16.1046 30.1753 17 31.625 17H47.375C48.8247 17 50 16.1046 50 15V13C50 11.8954 48.8247 11 47.375 11Z" fill="#1A1A1A"/>
16
+ <path opacity="0.4" d="M40.4545 21H31.5455C30.1396 21 29 21.8954 29 23V25C29 26.1046 30.1396 27 31.5455 27H40.4545C41.8604 27 43 26.1046 43 25V23C43 21.8954 41.8604 21 40.4545 21Z" fill="#1A1A1A"/>
17
+ <path d="M46.5 31H11.5C9.567 31 8 32.3431 8 34V44C8 45.6569 9.567 47 11.5 47H46.5C48.433 47 50 45.6569 50 44V34C50 32.3431 48.433 31 46.5 31Z" fill="#FF5B1F"/>
18
+ <path d="M83.214 42.588C80.19 42.588 77.782 41.762 75.99 40.11C74.226 38.458 73.344 36.148 73.344 33.18V30.408H78.888V33.18C78.888 34.496 79.252 35.532 79.98 36.288C80.708 37.016 81.73 37.38 83.046 37.38C84.278 37.38 85.23 37.016 85.902 36.288C86.602 35.56 86.952 34.524 86.952 33.18V17.64H81.912V12.6H95.856V17.64H92.496V33.18C92.496 36.232 91.67 38.57 90.018 40.194C88.366 41.79 86.098 42.588 83.214 42.588ZM108.467 42.588C106.395 42.588 104.561 42.154 102.965 41.286C101.397 40.39 100.165 39.144 99.2692 37.548C98.4012 35.924 97.9672 34.02 97.9672 31.836V31.332C97.9672 29.148 98.4012 27.258 99.2692 25.662C100.137 24.038 101.355 22.792 102.923 21.924C104.491 21.028 106.311 20.58 108.383 20.58C110.427 20.58 112.205 21.042 113.717 21.966C115.229 22.862 116.405 24.122 117.245 25.746C118.085 27.342 118.505 29.204 118.505 31.332V33.138H103.343C103.399 34.566 103.931 35.728 104.939 36.624C105.947 37.52 107.179 37.968 108.635 37.968C110.119 37.968 111.211 37.646 111.911 37.002C112.611 36.358 113.143 35.644 113.507 34.86L117.833 37.128C117.441 37.856 116.867 38.654 116.111 39.522C115.383 40.362 114.403 41.09 113.171 41.706C111.939 42.294 110.371 42.588 108.467 42.588ZM103.385 29.19H113.129C113.017 27.986 112.527 27.02 111.659 26.292C110.819 25.564 109.713 25.2 108.341 25.2C106.913 25.2 105.779 25.564 104.939 26.292C104.099 27.02 103.581 27.986 103.385 29.19ZM130.498 42C129.126 42 128.006 41.58 127.138 40.74C126.298 39.872 125.878 38.724 125.878 37.296V25.536H120.67V21.168H125.878V14.7H131.17V21.168H136.882V25.536H131.17V36.372C131.17 37.212 131.562 37.632 132.346 37.632H136.378V42H130.498ZM152.844 42.588C150.436 42.588 148.364 42.154 146.628 41.286C144.92 40.39 143.604 39.13 142.68 37.506C141.784 35.854 141.336 33.908 141.336 31.668V12.6H146.88V31.836C146.88 33.628 147.384 35.042 148.392 36.078C149.428 37.114 150.912 37.632 152.844 37.632C154.776 37.632 156.246 37.114 157.254 36.078C158.29 35.042 158.808 33.628 158.808 31.836V12.6H164.352V31.668C164.352 33.908 163.89 35.854 162.966 37.506C162.07 39.13 160.754 40.39 159.018 41.286C157.31 42.154 155.252 42.588 152.844 42.588ZM169.723 42V12.6H175.267V42H169.723Z" fill="#1A1A1A"/>
19
+ </g>
20
+ <defs>
21
+ <clipPath id="clip0_18891_31745">
22
+ <rect width="179" height="58"/>
23
+ </clipPath>
24
+ </defs>
25
+ </svg>
@@ -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