@zubra/runtime 5.4.1 → 5.6.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.
@@ -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
- for (const node of nodesOf(row)) node.remove();
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
- const next = list.map((value, index) => {
1351
- const row = byKey.get(keys[index]);
1352
- if (!row) return createRow(value, index, keys[index]);
1353
- byKey.delete(keys[index]);
1354
- row.item.set(value);
1355
- row.position.set(index);
1356
- return row;
1357
- });
1358
- batch(() => { for (const row of byKey.values()) removeRow(row); });
1359
- let at = open.nextSibling;
1360
- while (at && at.nodeType === TEXT) at = at.nextSibling;
1361
- for (const row of next) {
1362
- if (row.open === at) {
1363
- at = row.close.nextSibling;
1364
- continue;
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
- placeRow(row, at ?? close);
1367
- }
1368
- rows = next;
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.4.1",
3
+ "version": "5.6.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
- for (const node of nodesOf(row)) node.remove();
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
- const next = list.map((value, index) => {
1379
- const row = byKey.get(keys[index]);
1380
- if (!row) return createRow(value, index, keys[index]);
1381
- byKey.delete(keys[index]);
1382
- row.item.set(value);
1383
- row.position.set(index);
1384
- return row;
1385
- });
1386
- batch(() => { for (const row of byKey.values()) removeRow(row); });
1387
- let at = open.nextSibling;
1388
- while (at && at.nodeType === TEXT) at = at.nextSibling;
1389
- for (const row of next) {
1390
- if (row.open === at) {
1391
- at = row.close.nextSibling;
1392
- continue;
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
- placeRow(row, at ?? close);
1395
- }
1396
- rows = next;
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;