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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +29 -20
- data/app/assets/javascripts/jet_ui/dialog_controller.js +95 -0
- data/app/assets/javascripts/jet_ui/dialogs_controller.js +248 -0
- data/app/assets/javascripts/jet_ui/drawer_controller.js +6 -0
- data/app/assets/javascripts/jet_ui/drawers_controller.js +5 -0
- data/app/assets/javascripts/jet_ui/modal_controller.js +6 -0
- data/app/assets/javascripts/jet_ui/modals_controller.js +5 -0
- data/app/assets/stylesheets/jet_ui/dialog.css +134 -0
- data/app/assets/stylesheets/jet_ui/theme.css +12 -0
- data/app/assets/stylesheets/jet_ui.css +1 -0
- data/app/components/jet_ui/dialog/body_component.rb +21 -0
- data/app/components/jet_ui/dialog/component.rb +86 -0
- data/app/components/jet_ui/dialog/footer_component.rb +45 -0
- data/app/components/jet_ui/dialog/header_component.rb +45 -0
- data/app/components/jet_ui/dialogs/component.rb +18 -0
- data/app/components/jet_ui/drawer/body_component.rb +2 -14
- data/app/components/jet_ui/drawer/component.rb +14 -44
- data/app/components/jet_ui/drawer/footer_component.rb +2 -38
- data/app/components/jet_ui/drawer/header_component.rb +6 -38
- data/app/components/jet_ui/modal/body_component.rb +2 -14
- data/app/components/jet_ui/modal/component.rb +14 -44
- data/app/components/jet_ui/modal/footer_component.rb +2 -38
- data/app/components/jet_ui/modal/header_component.rb +6 -38
- data/app/components/jet_ui/turbo_confirm/component.html.erb +13 -15
- data/lib/generators/jet_ui/eject/eject_generator.rb +19 -0
- data/lib/generators/jet_ui/install/install_generator.rb +3 -3
- data/lib/jet_ui/version.rb +1 -1
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa259d3afc97d0f95133cdb1f0f4a5ab4ecdb8e202cc3e2915adf675747e8e3
|
|
4
|
+
data.tar.gz: e67258fe470b32b455ff87ff3c3d2121eba46f5b549cdce377c105e8f9485d9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/jetrockets/jet_ui/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE.md)
|
|
6
6
|
[](https://rubygems.org/gems/jet_ui)
|
|
7
|
-
[](https://rubygems.org/gems/jet_ui)
|
|
7
|
+
[](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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
+
Full walkthrough and troubleshooting for the Vite path: [docs/vite.md](docs/vite.md).
|
|
39
40
|
|
|
40
|
-
###
|
|
41
|
+
### Importmap
|
|
41
42
|
|
|
42
|
-
The
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
54
|
+
Register the controllers you use (`@hotwired/stimulus` is a peer dependency):
|
|
50
55
|
|
|
51
56
|
```javascript
|
|
52
|
-
import {
|
|
57
|
+
import { DialogController, DialogsController } from "@jetrockets/jet_ui"
|
|
53
58
|
|
|
54
|
-
application.register("
|
|
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
|
-
|
|
|
116
|
-
|
|
|
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
|
+
}
|