Skip to content

Commit de7df64

Browse files
feat: support transitions
1 parent b6cf14d commit de7df64

File tree

12 files changed

+357
-3
lines changed

12 files changed

+357
-3
lines changed

docs/source/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ polling
4040
visibility
4141
messages
4242
pagination
43+
transitions
4344
```
4445

4546
```{toctree}

docs/source/transitions.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Transitions
2+
3+
Transitions allow you to add CSS animations when elements are added to or removed from the DOM. This is particularly useful for modals, menus, and other UI elements that should fade or slide into place.
4+
5+
`Unicorn` follows the same transition pattern as Vue and Alpine.js, using six lifecycle stages defined by data attributes.
6+
7+
## Attributes
8+
9+
There are six attributes that can be used to define a transition:
10+
11+
- `u:transition:enter`: Classes applied during the entire entering phase.
12+
- `u:transition:enter-start`: Classes applied at the start of the entering phase. Removed after one frame.
13+
- `u:transition:enter-end`: Classes applied at the end of the entering phase.
14+
- `u:transition:leave`: Classes applied during the entire leaving phase.
15+
- `u:transition:leave-start`: Classes applied at the start of the leaving phase.
16+
- `u:transition:leave-end`: Classes applied at the end of the leaving phase.
17+
18+
Alternatively, you can use the `unicorn:` prefix instead of `u:`.
19+
20+
## Example
21+
22+
The following example uses Tailwind CSS classes to create a fade and scale transition.
23+
24+
```html
25+
<div>
26+
<button unicorn:click="toggle">Toggle</button>
27+
28+
{% if show %}
29+
<div u:transition:enter="transition ease-out duration-300"
30+
u:transition:enter-start="opacity-0 transform scale-90"
31+
u:transition:enter-end="opacity-100 transform scale-100"
32+
u:transition:leave="transition ease-in duration-300"
33+
u:transition:leave-start="opacity-100 transform scale-100"
34+
u:transition:leave-end="opacity-0 transform scale-90"
35+
class="bg-gray-100 p-4 mt-2">
36+
Transitional Content
37+
</div>
38+
{% endif %}
39+
</div>
40+
```
41+
42+
## How it works
43+
44+
When an element with a transition is added to the DOM:
45+
1. `u:transition:enter` and `u:transition:enter-start` classes are added.
46+
2. After one frame, `u:transition:enter-start` is removed and `u:transition:enter-end` is added.
47+
3. Once the transition finishes, `u:transition:enter` and `u:transition:enter-end` are removed.
48+
49+
When an element with a transition is removed from the DOM:
50+
1. `u:transition:leave` and `u:transition:leave-start` classes are added.
51+
2. After one frame, `u:transition:leave-start` is removed and `u:transition:leave-end` is added.
52+
3. `Unicorn` waits for the transition to finish (`element.getAnimations()`) before finally removing the element from the DOM.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django_unicorn.components import UnicornView
2+
3+
class TransitionView(UnicornView):
4+
show = False
5+
6+
def toggle(self):
7+
self.show = not self.show
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div>
2+
<button unicorn:click="toggle">Toggle</button>
3+
4+
{% if show %}
5+
<div u:transition:enter="transition ease-out duration-300"
6+
u:transition:enter-start="opacity-0 transform scale-90"
7+
u:transition:enter-end="opacity-100 transform scale-100"
8+
u:transition:leave="transition ease-in duration-300"
9+
u:transition:leave-start="opacity-100 transform scale-100"
10+
u:transition:leave-end="opacity-0 transform scale-90"
11+
style="background: #f0f0f0; padding: 20px; margin-top: 10px;">
12+
Transitional Content
13+
</div>
14+
{% endif %}
15+
</div>

example/www/templates/www/base.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
#menu li:first-child {
2121
border-left: none !important;
2222
}
23-
2423
</style>
2524

2625
<script defer src="https://unpkg.com/@alpinejs/morph@3.x.x/dist/cdn.min.js"></script>
@@ -48,11 +47,12 @@ <h1>django-unicorn</h1>
4847
<li><a href="{% url 'www:direct-view' %}">Direct View</a></li>
4948
<li><a href="{% url 'www:redirects' %}">Redirects</a></li>
5049
<li><a href="{% url 'www:template' 'wizard' %}">Wizard</a></li>
50+
<li><a href="{% url 'www:template' 'transition' %}">Transitions</a></li>
5151
</ul>
5252

5353
{% block content %}{% endblock content %}
5454

5555
</main>
5656
</body>
5757

58-
</html>
58+
</html>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{% extends "www/base.html" %}
2+
{% load unicorn %}
3+
4+
{% block content %}
5+
<div class="container">
6+
<div class="row">
7+
<div class="col">
8+
<h1>Transitions</h1>
9+
{% unicorn 'transition' %}
10+
</div>
11+
</div>
12+
</div>
13+
{% endblock %}

