turbo_overlay 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 +7 -0
- data/CHANGELOG.md +436 -0
- data/LICENSE.txt +21 -0
- data/README.md +330 -0
- data/Rakefile +35 -0
- data/app/assets/stylesheets/turbo_overlay.css +234 -0
- data/app/javascript/turbo_overlay/dialog_utils.js +46 -0
- data/app/javascript/turbo_overlay/hint.js +670 -0
- data/app/javascript/turbo_overlay/history.js +184 -0
- data/app/javascript/turbo_overlay/index.js +53 -0
- data/app/javascript/turbo_overlay/options.js +152 -0
- data/app/javascript/turbo_overlay/overlay_controller.js +882 -0
- data/app/javascript/turbo_overlay/popover_position.js +64 -0
- data/app/javascript/turbo_overlay/setup.js +885 -0
- data/app/javascript/turbo_overlay/stack_controller.js +131 -0
- data/app/javascript/turbo_overlay/submit_close.js +49 -0
- data/app/javascript/turbo_overlay/visit.js +52 -0
- data/app/views/layouts/turbo_overlay/drawer.html.erb +5 -0
- data/app/views/layouts/turbo_overlay/hint.html.erb +10 -0
- data/app/views/layouts/turbo_overlay/modal.html.erb +5 -0
- data/app/views/layouts/turbo_overlay/popover.html.erb +5 -0
- data/app/views/turbo_overlay/_drawer.html.erb +49 -0
- data/app/views/turbo_overlay/_hint.html.erb +6 -0
- data/app/views/turbo_overlay/_loading.html.erb +12 -0
- data/app/views/turbo_overlay/_modal.html.erb +46 -0
- data/app/views/turbo_overlay/_popover.html.erb +54 -0
- data/config/importmap.rb +11 -0
- data/lib/generators/turbo_overlay/eject_generator.rb +115 -0
- data/lib/generators/turbo_overlay/install_generator.rb +443 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_confirm.html.erb +13 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_drawer.html.erb +50 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_hint.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_loading.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_modal.html.erb +49 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_popover.html.erb +54 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_confirm.html.erb +13 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_drawer.html.erb +55 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_hint.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_loading.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_modal.html.erb +58 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_popover.html.erb +53 -0
- data/lib/generators/turbo_overlay/templates/chrome/plain/_confirm.html.erb +14 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_confirm.html.erb +17 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_drawer.html.erb +55 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_hint.html.erb +6 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_loading.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_modal.html.erb +46 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_popover.html.erb +54 -0
- data/lib/generators/turbo_overlay/templates/initializer.rb.tt +67 -0
- data/lib/turbo_overlay/configuration.rb +226 -0
- data/lib/turbo_overlay/controller.rb +405 -0
- data/lib/turbo_overlay/engine.rb +52 -0
- data/lib/turbo_overlay/helpers/stream_helper.rb +77 -0
- data/lib/turbo_overlay/helpers/view_helper.rb +651 -0
- data/lib/turbo_overlay/version.rb +3 -0
- data/lib/turbo_overlay.rb +20 -0
- metadata +161 -0
data/README.md
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Turbo Overlay
|
|
2
|
+
|
|
3
|
+
Render any Rails view inside a stackable modal, drawer, or popover
|
|
4
|
+
using Turbo Streams — without duplicating templates, hand-rolling
|
|
5
|
+
Stimulus controllers, or coupling your domain to a CSS framework.
|
|
6
|
+
|
|
7
|
+
- **Four overlay types**: modal, drawer, popover, hover hint.
|
|
8
|
+
- **Stacking by default.** Open an overlay from inside another and the
|
|
9
|
+
new one slides on top instead of replacing it. Dismissal affects
|
|
10
|
+
only the topmost layer.
|
|
11
|
+
- **Themes ship in the gem** for Tailwind, Bootstrap 5, Bootstrap 3,
|
|
12
|
+
and plain CSS. Switch with one config option.
|
|
13
|
+
- **Native `<dialog>`** for every theme — top-layer stacking, focus
|
|
14
|
+
trap, ESC/backdrop dismiss come from the browser. No `window.bootstrap`,
|
|
15
|
+
no jQuery, no z-index wars.
|
|
16
|
+
- **Hover hints** with Turbo prefetch coordination. One fetch warms the
|
|
17
|
+
navigation *and* seeds a preview popover.
|
|
18
|
+
- **Themed `data-turbo-confirm`** prompts that match your overlay look
|
|
19
|
+
and stack like one.
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Rails ≥ 6.1
|
|
24
|
+
- turbo-rails ≥ 2.0 (Turbo 8) — required for `data-turbo-stream="true"` on GET links
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# Gemfile
|
|
30
|
+
gem "turbo_overlay"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle install
|
|
35
|
+
bin/rails generate turbo_overlay:install --theme tailwind
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Themes: `plain` (default), `tailwind`, `bootstrap5`, `bootstrap3`.
|
|
39
|
+
|
|
40
|
+
The generator wires the host app: it copies the theme's chrome
|
|
41
|
+
partials into `app/views/turbo_overlay/`, writes
|
|
42
|
+
`config/initializers/turbo_overlay.rb`, injects `overlay_stack_tag`
|
|
43
|
+
into your application layout, and registers the Stimulus / asset
|
|
44
|
+
wiring appropriate to your build setup (importmap, propshaft,
|
|
45
|
+
sprockets, jsbundling, cssbundling).
|
|
46
|
+
|
|
47
|
+
### Required: `overlay_stack_tag` in your layout
|
|
48
|
+
|
|
49
|
+
The gem **will not render overlays** without `overlay_stack_tag`
|
|
50
|
+
present in your application layout. The generator injects it
|
|
51
|
+
automatically when it finds `app/views/layouts/application.html.erb`,
|
|
52
|
+
but verify it's there — and add it manually if you use a non-standard
|
|
53
|
+
layout, run the generator with `--skip-layout-inject`, or the
|
|
54
|
+
generator prints a yellow warning about it:
|
|
55
|
+
|
|
56
|
+
```erb
|
|
57
|
+
<%# app/views/layouts/application.html.erb %>
|
|
58
|
+
<body>
|
|
59
|
+
<%= yield %>
|
|
60
|
+
<%= overlay_stack_tag %>
|
|
61
|
+
</body>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This renders the slots overlays mount into. Without it, modal /
|
|
65
|
+
drawer / popover / hint links navigate full-page instead of opening
|
|
66
|
+
as overlays.
|
|
67
|
+
|
|
68
|
+
### Required: `TurboOverlay::Controller` concern
|
|
69
|
+
|
|
70
|
+
The final wiring step is your `ApplicationController`. Include the
|
|
71
|
+
concern:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class ApplicationController < ActionController::Base
|
|
75
|
+
include TurboOverlay::Controller
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Including the concern installs a `layout` proc that swaps to the
|
|
80
|
+
matching overlay layout on overlay requests. Plain turbo-frame
|
|
81
|
+
requests keep their turbo-rails layout.
|
|
82
|
+
|
|
83
|
+
Overlay layouts **replace** your application layout for overlay
|
|
84
|
+
requests — only the view content is wrapped in the dialog markup,
|
|
85
|
+
not your nav, header, or footer.
|
|
86
|
+
|
|
87
|
+
### Custom layouts
|
|
88
|
+
|
|
89
|
+
If your controller uses its own layout method, call
|
|
90
|
+
`turbo_overlay_layout` from it:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
layout :custom_layout
|
|
94
|
+
|
|
95
|
+
def custom_layout
|
|
96
|
+
turbo_overlay_layout || "my_app_layout"
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A static `layout "admin"` declaration needs to be a method to thread
|
|
101
|
+
`turbo_overlay_layout` through.
|
|
102
|
+
|
|
103
|
+
If you skip the install generator the gem falls back to a plain
|
|
104
|
+
`<dialog>` chrome so modals and drawers still work, just unstyled
|
|
105
|
+
beyond the gem's CSS.
|
|
106
|
+
|
|
107
|
+
See [docs/installation.md](docs/installation.md) for the full
|
|
108
|
+
install generator output, bundling-app setup, and the `eject`
|
|
109
|
+
generator.
|
|
110
|
+
|
|
111
|
+
## Usage
|
|
112
|
+
|
|
113
|
+
### Open a view in an overlay
|
|
114
|
+
|
|
115
|
+
```erb
|
|
116
|
+
<%= modal_link_to "New User", new_user_path %>
|
|
117
|
+
<%= drawer_link_to "Filters", filters_path %>
|
|
118
|
+
<%= popover_link_to "Edit", edit_user_path(@user) %>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Modals and drawers stack. Popovers anchor to the trigger and replace
|
|
122
|
+
each other; modals and drawers still stack on top of an open popover.
|
|
123
|
+
See [docs/popovers.md](docs/popovers.md) for per-link options
|
|
124
|
+
(`position:`, `align:`, `offset:`, `backdrop:`) and the
|
|
125
|
+
single-popover behavior.
|
|
126
|
+
|
|
127
|
+
For non-GET triggers — deleting an item, creating a record, kicking
|
|
128
|
+
off a wizard — use the `button_to` counterparts:
|
|
129
|
+
|
|
130
|
+
```erb
|
|
131
|
+
<%= modal_button_to "Delete", widget_path(@w), method: :delete %>
|
|
132
|
+
<%= drawer_button_to "Start wizard", wizards_path, method: :post %>
|
|
133
|
+
<%= popover_button_to "Quick edit", widget_path(@w), method: :patch %>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The form submits with the same `X-Turbo-Overlay-*` headers the link
|
|
137
|
+
helpers send on click, so the controller renders identically.
|
|
138
|
+
|
|
139
|
+
### Open an overlay from JavaScript
|
|
140
|
+
|
|
141
|
+
For triggers that aren't anchors — a Google Maps marker, an SVG hit
|
|
142
|
+
region, a custom element — call `TurboOverlay.visit(url, options)`.
|
|
143
|
+
Full option parity with the link helpers, exposed as both a named
|
|
144
|
+
export and a `window.TurboOverlay` global.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
// Modal from a map marker
|
|
148
|
+
google.maps.event.addListener(marker, "click", () => {
|
|
149
|
+
TurboOverlay.visit("/places/123")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Drawer with URL advance
|
|
153
|
+
TurboOverlay.visit("/cart", { type: "drawer", advance: true })
|
|
154
|
+
|
|
155
|
+
// Popover anchored to a non-anchor element — `anchor` is required
|
|
156
|
+
button.addEventListener("click", (event) => {
|
|
157
|
+
TurboOverlay.visit("/preview/9", {
|
|
158
|
+
type: "popover",
|
|
159
|
+
anchor: event.currentTarget,
|
|
160
|
+
position: "top",
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Prefer the Rails link helpers for ordinary navigation; reach for
|
|
166
|
+
`TurboOverlay.visit` only when the trigger isn't a link. See
|
|
167
|
+
[docs/reference.md](docs/reference.md#javascript-api) for the full
|
|
168
|
+
option list.
|
|
169
|
+
|
|
170
|
+
### Customize what the overlay renders
|
|
171
|
+
|
|
172
|
+
The chrome yields a body and reads two `content_for` blocks. The
|
|
173
|
+
keys are generic so the same view renders correctly in a modal or
|
|
174
|
+
a drawer:
|
|
175
|
+
|
|
176
|
+
```erb
|
|
177
|
+
<%# app/views/users/new.html.erb %>
|
|
178
|
+
<% overlay_title "New User" %>
|
|
179
|
+
|
|
180
|
+
<%= form_with(model: @user) do |f| %>
|
|
181
|
+
<%= f.text_field :name %>
|
|
182
|
+
<% end %>
|
|
183
|
+
|
|
184
|
+
<% overlay_footer do %>
|
|
185
|
+
<%= modal_dismiss_link_to "Cancel", users_path, class: "btn btn-secondary" %>
|
|
186
|
+
<button type="submit" class="btn btn-primary">Save</button>
|
|
187
|
+
<% end %>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Variant templates pick different markup per chrome:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
app/views/users/show.html.erb # full-page
|
|
194
|
+
app/views/users/show.html+modal.erb # in a modal
|
|
195
|
+
app/views/users/show.html+drawer.erb # in a drawer
|
|
196
|
+
app/views/users/show.html+popover.erb # in a popover
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
See [docs/customization.md](docs/customization.md) for the
|
|
200
|
+
overlay-template footgun, chrome partial structure, close-button
|
|
201
|
+
suppression, and stable overlay ids.
|
|
202
|
+
|
|
203
|
+
### Close the overlay
|
|
204
|
+
|
|
205
|
+
Two paths, both supported.
|
|
206
|
+
|
|
207
|
+
**Implicit (the default).** A form submission inside an overlay that
|
|
208
|
+
redirects closes the overlay and visits the redirect target. Most
|
|
209
|
+
Rails CRUD actions need no overlay-specific code:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
def create
|
|
213
|
+
@user = User.new(user_params)
|
|
214
|
+
if @user.save
|
|
215
|
+
redirect_to users_path # overlay closes; browser lands on /users
|
|
216
|
+
else
|
|
217
|
+
render :new, status: :unprocessable_entity # form re-renders in place
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Validation failures (`:unprocessable_entity`, 422) don't redirect, so
|
|
223
|
+
the form re-renders in the overlay with errors in place. If the
|
|
224
|
+
redirect goes back to the page the overlay was opened from, the host
|
|
225
|
+
page morphs in place behind the closing overlay so there's no
|
|
226
|
+
flash-of-stale-content — no app configuration needed.
|
|
227
|
+
|
|
228
|
+
**Explicit.** `turbo_stream.overlay(:close)` closes the top overlay
|
|
229
|
+
from any non-redirect response. Useful when the action wants to
|
|
230
|
+
update other parts of the page in the same response:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
render turbo_stream: [
|
|
234
|
+
turbo_stream.update("flash", partial: "shared/flash"),
|
|
235
|
+
turbo_stream.overlay(:close)
|
|
236
|
+
]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
See [docs/close-on-redirect.md](docs/close-on-redirect.md) for
|
|
240
|
+
opt-outs (`keep_overlay_open_on_redirect`, per-form data attribute),
|
|
241
|
+
the smooth-same-page-redirect mechanics, and stack-scoped close
|
|
242
|
+
variants.
|
|
243
|
+
|
|
244
|
+
ESC and clicking the backdrop dismiss the top overlay out of the
|
|
245
|
+
box. Opt a specific overlay out with
|
|
246
|
+
`data-turbo-overlay-backdrop-dismiss-value="false"`.
|
|
247
|
+
|
|
248
|
+
### URL advance
|
|
249
|
+
|
|
250
|
+
Pass `advance: true` on a modal or drawer link to push the link's
|
|
251
|
+
target URL into the browser history bar when the overlay opens.
|
|
252
|
+
Browser-back closes the top overlay instead of navigating away.
|
|
253
|
+
Default off.
|
|
254
|
+
|
|
255
|
+
```erb
|
|
256
|
+
<%= modal_link_to "Edit", edit_user_path(@user), advance: true %>
|
|
257
|
+
<%= drawer_link_to "Filter", filters_path, advance: "/users?filtering" %>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Per-link `advance:` accepts `true` (push `link.href`), a String (push
|
|
261
|
+
a custom URL), or `false` (opt out when a type default is on). Set
|
|
262
|
+
the type default in the initializer:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
TurboOverlay.configure do |c|
|
|
266
|
+
c.modal { |m| m.advance = true }
|
|
267
|
+
c.drawer { |d| d.advance = true }
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Popovers and hints never advance — they're ephemeral and shouldn't
|
|
272
|
+
churn browser history.
|
|
273
|
+
|
|
274
|
+
Note: the pushed URL is not guaranteed to re-open the overlay on a
|
|
275
|
+
fresh visit; how the app routes that URL (full page, redirect, or a
|
|
276
|
+
controller that itself opens the overlay) is the host app's call.
|
|
277
|
+
|
|
278
|
+
### Loading state, themed confirms, and hover hints
|
|
279
|
+
|
|
280
|
+
- [docs/loading-and-confirm.md](docs/loading-and-confirm.md) —
|
|
281
|
+
loading placeholders and themed `data-turbo-confirm`.
|
|
282
|
+
- [docs/hints.md](docs/hints.md) — hover-preview popovers and the
|
|
283
|
+
`+hint` variant template.
|
|
284
|
+
|
|
285
|
+
## Themes
|
|
286
|
+
|
|
287
|
+
| Theme | Modal | Drawer | Popover | Hint | Notes |
|
|
288
|
+
|--------------|:-----:|:------:|:-------:|:----:|-------------------------------------------------------------|
|
|
289
|
+
| `plain` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>`, minimal vanilla CSS |
|
|
290
|
+
| `tailwind` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>`, Tailwind classes |
|
|
291
|
+
| `bootstrap5` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>` wrapping BS5 modal/offcanvas/popover markup |
|
|
292
|
+
| `bootstrap3` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>` wrapping BS3 modal/popover markup; vanilla drawer |
|
|
293
|
+
|
|
294
|
+
Every theme uses the same JavaScript and CSS — only the chrome
|
|
295
|
+
partial Rails renders inside the dialog varies. Animations honor
|
|
296
|
+
`prefers-reduced-motion: reduce`. Stacking is handled by the
|
|
297
|
+
browser's `<dialog>` top layer regardless of theme.
|
|
298
|
+
|
|
299
|
+
## Documentation
|
|
300
|
+
|
|
301
|
+
- [Installation](docs/installation.md) — generator details, bundling
|
|
302
|
+
apps, eject.
|
|
303
|
+
- [Popovers](docs/popovers.md) — per-link positioning, single-popover
|
|
304
|
+
behavior, link targeting inside popovers.
|
|
305
|
+
- [Hints](docs/hints.md) — hover previews, prefetch coordination,
|
|
306
|
+
the `+hint` variant template.
|
|
307
|
+
- [Loading & confirm](docs/loading-and-confirm.md) — loading
|
|
308
|
+
placeholders and themed confirm dialogs.
|
|
309
|
+
- [Customization](docs/customization.md) — chrome partials, variant
|
|
310
|
+
templates, stable ids, the full-page-render footgun.
|
|
311
|
+
- [Third-party form widgets](docs/third-party-widgets.md) — using
|
|
312
|
+
Tom Select, flatpickr, Select2, Tippy inside overlays.
|
|
313
|
+
- [Reference](docs/reference.md) — full configuration, helper
|
|
314
|
+
reference, JavaScript events.
|
|
315
|
+
- [Accessibility](docs/accessibility.md) — what the gem gives you,
|
|
316
|
+
what you provide, known limitations.
|
|
317
|
+
- [Architecture](docs/architecture.md) — request lifecycle, headers,
|
|
318
|
+
hint internals, JS module layout. Optional reading.
|
|
319
|
+
|
|
320
|
+
## Development
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
bundle install
|
|
324
|
+
bundle exec rake test # Ruby suite
|
|
325
|
+
node --test test/js/*.test.js # JS pure-function tests
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## License
|
|
329
|
+
|
|
330
|
+
MIT — see `LICENSE.txt`.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "bundler/gem_tasks"
|
|
2
|
+
require "rake/testtask"
|
|
3
|
+
|
|
4
|
+
Rake::TestTask.new(:test) do |t|
|
|
5
|
+
t.libs << "test"
|
|
6
|
+
t.libs << "lib"
|
|
7
|
+
# Unit tests only — system tests bootstrap a Rails app and run
|
|
8
|
+
# under a separate `rake test:system` task so the dummy app doesn't
|
|
9
|
+
# have to load for every unit-test run.
|
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"].exclude("test/system/**/*")
|
|
11
|
+
t.warning = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
namespace :test do
|
|
15
|
+
Rake::TestTask.new(:system) do |t|
|
|
16
|
+
t.libs << "test"
|
|
17
|
+
t.libs << "lib"
|
|
18
|
+
t.test_files = FileList["test/system/**/*_test.rb"]
|
|
19
|
+
t.warning = false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
namespace :dummy do
|
|
24
|
+
desc "Boot the test/dummy Rails app on localhost so you can poke at it in a real browser (PORT=4001 by default)"
|
|
25
|
+
task :serve do
|
|
26
|
+
ENV["RAILS_ENV"] = "test" # only env the dummy has configured
|
|
27
|
+
require_relative "test/dummy/config/environment"
|
|
28
|
+
require "rack/handler/puma"
|
|
29
|
+
port = ENV.fetch("PORT", "4001").to_i
|
|
30
|
+
puts "→ Dummy app on http://localhost:#{port} (Ctrl-C to stop)"
|
|
31
|
+
Rack::Handler::Puma.run(Rails.application, Port: port, Silent: true)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
task default: :test
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* === Bootstrap-style scaffold (transparent full-viewport <dialog>) === */
|
|
2
|
+
dialog.turbo-overlay-scaffold {
|
|
3
|
+
border: 0; padding: 0; margin: 0; background: transparent;
|
|
4
|
+
max-width: none; max-height: none;
|
|
5
|
+
}
|
|
6
|
+
dialog.turbo-overlay-scaffold::backdrop { background: rgba(0, 0, 0, 0.5); }
|
|
7
|
+
dialog.turbo-overlay-scaffold.turbo-overlay--modal {
|
|
8
|
+
width: 100%; height: 100%; overflow-y: auto;
|
|
9
|
+
}
|
|
10
|
+
dialog.turbo-overlay-scaffold.turbo-overlay--drawer { overflow: hidden; }
|
|
11
|
+
dialog.turbo-overlay-scaffold.turbo-overlay--drawer-right { inset: 0 0 0 auto; height: 100vh; width: 400px; max-width: 100vw; }
|
|
12
|
+
dialog.turbo-overlay-scaffold.turbo-overlay--drawer-left { inset: 0 auto 0 0; height: 100vh; width: 400px; max-width: 100vw; }
|
|
13
|
+
dialog.turbo-overlay-scaffold.turbo-overlay--drawer-top { inset: 0 0 auto 0; width: 100vw; max-height: 50vh; }
|
|
14
|
+
dialog.turbo-overlay-scaffold.turbo-overlay--drawer-bottom { inset: auto 0 0 0; width: 100vw; max-height: 50vh; }
|
|
15
|
+
dialog.turbo-overlay-scaffold .offcanvas {
|
|
16
|
+
position: static; visibility: visible; transform: none;
|
|
17
|
+
width: 100%; height: 100%; max-width: none; max-height: none;
|
|
18
|
+
}
|
|
19
|
+
dialog.turbo-overlay-scaffold .turbo-drawer-panel {
|
|
20
|
+
margin: 0; border-radius: 0; height: 100%; display: flex; flex-direction: column;
|
|
21
|
+
border: 0; box-shadow: 0 0 30px rgba(0, 0, 0, 0.15);
|
|
22
|
+
}
|
|
23
|
+
dialog.turbo-overlay-scaffold .turbo-drawer-body { flex: 1; overflow-y: auto; }
|
|
24
|
+
|
|
25
|
+
/* === Plain theme ===
|
|
26
|
+
Baseline dialog visuals are wrapped in `:where()` so they have
|
|
27
|
+
zero specificity. The plain theme partials (which only set the
|
|
28
|
+
`.turbo-modal` / `.turbo-drawer` / `.turbo-popover` class) still
|
|
29
|
+
pick them up, but tailwind/bootstrap themes that add their own
|
|
30
|
+
classes to the dialog (e.g. `bg-white dark:bg-gray-800`) win
|
|
31
|
+
without a specificity battle — regardless of which stylesheet
|
|
32
|
+
loads later in the cascade.
|
|
33
|
+
*/
|
|
34
|
+
:where(dialog.turbo-modal) {
|
|
35
|
+
border: 0; padding: 0; border-radius: 8px;
|
|
36
|
+
max-width: 32rem; width: 90vw;
|
|
37
|
+
max-height: 90vh;
|
|
38
|
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
|
39
|
+
}
|
|
40
|
+
:where(dialog.turbo-modal)::backdrop { background: rgba(0, 0, 0, 0.5); }
|
|
41
|
+
.turbo-modal__content { display: flex; flex-direction: column; max-height: 90vh; position: relative; }
|
|
42
|
+
.turbo-modal__header {
|
|
43
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
44
|
+
padding: 1rem 1.25rem; border-bottom: 1px solid #e5e7eb;
|
|
45
|
+
flex-shrink: 0;
|
|
46
|
+
}
|
|
47
|
+
.turbo-modal__title { margin: 0; font-size: 1.125rem; font-weight: 600; }
|
|
48
|
+
.turbo-modal__close { background: none; border: 0; font-size: 1.5rem; cursor: pointer; line-height: 1; }
|
|
49
|
+
.turbo-modal__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }
|
|
50
|
+
.turbo-modal__close--floating { position: absolute; top: 0.5rem; right: 0.75rem; z-index: 1; }
|
|
51
|
+
.turbo-modal__body { padding: 1.25rem; flex: 1; overflow-y: auto; }
|
|
52
|
+
.turbo-modal__footer {
|
|
53
|
+
padding: 1rem 1.25rem; border-top: 1px solid #e5e7eb;
|
|
54
|
+
display: flex; justify-content: flex-end; gap: 0.5rem;
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
:where(dialog.turbo-drawer) {
|
|
59
|
+
border: 0; padding: 0; margin: 0; background: white;
|
|
60
|
+
box-shadow: 0 0 30px rgba(0, 0, 0, 0.15);
|
|
61
|
+
max-width: 100vw; max-height: 100vh;
|
|
62
|
+
}
|
|
63
|
+
:where(dialog.turbo-drawer)::backdrop { background: rgba(0, 0, 0, 0.5); }
|
|
64
|
+
:where(dialog.turbo-drawer.turbo-overlay--drawer-right) { inset: 0 0 0 auto; height: 100vh; width: 24rem; }
|
|
65
|
+
:where(dialog.turbo-drawer.turbo-overlay--drawer-left) { inset: 0 auto 0 0; height: 100vh; width: 24rem; }
|
|
66
|
+
:where(dialog.turbo-drawer.turbo-overlay--drawer-top) { inset: 0 0 auto 0; width: 100vw; max-height: 50vh; }
|
|
67
|
+
:where(dialog.turbo-drawer.turbo-overlay--drawer-bottom) { inset: auto 0 0 0; width: 100vw; max-height: 50vh; }
|
|
68
|
+
.turbo-drawer__content { display: flex; flex-direction: column; height: 100%; position: relative; }
|
|
69
|
+
.turbo-drawer__header {
|
|
70
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
71
|
+
padding: 1rem 1.25rem; border-bottom: 1px solid #e5e7eb;
|
|
72
|
+
}
|
|
73
|
+
.turbo-drawer__title { margin: 0; font-size: 1.125rem; font-weight: 600; }
|
|
74
|
+
.turbo-drawer__close { background: none; border: 0; font-size: 1.5rem; cursor: pointer; line-height: 1; }
|
|
75
|
+
.turbo-drawer__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }
|
|
76
|
+
.turbo-drawer__close--floating { position: absolute; top: 0.5rem; right: 0.75rem; z-index: 1; }
|
|
77
|
+
.turbo-drawer__body { padding: 1.25rem; flex: 1; overflow-y: auto; }
|
|
78
|
+
.turbo-drawer__footer {
|
|
79
|
+
padding: 1rem 1.25rem; border-top: 1px solid #e5e7eb;
|
|
80
|
+
display: flex; justify-content: flex-end; gap: 0.5rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* === Popover (anchored to trigger, non-modal) === */
|
|
84
|
+
:where(dialog.turbo-popover) {
|
|
85
|
+
border: 0; padding: 0; margin: 0; background: white;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
88
|
+
max-width: 22rem; max-height: 80vh; overflow: auto;
|
|
89
|
+
}
|
|
90
|
+
/* Popovers opened from inside an open modal dialog use showModal()
|
|
91
|
+
themselves to avoid the parent modal's inertness blocking them.
|
|
92
|
+
Make the resulting ::backdrop transparent so they still look like
|
|
93
|
+
anchored popovers, not modals. The :modal pseudo matches only when
|
|
94
|
+
entered via showModal. */
|
|
95
|
+
dialog.turbo-overlay--popover:modal::backdrop { background: transparent; }
|
|
96
|
+
.turbo-popover__content { display: flex; flex-direction: column; position: relative; }
|
|
97
|
+
.turbo-popover__header {
|
|
98
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
99
|
+
padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb;
|
|
100
|
+
}
|
|
101
|
+
.turbo-popover__title { margin: 0; font-size: 0.95rem; font-weight: 600; }
|
|
102
|
+
.turbo-popover__close { background: none; border: 0; font-size: 1.25rem; cursor: pointer; line-height: 1; }
|
|
103
|
+
.turbo-popover__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }
|
|
104
|
+
.turbo-popover__close--floating { position: absolute; top: 0.25rem; right: 0.5rem; z-index: 1; }
|
|
105
|
+
.turbo-popover__body { padding: 0.75rem; }
|
|
106
|
+
.turbo-popover__footer {
|
|
107
|
+
padding: 0.5rem 0.75rem; border-top: 1px solid #e5e7eb;
|
|
108
|
+
display: flex; justify-content: flex-end; gap: 0.5rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* === Hint (hover-triggered preview, non-modal, non-dialog) === */
|
|
112
|
+
/* Structural rules apply to every hint regardless of theme — JS sets
|
|
113
|
+
top/left via the same anchor math as the popover, and the
|
|
114
|
+
animation/closing classes drive the fade in/out. */
|
|
115
|
+
.turbo-overlay-hint {
|
|
116
|
+
position: fixed; z-index: 1000;
|
|
117
|
+
pointer-events: auto;
|
|
118
|
+
}
|
|
119
|
+
.turbo-overlay-hint[data-state="entering"] { animation: turbo-overlay-fade-in 0.12s ease-out; }
|
|
120
|
+
.turbo-overlay-hint[data-state="leaving"] { animation: turbo-overlay-fade-out 0.12s ease-out forwards; }
|
|
121
|
+
|
|
122
|
+
/* Plain-theme visuals — opt in by adding `turbo-hint` to the chrome
|
|
123
|
+
partial. The tailwind/bootstrap themes drop this class so their
|
|
124
|
+
own utility classes drive background/border/shadow without
|
|
125
|
+
collisions with the gem's plain defaults. (Tailwind v4 wraps its
|
|
126
|
+
utilities in `@layer utilities`; the gem's CSS is unlayered, which
|
|
127
|
+
wins over any layered rule regardless of specificity — including
|
|
128
|
+
`:where()`. Separating classes is the only reliable fix.) */
|
|
129
|
+
.turbo-hint {
|
|
130
|
+
max-width: 20rem; padding: 0.75rem 1rem;
|
|
131
|
+
background: white;
|
|
132
|
+
border: 1px solid #e5e7eb;
|
|
133
|
+
border-radius: 6px;
|
|
134
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
|
135
|
+
font-size: 0.875rem;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* === Animations (apply to every theme via shared classes) === */
|
|
139
|
+
dialog.turbo-overlay--modal[open] { animation: turbo-overlay-fade-in 0.15s ease-out; }
|
|
140
|
+
dialog.turbo-overlay--modal[open]::backdrop { animation: turbo-overlay-backdrop-in 0.15s ease-out; }
|
|
141
|
+
dialog.turbo-overlay--modal.turbo-overlay-closing { animation: turbo-overlay-fade-out 0.15s ease-out forwards; }
|
|
142
|
+
dialog.turbo-overlay--modal.turbo-overlay-closing::backdrop { animation: turbo-overlay-backdrop-out 0.15s ease-out forwards; }
|
|
143
|
+
|
|
144
|
+
dialog.turbo-overlay--drawer-right[open] { animation: turbo-overlay-slide-in-right 0.3s ease-out; }
|
|
145
|
+
dialog.turbo-overlay--drawer-left[open] { animation: turbo-overlay-slide-in-left 0.3s ease-out; }
|
|
146
|
+
dialog.turbo-overlay--drawer-top[open] { animation: turbo-overlay-slide-in-top 0.3s ease-out; }
|
|
147
|
+
dialog.turbo-overlay--drawer-bottom[open] { animation: turbo-overlay-slide-in-bottom 0.3s ease-out; }
|
|
148
|
+
dialog.turbo-overlay--drawer[open]::backdrop { animation: turbo-overlay-backdrop-in 0.3s ease-out; }
|
|
149
|
+
dialog.turbo-overlay--drawer-right.turbo-overlay-closing { animation: turbo-overlay-slide-out-right 0.3s ease-out forwards; }
|
|
150
|
+
dialog.turbo-overlay--drawer-left.turbo-overlay-closing { animation: turbo-overlay-slide-out-left 0.3s ease-out forwards; }
|
|
151
|
+
dialog.turbo-overlay--drawer-top.turbo-overlay-closing { animation: turbo-overlay-slide-out-top 0.3s ease-out forwards; }
|
|
152
|
+
dialog.turbo-overlay--drawer-bottom.turbo-overlay-closing { animation: turbo-overlay-slide-out-bottom 0.3s ease-out forwards; }
|
|
153
|
+
dialog.turbo-overlay--drawer.turbo-overlay-closing::backdrop { animation: turbo-overlay-backdrop-out 0.3s ease-out forwards; }
|
|
154
|
+
|
|
155
|
+
/* Popovers position themselves by writing `transform: translate(...)`
|
|
156
|
+
so the placement stays in sync with compositor-thread scroll. The
|
|
157
|
+
open/close keyframes also use `transform`, so we set
|
|
158
|
+
`animation-composition: add` on the dialog — keyframe transforms
|
|
159
|
+
then compose with (rather than replace) the positioning transform. */
|
|
160
|
+
dialog.turbo-overlay--popover { animation-composition: add; }
|
|
161
|
+
dialog.turbo-overlay--popover:popover-open { animation: turbo-overlay-popover-in 0.12s ease-out; }
|
|
162
|
+
dialog.turbo-overlay--popover.turbo-overlay-closing { animation: turbo-overlay-popover-out 0.12s ease-out forwards; }
|
|
163
|
+
|
|
164
|
+
@keyframes turbo-overlay-fade-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
|
|
165
|
+
@keyframes turbo-overlay-fade-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.97); } }
|
|
166
|
+
@keyframes turbo-overlay-backdrop-in { from { background: rgba(0,0,0,0); } to { background: rgba(0,0,0,0.5); } }
|
|
167
|
+
@keyframes turbo-overlay-backdrop-out { from { background: rgba(0,0,0,0.5); } to { background: rgba(0,0,0,0); } }
|
|
168
|
+
@keyframes turbo-overlay-slide-in-right { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
169
|
+
@keyframes turbo-overlay-slide-in-left { from { transform: translateX(-100%); } to { transform: translateX(0); } }
|
|
170
|
+
@keyframes turbo-overlay-slide-in-top { from { transform: translateY(-100%); } to { transform: translateY(0); } }
|
|
171
|
+
@keyframes turbo-overlay-slide-in-bottom { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
172
|
+
@keyframes turbo-overlay-slide-out-right { from { transform: translateX(0); } to { transform: translateX(100%); } }
|
|
173
|
+
@keyframes turbo-overlay-slide-out-left { from { transform: translateX(0); } to { transform: translateX(-100%); } }
|
|
174
|
+
@keyframes turbo-overlay-slide-out-top { from { transform: translateY(0); } to { transform: translateY(-100%); } }
|
|
175
|
+
@keyframes turbo-overlay-slide-out-bottom { from { transform: translateY(0); } to { transform: translateY(100%); } }
|
|
176
|
+
@keyframes turbo-overlay-popover-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
|
177
|
+
@keyframes turbo-overlay-popover-out { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-4px); } }
|
|
178
|
+
|
|
179
|
+
@media (prefers-reduced-motion: reduce) {
|
|
180
|
+
dialog.turbo-overlay,
|
|
181
|
+
dialog.turbo-overlay::backdrop,
|
|
182
|
+
dialog.turbo-overlay.turbo-overlay-closing,
|
|
183
|
+
dialog.turbo-overlay.turbo-overlay-closing::backdrop,
|
|
184
|
+
.turbo-overlay-hint[data-state] { animation: none !important; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* === Loading state (cloned client-side while a request is in flight) === */
|
|
188
|
+
/* Wrapped in :where() so themes that drop their own classes on the
|
|
189
|
+
loading partial win without specificity battles. */
|
|
190
|
+
:where(.turbo-overlay-loading__body) {
|
|
191
|
+
display: flex; align-items: center; justify-content: center;
|
|
192
|
+
min-height: 8rem; padding: 2rem;
|
|
193
|
+
}
|
|
194
|
+
:where(.turbo-overlay-loading__spinner) {
|
|
195
|
+
display: inline-block;
|
|
196
|
+
width: 1.5rem; height: 1.5rem;
|
|
197
|
+
border: 3px solid currentColor;
|
|
198
|
+
border-right-color: transparent;
|
|
199
|
+
border-radius: 50%;
|
|
200
|
+
opacity: 0.6;
|
|
201
|
+
animation: turbo-overlay-loading-spin 0.8s linear infinite;
|
|
202
|
+
}
|
|
203
|
+
:where(.turbo-overlay-loading__sr-only) {
|
|
204
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
205
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
206
|
+
}
|
|
207
|
+
@keyframes turbo-overlay-loading-spin { to { transform: rotate(360deg); } }
|
|
208
|
+
|
|
209
|
+
/* Drawer loading should fill its panel rather than rely on min-height. */
|
|
210
|
+
:where(dialog.turbo-overlay--drawer.turbo-overlay--loading .turbo-overlay-loading__body) {
|
|
211
|
+
min-height: 100%;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Hint loading sits inside a tooltip-sized chrome — collapse the body's
|
|
215
|
+
default min-height/padding so the spinner doesn't bloat the hint. */
|
|
216
|
+
:where(.turbo-overlay-hint.turbo-overlay--loading .turbo-overlay-loading__body) {
|
|
217
|
+
min-height: 0;
|
|
218
|
+
padding: 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@media (prefers-reduced-motion: reduce) {
|
|
222
|
+
.turbo-overlay-loading__spinner { animation: none !important; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* === Non-modal drawer (opened via dialog.show(), no ::backdrop) === */
|
|
226
|
+
/* Non-modal <dialog> defaults to position: absolute (anchored to the
|
|
227
|
+
document), which breaks the side-pinned inset rules above. Force
|
|
228
|
+
fixed so the drawer stays anchored to the viewport. */
|
|
229
|
+
dialog.turbo-overlay--no-backdrop { position: fixed; }
|
|
230
|
+
|
|
231
|
+
/* === Scroll lock when any modal overlay is open === */
|
|
232
|
+
/* Skip non-backdrop drawers — they're meant to coexist with page
|
|
233
|
+
interaction, including scrolling. */
|
|
234
|
+
html:has(dialog.turbo-overlay[open]:not(.turbo-overlay--no-backdrop)) { overflow: hidden; }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Small DOM helpers shared between the setup module, the per-dialog
|
|
2
|
+
// Stimulus controller, and the hint state machine. Kept pure-DOM (no
|
|
3
|
+
// imports from setup.js or controllers) so any module can depend on
|
|
4
|
+
// it without creating cycles.
|
|
5
|
+
|
|
6
|
+
// Close a <dialog> that may already be closed. The native close()
|
|
7
|
+
// throws InvalidStateError when the dialog isn't open; we strip the
|
|
8
|
+
// `open` attribute as a fallback so the dialog is closed after this
|
|
9
|
+
// returns even when close() raises (e.g. a dialog left with [open]
|
|
10
|
+
// but no top-layer entry, which a browser may refuse to close).
|
|
11
|
+
export function safelyCloseDialog(dialog) {
|
|
12
|
+
if (!dialog) return
|
|
13
|
+
try { dialog.close() } catch (_) { dialog.removeAttribute("open") }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Dismiss a popover-mode dialog. hidePopover() is only available in
|
|
17
|
+
// browsers that support the Popover API, and throws when the dialog
|
|
18
|
+
// isn't showing as a popover. Both are non-fatal — callers want
|
|
19
|
+
// "ensure popover is hidden."
|
|
20
|
+
export function safelyHidePopover(dialog) {
|
|
21
|
+
if (!dialog || typeof dialog.hidePopover !== "function") return
|
|
22
|
+
try { dialog.hidePopover() } catch (_) { /* not currently a popover */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Reset a <dialog>'s positioning styles so popover-positioning math
|
|
26
|
+
// can place it via a single `transform: translate(...)`. Default
|
|
27
|
+
// <dialog> styles (margin: auto, right/bottom set, position relative
|
|
28
|
+
// or absolute depending on open state) and the `dialog:modal` UA
|
|
29
|
+
// `inset: 0` interfere with anchored placement.
|
|
30
|
+
//
|
|
31
|
+
// We pin the dialog at viewport origin (top: 0, left: 0) and carry
|
|
32
|
+
// the actual placement on `transform`. Transforms run on the
|
|
33
|
+
// compositor thread, so they stay in sync with scroll-induced repaint
|
|
34
|
+
// instead of trailing by a frame (which produces a "springy" feel on
|
|
35
|
+
// momentum scrolling). The CSS for popovers sets
|
|
36
|
+
// `animation-composition: add` so the entry/exit keyframes compose
|
|
37
|
+
// with our positioning transform instead of overriding it.
|
|
38
|
+
export function normalizePopoverDialogStyles(dialog) {
|
|
39
|
+
if (!dialog) return
|
|
40
|
+
dialog.style.position = "fixed"
|
|
41
|
+
dialog.style.top = "0"
|
|
42
|
+
dialog.style.left = "0"
|
|
43
|
+
dialog.style.right = "auto"
|
|
44
|
+
dialog.style.bottom = "auto"
|
|
45
|
+
dialog.style.margin = "0"
|
|
46
|
+
}
|