@zubra/runtime 5.4.0 → 5.5.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.
- package/dist/production/element.js +44 -20
- package/dist/production/motion.js +122 -0
- package/dist/production/spread.js +22 -0
- package/package.json +6 -1
- package/src/element.js +44 -20
- package/src/motion.js +121 -0
- package/src/spread.js +22 -0
- package/types/motion.d.ts +15 -0
|
@@ -523,6 +523,19 @@ function clearBetween(open, close) {
|
|
|
523
523
|
while (open.nextSibling && open.nextSibling !== close) open.nextSibling.remove();
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
/**
|
|
527
|
+
* `animate`: the region's nodes leave (`motion`, @zubra/runtime/motion, which the compiled module
|
|
528
|
+
* imports only when a component animates) instead of going at once. They stay where they are,
|
|
529
|
+
* and the region starts after them, so what replaces them is rendered beside them.
|
|
530
|
+
*/
|
|
531
|
+
function departBetween(open, close, motion) {
|
|
532
|
+
const nodes = [];
|
|
533
|
+
for (let node = open.nextSibling; node && node !== close; node = node.nextSibling) nodes.push(node);
|
|
534
|
+
if (!nodes.length) return;
|
|
535
|
+
nodes[nodes.length - 1].after(open);
|
|
536
|
+
motion.leave(nodes);
|
|
537
|
+
}
|
|
538
|
+
|
|
526
539
|
/** Remove everything between `open` and `close` in one operation. */
|
|
527
540
|
function deleteBetween(open, close) {
|
|
528
541
|
if (open.nextSibling === close) return;
|
|
@@ -1118,6 +1131,7 @@ function bindWhen(part, open, hydrating, context) {
|
|
|
1118
1131
|
first = false;
|
|
1119
1132
|
// Effects created while mounting belong to this effect, so switching branches disposes them.
|
|
1120
1133
|
untrack(() => {
|
|
1134
|
+
if (part.animate && !hydrate) departBetween(open, close, part.animate);
|
|
1121
1135
|
if (block) mountBlock(block, [], open, close, hydrate, context);
|
|
1122
1136
|
else clearBetween(open, close);
|
|
1123
1137
|
});
|
|
@@ -1258,8 +1272,10 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1258
1272
|
};
|
|
1259
1273
|
const removeRow = (row) => {
|
|
1260
1274
|
row.owner.dispose();
|
|
1261
|
-
|
|
1275
|
+
if (part.animate) part.animate.leave(nodesOf(row), { float: true });
|
|
1276
|
+
else for (const node of nodesOf(row)) node.remove();
|
|
1262
1277
|
};
|
|
1278
|
+
const elementsOf = (row) => nodesOf(row).filter((node) => node.nodeType === 1);
|
|
1263
1279
|
const placeRow = (row, before) => {
|
|
1264
1280
|
const parent = close.parentNode;
|
|
1265
1281
|
for (const node of nodesOf(row)) move(parent, node, before);
|
|
@@ -1331,7 +1347,7 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1331
1347
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
1332
1348
|
// Nothing reused (cleared, or replaced wholesale): drop every row at once, then build the
|
|
1333
1349
|
// new ones in one fragment and insert it once.
|
|
1334
|
-
if (rows.length && !keys.some((key) => byKey.has(key))) {
|
|
1350
|
+
if (!part.animate && rows.length && !keys.some((key) => byKey.has(key))) {
|
|
1335
1351
|
batch(() => { for (const row of rows) row.owner.dispose(); });
|
|
1336
1352
|
deleteBetween(open, close);
|
|
1337
1353
|
rows = [];
|
|
@@ -1347,25 +1363,33 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1347
1363
|
close.before(fragment);
|
|
1348
1364
|
return;
|
|
1349
1365
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1366
|
+
// `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
|
|
1367
|
+
const reorder = () => {
|
|
1368
|
+
const next = list.map((value, index) => {
|
|
1369
|
+
const row = byKey.get(keys[index]);
|
|
1370
|
+
if (!row) return createRow(value, index, keys[index]);
|
|
1371
|
+
byKey.delete(keys[index]);
|
|
1372
|
+
row.item.set(value);
|
|
1373
|
+
row.position.set(index);
|
|
1374
|
+
return row;
|
|
1375
|
+
});
|
|
1376
|
+
batch(() => { for (const row of byKey.values()) removeRow(row); });
|
|
1377
|
+
// Text between rows, and rows on their way out, aren't rows to place against.
|
|
1378
|
+
const skip = (node) => node && (node.nodeType === TEXT || part.animate?.isLeaving(node));
|
|
1379
|
+
let at = open.nextSibling;
|
|
1380
|
+
while (skip(at)) at = at.nextSibling;
|
|
1381
|
+
for (const row of next) {
|
|
1382
|
+
if (row.open === at) {
|
|
1383
|
+
at = row.close.nextSibling;
|
|
1384
|
+
while (skip(at)) at = at.nextSibling;
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
placeRow(row, at ?? close);
|
|
1365
1388
|
}
|
|
1366
|
-
|
|
1367
|
-
}
|
|
1368
|
-
rows
|
|
1389
|
+
rows = next;
|
|
1390
|
+
};
|
|
1391
|
+
if (part.animate) part.animate.glide(rows.flatMap(elementsOf), reorder);
|
|
1392
|
+
else reorder();
|
|
1369
1393
|
});
|
|
1370
1394
|
});
|
|
1371
1395
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/motion.js: the runtime without hot reloading or the DevTools hook. Do not edit.
|
|
2
|
+
// @zubra/runtime/motion: what `animate` on <zb-if>, <zb-switch> and <zb-each> does, as three
|
|
3
|
+
// functions any code can call.
|
|
4
|
+
//
|
|
5
|
+
// The platform animates an element coming in on its own: `@starting-style` gives a transition
|
|
6
|
+
// somewhere to start from, and a CSS animation runs when the element is inserted. What it can't do
|
|
7
|
+
// is animate one going out, because a removed element is gone. And a list that reorders jumps,
|
|
8
|
+
// because moving an element doesn't transition its position. These fill those two gaps, with CSS
|
|
9
|
+
// still deciding how everything looks:
|
|
10
|
+
//
|
|
11
|
+
// [data-leaving] { opacity: 0; transition: opacity 150ms; } (on its way out)
|
|
12
|
+
// .row { --zb-move-duration: 250ms; --zb-move-easing: ease-out; } (gliding to a new place)
|
|
13
|
+
//
|
|
14
|
+
// Under `prefers-reduced-motion: reduce`, elements leave at once and lists don't glide.
|
|
15
|
+
|
|
16
|
+
/** Whether the person has asked for less motion. */
|
|
17
|
+
export const reducedMotion = () => !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
18
|
+
|
|
19
|
+
// An element on its way out: it stays in the document until its animations end.
|
|
20
|
+
const LEAVING = Symbol('leaving');
|
|
21
|
+
/** Whether `node` is an element on its way out (`leave()` was called on it). */
|
|
22
|
+
export const isLeaving = (node) => !!node?.[LEAVING];
|
|
23
|
+
|
|
24
|
+
/** The animations that will end: CSS transitions and animations, except infinite ones (a spinner). */
|
|
25
|
+
function finite(element) {
|
|
26
|
+
if (typeof element.getAnimations !== 'function') return [];
|
|
27
|
+
// getAnimations() brings styles up to date first, so the transitions `data-leaving` starts are here.
|
|
28
|
+
return element.getAnimations({ subtree: true }).filter((animation) => {
|
|
29
|
+
const end = animation.effect?.getComputedTiming?.().endTime;
|
|
30
|
+
return end !== Infinity && animation.playState !== 'finished';
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lets `nodes` finish their exit before they go. Each element gets the `data-leaving` attribute
|
|
36
|
+
* (and `inert`, so it can't be clicked or focused on the way out); it's removed once the
|
|
37
|
+
* transitions and animations that starts have ended, or at once if there are none. Text, comments,
|
|
38
|
+
* and everything under reduced motion are removed at once.
|
|
39
|
+
*
|
|
40
|
+
* - `float`: take leaving elements out of the flow first, fixed where they are, so what's around
|
|
41
|
+
* them can move into their space as they go (a list does this).
|
|
42
|
+
*
|
|
43
|
+
* Returns a promise for when every element is gone.
|
|
44
|
+
*/
|
|
45
|
+
export function leave(nodes, { float = false } = {}) {
|
|
46
|
+
const reduced = reducedMotion();
|
|
47
|
+
const elements = [];
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
if (node.nodeType === 1 && !reduced && node.isConnected) elements.push(node);
|
|
50
|
+
else node.remove();
|
|
51
|
+
}
|
|
52
|
+
// Read every position before changing any: floating one element moves the next.
|
|
53
|
+
const places = float ? elements.map(placeOf) : [];
|
|
54
|
+
elements.forEach((element, index) => {
|
|
55
|
+
element[LEAVING] = true;
|
|
56
|
+
if (float) Object.assign(element.style, places[index]);
|
|
57
|
+
element.inert = true;
|
|
58
|
+
element.setAttribute('data-leaving', '');
|
|
59
|
+
});
|
|
60
|
+
return Promise.all(elements.map((element) => {
|
|
61
|
+
const running = finite(element);
|
|
62
|
+
if (!running.length) return element.remove();
|
|
63
|
+
return Promise.allSettled(running.map((animation) => animation.finished)).then(() => element.remove());
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where an element is, as styles that hold it there out of the flow. */
|
|
68
|
+
function placeOf(element) {
|
|
69
|
+
const { width, height } = element.getBoundingClientRect();
|
|
70
|
+
return {
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
top: `${element.offsetTop}px`,
|
|
73
|
+
left: `${element.offsetLeft}px`,
|
|
74
|
+
width: `${width}px`,
|
|
75
|
+
height: `${height}px`,
|
|
76
|
+
margin: '0',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A CSS time (`250ms`, `0.3s`) in milliseconds, or null. */
|
|
81
|
+
function milliseconds(text) {
|
|
82
|
+
const match = /^\s*(-?[\d.]+)(ms|s)\s*$/.exec(text ?? '');
|
|
83
|
+
if (!match) return null;
|
|
84
|
+
return Number(match[1]) * (match[2] === 's' ? 1000 : 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Runs `change` (which moves `elements` about), then animates each element from where it was to
|
|
89
|
+
* where it is now: First, Last, Invert, Play. Elements added or leaving in `change` aren't moved.
|
|
90
|
+
* How long and how: each element's `--zb-move-duration` (250ms) and `--zb-move-easing` (a quick
|
|
91
|
+
* ease-out), so a theme or one list can change them. A duration of 0, or reduced motion, moves
|
|
92
|
+
* them without animating.
|
|
93
|
+
*/
|
|
94
|
+
export function glide(elements, change) {
|
|
95
|
+
if (reducedMotion()) return change();
|
|
96
|
+
const before = new Map();
|
|
97
|
+
for (const element of elements) if (element.isConnected) before.set(element, element.getBoundingClientRect());
|
|
98
|
+
const result = change();
|
|
99
|
+
for (const [element, was] of before) {
|
|
100
|
+
if (!element.isConnected) continue;
|
|
101
|
+
const now = element.getBoundingClientRect();
|
|
102
|
+
const x = was.left - now.left;
|
|
103
|
+
const y = was.top - now.top;
|
|
104
|
+
if (!x && !y) continue;
|
|
105
|
+
if (element[LEAVING]) {
|
|
106
|
+
// Held in place as it leaves: where it was on screen, even if its container moved (one
|
|
107
|
+
// anchored to the bottom shrinks from the top).
|
|
108
|
+
element.style.top = `${parseFloat(element.style.top) + y}px`;
|
|
109
|
+
element.style.left = `${parseFloat(element.style.left) + x}px`;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const style = getComputedStyle(element);
|
|
113
|
+
const duration = milliseconds(style.getPropertyValue('--zb-move-duration')) ?? 250;
|
|
114
|
+
if (duration <= 0) continue;
|
|
115
|
+
element.animate(
|
|
116
|
+
[{ transform: `translate(${x}px, ${y}px)` }, { transform: 'none' }],
|
|
117
|
+
// `add`: on top of whatever transform the element has of its own.
|
|
118
|
+
{ duration, easing: style.getPropertyValue('--zb-move-easing').trim() || 'cubic-bezier(0.2, 0, 0, 1)', composite: 'add' },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
@@ -27,6 +27,11 @@ const kebab = (key) => (key.startsWith('--') ? key : key.replace(/[A-Z]/g, (lett
|
|
|
27
27
|
*/
|
|
28
28
|
export function spread(part, node) {
|
|
29
29
|
const own = new Set(part.own ?? []);
|
|
30
|
+
// On a component (`part.props`): the entries that are its props are set as props, as the
|
|
31
|
+
// component's own `prop={...}` is, and anything else lands on its host like on any element.
|
|
32
|
+
const normalize = (name) => name.replace(/-/g, '').toLowerCase();
|
|
33
|
+
const props = new Map((part.props ?? []).map((name) => [normalize(name), name]));
|
|
34
|
+
let givenProps = new Set();
|
|
30
35
|
const listeners = new Map(); // event → { latest, listener }
|
|
31
36
|
let previous = new Map(); // normalized name → value
|
|
32
37
|
let classes = [];
|
|
@@ -39,12 +44,20 @@ export function spread(part, node) {
|
|
|
39
44
|
given = {};
|
|
40
45
|
}
|
|
41
46
|
const next = new Map();
|
|
47
|
+
const nextProps = new Map();
|
|
42
48
|
const events = new Set();
|
|
43
49
|
let nextClasses = [];
|
|
44
50
|
let nextStyles = new Set();
|
|
45
51
|
for (const [key, value] of Object.entries(given)) {
|
|
46
52
|
if (value === undefined) continue;
|
|
47
53
|
const lower = key.toLowerCase();
|
|
54
|
+
const prop = props.get(normalize(key));
|
|
55
|
+
if (prop) {
|
|
56
|
+
// The element's own `prop={...}` wins, in either spelling. `null` isn't a value: the
|
|
57
|
+
// prop's default, as on the server.
|
|
58
|
+
if (value !== null && !own.has(prop.toLowerCase()) && !own.has(kebab(prop))) nextProps.set(prop, value);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
48
61
|
if (lower.length > 2 && lower.startsWith('on') && (typeof value === 'function' || typeof value === 'string')) {
|
|
49
62
|
if (typeof value === 'string') {
|
|
50
63
|
console.error(`zubra: a spread can't add a handler as text ("${key}"); pass a function${see('safety')}`);
|
|
@@ -89,6 +102,15 @@ export function spread(part, node) {
|
|
|
89
102
|
if (own.has(name.toLowerCase())) continue;
|
|
90
103
|
next.set(name, value);
|
|
91
104
|
}
|
|
105
|
+
// Props: set, and back to the component's default once the key is gone (or null), which is
|
|
106
|
+
// what the server renders for a spread without it.
|
|
107
|
+
for (const name of givenProps) {
|
|
108
|
+
if (nextProps.has(name)) continue;
|
|
109
|
+
const fallback = node.constructor.zubra?.props[name]?.default;
|
|
110
|
+
node[name] = fallback && typeof fallback === 'object' ? structuredClone(fallback) : fallback;
|
|
111
|
+
}
|
|
112
|
+
for (const [name, value] of nextProps) node[name] = value; // not read first: that would subscribe this effect to the child's own prop
|
|
113
|
+
givenProps = new Set(nextProps.keys());
|
|
92
114
|
// Listeners whose keys went away.
|
|
93
115
|
for (const [event, entry] of listeners) {
|
|
94
116
|
if (!events.has(event)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/runtime",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"production": "./dist/production/primitives.js",
|
|
41
41
|
"default": "./src/primitives.js"
|
|
42
42
|
},
|
|
43
|
+
"./motion": {
|
|
44
|
+
"types": "./types/motion.d.ts",
|
|
45
|
+
"production": "./dist/production/motion.js",
|
|
46
|
+
"default": "./src/motion.js"
|
|
47
|
+
},
|
|
43
48
|
"./react": {
|
|
44
49
|
"types": "./types/react.d.ts",
|
|
45
50
|
"production": "./dist/production/react.js",
|
package/src/element.js
CHANGED
|
@@ -542,6 +542,19 @@ function clearBetween(open, close) {
|
|
|
542
542
|
while (open.nextSibling && open.nextSibling !== close) open.nextSibling.remove();
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
/**
|
|
546
|
+
* `animate`: the region's nodes leave (`motion`, @zubra/runtime/motion, which the compiled module
|
|
547
|
+
* imports only when a component animates) instead of going at once. They stay where they are,
|
|
548
|
+
* and the region starts after them, so what replaces them is rendered beside them.
|
|
549
|
+
*/
|
|
550
|
+
function departBetween(open, close, motion) {
|
|
551
|
+
const nodes = [];
|
|
552
|
+
for (let node = open.nextSibling; node && node !== close; node = node.nextSibling) nodes.push(node);
|
|
553
|
+
if (!nodes.length) return;
|
|
554
|
+
nodes[nodes.length - 1].after(open);
|
|
555
|
+
motion.leave(nodes);
|
|
556
|
+
}
|
|
557
|
+
|
|
545
558
|
/** Remove everything between `open` and `close` in one operation. */
|
|
546
559
|
function deleteBetween(open, close) {
|
|
547
560
|
if (open.nextSibling === close) return;
|
|
@@ -1146,6 +1159,7 @@ function bindWhen(part, open, hydrating, context) {
|
|
|
1146
1159
|
first = false;
|
|
1147
1160
|
// Effects created while mounting belong to this effect, so switching branches disposes them.
|
|
1148
1161
|
untrack(() => {
|
|
1162
|
+
if (part.animate && !hydrate) departBetween(open, close, part.animate);
|
|
1149
1163
|
if (block) mountBlock(block, [], open, close, hydrate, context);
|
|
1150
1164
|
else clearBetween(open, close);
|
|
1151
1165
|
});
|
|
@@ -1286,8 +1300,10 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1286
1300
|
};
|
|
1287
1301
|
const removeRow = (row) => {
|
|
1288
1302
|
row.owner.dispose();
|
|
1289
|
-
|
|
1303
|
+
if (part.animate) part.animate.leave(nodesOf(row), { float: true });
|
|
1304
|
+
else for (const node of nodesOf(row)) node.remove();
|
|
1290
1305
|
};
|
|
1306
|
+
const elementsOf = (row) => nodesOf(row).filter((node) => node.nodeType === 1);
|
|
1291
1307
|
const placeRow = (row, before) => {
|
|
1292
1308
|
const parent = close.parentNode;
|
|
1293
1309
|
for (const node of nodesOf(row)) move(parent, node, before);
|
|
@@ -1359,7 +1375,7 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1359
1375
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
1360
1376
|
// Nothing reused (cleared, or replaced wholesale): drop every row at once, then build the
|
|
1361
1377
|
// new ones in one fragment and insert it once.
|
|
1362
|
-
if (rows.length && !keys.some((key) => byKey.has(key))) {
|
|
1378
|
+
if (!part.animate && rows.length && !keys.some((key) => byKey.has(key))) {
|
|
1363
1379
|
batch(() => { for (const row of rows) row.owner.dispose(); });
|
|
1364
1380
|
deleteBetween(open, close);
|
|
1365
1381
|
rows = [];
|
|
@@ -1375,25 +1391,33 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1375
1391
|
close.before(fragment);
|
|
1376
1392
|
return;
|
|
1377
1393
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1394
|
+
// `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
|
|
1395
|
+
const reorder = () => {
|
|
1396
|
+
const next = list.map((value, index) => {
|
|
1397
|
+
const row = byKey.get(keys[index]);
|
|
1398
|
+
if (!row) return createRow(value, index, keys[index]);
|
|
1399
|
+
byKey.delete(keys[index]);
|
|
1400
|
+
row.item.set(value);
|
|
1401
|
+
row.position.set(index);
|
|
1402
|
+
return row;
|
|
1403
|
+
});
|
|
1404
|
+
batch(() => { for (const row of byKey.values()) removeRow(row); });
|
|
1405
|
+
// Text between rows, and rows on their way out, aren't rows to place against.
|
|
1406
|
+
const skip = (node) => node && (node.nodeType === TEXT || part.animate?.isLeaving(node));
|
|
1407
|
+
let at = open.nextSibling;
|
|
1408
|
+
while (skip(at)) at = at.nextSibling;
|
|
1409
|
+
for (const row of next) {
|
|
1410
|
+
if (row.open === at) {
|
|
1411
|
+
at = row.close.nextSibling;
|
|
1412
|
+
while (skip(at)) at = at.nextSibling;
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
placeRow(row, at ?? close);
|
|
1393
1416
|
}
|
|
1394
|
-
|
|
1395
|
-
}
|
|
1396
|
-
rows
|
|
1417
|
+
rows = next;
|
|
1418
|
+
};
|
|
1419
|
+
if (part.animate) part.animate.glide(rows.flatMap(elementsOf), reorder);
|
|
1420
|
+
else reorder();
|
|
1397
1421
|
});
|
|
1398
1422
|
});
|
|
1399
1423
|
}
|
package/src/motion.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @zubra/runtime/motion: what `animate` on <zb-if>, <zb-switch> and <zb-each> does, as three
|
|
2
|
+
// functions any code can call.
|
|
3
|
+
//
|
|
4
|
+
// The platform animates an element coming in on its own: `@starting-style` gives a transition
|
|
5
|
+
// somewhere to start from, and a CSS animation runs when the element is inserted. What it can't do
|
|
6
|
+
// is animate one going out, because a removed element is gone. And a list that reorders jumps,
|
|
7
|
+
// because moving an element doesn't transition its position. These fill those two gaps, with CSS
|
|
8
|
+
// still deciding how everything looks:
|
|
9
|
+
//
|
|
10
|
+
// [data-leaving] { opacity: 0; transition: opacity 150ms; } (on its way out)
|
|
11
|
+
// .row { --zb-move-duration: 250ms; --zb-move-easing: ease-out; } (gliding to a new place)
|
|
12
|
+
//
|
|
13
|
+
// Under `prefers-reduced-motion: reduce`, elements leave at once and lists don't glide.
|
|
14
|
+
|
|
15
|
+
/** Whether the person has asked for less motion. */
|
|
16
|
+
export const reducedMotion = () => !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
17
|
+
|
|
18
|
+
// An element on its way out: it stays in the document until its animations end.
|
|
19
|
+
const LEAVING = Symbol('leaving');
|
|
20
|
+
/** Whether `node` is an element on its way out (`leave()` was called on it). */
|
|
21
|
+
export const isLeaving = (node) => !!node?.[LEAVING];
|
|
22
|
+
|
|
23
|
+
/** The animations that will end: CSS transitions and animations, except infinite ones (a spinner). */
|
|
24
|
+
function finite(element) {
|
|
25
|
+
if (typeof element.getAnimations !== 'function') return [];
|
|
26
|
+
// getAnimations() brings styles up to date first, so the transitions `data-leaving` starts are here.
|
|
27
|
+
return element.getAnimations({ subtree: true }).filter((animation) => {
|
|
28
|
+
const end = animation.effect?.getComputedTiming?.().endTime;
|
|
29
|
+
return end !== Infinity && animation.playState !== 'finished';
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lets `nodes` finish their exit before they go. Each element gets the `data-leaving` attribute
|
|
35
|
+
* (and `inert`, so it can't be clicked or focused on the way out); it's removed once the
|
|
36
|
+
* transitions and animations that starts have ended, or at once if there are none. Text, comments,
|
|
37
|
+
* and everything under reduced motion are removed at once.
|
|
38
|
+
*
|
|
39
|
+
* - `float`: take leaving elements out of the flow first, fixed where they are, so what's around
|
|
40
|
+
* them can move into their space as they go (a list does this).
|
|
41
|
+
*
|
|
42
|
+
* Returns a promise for when every element is gone.
|
|
43
|
+
*/
|
|
44
|
+
export function leave(nodes, { float = false } = {}) {
|
|
45
|
+
const reduced = reducedMotion();
|
|
46
|
+
const elements = [];
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
if (node.nodeType === 1 && !reduced && node.isConnected) elements.push(node);
|
|
49
|
+
else node.remove();
|
|
50
|
+
}
|
|
51
|
+
// Read every position before changing any: floating one element moves the next.
|
|
52
|
+
const places = float ? elements.map(placeOf) : [];
|
|
53
|
+
elements.forEach((element, index) => {
|
|
54
|
+
element[LEAVING] = true;
|
|
55
|
+
if (float) Object.assign(element.style, places[index]);
|
|
56
|
+
element.inert = true;
|
|
57
|
+
element.setAttribute('data-leaving', '');
|
|
58
|
+
});
|
|
59
|
+
return Promise.all(elements.map((element) => {
|
|
60
|
+
const running = finite(element);
|
|
61
|
+
if (!running.length) return element.remove();
|
|
62
|
+
return Promise.allSettled(running.map((animation) => animation.finished)).then(() => element.remove());
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where an element is, as styles that hold it there out of the flow. */
|
|
67
|
+
function placeOf(element) {
|
|
68
|
+
const { width, height } = element.getBoundingClientRect();
|
|
69
|
+
return {
|
|
70
|
+
position: 'absolute',
|
|
71
|
+
top: `${element.offsetTop}px`,
|
|
72
|
+
left: `${element.offsetLeft}px`,
|
|
73
|
+
width: `${width}px`,
|
|
74
|
+
height: `${height}px`,
|
|
75
|
+
margin: '0',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A CSS time (`250ms`, `0.3s`) in milliseconds, or null. */
|
|
80
|
+
function milliseconds(text) {
|
|
81
|
+
const match = /^\s*(-?[\d.]+)(ms|s)\s*$/.exec(text ?? '');
|
|
82
|
+
if (!match) return null;
|
|
83
|
+
return Number(match[1]) * (match[2] === 's' ? 1000 : 1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Runs `change` (which moves `elements` about), then animates each element from where it was to
|
|
88
|
+
* where it is now: First, Last, Invert, Play. Elements added or leaving in `change` aren't moved.
|
|
89
|
+
* How long and how: each element's `--zb-move-duration` (250ms) and `--zb-move-easing` (a quick
|
|
90
|
+
* ease-out), so a theme or one list can change them. A duration of 0, or reduced motion, moves
|
|
91
|
+
* them without animating.
|
|
92
|
+
*/
|
|
93
|
+
export function glide(elements, change) {
|
|
94
|
+
if (reducedMotion()) return change();
|
|
95
|
+
const before = new Map();
|
|
96
|
+
for (const element of elements) if (element.isConnected) before.set(element, element.getBoundingClientRect());
|
|
97
|
+
const result = change();
|
|
98
|
+
for (const [element, was] of before) {
|
|
99
|
+
if (!element.isConnected) continue;
|
|
100
|
+
const now = element.getBoundingClientRect();
|
|
101
|
+
const x = was.left - now.left;
|
|
102
|
+
const y = was.top - now.top;
|
|
103
|
+
if (!x && !y) continue;
|
|
104
|
+
if (element[LEAVING]) {
|
|
105
|
+
// Held in place as it leaves: where it was on screen, even if its container moved (one
|
|
106
|
+
// anchored to the bottom shrinks from the top).
|
|
107
|
+
element.style.top = `${parseFloat(element.style.top) + y}px`;
|
|
108
|
+
element.style.left = `${parseFloat(element.style.left) + x}px`;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const style = getComputedStyle(element);
|
|
112
|
+
const duration = milliseconds(style.getPropertyValue('--zb-move-duration')) ?? 250;
|
|
113
|
+
if (duration <= 0) continue;
|
|
114
|
+
element.animate(
|
|
115
|
+
[{ transform: `translate(${x}px, ${y}px)` }, { transform: 'none' }],
|
|
116
|
+
// `add`: on top of whatever transform the element has of its own.
|
|
117
|
+
{ duration, easing: style.getPropertyValue('--zb-move-easing').trim() || 'cubic-bezier(0.2, 0, 0, 1)', composite: 'add' },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
package/src/spread.js
CHANGED
|
@@ -26,6 +26,11 @@ const kebab = (key) => (key.startsWith('--') ? key : key.replace(/[A-Z]/g, (lett
|
|
|
26
26
|
*/
|
|
27
27
|
export function spread(part, node) {
|
|
28
28
|
const own = new Set(part.own ?? []);
|
|
29
|
+
// On a component (`part.props`): the entries that are its props are set as props, as the
|
|
30
|
+
// component's own `prop={...}` is, and anything else lands on its host like on any element.
|
|
31
|
+
const normalize = (name) => name.replace(/-/g, '').toLowerCase();
|
|
32
|
+
const props = new Map((part.props ?? []).map((name) => [normalize(name), name]));
|
|
33
|
+
let givenProps = new Set();
|
|
29
34
|
const listeners = new Map(); // event → { latest, listener }
|
|
30
35
|
let previous = new Map(); // normalized name → value
|
|
31
36
|
let classes = [];
|
|
@@ -38,12 +43,20 @@ export function spread(part, node) {
|
|
|
38
43
|
given = {};
|
|
39
44
|
}
|
|
40
45
|
const next = new Map();
|
|
46
|
+
const nextProps = new Map();
|
|
41
47
|
const events = new Set();
|
|
42
48
|
let nextClasses = [];
|
|
43
49
|
let nextStyles = new Set();
|
|
44
50
|
for (const [key, value] of Object.entries(given)) {
|
|
45
51
|
if (value === undefined) continue;
|
|
46
52
|
const lower = key.toLowerCase();
|
|
53
|
+
const prop = props.get(normalize(key));
|
|
54
|
+
if (prop) {
|
|
55
|
+
// The element's own `prop={...}` wins, in either spelling. `null` isn't a value: the
|
|
56
|
+
// prop's default, as on the server.
|
|
57
|
+
if (value !== null && !own.has(prop.toLowerCase()) && !own.has(kebab(prop))) nextProps.set(prop, value);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
47
60
|
if (lower.length > 2 && lower.startsWith('on') && (typeof value === 'function' || typeof value === 'string')) {
|
|
48
61
|
if (typeof value === 'string') {
|
|
49
62
|
console.error(`zubra: a spread can't add a handler as text ("${key}"); pass a function${see('safety')}`);
|
|
@@ -88,6 +101,15 @@ export function spread(part, node) {
|
|
|
88
101
|
if (own.has(name.toLowerCase())) continue;
|
|
89
102
|
next.set(name, value);
|
|
90
103
|
}
|
|
104
|
+
// Props: set, and back to the component's default once the key is gone (or null), which is
|
|
105
|
+
// what the server renders for a spread without it.
|
|
106
|
+
for (const name of givenProps) {
|
|
107
|
+
if (nextProps.has(name)) continue;
|
|
108
|
+
const fallback = node.constructor.zubra?.props[name]?.default;
|
|
109
|
+
node[name] = fallback && typeof fallback === 'object' ? structuredClone(fallback) : fallback;
|
|
110
|
+
}
|
|
111
|
+
for (const [name, value] of nextProps) node[name] = value; // not read first: that would subscribe this effect to the child's own prop
|
|
112
|
+
givenProps = new Set(nextProps.keys());
|
|
91
113
|
// Listeners whose keys went away.
|
|
92
114
|
for (const [event, entry] of listeners) {
|
|
93
115
|
if (!events.has(event)) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Whether the person has asked for less motion (`prefers-reduced-motion: reduce`). */
|
|
2
|
+
export declare function reducedMotion(): boolean;
|
|
3
|
+
/** Whether `node` is an element on its way out (`leave()` was called on it). */
|
|
4
|
+
export declare function isLeaving(node: Node | null | undefined): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Lets `nodes` finish their exit: each element gets `data-leaving` and `inert`, and is removed
|
|
7
|
+
* once the transitions and animations that starts have ended (at once under reduced motion).
|
|
8
|
+
* `float` takes them out of the flow first, so what's around them can move into their space.
|
|
9
|
+
*/
|
|
10
|
+
export declare function leave(nodes: Iterable<Node>, options?: { float?: boolean }): Promise<unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Runs `change`, then animates each of `elements` from where it was to where it is now (FLIP),
|
|
13
|
+
* using its `--zb-move-duration` and `--zb-move-easing`. Returns what `change` returns.
|
|
14
|
+
*/
|
|
15
|
+
export declare function glide<T>(elements: Iterable<Element>, change: () => T): T;
|