src/django_unicorn/static/unicorn/js/attribute.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class Attribute {
2020
this.isError = false;
2121
this.modifiers = {};
2222
this.eventType = null;
23+
this.isTransition = false
2324

2425
this.init();
2526
}
@@ -52,6 +53,8 @@ export class Attribute {
5253
this.isKey = true;
5354
} else if (contains(this.name, ":error:")) {
5455
this.isError = true;
56+
} else if (contains(this.name, ":transition")) {
57+
this.isTransition = true;
5558
} else {
5659
const actionEventType = this.name
5760
.replace("unicorn:", "")

src/django_unicorn/static/unicorn/js/element.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class Element {
3535
this.key = null;
3636
this.events = [];
3737
this.errors = [];
38+
this.transitions = {};
3839

3940
if (!this.el.attributes) {
4041
return;
@@ -156,6 +157,17 @@ export class Element {
156157
this.key = attribute.value;
157158
}
158159

160+
if (attribute.isTransition) {
161+
let transitionType = attribute.name.replace("unicorn:transition:", "").replace("u:transition:", "");
162+
163+
// If no type is specified, default to "enter" (though usually it's explicitly specified)
164+
if (transitionType === "unicorn:transition" || transitionType === "u:transition") {
165+
transitionType = "enter";
166+
}
167+
168+
this.transitions[transitionType] = attribute.value;
169+
}
170+
159171
if (attribute.isError) {
160172
const code = attribute.name.replace("unicorn:error:", "");
161173
this.errors.push({ code, message: attribute.value });

src/django_unicorn/static/unicorn/js/morphers/morphdom.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import morphdom from "../morphdom/2.6.1/morphdom.js";
2+
import { Transition } from "../transition.js";
3+
import { Element as UnicornElement } from "../element.js";
24

35
export class MorphdomMorpher {
46
constructor(options) {
@@ -57,6 +59,10 @@ export class MorphdomMorpher {
5759
return true;
5860
},
5961
onNodeAdded(node) {
62+
if (node.nodeType === Node.ELEMENT_NODE) {
63+
Transition.enter(node);
64+
}
65+
6066
if (reloadScriptElements) {
6167
if (node.nodeName === "SCRIPT") {
6268
// https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769
@@ -71,6 +77,21 @@ export class MorphdomMorpher {
7177
}
7278
}
7379
},
80+
onBeforeNodeDiscarded(node) {
81+
if (node.nodeType === Node.ELEMENT_NODE) {
82+
const element = new UnicornElement(node);
83+
84+
if (element.transitions.leave) {
85+
Transition.leave(node).then(() => {
86+
node.remove();
87+
});
88+
89+
return false;
90+
}
91+
}
92+
93+
return true;
94+
},
7495
};
7596
}
7697
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Element as UnicornElement } from "./element.js";
2+
3+
/**
4+
* Handles CSS transitions for an element.
5+
*/
6+
export class Transition {
7+
/**
8+
* Run the enter transition.
9+
* @param {Element} el DOM element
10+
*/
11+
static async enter(el) {
12+
await this.run(el, "enter");
13+
}
14+
15+
/**
16+
* Run the leave transition.
17+
* @param {Element} el DOM element
18+
*/
19+
static async leave(el) {
20+
await this.run(el, "leave");
21+
}
22+
23+
/**
24+
* Run a transition for a given stage (enter or leave).
25+
*/
26+
static async run(el, stage) {
27+
const element = new UnicornElement(el);
28+
const transitions = element.transitions;
29+
30+
const classes = (transitions[stage] || "").split(" ").filter(Boolean);
31+
const startClasses = (transitions[`${stage}-start`] || "").split(" ").filter(Boolean);
32+
const endClasses = (transitions[`${stage}-end`] || "").split(" ").filter(Boolean);
33+
34+
if (classes.length === 0 && startClasses.length === 0 && endClasses.length === 0) {
35+
return;
36+
}
37+
38+
// Prepare transition
39+
el.classList.add(...classes);
40+
el.classList.add(...startClasses);
41+
42+
// Wait for a frame to ensure classes are applied
43+
await nextFrame();
44+
45+
el.classList.remove(...startClasses);
46+
el.classList.add(...endClasses);
47+
48+
await afterTransition(el);
49+
50+
el.classList.remove(...classes);
51+
el.classList.remove(...endClasses);
52+
}
53+
}
54+
55+
/**
56+
* Wait for the next animation frame.
57+
*/
58+
function nextFrame() {
59+
return new Promise((resolve) => {
60+
requestAnimationFrame(() => {
61+
requestAnimationFrame(resolve);
62+
});
63+
});
64+
}
65+
66+
/**
67+
* Wait for a transition or animation to finish.
68+
*/
69+
function afterTransition(el) {
70+
return Promise.all(el.getAnimations().map((animation) => animation.finished));
71+
}

0 commit comments

Comments
 (0)