streetui 1.8.0 → 2.0.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/testing.js CHANGED
@@ -265,6 +265,9 @@ var BrowserDOMAdapter = class {
265
265
  body() {
266
266
  return document.body ?? null;
267
267
  }
268
+ head() {
269
+ return document.head ?? null;
270
+ }
268
271
  activeElement() {
269
272
  return document.activeElement ?? null;
270
273
  }
@@ -643,6 +646,9 @@ var ServerDOMAdapter = class {
643
646
  body() {
644
647
  return null;
645
648
  }
649
+ head() {
650
+ return null;
651
+ }
646
652
  activeElement() {
647
653
  return null;
648
654
  }
@@ -756,18 +762,25 @@ function trapFocus(dom, container) {
756
762
  dom.addEventListener(container, "keydown", onKeydown);
757
763
  return () => dom.removeEventListener(container, "keydown", onKeydown);
758
764
  }
765
+ var containmentStack = [];
759
766
  function containFocus(dom, container) {
760
767
  const body = dom.body();
761
768
  if (body === null) return () => {
762
769
  };
770
+ containmentStack.push(container);
763
771
  const onFocusIn = (event) => {
772
+ if (containmentStack[containmentStack.length - 1] !== container) return;
764
773
  const target = event.target;
765
774
  if (target !== null && !dom.contains(container, target)) {
766
775
  focusFirst(dom, container);
767
776
  }
768
777
  };
769
778
  dom.addEventListener(body, "focusin", onFocusIn);
770
- return () => dom.removeEventListener(body, "focusin", onFocusIn);
779
+ return () => {
780
+ dom.removeEventListener(body, "focusin", onFocusIn);
781
+ const index = containmentStack.lastIndexOf(container);
782
+ if (index !== -1) containmentStack.splice(index, 1);
783
+ };
771
784
  }
772
785
  function onEscape(dom, target, handler) {
773
786
  const onKeydown = (event) => {
@@ -776,6 +789,33 @@ function onEscape(dom, target, handler) {
776
789
  dom.addEventListener(target, "keydown", onKeydown);
777
790
  return () => dom.removeEventListener(target, "keydown", onKeydown);
778
791
  }
792
+ function rovingMenu(dom, container, selector = FOCUSABLE_SELECTOR) {
793
+ const onKeydown = (event) => {
794
+ const key = event.key;
795
+ const isActivate = key === "Enter" || key === " " || key === "Spacebar";
796
+ const isMove = key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End";
797
+ if (!isActivate && !isMove) return;
798
+ const items = getFocusable(dom, container, selector);
799
+ if (items.length === 0) return;
800
+ const active = dom.activeElement();
801
+ const index = active === null ? -1 : items.indexOf(active);
802
+ if (isActivate) {
803
+ if (index < 0) return;
804
+ event.preventDefault();
805
+ items[index].click?.();
806
+ return;
807
+ }
808
+ event.preventDefault();
809
+ let next;
810
+ if (key === "Home") next = 0;
811
+ else if (key === "End") next = items.length - 1;
812
+ else if (key === "ArrowDown") next = index < 0 ? 0 : (index + 1) % items.length;
813
+ else next = index <= 0 ? items.length - 1 : index - 1;
814
+ dom.focus(items[next]);
815
+ };
816
+ dom.addEventListener(container, "keydown", onKeydown);
817
+ return () => dom.removeEventListener(container, "keydown", onKeydown);
818
+ }
779
819
 
780
820
  // ../renderer/src/render-context.ts
781
821
  function createRenderContext(dom, graph, container, hydrationDiagnostics, staticHTML) {
@@ -939,7 +979,13 @@ var TAG_MAP = {
939
979
  // its children are relocated to a document.body container on the browser
940
980
  // (see the portal branch in mount.ts). On the server (no body) it renders
941
981
  // inline, so the anchor tag is what SSR/hydration positionally match on.
942
- portal: "div"
982
+ portal: "div",
983
+ // A `head()` node renders as a neutral, empty inline anchor <div> at its
984
+ // declaration site (like a portal anchor). Its actual contribution — title/
985
+ // meta/link/etc. — is applied to `document.head` by `wireHeadBehavior` on the
986
+ // browser, and emitted separately by `renderHead()` on the server. Keeping a
987
+ // one-node/one-element anchor preserves positional hydration.
988
+ head: "div"
943
989
  };
944
990
  function resolveTag(type) {
945
991
  return TAG_MAP[type] ?? "div";
@@ -987,7 +1033,7 @@ function patchNode(ctx, graphNode, propKey, newValue) {
987
1033
  }
988
1034
 
989
1035
  // ../renderer/src/reconciliation.ts
990
- function reconcileChildren(ctx, parentDom, oldInstances, newNodes, mountFn) {
1036
+ function reconcileChildren(ctx, parentDom, oldInstances, newNodes, mountFn, hooks) {
991
1037
  const oldByKey = /* @__PURE__ */ new Map();
992
1038
  for (const inst of oldInstances) {
993
1039
  const key = inst.graphNode.key ?? inst.graphNode.id;
@@ -1008,28 +1054,33 @@ function reconcileChildren(ctx, parentDom, oldInstances, newNodes, mountFn) {
1008
1054
  }
1009
1055
  newInstances.push(existing);
1010
1056
  } else {
1011
- const inst = mountFn(newNode, parentDom);
1012
- newInstances.push(inst);
1057
+ const reclaimed = hooks?.takeLeaving(key);
1058
+ if (reclaimed !== void 0) {
1059
+ patchExistingInstance(ctx, reclaimed, newNode);
1060
+ reconcileItemChildren(ctx, reclaimed, newNode, mountFn);
1061
+ hooks?.onEnter(reclaimed);
1062
+ newInstances.push(reclaimed);
1063
+ } else {
1064
+ const inst = mountFn(newNode, parentDom);
1065
+ hooks?.onEnter(inst);
1066
+ newInstances.push(inst);
1067
+ }
1013
1068
  }
1014
1069
  }
1015
1070
  const removed = [];
1016
1071
  for (const inst of oldInstances) {
1017
1072
  const key = inst.graphNode.key ?? inst.graphNode.id;
1018
- if (!usedKeys.has(key)) {
1019
- removed.push(inst);
1020
- }
1021
- }
1022
- for (const inst of removed) {
1073
+ if (usedKeys.has(key)) continue;
1074
+ if (hooks !== void 0 && hooks.beginLeave(inst)) continue;
1023
1075
  const parent = ctx.dom.parentNode(inst.domNode);
1024
- if (parent !== null) {
1025
- ctx.dom.removeChild(parent, inst.domNode);
1026
- }
1076
+ if (parent !== null) ctx.dom.removeChild(parent, inst.domNode);
1027
1077
  inst.dispose();
1078
+ removed.push(inst);
1028
1079
  }
1029
1080
  reorderDom(ctx, parentDom, newInstances);
1030
1081
  return { instances: newInstances, removed };
1031
1082
  }
1032
- function reconcileChildrenByPlan(ctx, parentDom, oldInstances, plan, mountFn) {
1083
+ function reconcileChildrenByPlan(ctx, parentDom, oldInstances, plan, mountFn, hooks) {
1033
1084
  const oldByKey = /* @__PURE__ */ new Map();
1034
1085
  for (const inst of oldInstances) {
1035
1086
  oldByKey.set(inst.graphNode.key ?? inst.graphNode.id, inst);
@@ -1056,21 +1107,34 @@ function reconcileChildrenByPlan(ctx, parentDom, oldInstances, plan, mountFn) {
1056
1107
  }
1057
1108
  newInstances.push(existing);
1058
1109
  } else {
1059
- const freshNode = entry.build();
1060
- built.push(freshNode);
1061
- const inst = mountFn(freshNode, parentDom);
1062
- newInstances.push(inst);
1110
+ const reclaimed = hooks?.takeLeaving(entry.key);
1111
+ if (reclaimed !== void 0) {
1112
+ const freshNode = entry.build();
1113
+ built.push(freshNode);
1114
+ patchExistingInstance(ctx, reclaimed, freshNode);
1115
+ reconcileItemChildren(ctx, reclaimed, freshNode, mountFn);
1116
+ reclaimed.graphNode.setProp("_sig", freshNode.getProp("_sig"));
1117
+ reclaimed.graphNode.setProp("_item", entry.item);
1118
+ hooks?.onEnter(reclaimed);
1119
+ newInstances.push(reclaimed);
1120
+ } else {
1121
+ const freshNode = entry.build();
1122
+ built.push(freshNode);
1123
+ const inst = mountFn(freshNode, parentDom);
1124
+ hooks?.onEnter(inst);
1125
+ newInstances.push(inst);
1126
+ }
1063
1127
  }
1064
1128
  }
1065
1129
  const removed = [];
1066
1130
  for (const inst of oldInstances) {
1067
1131
  const key = inst.graphNode.key ?? inst.graphNode.id;
1068
- if (!usedKeys.has(key)) removed.push(inst);
1069
- }
1070
- for (const inst of removed) {
1132
+ if (usedKeys.has(key)) continue;
1133
+ if (hooks !== void 0 && hooks.beginLeave(inst)) continue;
1071
1134
  const parent = ctx.dom.parentNode(inst.domNode);
1072
1135
  if (parent !== null) ctx.dom.removeChild(parent, inst.domNode);
1073
1136
  inst.dispose();
1137
+ removed.push(inst);
1074
1138
  }
1075
1139
  reorderDomMinimal(ctx, parentDom, oldInstances, newInstances);
1076
1140
  return { instances: newInstances, removed, built };
@@ -1196,6 +1260,328 @@ function patchExistingInstance(ctx, instance, newNode) {
1196
1260
  }
1197
1261
  }
1198
1262
 
1263
+ // ../renderer/src/transition.ts
1264
+ function getResolvedTransition(graph, nodeId) {
1265
+ const fn = graph.getHandler(`__transition__${nodeId}`);
1266
+ return fn === void 0 ? void 0 : fn();
1267
+ }
1268
+ function host() {
1269
+ return globalThis;
1270
+ }
1271
+ function nextFrame(cb) {
1272
+ const h = host();
1273
+ const raf = h.requestAnimationFrame;
1274
+ if (typeof raf === "function") {
1275
+ raf(() => raf(cb));
1276
+ } else {
1277
+ h.setTimeout(cb, 0);
1278
+ }
1279
+ }
1280
+ function splitClass(value) {
1281
+ if (value === null) return [];
1282
+ const out = [];
1283
+ for (const t of value.split(/\s+/)) if (t.length > 0) out.push(t);
1284
+ return out;
1285
+ }
1286
+ function addClasses(dom, el, classes2) {
1287
+ if (classes2.length === 0) return;
1288
+ const current = splitClass(dom.getAttribute(el, "class"));
1289
+ let changed = false;
1290
+ for (const c of classes2) {
1291
+ if (!current.includes(c)) {
1292
+ current.push(c);
1293
+ changed = true;
1294
+ }
1295
+ }
1296
+ if (changed) dom.setAttribute(el, "class", current.join(" "));
1297
+ }
1298
+ function removeClasses(dom, el, classes2) {
1299
+ if (classes2.length === 0) return;
1300
+ const current = splitClass(dom.getAttribute(el, "class"));
1301
+ const next = current.filter((c) => !classes2.includes(c));
1302
+ if (next.length !== current.length) {
1303
+ if (next.length === 0) dom.removeAttribute(el, "class");
1304
+ else dom.setAttribute(el, "class", next.join(" "));
1305
+ }
1306
+ }
1307
+ function startRun(dom, el, active, from, to, duration, onDone) {
1308
+ const h = host();
1309
+ let finished = false;
1310
+ let timer = null;
1311
+ const onEvent = (e) => {
1312
+ if (e.target !== el) return;
1313
+ finish();
1314
+ };
1315
+ const detach = () => {
1316
+ dom.removeEventListener(el, "transitionend", onEvent);
1317
+ dom.removeEventListener(el, "animationend", onEvent);
1318
+ if (timer !== null) {
1319
+ h.clearTimeout(timer);
1320
+ timer = null;
1321
+ }
1322
+ };
1323
+ const finish = () => {
1324
+ if (finished) return;
1325
+ finished = true;
1326
+ detach();
1327
+ removeClasses(dom, el, active);
1328
+ removeClasses(dom, el, to);
1329
+ removeClasses(dom, el, from);
1330
+ onDone();
1331
+ };
1332
+ addClasses(dom, el, from);
1333
+ addClasses(dom, el, active);
1334
+ dom.addEventListener(el, "transitionend", onEvent);
1335
+ dom.addEventListener(el, "animationend", onEvent);
1336
+ timer = h.setTimeout(finish, duration);
1337
+ nextFrame(() => {
1338
+ if (finished) return;
1339
+ removeClasses(dom, el, from);
1340
+ addClasses(dom, el, to);
1341
+ });
1342
+ return {
1343
+ cancel: () => {
1344
+ if (finished) return;
1345
+ finished = true;
1346
+ detach();
1347
+ removeClasses(dom, el, active);
1348
+ removeClasses(dom, el, to);
1349
+ removeClasses(dom, el, from);
1350
+ }
1351
+ };
1352
+ }
1353
+ var TransitionController = class {
1354
+ constructor(dom, graph, finalize) {
1355
+ this.dom = dom;
1356
+ this.graph = graph;
1357
+ this.finalize = finalize;
1358
+ }
1359
+ leaving = /* @__PURE__ */ new Map();
1360
+ /** True only in a real DOM environment (browser). */
1361
+ get browser() {
1362
+ return this.dom.body() !== null;
1363
+ }
1364
+ keyOf(inst) {
1365
+ return inst.graphNode.key ?? inst.graphNode.id;
1366
+ }
1367
+ resolved(node) {
1368
+ return getResolvedTransition(this.graph, node.id);
1369
+ }
1370
+ /** Run the enter animation for `inst` if it carries a transition (browser only). */
1371
+ enter(inst) {
1372
+ if (!this.browser) return;
1373
+ const rt = this.resolved(inst.graphNode);
1374
+ if (rt === void 0) return;
1375
+ const el = inst.domNode;
1376
+ if (!this.dom.isElement(el)) return;
1377
+ startRun(this.dom, el, rt.enterActive, rt.enterFrom, rt.enterTo, rt.duration, () => {
1378
+ });
1379
+ }
1380
+ /**
1381
+ * Play `appear` for any initial child that opted into it (fresh browser mount
1382
+ * only — hydration must never animate appear, §22, and this is called only on
1383
+ * the mount path).
1384
+ */
1385
+ appear(children) {
1386
+ if (!this.browser) return;
1387
+ for (const child of children) {
1388
+ const rt = this.resolved(child.graphNode);
1389
+ if (rt !== void 0 && rt.appear) this.enter(child);
1390
+ }
1391
+ }
1392
+ hooks() {
1393
+ return {
1394
+ takeLeaving: (key) => {
1395
+ const entry = this.leaving.get(key);
1396
+ if (entry === void 0) return void 0;
1397
+ entry.run.cancel();
1398
+ this.leaving.delete(key);
1399
+ return entry.inst;
1400
+ },
1401
+ beginLeave: (inst) => {
1402
+ if (!this.browser) return false;
1403
+ const rt = this.resolved(inst.graphNode);
1404
+ if (rt === void 0) return false;
1405
+ const el = inst.domNode;
1406
+ if (!this.dom.isElement(el)) return false;
1407
+ const key = this.keyOf(inst);
1408
+ const prior = this.leaving.get(key);
1409
+ if (prior !== void 0) prior.run.cancel();
1410
+ const run = startRun(
1411
+ this.dom,
1412
+ el,
1413
+ rt.leaveActive,
1414
+ rt.leaveFrom,
1415
+ rt.leaveTo,
1416
+ rt.duration,
1417
+ () => {
1418
+ const current = this.leaving.get(key);
1419
+ if (current !== void 0 && current.run === run) {
1420
+ this.leaving.delete(key);
1421
+ this.finalize(inst);
1422
+ }
1423
+ }
1424
+ );
1425
+ this.leaving.set(key, { inst, run });
1426
+ return true;
1427
+ },
1428
+ onEnter: (inst) => this.enter(inst)
1429
+ };
1430
+ }
1431
+ };
1432
+
1433
+ // ../renderer/src/head.ts
1434
+ var HEAD_MARKER = "data-streetui-head";
1435
+ var HEAD_KEY = "data-streetui-head-key";
1436
+ function isSignalLike(v) {
1437
+ return v !== null && typeof v === "object" && typeof v["subscribe"] === "function" && typeof v["peek"] === "function";
1438
+ }
1439
+ function readValue(v) {
1440
+ if (isSignalLike(v)) return String(v.peek() ?? "");
1441
+ return String(v ?? "");
1442
+ }
1443
+ function resolveEntry(entry) {
1444
+ const attrs = {};
1445
+ for (const key of Object.keys(entry.attrs)) {
1446
+ attrs[key] = readValue(entry.attrs[key]);
1447
+ }
1448
+ const resolved = { tag: entry.tag, attrs };
1449
+ if (entry.tag === "title") {
1450
+ return { ...resolved, text: readValue(entry.text) };
1451
+ }
1452
+ return resolved;
1453
+ }
1454
+ var HeadManager = class {
1455
+ _dom;
1456
+ _head;
1457
+ _contributions = /* @__PURE__ */ new Map();
1458
+ _applied = /* @__PURE__ */ new Map();
1459
+ _order = 0;
1460
+ _adopted = false;
1461
+ constructor(dom, head) {
1462
+ this._dom = dom;
1463
+ this._head = head;
1464
+ }
1465
+ /** Register (or replace) a node's contribution and re-apply the merged result. */
1466
+ register(nodeId, entries) {
1467
+ this._contributions.set(nodeId, { order: this._order++, entries });
1468
+ this.apply();
1469
+ }
1470
+ /** Withdraw a node's contribution (unmount / route change) and re-apply. */
1471
+ unregister(nodeId) {
1472
+ if (this._contributions.delete(nodeId)) this.apply();
1473
+ }
1474
+ /** Recompute the merged head and patch `document.head` to match. */
1475
+ apply() {
1476
+ if (!this._adopted) {
1477
+ this._adoptServerTags();
1478
+ this._adopted = true;
1479
+ }
1480
+ const ordered = [...this._contributions.values()].sort((a, b) => a.order - b.order);
1481
+ const merged = /* @__PURE__ */ new Map();
1482
+ for (const contribution of ordered) {
1483
+ for (const entry of contribution.entries) {
1484
+ merged.set(entry.dedupKey, resolveEntry(entry));
1485
+ }
1486
+ }
1487
+ for (const [key, desired] of merged) {
1488
+ const existing = this._applied.get(key);
1489
+ if (existing !== void 0 && existing.tag === desired.tag) {
1490
+ this._reconcileAttrs(existing, desired);
1491
+ } else {
1492
+ if (existing !== void 0) {
1493
+ this._dom.removeChild(this._head, existing.el);
1494
+ this._applied.delete(key);
1495
+ }
1496
+ const el = this._createTag(key, desired);
1497
+ this._dom.appendChild(this._head, el);
1498
+ this._applied.set(key, { el, attrKeys: new Set(Object.keys(desired.attrs)), tag: desired.tag });
1499
+ }
1500
+ }
1501
+ for (const [key, record] of [...this._applied]) {
1502
+ if (!merged.has(key)) {
1503
+ this._dom.removeChild(this._head, record.el);
1504
+ this._applied.delete(key);
1505
+ }
1506
+ }
1507
+ }
1508
+ _createTag(key, desired) {
1509
+ const el = this._dom.createElement(desired.tag);
1510
+ this._dom.setAttribute(el, HEAD_MARKER, "");
1511
+ this._dom.setAttribute(el, HEAD_KEY, key);
1512
+ for (const attr of Object.keys(desired.attrs)) {
1513
+ this._dom.setAttribute(el, attr, desired.attrs[attr]);
1514
+ }
1515
+ if (desired.tag === "title") this._dom.setTextContent(el, desired.text ?? "");
1516
+ return el;
1517
+ }
1518
+ _reconcileAttrs(record, desired) {
1519
+ const nextKeys = new Set(Object.keys(desired.attrs));
1520
+ for (const attr of record.attrKeys) {
1521
+ if (!nextKeys.has(attr)) this._dom.removeAttribute(record.el, attr);
1522
+ }
1523
+ for (const attr of nextKeys) {
1524
+ this._dom.setAttribute(record.el, attr, desired.attrs[attr]);
1525
+ }
1526
+ if (desired.tag === "title") this._dom.setTextContent(record.el, desired.text ?? "");
1527
+ record.attrKeys = nextKeys;
1528
+ }
1529
+ /**
1530
+ * Seed `_applied` from server-emitted `[data-streetui-head-key]` tags already
1531
+ * in `document.head`. The subsequent diff reuses these elements when the
1532
+ * client desires the same key (no duplicate), rewrites them if the value
1533
+ * changed, or removes them if the client graph no longer wants them.
1534
+ */
1535
+ _adoptServerTags() {
1536
+ for (const child of this._dom.childNodes(this._head)) {
1537
+ if (!this._dom.isElement(child)) continue;
1538
+ const el = child;
1539
+ const key = this._dom.getAttribute(el, HEAD_KEY);
1540
+ if (key === null) continue;
1541
+ this._applied.set(key, {
1542
+ el,
1543
+ attrKeys: new Set(this._attrNames(el)),
1544
+ tag: this._dom.tagName(el)
1545
+ });
1546
+ }
1547
+ }
1548
+ /** The framework-managed attribute names currently on a server tag. */
1549
+ _attrNames(el) {
1550
+ const names = [];
1551
+ if (this._dom.getAttribute(el, HEAD_MARKER) !== null) names.push(HEAD_MARKER);
1552
+ if (this._dom.getAttribute(el, HEAD_KEY) !== null) names.push(HEAD_KEY);
1553
+ return names;
1554
+ }
1555
+ };
1556
+ function getHeadManager(ctx) {
1557
+ const head = ctx.dom.head();
1558
+ if (head === null) return null;
1559
+ const mutable = ctx;
1560
+ if (mutable.head === void 0) mutable.head = new HeadManager(ctx.dom, head);
1561
+ return mutable.head;
1562
+ }
1563
+ function wireHeadBehavior(ctx, graphNode, instance) {
1564
+ const manager = getHeadManager(ctx);
1565
+ if (manager === null) return;
1566
+ const descFn = ctx.graph.getHandler(`__head__${graphNode.id}`);
1567
+ if (descFn === void 0) return;
1568
+ const contribution = descFn();
1569
+ const nodeId = graphNode.id;
1570
+ manager.register(nodeId, contribution.entries);
1571
+ for (const entry of contribution.entries) {
1572
+ for (const attrKey of Object.keys(entry.attrs)) {
1573
+ const v = entry.attrs[attrKey];
1574
+ if (isSignalLike(v)) {
1575
+ instance.trackCleanup(v.subscribe(() => manager.apply()));
1576
+ }
1577
+ }
1578
+ if (isSignalLike(entry.text)) {
1579
+ instance.trackCleanup(entry.text.subscribe(() => manager.apply()));
1580
+ }
1581
+ }
1582
+ instance.trackCleanup(() => manager.unregister(nodeId));
1583
+ }
1584
+
1199
1585
  // ../renderer/src/mount.ts
1200
1586
  var SKIP_PROP_KEYS = /* @__PURE__ */ new Set([
1201
1587
  "text",
@@ -1318,6 +1704,9 @@ function mountNode(ctx, graphNode, parentDom) {
1318
1704
  const instance2 = new NodeInstance(graphNode, el2);
1319
1705
  ctx.instances.set(graphNode.id, instance2);
1320
1706
  wireEvents(dom, graph, graphNode, el2, instance2);
1707
+ if (graphNode.stateRefs.length !== 0) {
1708
+ wireSignalBindings(ctx, graphNode, instance2, linkUpdate(dom, el2));
1709
+ }
1321
1710
  dom.appendChild(parentDom, el2);
1322
1711
  return instance2;
1323
1712
  }
@@ -1348,7 +1737,7 @@ function mountNode(ctx, graphNode, parentDom) {
1348
1737
  instance2.addChild(childInstance);
1349
1738
  }
1350
1739
  dom.appendChild(parentDom, el2);
1351
- wireReactiveList(ctx, graphNode, instance2, el2);
1740
+ wireReactiveList(ctx, graphNode, instance2, el2, true);
1352
1741
  return instance2;
1353
1742
  }
1354
1743
  if (graphNode.type === "portal") {
@@ -1373,6 +1762,16 @@ function mountNode(ctx, graphNode, parentDom) {
1373
1762
  wireOverlayBehavior(ctx, graphNode, instance2, target);
1374
1763
  return instance2;
1375
1764
  }
1765
+ if (graphNode.type === "head") {
1766
+ const anchor = dom.createElement(resolveTag("head"));
1767
+ dom.setAttribute(anchor, "data-streetui-head-anchor", "");
1768
+ applyNodeProps(ctx, graphNode, anchor);
1769
+ const instance2 = new NodeInstance(graphNode, anchor);
1770
+ ctx.instances.set(graphNode.id, instance2);
1771
+ dom.appendChild(parentDom, anchor);
1772
+ wireHeadBehavior(ctx, graphNode, instance2);
1773
+ return instance2;
1774
+ }
1376
1775
  const tag = resolveTag(graphNode.type);
1377
1776
  const el = dom.createElement(tag);
1378
1777
  applyNodeProps(ctx, graphNode, el);
@@ -1437,6 +1836,17 @@ function buttonUpdate(dom, el) {
1437
1836
  }
1438
1837
  };
1439
1838
  }
1839
+ function linkUpdate(dom, el) {
1840
+ return (propKey, value) => {
1841
+ if (propKey === "label") {
1842
+ dom.setTextContent(el, String(value ?? ""));
1843
+ } else if (propKey === "href") {
1844
+ dom.setAttribute(el, "href", String(value ?? ""));
1845
+ } else {
1846
+ applyProp(dom, el, propKey, value);
1847
+ }
1848
+ };
1849
+ }
1440
1850
  function applyNodeProps(ctx, graphNode, el) {
1441
1851
  const props = graphNode.props;
1442
1852
  for (const key in props) {
@@ -1457,32 +1867,42 @@ function wireSignalBindings(ctx, graphNode, instance, onUpdate) {
1457
1867
  instance.trackCleanup(unsub);
1458
1868
  }
1459
1869
  }
1460
- function wireReactiveList(ctx, graphNode, instance, el) {
1870
+ function wireReactiveList(ctx, graphNode, instance, el, runAppear = false) {
1461
1871
  const plan = ctx.graph.getHandler(`__listplan__${graphNode.id}`);
1462
1872
  const build = ctx.graph.getHandler(`__listbuild__${graphNode.id}`);
1463
1873
  if (plan === void 0 && build === void 0) return;
1874
+ const controller = new TransitionController(ctx.dom, ctx.graph, (leaving) => {
1875
+ const parent = ctx.dom.parentNode(leaving.domNode);
1876
+ if (parent !== null) ctx.dom.removeChild(parent, leaving.domNode);
1877
+ leaving.dispose();
1878
+ forgetInstance(ctx, leaving);
1879
+ ctx.graph.detachNode(leaving.graphNode);
1880
+ });
1881
+ const hooks = controller.hooks();
1882
+ if (runAppear) controller.appear(instance.children);
1464
1883
  for (const stateRef of graphNode.stateRefs) {
1465
1884
  if (stateRef.propKey !== "items") continue;
1466
1885
  const sig = ctx.graph.getHandler(`__signal__${stateRef.signalId}`);
1467
1886
  if (sig === void 0 || typeof sig.subscribe !== "function") continue;
1468
1887
  const unsub = sig.subscribe((value) => {
1469
1888
  if (plan !== void 0) {
1470
- reconcileReactiveListByPlan(ctx, graphNode, instance, el, plan(value));
1889
+ reconcileReactiveListByPlan(ctx, graphNode, instance, el, plan(value), hooks);
1471
1890
  } else {
1472
- reconcileReactiveList(ctx, graphNode, instance, el, build(value));
1891
+ reconcileReactiveList(ctx, graphNode, instance, el, build(value), hooks);
1473
1892
  }
1474
1893
  });
1475
1894
  instance.trackCleanup(unsub);
1476
1895
  }
1477
1896
  }
1478
- function reconcileReactiveListByPlan(ctx, listNode, listInstance, listEl, plan) {
1897
+ function reconcileReactiveListByPlan(ctx, listNode, listInstance, listEl, plan, hooks) {
1479
1898
  const oldInstances = [...listInstance.children];
1480
1899
  const result = reconcileChildrenByPlan(
1481
1900
  ctx,
1482
1901
  listEl,
1483
1902
  oldInstances,
1484
1903
  plan,
1485
- (node, parent) => mountNode(ctx, node, parent)
1904
+ (node, parent) => mountNode(ctx, node, parent),
1905
+ hooks
1486
1906
  );
1487
1907
  listInstance.children.length = 0;
1488
1908
  for (const inst of result.instances) listInstance.children.push(inst);
@@ -1497,14 +1917,15 @@ function reconcileReactiveListByPlan(ctx, listNode, listInstance, listEl, plan)
1497
1917
  for (const child of [...listNode.children]) listNode.removeChild(child);
1498
1918
  for (const inst of result.instances) listNode.appendChild(inst.graphNode);
1499
1919
  }
1500
- function reconcileReactiveList(ctx, listNode, listInstance, listEl, newNodes) {
1920
+ function reconcileReactiveList(ctx, listNode, listInstance, listEl, newNodes, hooks) {
1501
1921
  const oldInstances = [...listInstance.children];
1502
1922
  const result = reconcileChildren(
1503
1923
  ctx,
1504
1924
  listEl,
1505
1925
  oldInstances,
1506
1926
  newNodes,
1507
- (node, parent) => mountNode(ctx, node, parent)
1927
+ (node, parent) => mountNode(ctx, node, parent),
1928
+ hooks
1508
1929
  );
1509
1930
  listInstance.children.length = 0;
1510
1931
  for (const inst of result.instances) listInstance.children.push(inst);
@@ -1540,11 +1961,14 @@ function wireOverlayBehavior(ctx, graphNode, instance, target) {
1540
1961
  const onOpenChange = (isOpen) => {
1541
1962
  if (isOpen) {
1542
1963
  if (desc.restoreFocus) saved = saveFocus(dom);
1543
- if (desc.takesFocus) focusInitial(dom, target, desc.initialFocusId);
1544
1964
  if (desc.modal) {
1545
1965
  active.push(trapFocus(dom, target));
1546
1966
  active.push(containFocus(dom, target));
1547
1967
  }
1968
+ if (desc.menu) {
1969
+ active.push(rovingMenu(dom, target));
1970
+ }
1971
+ if (desc.takesFocus) focusInitial(dom, target, desc.initialFocusId);
1548
1972
  if (desc.closeOnEscape && desc.onClose !== void 0) {
1549
1973
  active.push(onEscape(dom, target, desc.onClose));
1550
1974
  }
@@ -1650,7 +2074,12 @@ function hydrateNode(ctx, graphNode, domNode, path) {
1650
2074
  case "link": {
1651
2075
  const instance = new NodeInstance(graphNode, domNode);
1652
2076
  ctx.instances.set(graphNode.id, instance);
1653
- if (graphNode.type === "link") wireEvents(dom, graph, graphNode, domNode, instance);
2077
+ if (graphNode.type === "link") {
2078
+ wireEvents(dom, graph, graphNode, domNode, instance);
2079
+ if (graphNode.stateRefs.length !== 0) {
2080
+ wireSignalBindings(ctx, graphNode, instance, linkUpdate(dom, domNode));
2081
+ }
2082
+ }
1654
2083
  return instance;
1655
2084
  }
1656
2085
  case "reactive-list":
@@ -1680,6 +2109,12 @@ function hydrateNode(ctx, graphNode, domNode, path) {
1680
2109
  wireOverlayBehavior(ctx, graphNode, instance, target);
1681
2110
  return instance;
1682
2111
  }
2112
+ case "head": {
2113
+ const instance = new NodeInstance(graphNode, domNode);
2114
+ ctx.instances.set(graphNode.id, instance);
2115
+ wireHeadBehavior(ctx, graphNode, instance);
2116
+ return instance;
2117
+ }
1683
2118
  default: {
1684
2119
  const instance = new NodeInstance(graphNode, domNode);
1685
2120
  ctx.instances.set(graphNode.id, instance);
@@ -1890,7 +2325,8 @@ function analyzeGraph(graph) {
1890
2325
  const isConditional = node.type === "conditional";
1891
2326
  const isPortal = node.type === "portal";
1892
2327
  const isComponent = node.type === "component";
1893
- const isStatic = node.stateRefs.length === 0 && !hasEvents && !isList && !isConditional && !isPortal && !isComponent;
2328
+ const isHead = node.type === "head";
2329
+ const isStatic = node.stateRefs.length === 0 && !hasEvents && !isList && !isConditional && !isPortal && !isComponent && !isHead;
1894
2330
  const isStaticSubtree = isStatic && allChildrenStatic;
1895
2331
  nodes.set(node.id, {
1896
2332
  isStatic,
@@ -2221,6 +2657,37 @@ async function waitFor(check, options = {}) {
2221
2657
  await new Promise((resolve) => setTimeout(resolve, interval));
2222
2658
  }
2223
2659
  }
2660
+ function focus(el) {
2661
+ el.focus?.();
2662
+ }
2663
+ function blur(el) {
2664
+ el.blur?.();
2665
+ }
2666
+ function pressKey(key, el = document.activeElement, init = {}) {
2667
+ if (el === null) return;
2668
+ el.dispatchEvent(
2669
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init })
2670
+ );
2671
+ }
2672
+ function clickOutside(container, target = document.body) {
2673
+ if (container.contains(target)) {
2674
+ throw new Error("[StreetUI Testing] clickOutside: target is inside the container");
2675
+ }
2676
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
2677
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
2678
+ }
2679
+ async function openOverlay(open) {
2680
+ open.set(true);
2681
+ await flushUpdates();
2682
+ }
2683
+ async function closeOverlay(open) {
2684
+ open.set(false);
2685
+ await flushUpdates();
2686
+ }
2687
+ async function waitForTransition(el) {
2688
+ el.dispatchEvent(new Event("transitionend", { bubbles: true }));
2689
+ await flushUpdates();
2690
+ }
2224
2691
  function findByText(container, text) {
2225
2692
  const match = Array.from(container.querySelectorAll("*")).find(
2226
2693
  (el) => el.children.length === 0 && (el.textContent?.includes(text) ?? false)
@@ -2315,6 +2782,154 @@ function renderServerThenHydrate(build, options = {}) {
2315
2782
  };
2316
2783
  }
2317
2784
 
2785
+ // ../dsl/src/transition.ts
2786
+ function classes(value) {
2787
+ if (value === void 0) return [];
2788
+ const out = [];
2789
+ for (const token of value.split(/\s+/)) {
2790
+ if (token.length > 0 && !out.includes(token)) out.push(token);
2791
+ }
2792
+ return out;
2793
+ }
2794
+ function merge(a, b) {
2795
+ const out = [...a];
2796
+ for (const token of b) if (!out.includes(token)) out.push(token);
2797
+ return out;
2798
+ }
2799
+ function resolveTransition(config) {
2800
+ const n = config.name;
2801
+ const enterActive = merge(
2802
+ classes(config.enter),
2803
+ classes(config.enterActive ?? (n !== void 0 ? `${n}-enter-active` : void 0))
2804
+ );
2805
+ const leaveActive = merge(
2806
+ classes(config.leave),
2807
+ classes(config.leaveActive ?? (n !== void 0 ? `${n}-leave-active` : void 0))
2808
+ );
2809
+ return {
2810
+ enterActive,
2811
+ enterFrom: classes(config.enterFrom ?? (n !== void 0 ? `${n}-enter-from` : void 0)),
2812
+ enterTo: classes(config.enterTo ?? (n !== void 0 ? `${n}-enter-to` : void 0)),
2813
+ leaveActive,
2814
+ leaveFrom: classes(config.leaveFrom ?? (n !== void 0 ? `${n}-leave-from` : void 0)),
2815
+ leaveTo: classes(config.leaveTo ?? (n !== void 0 ? `${n}-leave-to` : void 0)),
2816
+ appear: config.appear ?? false,
2817
+ duration: config.duration ?? 1e3
2818
+ };
2819
+ }
2820
+
2821
+ // ../dsl/src/head.ts
2822
+ function metaDedupKey(m) {
2823
+ if (m.charset !== void 0) return "meta:charset";
2824
+ if (m.name !== void 0) return `meta:name=${m.name}`;
2825
+ if (m.property !== void 0) return `meta:property=${m.property}`;
2826
+ if (m.httpEquiv !== void 0) return `meta:http-equiv=${m.httpEquiv}`;
2827
+ return void 0;
2828
+ }
2829
+ function metaAttrs(m) {
2830
+ const attrs = {};
2831
+ if (m.charset !== void 0) attrs["charset"] = m.charset;
2832
+ if (m.name !== void 0) attrs["name"] = m.name;
2833
+ if (m.property !== void 0) attrs["property"] = m.property;
2834
+ if (m.httpEquiv !== void 0) attrs["http-equiv"] = m.httpEquiv;
2835
+ if (m.content !== void 0) attrs["content"] = m.content;
2836
+ return attrs;
2837
+ }
2838
+ function linkAttrs(l) {
2839
+ const attrs = { rel: l.rel, href: l.href };
2840
+ if (l.sizes !== void 0) attrs["sizes"] = l.sizes;
2841
+ if (l.type !== void 0) attrs["type"] = l.type;
2842
+ if (l.media !== void 0) attrs["media"] = l.media;
2843
+ if (l.as !== void 0) attrs["as"] = l.as;
2844
+ if (l.crossorigin !== void 0) attrs["crossorigin"] = l.crossorigin;
2845
+ if (l.hreflang !== void 0) attrs["hreflang"] = l.hreflang;
2846
+ return attrs;
2847
+ }
2848
+ function resolveHead(config) {
2849
+ const entries = [];
2850
+ if (config.charset !== void 0) {
2851
+ entries.push({ tag: "meta", dedupKey: "meta:charset", attrs: { charset: config.charset } });
2852
+ }
2853
+ if (config.base !== void 0) {
2854
+ entries.push({ tag: "base", dedupKey: "base", attrs: { href: config.base } });
2855
+ }
2856
+ if (config.title !== void 0) {
2857
+ entries.push({ tag: "title", dedupKey: "title", attrs: {}, text: config.title });
2858
+ }
2859
+ if (config.description !== void 0) {
2860
+ entries.push({
2861
+ tag: "meta",
2862
+ dedupKey: "meta:name=description",
2863
+ attrs: { name: "description", content: config.description }
2864
+ });
2865
+ }
2866
+ if (config.canonical !== void 0) {
2867
+ entries.push({
2868
+ tag: "link",
2869
+ dedupKey: "link:rel=canonical",
2870
+ attrs: { rel: "canonical", href: config.canonical }
2871
+ });
2872
+ }
2873
+ if (config.robots !== void 0) {
2874
+ entries.push({
2875
+ tag: "meta",
2876
+ dedupKey: "meta:name=robots",
2877
+ attrs: { name: "robots", content: config.robots }
2878
+ });
2879
+ }
2880
+ if (config.themeColor !== void 0) {
2881
+ entries.push({
2882
+ tag: "meta",
2883
+ dedupKey: "meta:name=theme-color",
2884
+ attrs: { name: "theme-color", content: config.themeColor }
2885
+ });
2886
+ }
2887
+ if (config.viewport !== void 0) {
2888
+ entries.push({
2889
+ tag: "meta",
2890
+ dedupKey: "meta:name=viewport",
2891
+ attrs: { name: "viewport", content: config.viewport }
2892
+ });
2893
+ }
2894
+ if (config.favicon !== void 0) {
2895
+ const l = typeof config.favicon === "string" ? { rel: "icon", href: config.favicon } : config.favicon;
2896
+ entries.push({ tag: "link", dedupKey: `link:rel=${l.rel}`, attrs: linkAttrs(l) });
2897
+ }
2898
+ if (config.openGraph !== void 0) {
2899
+ for (const key of Object.keys(config.openGraph)) {
2900
+ const property = `og:${key}`;
2901
+ entries.push({
2902
+ tag: "meta",
2903
+ dedupKey: `meta:property=${property}`,
2904
+ attrs: { property, content: config.openGraph[key] }
2905
+ });
2906
+ }
2907
+ }
2908
+ if (config.twitter !== void 0) {
2909
+ for (const key of Object.keys(config.twitter)) {
2910
+ const name = `twitter:${key}`;
2911
+ entries.push({
2912
+ tag: "meta",
2913
+ dedupKey: `meta:name=${name}`,
2914
+ attrs: { name, content: config.twitter[key] }
2915
+ });
2916
+ }
2917
+ }
2918
+ if (config.meta !== void 0) {
2919
+ for (const m of config.meta) {
2920
+ const key = metaDedupKey(m);
2921
+ if (key === void 0) continue;
2922
+ entries.push({ tag: "meta", dedupKey: key, attrs: metaAttrs(m) });
2923
+ }
2924
+ }
2925
+ if (config.link !== void 0) {
2926
+ for (const l of config.link) {
2927
+ entries.push({ tag: "link", dedupKey: `link:rel=${l.rel}:href=${l.href}`, attrs: linkAttrs(l) });
2928
+ }
2929
+ }
2930
+ return { entries };
2931
+ }
2932
+
2318
2933
  // ../state/src/signal.ts
2319
2934
  var _activeConsumer = null;
2320
2935
  function withConsumer(consumer, fn) {
@@ -2524,6 +3139,18 @@ function applyA11yProps(props, options) {
2524
3139
  if (options.ariaInvalid !== void 0) props["aria-invalid"] = String(options.ariaInvalid);
2525
3140
  if (options.ariaRequired !== void 0) props["aria-required"] = String(options.ariaRequired);
2526
3141
  if (options.ariaModal !== void 0) props["aria-modal"] = String(options.ariaModal);
3142
+ if (options.ariaOwns !== void 0) props["aria-owns"] = options.ariaOwns;
3143
+ if (options.ariaActiveDescendant !== void 0) props["aria-activedescendant"] = options.ariaActiveDescendant;
3144
+ if (options.ariaHasPopup !== void 0) props["aria-haspopup"] = String(options.ariaHasPopup);
3145
+ if (options.ariaSelected !== void 0) props["aria-selected"] = String(options.ariaSelected);
3146
+ }
3147
+ function registerTransition(graph, node, config) {
3148
+ if (config === void 0) return;
3149
+ const resolved = resolveTransition(config);
3150
+ graph.registerHandler(
3151
+ `__transition__${node.id}`,
3152
+ () => resolved
3153
+ );
2527
3154
  }
2528
3155
  function containerProps(options) {
2529
3156
  const props = {};
@@ -2584,6 +3211,7 @@ var OVERLAY_KINDS = {
2584
3211
  role: "menu",
2585
3212
  modal: false,
2586
3213
  takesFocus: true,
3214
+ menu: true,
2587
3215
  ariaModal: false,
2588
3216
  defaultCloseOnEscape: true,
2589
3217
  defaultRestoreFocus: true
@@ -2716,6 +3344,7 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2716
3344
  parent: this._node,
2717
3345
  props: containerProps(options)
2718
3346
  });
3347
+ registerTransition(this._graph, node, options.transition);
2719
3348
  builder(new SectionBuilderImpl(node, this._graph));
2720
3349
  }
2721
3350
  container(key, builder, options = {}) {
@@ -2724,6 +3353,7 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2724
3353
  parent: this._node,
2725
3354
  props: containerProps(options)
2726
3355
  });
3356
+ registerTransition(this._graph, node, options.transition);
2727
3357
  builder(new ContainerBuilderImpl(node, this._graph));
2728
3358
  }
2729
3359
  list(key, builder, options = {}) {
@@ -2732,6 +3362,7 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2732
3362
  parent: this._node,
2733
3363
  props: containerProps(options)
2734
3364
  });
3365
+ registerTransition(this._graph, node, options.transition);
2735
3366
  builder(new ListBuilderImpl(node, this._graph));
2736
3367
  }
2737
3368
  listOf(key, items, renderItem, options = {}) {
@@ -2741,6 +3372,7 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2741
3372
  parent: this._node,
2742
3373
  props: containerProps(options)
2743
3374
  });
3375
+ registerTransition(graph, node, options.transition);
2744
3376
  const signalId = `${node.id}:items`;
2745
3377
  node.stateRefs.push({ signalId, propKey: "items" });
2746
3378
  graph.registerHandler(`__signal__${signalId}`, items);
@@ -2762,6 +3394,7 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2762
3394
  }
2763
3395
  });
2764
3396
  renderItem(item, index, new ContainerBuilderImpl(itemNode, graph));
3397
+ registerTransition(graph, itemNode, options.itemTransition);
2765
3398
  return itemNode;
2766
3399
  };
2767
3400
  const buildPlan = (raw) => {
@@ -2793,6 +3426,7 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2793
3426
  parent: this._node,
2794
3427
  props
2795
3428
  });
3429
+ registerTransition(this._graph, node, options.transition);
2796
3430
  if (options.onSubmit !== void 0) {
2797
3431
  const handlerKey = `submit:${node.id}`;
2798
3432
  this._graph.registerHandler(handlerKey, options.onSubmit);
@@ -2800,18 +3434,20 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2800
3434
  }
2801
3435
  builder(new FormBuilderImpl(node, this._graph));
2802
3436
  }
2803
- when(condition, builder, elseBuilder) {
3437
+ when(condition, builder, elseBuilder, options = {}) {
2804
3438
  const graph = this._graph;
2805
3439
  const node = graph.createNode("conditional", {
2806
3440
  parent: this._node,
2807
3441
  props: containerProps({})
2808
3442
  });
3443
+ const branchTransition = options.transition !== void 0 && options.appear === true ? { ...options.transition, appear: true } : options.transition;
2809
3444
  const buildBranch = (build, tag) => {
2810
3445
  const branchKey = `when-${tag}:${node.id}`;
2811
3446
  const branch = graph.createNode("container", {
2812
3447
  key: branchKey,
2813
3448
  props: { key: branchKey }
2814
3449
  });
3450
+ registerTransition(graph, branch, branchTransition);
2815
3451
  build(new ContainerBuilderImpl(branch, graph));
2816
3452
  return branch;
2817
3453
  };
@@ -2857,8 +3493,14 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2857
3493
  this.container(id, (c) => {
2858
3494
  c.when(
2859
3495
  hasError,
2860
- // Error state → fallback.
2861
- (fb) => options.fallback(fb, readError(), retry),
3496
+ // Error state → fallback. The fallback branch mounts exactly when the
3497
+ // boundary enters its error state, so this is also where the optional
3498
+ // `onError` reporting hook fires (observe-only; §6/§7).
3499
+ (fb) => {
3500
+ const currentError = readError();
3501
+ options.onError?.(currentError);
3502
+ options.fallback(fb, currentError, retry);
3503
+ },
2862
3504
  // Healthy state → body, guarded against synchronous build throws.
2863
3505
  (body) => {
2864
3506
  try {
@@ -2870,6 +3512,24 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2870
3512
  );
2871
3513
  }, { id });
2872
3514
  }
3515
+ asyncBoundary(key, res, branches) {
3516
+ const isError = derived(() => res.status.get() === "error");
3517
+ const showSuccess = derived(
3518
+ () => res.status.get() !== "error" && res.data.get() !== void 0
3519
+ );
3520
+ const showLoading = derived(
3521
+ () => res.status.get() !== "error" && res.data.get() === void 0
3522
+ );
3523
+ const dataSignal = derived(() => res.data.get());
3524
+ const retry = () => {
3525
+ void res.refetch();
3526
+ };
3527
+ this.container(key, (c) => {
3528
+ c.when(isError, (fb) => branches.error?.(fb, res.error.peek(), retry));
3529
+ c.when(showSuccess, (sb) => branches.success(sb, dataSignal));
3530
+ c.when(showLoading, (lb) => branches.loading?.(lb));
3531
+ }, { key });
3532
+ }
2873
3533
  // ── Portals & overlays ──────────────────────────────────────────────────────
2874
3534
  portal(key, builder, options = {}) {
2875
3535
  const node = this._graph.createNode("portal", {
@@ -2879,6 +3539,31 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2879
3539
  });
2880
3540
  builder(new ContainerBuilderImpl(node, this._graph));
2881
3541
  }
3542
+ /**
3543
+ * Declare document metadata (2.0 §1–§3). Creates a `'head'` node — a neutral,
3544
+ * empty inline anchor at this position (one node / one element, so positional
3545
+ * hydration is preserved) — and registers a `__head__<id>` descriptor holding
3546
+ * this call's normalized, dedup-keyed {@link resolveHead} contribution. The
3547
+ * renderer applies it to `document.head` on the browser (adopting server tags
3548
+ * on hydration, cleaning up on unmount / route change) and emits the active
3549
+ * graph's merged metadata as a string on the server (`renderHead`).
3550
+ *
3551
+ * Multiple `head()` nodes may be live at once (app default + route + component)
3552
+ * — the renderer merges them and, per dedup key, the last in document order
3553
+ * wins (see head.ts). No new render path: this reuses the same graph-node +
3554
+ * handler-registry convention as overlays/transitions/components.
3555
+ */
3556
+ head(metadata) {
3557
+ const node = this._graph.createNode("head", {
3558
+ parent: this._node,
3559
+ props: { "data-streetui-head-anchor": "" }
3560
+ });
3561
+ const contribution = resolveHead(metadata);
3562
+ this._graph.registerHandler(
3563
+ `__head__${node.id}`,
3564
+ () => contribution
3565
+ );
3566
+ }
2882
3567
  /**
2883
3568
  * Shared assembly for every overlay kind: a `portal` node whose single child
2884
3569
  * is a `when(open, panel)` conditional. The panel container carries the
@@ -2906,13 +3591,24 @@ var ContainerBuilderBase = class extends ContentBuilderBase {
2906
3591
  ...options.ariaDescribedBy !== void 0 ? { ariaDescribedBy: options.ariaDescribedBy } : {}
2907
3592
  };
2908
3593
  const portalBuilder = new ContainerBuilderImpl(portalNode, graph);
2909
- portalBuilder.when(openSignal, (panelHost) => {
2910
- panelHost.container(`${key}__panel`, builder, panelOptions);
2911
- });
3594
+ portalBuilder.when(
3595
+ openSignal,
3596
+ (panelHost) => {
3597
+ panelHost.container(`${key}__panel`, builder, panelOptions);
3598
+ },
3599
+ void 0,
3600
+ // Overlay open/close rides the panel's `when`; a transition animates the
3601
+ // panel in on open and — via the reconciler's deferred-leave — plays the
3602
+ // leave before the panel is removed (§10). Focus is restored at close-
3603
+ // request time (see wireOverlayBehavior), so it never stays trapped inside
3604
+ // a panel that is animating away.
3605
+ options.transition !== void 0 ? { transition: options.transition } : {}
3606
+ );
2912
3607
  const descriptor = {
2913
3608
  open: openSignal,
2914
3609
  modal: kind.modal,
2915
3610
  takesFocus: kind.takesFocus,
3611
+ menu: kind.menu ?? false,
2916
3612
  closeOnEscape: options.closeOnEscape ?? kind.defaultCloseOnEscape,
2917
3613
  restoreFocus: options.restoreFocus ?? kind.defaultRestoreFocus,
2918
3614
  ...options.initialFocusId !== void 0 ? { initialFocusId: options.initialFocusId } : {},
@@ -3009,6 +3705,7 @@ var ListBuilderImpl = class extends ContentBuilderBase {
3009
3705
  parent: this._node,
3010
3706
  props: containerProps(options)
3011
3707
  });
3708
+ registerTransition(this._graph, node, options.transition);
3012
3709
  builder(new ContainerBuilderImpl(node, this._graph));
3013
3710
  }
3014
3711
  };
@@ -3196,6 +3893,8 @@ var ApplicationGraph = class {
3196
3893
  this.handlers.delete(`__listplan__${node.id}`);
3197
3894
  this.handlers.delete(`__overlay__${node.id}`);
3198
3895
  this.handlers.delete(`__component__${node.id}`);
3896
+ this.handlers.delete(`__transition__${node.id}`);
3897
+ this.handlers.delete(`__head__${node.id}`);
3199
3898
  }
3200
3899
  // ── Handler registry ──────────────────────────────────────────────────────
3201
3900
  registerHandler(key, fn) {
@@ -3373,25 +4072,32 @@ function trigger(el, type, init = {}) {
3373
4072
  }
3374
4073
 
3375
4074
  // src/version.ts
3376
- var VERSION = "1.8.0";
4075
+ var VERSION = "2.0.0";
3377
4076
  export {
3378
4077
  VERSION,
3379
4078
  analyzeGraph,
4079
+ blur,
4080
+ clickOutside,
4081
+ closeOverlay,
3380
4082
  findAllByRole,
3381
4083
  findAllComponents,
3382
4084
  findByRole,
3383
4085
  findByText,
3384
4086
  findComponent,
3385
4087
  flushUpdates,
4088
+ focus,
3386
4089
  formatInspection,
3387
4090
  getComponentName,
3388
4091
  hydrateComponent,
3389
4092
  inspectCompilation,
4093
+ openOverlay,
4094
+ pressKey,
3390
4095
  render,
3391
4096
  renderComponent,
3392
4097
  renderOnce,
3393
4098
  renderServerThenHydrate,
3394
4099
  trigger,
3395
- waitFor
4100
+ waitFor,
4101
+ waitForTransition
3396
4102
  };
3397
4103
  //# sourceMappingURL=testing.js.map