turbo-refresh-animations 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/index.js +2 -0
- package/package.json +38 -0
- package/turbo-refresh-animations.js +539 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FIRSTDRAFT LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# Turbo Refresh Animations
|
|
2
|
+
|
|
3
|
+
Animates elements that enter, exit, or change during [Turbo Page Refreshes](https://turbo.hotwired.dev/handbook/page_refreshes).
|
|
4
|
+
|
|
5
|
+
**Features:**
|
|
6
|
+
|
|
7
|
+
- Opt-in animations via `data-turbo-refresh-animate` attribute.
|
|
8
|
+
- Animates entries, exits, and changes.
|
|
9
|
+
- Protect elements (especially forms) during same-page refresh morphs; the initiator still morphs.
|
|
10
|
+
- Customize animations via CSS classes.
|
|
11
|
+
- Works with importmaps, esbuild, webpack, or any bundler.
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [With importmaps (Rails 7+)](#with-importmaps-rails-7)
|
|
17
|
+
- [With npm/yarn](#with-npmyarn)
|
|
18
|
+
- [Quick Start](#quick-start)
|
|
19
|
+
- [1. Import the library](#1-import-the-library)
|
|
20
|
+
- [2. Add the CSS](#2-add-the-css)
|
|
21
|
+
- [3. Enable morphing in your layout](#3-enable-morphing-in-your-layout)
|
|
22
|
+
- [4. Opt in elements for animations](#4-opt-in-elements-for-animations)
|
|
23
|
+
- [Data Attributes Reference](#data-attributes-reference)
|
|
24
|
+
- [How It Works](#how-it-works)
|
|
25
|
+
- [Change Detection](#change-detection)
|
|
26
|
+
- [Protecting Elements During Same-Page Refreshes](#protecting-elements-during-same-page-refreshes)
|
|
27
|
+
- [`data-turbo-refresh-stream-permanent`](#data-turbo-refresh-stream-permanent)
|
|
28
|
+
- [Form-specific conveniences](#form-specific-conveniences)
|
|
29
|
+
- [Flash protected elements on update](#flash-protected-elements-on-update)
|
|
30
|
+
- [Customization](#customization)
|
|
31
|
+
- [Custom animation classes per element](#custom-animation-classes-per-element)
|
|
32
|
+
- [Enable specific animations](#enable-specific-animations)
|
|
33
|
+
- [Define your own animations](#define-your-own-animations)
|
|
34
|
+
- [Example animations](#example-animations)
|
|
35
|
+
- [Refresh Deduping Notes](#refresh-deduping-notes)
|
|
36
|
+
- [Disabling the Turbo Progress Bar](#disabling-the-turbo-progress-bar)
|
|
37
|
+
- [TODOs](#todos)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### With importmaps (Rails 7+)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bin/importmap pin turbo-refresh-animations
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### With npm/yarn
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install turbo-refresh-animations
|
|
52
|
+
# or
|
|
53
|
+
yarn add turbo-refresh-animations
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Peer dependency: `@hotwired/turbo` >= 8. If you're using Rails with `turbo-rails`, it's already included; otherwise install `@hotwired/turbo` alongside this package.
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### 1. Import the library
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
// app/javascript/application.js
|
|
64
|
+
import "@hotwired/turbo-rails"
|
|
65
|
+
import "turbo-refresh-animations"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Add the CSS
|
|
69
|
+
|
|
70
|
+
Add CSS for the animation classes in your app's stylesheet. This package does not ship CSS. You can copy the example styles from the [Example animations](#example-animations) section or write your own.
|
|
71
|
+
|
|
72
|
+
### 3. Enable morphing in your layout
|
|
73
|
+
|
|
74
|
+
```erb
|
|
75
|
+
<%# app/views/layouts/application.html.erb %>
|
|
76
|
+
<head>
|
|
77
|
+
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
78
|
+
</head>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 4. Opt in elements for animations
|
|
82
|
+
|
|
83
|
+
Add `data-turbo-refresh-animate` and an `id` to elements you want to animate:
|
|
84
|
+
|
|
85
|
+
```erb
|
|
86
|
+
<%# app/views/items/_item.html.erb %>
|
|
87
|
+
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate>
|
|
88
|
+
<%= item.title %>
|
|
89
|
+
</div>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Elements will animate when created, changed, or deleted during Turbo morphs.
|
|
93
|
+
|
|
94
|
+
## Data Attributes Reference
|
|
95
|
+
|
|
96
|
+
| Attribute | Purpose |
|
|
97
|
+
|-----------|---------|
|
|
98
|
+
| `id` | Element identifier (required for enter/change/exit animations) |
|
|
99
|
+
| `data-turbo-refresh-animate` | Opt-in for animations (`=""`/present enables all, `="enter,exit"` enables subset, `="none"` disables) |
|
|
100
|
+
| `data-turbo-refresh-enter="class"` | Custom enter animation class (single class token; no spaces) |
|
|
101
|
+
| `data-turbo-refresh-change="class"` | Custom change animation class (single class token; no spaces) |
|
|
102
|
+
| `data-turbo-refresh-exit="class"` | Custom exit animation class (single class token; no spaces) |
|
|
103
|
+
| `data-turbo-refresh-stream-permanent` | Protect element during same-page refresh morphs (except initiator) |
|
|
104
|
+
| `data-turbo-refresh-version` | Override change detection (used instead of `textContent`, e.g. `item.cache_key_with_version`) |
|
|
105
|
+
|
|
106
|
+
## How It Works
|
|
107
|
+
|
|
108
|
+
The library compares each element's "meaningful signature" before and after Turbo renders a page refresh morph. Elements with both an `id` and the `data-turbo-refresh-animate` attribute will be animated:
|
|
109
|
+
|
|
110
|
+
| Animation | Trigger | Default CSS class |
|
|
111
|
+
|-----------|---------|-------------------|
|
|
112
|
+
| Enter | New element added to DOM | `turbo-refresh-enter` |
|
|
113
|
+
| Change | Element text changes (or version changes) | `turbo-refresh-change` |
|
|
114
|
+
| Exit | Element removed from DOM | `turbo-refresh-exit` |
|
|
115
|
+
|
|
116
|
+
### Change Detection
|
|
117
|
+
|
|
118
|
+
By default, change animations run only when an element's normalized `textContent` differs between the old and new page. This naturally ignores most "noise" that isn't user-visible (CSRF tokens, framework attributes, etc.).
|
|
119
|
+
|
|
120
|
+
Normalization collapses all whitespace to single spaces and trims leading/trailing whitespace.
|
|
121
|
+
|
|
122
|
+
For precise control (and to count non-text changes as meaningful), use `data-turbo-refresh-version`:
|
|
123
|
+
|
|
124
|
+
```erb
|
|
125
|
+
<div id="<%= dom_id(item) %>"
|
|
126
|
+
data-turbo-refresh-animate
|
|
127
|
+
data-turbo-refresh-version="<%= item.cache_key_with_version %>">
|
|
128
|
+
<%= item.title %>
|
|
129
|
+
<%= button_to "Delete", item, method: :delete %>
|
|
130
|
+
</div>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
When `data-turbo-refresh-version` is present, it's used instead of `textContent` to decide whether a change is meaningful. This is useful when:
|
|
134
|
+
|
|
135
|
+
- If you want to respect invisible changes like hidden inputs (e.g. CSRF tokens) or attributes.
|
|
136
|
+
- Elements include dynamic attributes from JavaScript frameworks.
|
|
137
|
+
- You want explicit control over what constitutes a "change".
|
|
138
|
+
|
|
139
|
+
## Protecting Elements During Same-Page Refreshes
|
|
140
|
+
|
|
141
|
+
### `data-turbo-refresh-stream-permanent`
|
|
142
|
+
|
|
143
|
+
When using `broadcasts_refreshes_to` (or any same-page refresh morph), any element with this attribute will be protected from morphing when the refresh comes from elsewhere (e.g., another user's refresh stream). This is useful for any element whose current DOM state you want to preserve during external refreshes.
|
|
144
|
+
|
|
145
|
+
Unlike Turbo's `data-turbo-permanent`, this protection is conditional: the element is allowed to morph when it contains the form submit or same-page link that initiated the refresh, so user-initiated updates still apply.
|
|
146
|
+
|
|
147
|
+
```erb
|
|
148
|
+
<div data-turbo-refresh-stream-permanent>
|
|
149
|
+
<!-- This element won't be morphed during refresh streams -->
|
|
150
|
+
</div>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This protection does not require an `id` (unless you also want the element to participate in enter/change/exit animations).
|
|
154
|
+
|
|
155
|
+
**The most common use case is forms.** Without protection, a user typing in a form would lose their input whenever another user's action triggers a refresh stream:
|
|
156
|
+
|
|
157
|
+
```erb
|
|
158
|
+
<div id="new_item_form" data-turbo-refresh-stream-permanent>
|
|
159
|
+
<%= form_with model: item do |f| %>
|
|
160
|
+
<%= f.text_field :title %>
|
|
161
|
+
<%= f.submit "Add" %>
|
|
162
|
+
<% end %>
|
|
163
|
+
</div>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Form-specific conveniences
|
|
167
|
+
|
|
168
|
+
Since forms are the most common use case, the library includes special handling:
|
|
169
|
+
|
|
170
|
+
1. **Submitter's form still clears**: When a user submits a form inside a protected element, that specific element is allowed to morph normally (so the form clears after submission via the redirect response). Other protected elements remain protected.
|
|
171
|
+
|
|
172
|
+
2. **Same-page refreshes preserve state**: Even during refresh morphs that stay on the same URL (e.g., `redirect_to` back to the current page), elements with `data-turbo-refresh-stream-permanent` stay protected. This preserves user-created UI state like open edit forms. If a user clicks a same‑page link inside a protected element (e.g., "Cancel"), the library sets `data-turbo-action="replace"` on that link so Turbo uses a refresh morph; the initiating element updates while other protected elements remain open.
|
|
173
|
+
- For this behavior, “same page” means the same `origin + pathname + search` (hash ignored).
|
|
174
|
+
- Note: links to an anchor in the current document (e.g. `/lists/1#comments`) are treated as in-page navigation and are not forced into a refresh morph.
|
|
175
|
+
|
|
176
|
+
### Flash protected elements on update
|
|
177
|
+
|
|
178
|
+
To show a visual indicator when a protected element's underlying data changes (e.g., another user edits the same item), add `data-turbo-refresh-version`:
|
|
179
|
+
|
|
180
|
+
```erb
|
|
181
|
+
<div id="<%= dom_id(item) %>"
|
|
182
|
+
data-turbo-refresh-stream-permanent
|
|
183
|
+
data-turbo-refresh-animate
|
|
184
|
+
data-turbo-refresh-version="<%= item.cache_key_with_version %>">
|
|
185
|
+
<%= form_with model: [item.list, item] do |f| %>
|
|
186
|
+
<%= f.text_field :title %>
|
|
187
|
+
<%= f.submit "Save" %>
|
|
188
|
+
<% end %>
|
|
189
|
+
</div>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
When the version changes during an external refresh, the element flashes with the change animation while keeping its current content protected.
|
|
193
|
+
|
|
194
|
+
Note: protected elements can temporarily be in a different "view state" than the server-rendered HTML (e.g., an open edit form vs a read-only item view). To avoid false positives, the library only flashes protected elements based on `data-turbo-refresh-version` from the incoming HTML. In practice, add `data-turbo-refresh-version` to all render variants of a given `id` if you want flashing to work reliably.
|
|
195
|
+
|
|
196
|
+
## Customization
|
|
197
|
+
|
|
198
|
+
### Custom animation classes per element
|
|
199
|
+
|
|
200
|
+
Use a different animation class for specific elements:
|
|
201
|
+
|
|
202
|
+
```erb
|
|
203
|
+
<div id="<%= dom_id(item) %>"
|
|
204
|
+
data-turbo-refresh-animate
|
|
205
|
+
data-turbo-refresh-enter="my-custom-enter"
|
|
206
|
+
data-turbo-refresh-exit="my-custom-exit">
|
|
207
|
+
<!-- Uses my-custom-enter and my-custom-exit instead of the default class names -->
|
|
208
|
+
</div>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Enable specific animations
|
|
212
|
+
|
|
213
|
+
By default, `data-turbo-refresh-animate` enables all three animation types. Specify a comma-separated list to enable only certain types:
|
|
214
|
+
|
|
215
|
+
```erb
|
|
216
|
+
<%# Only animate exits (no enter or change) %>
|
|
217
|
+
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate="exit">
|
|
218
|
+
|
|
219
|
+
<%# Animate enter and exit (no change) %>
|
|
220
|
+
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate="enter,exit">
|
|
221
|
+
|
|
222
|
+
<%# All animations (default) %>
|
|
223
|
+
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Options: `enter`, `exit`, `change`
|
|
227
|
+
|
|
228
|
+
To explicitly disable animations on an element, use `data-turbo-refresh-animate="none"` (or `"false"`). This can be useful when a helper emits the attribute automatically.
|
|
229
|
+
|
|
230
|
+
### Define your own animations
|
|
231
|
+
|
|
232
|
+
You can override the default class names or use custom class names per element.
|
|
233
|
+
|
|
234
|
+
If you already have an existing class you want the library to use by default, CSS doesn't provide true
|
|
235
|
+
"class aliasing", but you can get the same effect:
|
|
236
|
+
|
|
237
|
+
```css
|
|
238
|
+
/* Apply the same rules to both selectors */
|
|
239
|
+
.turbo-refresh-enter,
|
|
240
|
+
.my-enter {
|
|
241
|
+
animation: myEnter 180ms ease-out;
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
If you use Sass/SCSS, you can also do:
|
|
246
|
+
|
|
247
|
+
```scss
|
|
248
|
+
/* Make .turbo-refresh-enter reuse .my-enter rules */
|
|
249
|
+
.turbo-refresh-enter { @extend .my-enter; }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Alternatively, set `data-turbo-refresh-enter="my-enter"` (or `...-change` / `...-exit`) on specific elements.
|
|
253
|
+
|
|
254
|
+
Exit animations can be implemented with CSS transitions (not just keyframes). The exit class should
|
|
255
|
+
change a property with a non-zero transition duration (for example, opacity or transform). The
|
|
256
|
+
element is removed after the transition ends (with a timeout fallback).
|
|
257
|
+
|
|
258
|
+
For predictable timing, use explicit transition properties (e.g., `opacity, transform`) instead of
|
|
259
|
+
`transition-property: all`.
|
|
260
|
+
|
|
261
|
+
#### Example: Background color flash
|
|
262
|
+
|
|
263
|
+
```css
|
|
264
|
+
/* Enter - green background fade */
|
|
265
|
+
@keyframes bg-flash-enter {
|
|
266
|
+
from { background-color: #D1E7DD; }
|
|
267
|
+
to { background-color: inherit; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.bg-flash-enter {
|
|
271
|
+
animation: bg-flash-enter 1.2s ease-out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* Change - yellow background fade */
|
|
275
|
+
@keyframes bg-flash-change {
|
|
276
|
+
from { background-color: #FFF3CD; }
|
|
277
|
+
to { background-color: inherit; }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.bg-flash-change {
|
|
281
|
+
animation: bg-flash-change 1.2s ease-out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* Exit - red background fade + opacity */
|
|
285
|
+
@keyframes bg-flash-exit {
|
|
286
|
+
from { background-color: #F8D7DA; opacity: 1; }
|
|
287
|
+
to { background-color: inherit; opacity: 0; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.bg-flash-exit {
|
|
291
|
+
animation: bg-flash-exit 0.6s ease-out forwards;
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
#### Example: Slide in/out
|
|
296
|
+
|
|
297
|
+
```css
|
|
298
|
+
@keyframes slideInDown {
|
|
299
|
+
from {
|
|
300
|
+
transform: translate3d(0, -100%, 0);
|
|
301
|
+
visibility: visible;
|
|
302
|
+
}
|
|
303
|
+
to {
|
|
304
|
+
transform: translate3d(0, 0, 0);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.slideInDown {
|
|
309
|
+
animation: slideInDown 0.5s ease-out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@keyframes slideOutUp {
|
|
313
|
+
from {
|
|
314
|
+
transform: translate3d(0, 0, 0);
|
|
315
|
+
}
|
|
316
|
+
to {
|
|
317
|
+
visibility: hidden;
|
|
318
|
+
transform: translate3d(0, -100%, 0);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.slideOutUp {
|
|
323
|
+
animation: slideOutUp 0.5s ease-out forwards;
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
##### Masking slide animations
|
|
328
|
+
|
|
329
|
+
Transform-based slide animations can "ghost" over adjacent rows because they paint outside their own box.
|
|
330
|
+
To keep the motion contained, apply `overflow: hidden` on the animated element and animate a child:
|
|
331
|
+
|
|
332
|
+
```erb
|
|
333
|
+
<li data-turbo-refresh-animate
|
|
334
|
+
data-turbo-refresh-enter="slide-mask-enter"
|
|
335
|
+
data-turbo-refresh-exit="slide-mask-exit">
|
|
336
|
+
<div class="slide-mask-content">
|
|
337
|
+
<%= item.title %>
|
|
338
|
+
</div>
|
|
339
|
+
</li>
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
```css
|
|
343
|
+
.slide-mask-enter,
|
|
344
|
+
.slide-mask-exit {
|
|
345
|
+
overflow: hidden;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.slide-mask-enter > .slide-mask-content {
|
|
349
|
+
animation: slideInDown 0.5s ease-out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.slide-mask-exit > .slide-mask-content {
|
|
353
|
+
animation: slideOutUp 0.5s ease-out forwards;
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Use custom classes per element:
|
|
358
|
+
|
|
359
|
+
```erb
|
|
360
|
+
<div id="<%= dom_id(item) %>"
|
|
361
|
+
data-turbo-refresh-animate
|
|
362
|
+
data-turbo-refresh-enter="slideInDown"
|
|
363
|
+
data-turbo-refresh-exit="slideOutUp">
|
|
364
|
+
<%= item.title %>
|
|
365
|
+
</div>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Example animations
|
|
369
|
+
|
|
370
|
+
Copy/paste this example into your app's stylesheet:
|
|
371
|
+
|
|
372
|
+
```css
|
|
373
|
+
/* Enter - fade in */
|
|
374
|
+
.turbo-refresh-enter {
|
|
375
|
+
animation: turbo-refresh-enter 300ms ease-out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@keyframes turbo-refresh-enter {
|
|
379
|
+
from { opacity: 0; }
|
|
380
|
+
to { opacity: 1; }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* Exit - fade out */
|
|
384
|
+
.turbo-refresh-exit {
|
|
385
|
+
animation: turbo-refresh-exit 300ms ease-out forwards;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@keyframes turbo-refresh-exit {
|
|
389
|
+
from { opacity: 1; }
|
|
390
|
+
to { opacity: 0; }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* Change - yellow background flash */
|
|
394
|
+
.turbo-refresh-change {
|
|
395
|
+
animation: turbo-refresh-change 800ms ease-out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
@keyframes turbo-refresh-change {
|
|
399
|
+
from { background-color: #FFF3CD; }
|
|
400
|
+
to { background-color: inherit; }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@media (prefers-reduced-motion: reduce) {
|
|
404
|
+
.turbo-refresh-enter,
|
|
405
|
+
.turbo-refresh-change,
|
|
406
|
+
.turbo-refresh-exit {
|
|
407
|
+
animation-duration: 1ms;
|
|
408
|
+
animation-iteration-count: 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Refresh Deduping Notes
|
|
414
|
+
|
|
415
|
+
You might be worried about the performance of using Turbo Refreshes so heavily, especially when paired with `broadcasts` from models. It's not as bad as you might think, because Turbo does two kinds of refresh deduping:
|
|
416
|
+
|
|
417
|
+
- Backend (Turbo Rails): `broadcasts_refreshes_to` uses `broadcast_refresh_later_to`, which is debounced per stream name + `request_id` on the current thread. Multiple refreshes in quick succession coalesce into the last one. This does not apply to `broadcast_refresh_to`, and it is not a process-wide/global dedupe.
|
|
418
|
+
- Frontend (Turbo Source): refresh stream actions are debounced in the session (default 150ms via `pageRefreshDebouncePeriod`), and refreshes with a `request-id` that matches a recent client request are ignored. The `request-id` is set automatically when you use `Turbo.fetch` (it adds `X-Turbo-Request-Id`).
|
|
419
|
+
|
|
420
|
+
## Disabling the Turbo Progress Bar
|
|
421
|
+
|
|
422
|
+
This library disables the Turbo progress bar during morph operations (but keeps it for regular navigation):
|
|
423
|
+
|
|
424
|
+
```javascript
|
|
425
|
+
document.addEventListener("turbo:morph", () => {
|
|
426
|
+
Turbo.navigator.delegate.adapter.progressBar.hide()
|
|
427
|
+
})
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
See [hotwired/turbo#1221](https://github.com/hotwired/turbo/issues/1221) for discussion on making this configurable in Turbo itself.
|
|
431
|
+
|
|
432
|
+
## TODOs
|
|
433
|
+
|
|
434
|
+
- Expose a hook to set animation parameters before animations run (e.g., to measure `scrollHeight`
|
|
435
|
+
for jQuery UI-style "push siblings" slide animations).
|
|
436
|
+
|
|
437
|
+
## License
|
|
438
|
+
|
|
439
|
+
MIT
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "turbo-refresh-animations",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CSS class-based animations for Turbo page refresh morphs with form protection",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./turbo-refresh-animations.js": "./turbo-refresh-animations.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"turbo-refresh-animations.js"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"turbo",
|
|
17
|
+
"hotwire",
|
|
18
|
+
"rails",
|
|
19
|
+
"morph",
|
|
20
|
+
"animations",
|
|
21
|
+
"turbo-stream",
|
|
22
|
+
"turbo-refresh",
|
|
23
|
+
"broadcasts_refreshes_to"
|
|
24
|
+
],
|
|
25
|
+
"author": "Raghu Betina",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/firstdraft/turbo-refresh-animations.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/firstdraft/turbo-refresh-animations/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/firstdraft/turbo-refresh-animations",
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@hotwired/turbo": ">=8.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// ========== TURBO REFRESH ANIMATIONS ==========
|
|
2
|
+
// Animates elements during Turbo page refresh morphs using Turbo events + before/after signatures
|
|
3
|
+
|
|
4
|
+
const canInstall = typeof window !== "undefined" && typeof document !== "undefined"
|
|
5
|
+
|
|
6
|
+
if (canInstall && !window.TurboRefreshAnimationsInstalled) {
|
|
7
|
+
window.TurboRefreshAnimationsInstalled = true
|
|
8
|
+
|
|
9
|
+
let lastRenderedPathname = window.location.pathname
|
|
10
|
+
let pendingVisitPathname = null
|
|
11
|
+
let pendingVisitIsReplace = false
|
|
12
|
+
let shouldAnimateAfterRender = false
|
|
13
|
+
let signaturesBefore = new Map()
|
|
14
|
+
const animationClassCleanupTimers = new WeakMap()
|
|
15
|
+
|
|
16
|
+
// ========== FORM PROTECTION ==========
|
|
17
|
+
// Protect elements with data-turbo-refresh-stream-permanent during same-page refresh
|
|
18
|
+
// morphs ("page refreshes"). Allow the initiating element
|
|
19
|
+
// (form submit / link click inside it) to morph so user-intended updates apply.
|
|
20
|
+
|
|
21
|
+
let submittingPermanentEl = null
|
|
22
|
+
let pendingVisitingPermanentEl = null
|
|
23
|
+
let pendingVisitingPermanentUrl = null
|
|
24
|
+
let pendingVisitingPermanentAtMs = 0
|
|
25
|
+
let pendingVisitingPermanentClearTimer = null
|
|
26
|
+
let visitingPermanentEl = null
|
|
27
|
+
|
|
28
|
+
function clearPendingVisitingPermanent() {
|
|
29
|
+
pendingVisitingPermanentEl = null
|
|
30
|
+
pendingVisitingPermanentUrl = null
|
|
31
|
+
pendingVisitingPermanentAtMs = 0
|
|
32
|
+
|
|
33
|
+
if (pendingVisitingPermanentClearTimer) {
|
|
34
|
+
window.clearTimeout(pendingVisitingPermanentClearTimer)
|
|
35
|
+
pendingVisitingPermanentClearTimer = null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hideTurboProgressBar() {
|
|
40
|
+
const progressBar = window.Turbo?.navigator?.delegate?.adapter?.progressBar
|
|
41
|
+
progressBar?.hide?.()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
document.addEventListener("turbo:morph", hideTurboProgressBar)
|
|
45
|
+
|
|
46
|
+
document.addEventListener("turbo:submit-start", (event) => {
|
|
47
|
+
const wrapper = event.target.closest("[data-turbo-refresh-stream-permanent]")
|
|
48
|
+
submittingPermanentEl = wrapper || null
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
document.addEventListener("turbo:submit-end", (event) => {
|
|
52
|
+
const contentType = event.detail?.fetchResponse?.contentType || ""
|
|
53
|
+
if (contentType.startsWith("text/vnd.turbo-stream.html")) {
|
|
54
|
+
submittingPermanentEl = null
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
document.addEventListener("turbo:click", (event) => {
|
|
59
|
+
const wrapper = event.target.closest("[data-turbo-refresh-stream-permanent]")
|
|
60
|
+
const link = event.target.closest("a[href]")
|
|
61
|
+
const clickUrl = event.detail?.url || link?.href || null
|
|
62
|
+
|
|
63
|
+
if (!wrapper || !link || !clickUrl) return
|
|
64
|
+
|
|
65
|
+
const samePage = visitKeyForUrl(clickUrl) === visitKeyForUrl(window.location.href)
|
|
66
|
+
const turboDisabled = link.getAttribute("data-turbo") === "false"
|
|
67
|
+
const hasTurboAction = link.hasAttribute("data-turbo-action")
|
|
68
|
+
const hasTurboMethod = link.hasAttribute("data-turbo-method")
|
|
69
|
+
const hasTurboStream = link.hasAttribute("data-turbo-stream")
|
|
70
|
+
const target = link.getAttribute("target")
|
|
71
|
+
|
|
72
|
+
// When a link points to the current document but includes an anchor (e.g. /path#section),
|
|
73
|
+
// don't force it into a "replace" refresh visit. That breaks expected anchor scrolling,
|
|
74
|
+
// especially with turbo-refresh-scroll=preserve. Let the browser handle it.
|
|
75
|
+
let clickHasAnchor = false
|
|
76
|
+
if (samePage) {
|
|
77
|
+
try {
|
|
78
|
+
clickHasAnchor = new URL(clickUrl, document.baseURI).hash !== ""
|
|
79
|
+
} catch {
|
|
80
|
+
clickHasAnchor = false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (samePage && clickHasAnchor && !turboDisabled && !hasTurboAction && !hasTurboMethod && !hasTurboStream && target !== "_blank") {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pendingVisitingPermanentEl = wrapper
|
|
90
|
+
pendingVisitingPermanentUrl = clickUrl
|
|
91
|
+
pendingVisitingPermanentAtMs = Date.now()
|
|
92
|
+
|
|
93
|
+
if (pendingVisitingPermanentClearTimer) {
|
|
94
|
+
window.clearTimeout(pendingVisitingPermanentClearTimer)
|
|
95
|
+
}
|
|
96
|
+
pendingVisitingPermanentClearTimer = window.setTimeout(clearPendingVisitingPermanent, 2000)
|
|
97
|
+
|
|
98
|
+
if (samePage && !turboDisabled && !hasTurboAction && !hasTurboMethod && !hasTurboStream && target !== "_blank") {
|
|
99
|
+
link.dataset.turboAction = "replace"
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
document.addEventListener("turbo:visit", (event) => {
|
|
104
|
+
pendingVisitPathname = pathnameForUrl(event.detail.url)
|
|
105
|
+
pendingVisitIsReplace = event.detail.action === "replace"
|
|
106
|
+
|
|
107
|
+
const ageMs = Date.now() - pendingVisitingPermanentAtMs
|
|
108
|
+
const pendingKey = visitKeyForUrl(pendingVisitingPermanentUrl)
|
|
109
|
+
const visitKey = visitKeyForUrl(event.detail.url)
|
|
110
|
+
const urlsMatch = pendingKey && visitKey && pendingKey === visitKey
|
|
111
|
+
visitingPermanentEl = ageMs >= 0 && ageMs < 2000 && urlsMatch ? pendingVisitingPermanentEl : null
|
|
112
|
+
clearPendingVisitingPermanent()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
function clearPendingVisit() {
|
|
116
|
+
pendingVisitPathname = null
|
|
117
|
+
pendingVisitIsReplace = false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
121
|
+
document.querySelectorAll("[data-turbo-refresh-animate]").forEach(el => {
|
|
122
|
+
const timers = animationClassCleanupTimers.get(el)
|
|
123
|
+
if (timers) {
|
|
124
|
+
for (const timer of timers.values()) {
|
|
125
|
+
window.clearTimeout(timer)
|
|
126
|
+
}
|
|
127
|
+
timers.clear()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const animationClasses = new Set([
|
|
131
|
+
"turbo-refresh-enter",
|
|
132
|
+
"turbo-refresh-change",
|
|
133
|
+
"turbo-refresh-exit",
|
|
134
|
+
el.getAttribute("data-turbo-refresh-enter"),
|
|
135
|
+
el.getAttribute("data-turbo-refresh-change"),
|
|
136
|
+
el.getAttribute("data-turbo-refresh-exit")
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
for (const className of animationClasses) {
|
|
140
|
+
if (className) el.classList.remove(className)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function getAnimationClass(el, animType, defaultClass) {
|
|
146
|
+
const animateValue = el.getAttribute("data-turbo-refresh-animate")
|
|
147
|
+
|
|
148
|
+
// data-turbo-refresh-animate semantics:
|
|
149
|
+
// - Absent => disabled
|
|
150
|
+
// - "none"/"false" => disabled
|
|
151
|
+
// - "" (present but empty) => all enabled
|
|
152
|
+
// - Comma list containing any of enter/exit/change => only the recognized subset enabled
|
|
153
|
+
// - Any other value (including common Rails helper "true") => all enabled
|
|
154
|
+
if (animateValue === null) return null
|
|
155
|
+
|
|
156
|
+
const normalized = animateValue.trim().toLowerCase()
|
|
157
|
+
if (normalized === "none" || normalized === "false") return null
|
|
158
|
+
|
|
159
|
+
if (normalized !== "") {
|
|
160
|
+
const enabledTypes = animateValue
|
|
161
|
+
.split(",")
|
|
162
|
+
.map(s => s.trim().toLowerCase())
|
|
163
|
+
.filter(type => type === "enter" || type === "exit" || type === "change")
|
|
164
|
+
|
|
165
|
+
if (enabledTypes.length > 0 && !enabledTypes.includes(animType)) return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check for custom class via data-turbo-refresh-{type}="my-class"
|
|
169
|
+
const customClass = el.getAttribute(`data-turbo-refresh-${animType}`)
|
|
170
|
+
return customClass || defaultClass
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function applyAnimation(el, defaultClass) {
|
|
174
|
+
// Extract animation type from class name (e.g., "turbo-refresh-enter" -> "enter")
|
|
175
|
+
const animType = defaultClass.replace("turbo-refresh-", "")
|
|
176
|
+
const animClass = getAnimationClass(el, animType, defaultClass)
|
|
177
|
+
if (!animClass) return
|
|
178
|
+
|
|
179
|
+
if (el.classList.contains(animClass)) {
|
|
180
|
+
el.classList.remove(animClass)
|
|
181
|
+
// Force a reflow so the same animation class can retrigger.
|
|
182
|
+
void el.offsetWidth
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
el.classList.add(animClass)
|
|
186
|
+
|
|
187
|
+
let timers = animationClassCleanupTimers.get(el)
|
|
188
|
+
if (!timers) {
|
|
189
|
+
timers = new Map()
|
|
190
|
+
animationClassCleanupTimers.set(el, timers)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const existingTimer = timers.get(animClass)
|
|
194
|
+
if (existingTimer) window.clearTimeout(existingTimer)
|
|
195
|
+
|
|
196
|
+
const waitMs = maxWaitMsForAnimationOrTransition(el)
|
|
197
|
+
if (waitMs === 0) {
|
|
198
|
+
el.classList.remove(animClass)
|
|
199
|
+
timers.delete(animClass)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const timer = window.setTimeout(() => {
|
|
204
|
+
el.classList.remove(animClass)
|
|
205
|
+
const currentTimers = animationClassCleanupTimers.get(el)
|
|
206
|
+
currentTimers?.delete(animClass)
|
|
207
|
+
}, waitMs)
|
|
208
|
+
timers.set(animClass, timer)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizedTextContent(el) {
|
|
212
|
+
return (el.textContent || "").replace(/\s+/g, " ").trim()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function meaningfulUpdateSignature(el) {
|
|
216
|
+
const version = el.getAttribute("data-turbo-refresh-version")
|
|
217
|
+
if (version !== null) return `v:${version}`
|
|
218
|
+
return `t:${normalizedTextContent(el)}`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function visitKeyForUrl(url) {
|
|
222
|
+
try {
|
|
223
|
+
const parsed = new URL(url, document.baseURI)
|
|
224
|
+
return `${parsed.origin}${parsed.pathname}${parsed.search}`
|
|
225
|
+
} catch {
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pathnameForUrl(url) {
|
|
231
|
+
try {
|
|
232
|
+
return new URL(url, document.baseURI).pathname
|
|
233
|
+
} catch {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isPageRefreshVisit() {
|
|
239
|
+
if (!pendingVisitIsReplace) return false
|
|
240
|
+
if (!pendingVisitPathname || !lastRenderedPathname) return false
|
|
241
|
+
|
|
242
|
+
return pendingVisitPathname === lastRenderedPathname
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseCssTimeMs(value) {
|
|
246
|
+
const trimmed = value.trim()
|
|
247
|
+
if (trimmed === "") return 0
|
|
248
|
+
if (trimmed.endsWith("ms")) return Number.parseFloat(trimmed)
|
|
249
|
+
if (trimmed.endsWith("s")) return Number.parseFloat(trimmed) * 1000
|
|
250
|
+
const fallback = Number.parseFloat(trimmed)
|
|
251
|
+
return Number.isFinite(fallback) ? fallback * 1000 : 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseCssTimeListMs(value) {
|
|
255
|
+
return value
|
|
256
|
+
.split(",")
|
|
257
|
+
.map(part => parseCssTimeMs(part))
|
|
258
|
+
.filter(Number.isFinite)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseCssNumberList(value) {
|
|
262
|
+
return value
|
|
263
|
+
.split(",")
|
|
264
|
+
.map(part => {
|
|
265
|
+
const trimmed = part.trim()
|
|
266
|
+
if (trimmed === "infinite") return 1
|
|
267
|
+
const num = Number.parseFloat(trimmed)
|
|
268
|
+
return Number.isFinite(num) ? num : 1
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function maxTimingMs(durationsMs, delaysMs, iterations) {
|
|
273
|
+
const count = Math.max(durationsMs.length, delaysMs.length, iterations.length)
|
|
274
|
+
if (count === 0) return 0
|
|
275
|
+
|
|
276
|
+
let maxMs = 0
|
|
277
|
+
for (let i = 0; i < count; i++) {
|
|
278
|
+
const duration = durationsMs[i % durationsMs.length] || 0
|
|
279
|
+
const delay = delaysMs[i % delaysMs.length] || 0
|
|
280
|
+
const iteration = iterations[i % iterations.length] || 1
|
|
281
|
+
maxMs = Math.max(maxMs, delay + duration * iteration)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return maxMs
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function expectedAnimationEndCount(el) {
|
|
288
|
+
const style = window.getComputedStyle(el)
|
|
289
|
+
if (!style.animationName || style.animationName === "none") return 0
|
|
290
|
+
|
|
291
|
+
const names = style.animationName.split(",").map(name => name.trim())
|
|
292
|
+
const durationsMs = parseCssTimeListMs(style.animationDuration)
|
|
293
|
+
const iterations = parseCssNumberList(style.animationIterationCount)
|
|
294
|
+
|
|
295
|
+
let count = 0
|
|
296
|
+
for (let i = 0; i < names.length; i++) {
|
|
297
|
+
const name = names[i]
|
|
298
|
+
if (!name || name === "none") continue
|
|
299
|
+
const duration = durationsMs[i % durationsMs.length] || 0
|
|
300
|
+
const iteration = iterations[i % iterations.length] || 1
|
|
301
|
+
if (duration > 0 && iteration > 0) count += 1
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return count
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function expectedTransitionEndCount(el) {
|
|
308
|
+
const style = window.getComputedStyle(el)
|
|
309
|
+
if (!style.transitionProperty || style.transitionProperty === "none") return 0
|
|
310
|
+
|
|
311
|
+
const properties = style.transitionProperty.split(",").map(prop => prop.trim())
|
|
312
|
+
// "all" means we can't know which properties will change, so we can't reliably
|
|
313
|
+
// predict how many transitionend events will fire. Fall back to the timeout.
|
|
314
|
+
if (properties.some(prop => prop.toLowerCase() === "all")) return 0
|
|
315
|
+
|
|
316
|
+
const durationsMs = parseCssTimeListMs(style.transitionDuration)
|
|
317
|
+
|
|
318
|
+
let count = 0
|
|
319
|
+
for (let i = 0; i < properties.length; i++) {
|
|
320
|
+
const name = properties[i]
|
|
321
|
+
if (!name || name === "none") continue
|
|
322
|
+
const duration = durationsMs[i % durationsMs.length] || 0
|
|
323
|
+
if (duration > 0) count += 1
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return count
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function maxWaitMsForAnimationOrTransition(el) {
|
|
330
|
+
const style = window.getComputedStyle(el)
|
|
331
|
+
let maxMs = 0
|
|
332
|
+
|
|
333
|
+
if (style.animationName && style.animationName !== "none") {
|
|
334
|
+
maxMs = Math.max(
|
|
335
|
+
maxMs,
|
|
336
|
+
maxTimingMs(
|
|
337
|
+
parseCssTimeListMs(style.animationDuration),
|
|
338
|
+
parseCssTimeListMs(style.animationDelay),
|
|
339
|
+
parseCssNumberList(style.animationIterationCount)
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (style.transitionProperty && style.transitionProperty !== "none") {
|
|
345
|
+
maxMs = Math.max(
|
|
346
|
+
maxMs,
|
|
347
|
+
maxTimingMs(
|
|
348
|
+
parseCssTimeListMs(style.transitionDuration),
|
|
349
|
+
parseCssTimeListMs(style.transitionDelay),
|
|
350
|
+
[1]
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return maxMs > 0 ? maxMs + 50 : 0
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function animateAndRemove(el, animClass) {
|
|
359
|
+
return new Promise(resolve => {
|
|
360
|
+
let finished = false
|
|
361
|
+
let timer = null
|
|
362
|
+
let endedCount = 0
|
|
363
|
+
let expectedEnds = 0
|
|
364
|
+
|
|
365
|
+
const finish = () => {
|
|
366
|
+
if (finished) return
|
|
367
|
+
finished = true
|
|
368
|
+
|
|
369
|
+
if (timer) clearTimeout(timer)
|
|
370
|
+
el.removeEventListener("animationend", onEnd)
|
|
371
|
+
el.removeEventListener("animationcancel", onCancel)
|
|
372
|
+
el.removeEventListener("transitionend", onTransitionEnd)
|
|
373
|
+
el.removeEventListener("transitioncancel", onCancel)
|
|
374
|
+
|
|
375
|
+
el.remove()
|
|
376
|
+
resolve()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const onEnd = (event) => {
|
|
380
|
+
if (event.target !== el) return
|
|
381
|
+
endedCount += 1
|
|
382
|
+
if (expectedEnds > 0 && endedCount >= expectedEnds) {
|
|
383
|
+
finish()
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const onCancel = (event) => {
|
|
388
|
+
if (event.target !== el) return
|
|
389
|
+
finish()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const onTransitionEnd = (event) => {
|
|
393
|
+
if (event.target !== el) return
|
|
394
|
+
endedCount += 1
|
|
395
|
+
if (expectedEnds > 0 && endedCount >= expectedEnds) {
|
|
396
|
+
finish()
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
el.addEventListener("animationend", onEnd)
|
|
401
|
+
el.addEventListener("animationcancel", onCancel)
|
|
402
|
+
el.addEventListener("transitionend", onTransitionEnd)
|
|
403
|
+
el.addEventListener("transitioncancel", onCancel)
|
|
404
|
+
|
|
405
|
+
el.classList.add(animClass)
|
|
406
|
+
|
|
407
|
+
expectedEnds = expectedAnimationEndCount(el) + expectedTransitionEndCount(el)
|
|
408
|
+
const waitMs = maxWaitMsForAnimationOrTransition(el)
|
|
409
|
+
|
|
410
|
+
if (expectedEnds === 0 && waitMs === 0) {
|
|
411
|
+
finish()
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
timer = setTimeout(finish, waitMs > 0 ? waitMs : 2000)
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Handle morphing: protect permanent elements, animate deletes
|
|
420
|
+
document.addEventListener("turbo:before-morph-element", (event) => {
|
|
421
|
+
const currentEl = event.target
|
|
422
|
+
const newEl = event.detail.newElement
|
|
423
|
+
|
|
424
|
+
// Protect permanent elements:
|
|
425
|
+
// - During same-page refresh morphs (preserve user state like open forms)
|
|
426
|
+
// - EXCEPT the element initiating the refresh (form submit or link click within it)
|
|
427
|
+
if (currentEl.hasAttribute("data-turbo-refresh-stream-permanent")) {
|
|
428
|
+
// If the element is being removed, allow Turbo to remove it normally.
|
|
429
|
+
if (newEl === undefined) return
|
|
430
|
+
|
|
431
|
+
const isSubmitting = currentEl === submittingPermanentEl
|
|
432
|
+
const isVisiting = currentEl === visitingPermanentEl
|
|
433
|
+
const isInitiator = isSubmitting || isVisiting
|
|
434
|
+
const shouldProtect = !isInitiator && shouldAnimateAfterRender
|
|
435
|
+
|
|
436
|
+
if (shouldProtect) {
|
|
437
|
+
event.preventDefault()
|
|
438
|
+
|
|
439
|
+
// Flash protected elements on meaningful updates, based on data-turbo-refresh-version.
|
|
440
|
+
// This avoids false positives from view-state differences (e.g., open form vs read-only).
|
|
441
|
+
if (currentEl.hasAttribute("data-turbo-refresh-animate") && currentEl.hasAttribute("data-turbo-refresh-version")) {
|
|
442
|
+
const oldVersion = currentEl.getAttribute("data-turbo-refresh-version")
|
|
443
|
+
const newVersion = newEl.getAttribute("data-turbo-refresh-version")
|
|
444
|
+
if (newVersion !== null && oldVersion !== newVersion) {
|
|
445
|
+
currentEl.setAttribute("data-turbo-refresh-version", newVersion)
|
|
446
|
+
if (currentEl.id) {
|
|
447
|
+
signaturesBefore.set(currentEl.id, meaningfulUpdateSignature(currentEl))
|
|
448
|
+
}
|
|
449
|
+
applyAnimation(currentEl, "turbo-refresh-change")
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// Before render: detect deletions and animate BEFORE morph
|
|
458
|
+
document.addEventListener("turbo:before-render", async (event) => {
|
|
459
|
+
if (!event.detail.newBody) {
|
|
460
|
+
clearPendingVisit()
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
signaturesBefore = new Map()
|
|
465
|
+
|
|
466
|
+
shouldAnimateAfterRender = isPageRefreshVisit()
|
|
467
|
+
clearPendingVisit()
|
|
468
|
+
if (!shouldAnimateAfterRender) {
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const animatedElements = Array.from(document.querySelectorAll("[data-turbo-refresh-animate][id]"))
|
|
473
|
+
animatedElements.forEach(el => {
|
|
474
|
+
signaturesBefore.set(el.id, meaningfulUpdateSignature(el))
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
let shouldResume = false
|
|
478
|
+
|
|
479
|
+
// Detect elements that will be deleted
|
|
480
|
+
const deletions = animatedElements.filter(el => {
|
|
481
|
+
return !event.detail.newBody.querySelector(`#${CSS.escape(el.id)}`)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// If there are deletions, animate them BEFORE the morph
|
|
485
|
+
if (deletions.length > 0) {
|
|
486
|
+
// Filter to only elements that want exit animation and get their classes
|
|
487
|
+
const candidateDeletions = deletions
|
|
488
|
+
.map(el => ({ el, exitClass: getAnimationClass(el, "exit", "turbo-refresh-exit") }))
|
|
489
|
+
.filter(({ exitClass }) => exitClass)
|
|
490
|
+
|
|
491
|
+
if (candidateDeletions.length > 0) {
|
|
492
|
+
event.preventDefault()
|
|
493
|
+
shouldResume = true
|
|
494
|
+
|
|
495
|
+
const candidateSet = new Set(candidateDeletions.map(({ el }) => el))
|
|
496
|
+
const topLevelDeletions = candidateDeletions.filter(({ el }) => {
|
|
497
|
+
let parent = el.parentElement
|
|
498
|
+
while (parent) {
|
|
499
|
+
if (candidateSet.has(parent)) return false
|
|
500
|
+
parent = parent.parentElement
|
|
501
|
+
}
|
|
502
|
+
return true
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
await Promise.all(topLevelDeletions.map(({ el, exitClass }) => animateAndRemove(el, exitClass)))
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (shouldResume) {
|
|
510
|
+
event.detail.resume()
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
document.addEventListener("turbo:render", () => {
|
|
515
|
+
lastRenderedPathname = window.location.pathname
|
|
516
|
+
submittingPermanentEl = null
|
|
517
|
+
visitingPermanentEl = null
|
|
518
|
+
clearPendingVisit()
|
|
519
|
+
|
|
520
|
+
if (shouldAnimateAfterRender) {
|
|
521
|
+
document.querySelectorAll("[data-turbo-refresh-animate][id]").forEach(el => {
|
|
522
|
+
const beforeSignature = signaturesBefore.get(el.id)
|
|
523
|
+
if (beforeSignature === undefined) {
|
|
524
|
+
applyAnimation(el, "turbo-refresh-enter")
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const afterSignature = meaningfulUpdateSignature(el)
|
|
529
|
+
if (beforeSignature !== afterSignature) {
|
|
530
|
+
applyAnimation(el, "turbo-refresh-change")
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
shouldAnimateAfterRender = false
|
|
536
|
+
signaturesBefore = new Map()
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
}
|