@faqir-ui/core 1.1.0 → 1.1.2

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.
Files changed (37) hide show
  1. package/cdn.json +35 -35
  2. package/dist/faqir-core.js +528 -333
  3. package/dist/faqir-core.min.js +3 -3
  4. package/dist/faqir-core.min.js.map +3 -3
  5. package/dist/faqir-core.mjs +532 -482
  6. package/dist/faqir.aurora.css +1486 -1036
  7. package/dist/faqir.brutalist.css +1486 -1036
  8. package/dist/faqir.candy.css +1486 -1036
  9. package/dist/faqir.clinical.css +1486 -1036
  10. package/dist/faqir.contrast.css +1486 -1036
  11. package/dist/faqir.default.css +1486 -1036
  12. package/dist/faqir.document-serif.css +1486 -1036
  13. package/dist/faqir.document.css +1486 -1036
  14. package/dist/faqir.editorial-document.css +1486 -1036
  15. package/dist/faqir.editorial.css +1486 -1036
  16. package/dist/faqir.fintech.css +1486 -1036
  17. package/dist/faqir.glass.css +1486 -1036
  18. package/dist/faqir.ink-document.css +1486 -1036
  19. package/dist/faqir.ink.css +1486 -1036
  20. package/dist/faqir.luxe.css +1486 -1036
  21. package/dist/faqir.midnight.css +1486 -1036
  22. package/dist/faqir.neo.css +1486 -1036
  23. package/dist/faqir.neumorph.css +1486 -1036
  24. package/dist/faqir.nordic.css +1486 -1036
  25. package/dist/faqir.organic.css +1486 -1036
  26. package/dist/faqir.paper.css +1486 -1036
  27. package/dist/faqir.slate.css +1486 -1036
  28. package/dist/faqir.soft.css +1486 -1036
  29. package/dist/faqir.sunset.css +1486 -1036
  30. package/dist/faqir.swiss-document.css +1486 -1036
  31. package/dist/faqir.swiss.css +1486 -1036
  32. package/dist/faqir.terminal.css +1486 -1036
  33. package/dist/plugins/faqir-mask.js +1 -2
  34. package/dist/plugins/faqir-rules.js +1 -1
  35. package/dist/sri.json +33 -33
  36. package/faqir-core.d.ts +18 -3
  37. package/package.json +1 -1
@@ -10,7 +10,7 @@
10
10
  // PRODUCTION BUILD — dev-only diagnostics stripped at assembly time.
11
11
  // Load faqir-core.dev.js instead while developing for full warnings.
12
12
  // Regenerate with: bun run build:core
13
- // Package version: 1.1.0
13
+ // Package version: 1.1.2
14
14
  // ============================================================================
15
15
 
16
16
  /**
@@ -227,7 +227,7 @@
227
227
  function evaluate(expression, scope, el) {
228
228
  try {
229
229
  const fn = compileExpression(expression);
230
- return fn.call(scope, scope, el);
230
+ return fn.call(scope, scope, el, el);
231
231
  } catch (e) {
232
232
  console.warn('[Faqir] Expression error: "' + expression + '"', e);
233
233
  return undefined;
@@ -237,7 +237,7 @@
237
237
  function evaluateAssignment(expression, scope, el) {
238
238
  try {
239
239
  const fn = compileStatement(expression);
240
- fn.call(scope, scope, el);
240
+ fn.call(scope, scope, el, el);
241
241
  } catch (e) {
242
242
  console.warn('[Faqir] Statement error: "' + expression + '"', e);
243
243
  }
@@ -261,12 +261,17 @@
261
261
  }
262
262
  }
263
263
 
264
+ // `$el` and `$this` are both the element the directive is written on. Inside
265
+ // `with($scope)` the scope's own `$el` magic (the root) wins, so `$el` only
266
+ // reaches this parameter where nothing shadows it: a top-level `l-data`,
267
+ // evaluated against `{}`. `$this` is never a scope key, so it always reaches
268
+ // it. [1.1F-06]
264
269
  function compileExpression(expr) {
265
270
  var key = 'expr:' + expr;
266
271
  if (expressionCache.has(key)) return expressionCache.get(key);
267
272
 
268
273
  var fn = new Function(
269
- '$scope', '$el',
274
+ '$scope', '$el', '$this',
270
275
  'with($scope) { return (' + expr + ') }'
271
276
  );
272
277
  expressionCache.set(key, fn);
@@ -278,7 +283,7 @@
278
283
  if (expressionCache.has(key)) return expressionCache.get(key);
279
284
 
280
285
  var fn = new Function(
281
- '$scope', '$el',
286
+ '$scope', '$el', '$this',
282
287
  'with($scope) { ' + expr + ' }'
283
288
  );
284
289
  expressionCache.set(key, fn);
@@ -316,21 +321,21 @@
316
321
  // (§3.9) and MOTION_PRESETS (§3.14) — are read from the code itself, not
317
322
  // repeated here.
318
323
  //
319
- // @ui:directive l-data | — | any element, which becomes the scope root | l-data="{ count: 0 }" | Declares a reactive scope from an object literal, or from a name registered with `Faqir.data()`. `data-prop-*` attributes are JSON-parsed and merged over it. Descendants share the scope until the next `l-data`.
320
- // @ui:directive l-init | — | a scope root, beside `l-data` | l-init="load()" | Runs once, after the scope exists and its `l-source` bindings are injected.
324
+ // @ui:directive l-data | — | any element, which becomes the scope root | l-data="{ count: 0 }" | Declares a reactive scope from an object literal, or from a name registered with `Faqir.data()`. `data-prop-*` attributes are JSON-parsed and merged over it. Descendants share the scope until the next `l-data`. Its methods are bound to the scope, so `this` is the component even when an `l-for` row calls them; a function assigned later is not bound.
325
+ // @ui:directive l-init | — | a scope root, beside `l-data` | l-init="load()" | Runs once, after the scope exists and its `l-source` bindings are injected, with the subtree's controllers already started. In `l-if` / `l-for` content it runs after insertion: bindings, insert, controllers, then `l-init` / first `l-effect`.
321
326
  // @ui:directive l-source:<name> | — | a scope root, beside `l-data` | l-source:tasks="/api/tasks" | Binds a REST collection into the scope as `<name>`, `<name>Loading`, `<name>Error` and the `$<name>` CRUD controller. See below.
322
327
  // @ui:directive l-text | — | any element | l-text="count" | Writes the value to `textContent`; `null` and `undefined` write an empty string.
323
328
  // @ui:directive l-html | — | any element | l-html="body" | Writes the value to `innerHTML`, unsanitized — never pass user input. The dev engine reports every use.
324
- // @ui:directive l-bind:<attr> | :<attr> | any element | :disabled="loading" | Binds one attribute. `class` and `style` take a string, an array or an object; boolean attributes are added or removed; `null`, `undefined` and `false` remove the attribute.
329
+ // @ui:directive l-bind:<attr> | :<attr> | any element | :disabled="loading" | Binds one attribute. `class` and `style` take a string, an array or an object and merge with what is there: each run removes only what its previous run set, so static markup, `l-show` and controllers keep theirs. Boolean attributes are added or removed; `null`, `undefined` and `false` remove the attribute.
325
330
  // @ui:directive l-on:<event> | @<event> | any element | @click="count++" | Adds a listener for `<event>`. Inside the expression `$event` is the DOM event.
326
331
  // @ui:directive l-model | — | an input, textarea or select, or `[data-ui="switch"]` | l-model="name" | Two-way binding. A checkbox bound to an array is a checkbox group; radios bind by value; `<select multiple>` is not handled. The modifiers below apply to the text-like branch (input, textarea, number) only.
327
332
  // @ui:directive l-show | — | any element | l-show="open" | Toggles `display: none`, leaving the element in the DOM. Runs `l-transition` on each flip.
328
333
  // @ui:directive l-if | — | a `<template>` element | <template l-if="open"> | Inserts and removes the template's content. Removal tears the subtree down: cleanups run and in-flight `l-source` requests abort.
329
334
  // @ui:directive l-for | — | a `<template>` element | <template l-for="(task, i) in tasks"> | Repeats the template's content once per item, with the item and index names bound in a child scope.
330
335
  // @ui:directive l-key | — | the same `<template>` as `l-for` | l-key="task.id" | The reconciliation key. Without it items are matched by position, so a reorder re-renders rather than moves; the dev engine reports that case.
331
- // @ui:directive l-ref | — | any element | l-ref="field" | Registers the element on the scope's `$refs` under that name.
336
+ // @ui:directive l-ref | — | any element | l-ref="field" | Registers the element on the scope's `$refs` under that name — also from inside `l-if`, `l-for` and `l-teleport`, where the scope is the enclosing one. In an `l-for` the last row rendered wins, and the ref is cleared when that row goes. Effects that read it re-run when it appears or goes.
332
337
  // @ui:directive l-effect | — | any element | l-effect="document.title = title" | Runs the expression immediately, then again whenever a value it read changes.
333
- // @ui:directive l-cloak | — | any element | l-cloak | Removed from every element once the tree is initialized. Pair with `[l-cloak] { display: none }` to hide markup before it binds.
338
+ // @ui:directive l-cloak | — | any element | l-cloak | Removed from every element once the tree is initialized. Faqir's CSS (`base/reset.css`) hides `[l-cloak]` until then; the engine injects no style, so a page without that CSS needs its own `[l-cloak] { display: none }` rule.
334
339
  // @ui:directive l-transition | — | an `l-show` element, or a top-level element inside a `<template l-if>` | l-transition="slide-up" | Names the motion preset for that element's enter/leave cycle. The engine only stamps `data-motion`; the CSS animates.
335
340
  // @ui:directive l-teleport | — | any element | l-teleport="body" | Moves the element into the first element matching the value, which is a plain CSS selector rather than an expression.
336
341
  //
@@ -354,6 +359,7 @@
354
359
  // @ui:modifier l-source .key | Names the identity property, `id` by default: `.key.uuid`.
355
360
  //
356
361
  // @ui:magic $el | every expression | The scope ROOT — the element carrying `l-data`, not the element the expression is written on. Every magic that walks the DOM starts from here.
362
+ // @ui:magic $this | every expression | The element the directive is written on — the `<input>` carrying `l-init`, the button carrying `@click`. In `l-data` and a root's `l-init` it is the root; in `l-for`'s list and `l-key` expressions, the `<template>`. Not a scope key, so `Faqir.magic()` cannot shadow it.
357
363
  // @ui:magic $refs | every expression | The scope's `l-ref` elements, keyed by name. Cleared entry by entry as elements are destroyed.
358
364
  // @ui:magic $store | every expression | Every store registered with `Faqir.store()`.
359
365
  // @ui:magic $state | every expression | `data-state` of the `[data-ui]` closest to the scope root. Writable — assigning sets the attribute — and reads re-run when a controller changes it.
@@ -478,6 +484,37 @@
478
484
  // point authors at ("a scope root, beside `l-data`"). [W2-1]
479
485
  var ROOT_CONSUMED = { data: 1, source: 1, init: 1 };
480
486
 
487
+ // `l-init` and each `l-effect`'s first run, held back while a structural
488
+ // render builds content that is not in the document yet. [1.1F-03]
489
+ var pendingInits = null;
490
+
491
+ function runInit(fn) {
492
+ if (pendingInits) pendingInits.push(fn); else fn();
493
+ }
494
+
495
+ // `l-if` / `l-for` content ran its `l-init` and its effects' first pass while
496
+ // still detached — before insertion, and before any controller inside it had
497
+ // started — so `$ui('#x')` there was null, and an effect that read nothing
498
+ // reactive never ran again. `render` binds and inserts, and returns the nodes
499
+ // it inserted; their controllers start next, and only then does the held work
500
+ // run: bindings → insertion → controllers → `l-init` / `l-effect`. A render
501
+ // nested inside another (an `l-if` in an `l-for` row) joins the outer one,
502
+ // which alone knows when the content is attached. Controllers are not started
503
+ // on content that is still detached. Untracked, so nothing the held work reads
504
+ // subscribes the structural effect that rendered it. [1.1F-03]
505
+ function renderThenInit(render) {
506
+ if (pendingInits) return render();
507
+ var queue = pendingInits = [];
508
+ var nodes;
509
+ try { nodes = render(); } finally { pendingInits = null; }
510
+ untrack(function() {
511
+ for (var i = 0; i < nodes.length; i++) {
512
+ if (nodes[i].nodeType === 1 && nodes[i].isConnected) startControllers(nodes[i]);
513
+ }
514
+ for (var q = 0; q < queue.length; q++) queue[q]();
515
+ });
516
+ }
517
+
481
518
  function initTree(root, parentScope) {
482
519
  var scope = initScope(root, parentScope);
483
520
 
@@ -558,6 +595,17 @@
558
595
  // at `<body>`. Created once, on first use, so a page with no stray directive
559
596
  // pays nothing for it.
560
597
  var strayScope = null;
598
+
599
+ // The element a scope's `$refs` live on, keyed by the scope object. Not
600
+ // `findScopeRoot`: `__faqirScope` also marks "already initialised", so an
601
+ // `l-if` clone or an `l-for` row carries it too, and a ref resolved by walking
602
+ // up landed on the clone while `$refs` read the real root — invisible. A
603
+ // teleported element has left the root's subtree, and a stray element has no
604
+ // stamped ancestor at all. A WeakMap rather than a scope property, because a
605
+ // read through the reactive scope inside a structural effect would subscribe
606
+ // it. [1.1F-01]
607
+ var scopeRoots = new WeakMap();
608
+
561
609
  function getStrayScope() {
562
610
  if (!strayScope) {
563
611
  if (!document.body.__scopeId) document.body.__scopeId = ++scopeCounter;
@@ -567,6 +615,7 @@
567
615
  // MutationObserver would stop giving standalone `[data-ui]` islands their
568
616
  // own scope.
569
617
  strayScope = createScopeWithMagics({}, document.body, document.body);
618
+ scopeRoots.set(strayScope, document.body);
570
619
  }
571
620
  return strayScope;
572
621
  }
@@ -619,6 +668,7 @@
619
668
 
620
669
  var scopeId = ++scopeCounter;
621
670
  var scope = createScopeWithMagics(userData, root, root);
671
+ scopeRoots.set(scope, root);
622
672
  root.__faqirScope = scope;
623
673
  root.__scopeId = scopeId;
624
674
  root.__faqirCleanups = [];
@@ -643,7 +693,7 @@
643
693
 
644
694
  var initExpr = root.getAttribute('l-init');
645
695
  if (initExpr) {
646
- evaluateAssignment(initExpr, scope, root);
696
+ runInit(function() { evaluateAssignment(initExpr, scope, root); });
647
697
  }
648
698
 
649
699
  return scope;
@@ -733,6 +783,15 @@
733
783
  Object.defineProperties(target, descriptors);
734
784
 
735
785
  var scope = reactive(target);
786
+ // A bare call `pick()` inside `with(rowScope)` gets the l-for row scope as
787
+ // `this`, and that scope owns the loop variable and index: `this.item = x`
788
+ // wrote the row instead of the component. Bind each own method to the
789
+ // scope. Getters keep their receiver; functions assigned later stay
790
+ // unbound. Written to the target, so nothing is triggered. [1.1F-05]
791
+ for (var k in descriptors) {
792
+ var d = descriptors[k];
793
+ if (d.writable && typeof d.value === 'function') target[k] = d.value.bind(scope);
794
+ }
736
795
  return scope;
737
796
  }
738
797
 
@@ -751,8 +810,10 @@
751
810
 
752
811
  // --- Scope utilities ---
753
812
 
813
+ // Created on first read, so an effect that reads a ref before it exists
814
+ // subscribes to the same object `handleRef` later writes through.
754
815
  function getScopeRefs(root) {
755
- return root.__faqirRefs || {};
816
+ return root.__faqirRefs || (root.__faqirRefs = {});
756
817
  }
757
818
 
758
819
  function findScopeRoot(el) {
@@ -1185,16 +1246,38 @@
1185
1246
  'open', 'novalidate', 'formnovalidate', 'inert'
1186
1247
  ]);
1187
1248
 
1249
+ // Properties whose live state stops following the attribute once the user
1250
+ // has touched the control (the "dirty" flag), so an attribute-only write
1251
+ // leaves a stale control. `muted` and `indeterminate` have no attribute at all.
1252
+ var PROPS = new Set(['checked', 'selected', 'muted', 'indeterminate', 'value']);
1253
+
1254
+ // Write a DOM property only when it differs. `checked`/`selected` writes
1255
+ // notify with a bubbling `faqir:model` — never a native `change`, which would
1256
+ // re-enter l-model's own listener and fire user `@change` handlers on a
1257
+ // store-driven write.
1258
+ function writeProp(el, name, value) {
1259
+ if (!(name in el) || el[name] === value) return;
1260
+ el[name] = value;
1261
+ if (name === 'checked' || name === 'selected') {
1262
+ el.dispatchEvent(new CustomEvent('faqir:model', { bubbles: true }));
1263
+ }
1264
+ }
1265
+
1188
1266
  function handleBind(el, dir, scope) {
1189
1267
  var attrName = dir.arg;
1268
+ // What this binding's last run set on `class`/`style`. Both merge: a run
1269
+ // applies its own classes/declarations and removes only the ones its
1270
+ // previous run set and this one dropped, so static markup, `l-show` and
1271
+ // controller writes survive a re-run. [1.1F-04]
1272
+ var prev = {};
1190
1273
 
1191
1274
  var cl = effect(function() {
1192
1275
  var value = evaluate(dir.expression, scope, el);
1193
1276
 
1194
1277
  if (attrName === 'class') {
1195
- applyClassBinding(el, value);
1278
+ prev = applyClassBinding(el, value, prev);
1196
1279
  } else if (attrName === 'style') {
1197
- applyStyleBinding(el, value);
1280
+ prev = applyStyleBinding(el, value, prev);
1198
1281
  } else if (BOOLEAN_ATTRS.has(attrName)) {
1199
1282
  if (value) {
1200
1283
  el.setAttribute(attrName, '');
@@ -1208,41 +1291,66 @@
1208
1291
  el.setAttribute(attrName, String(value));
1209
1292
  }
1210
1293
  }
1294
+ if (PROPS.has(attrName)) {
1295
+ writeProp(el, attrName, attrName === 'value'
1296
+ ? (value == null || value === false ? '' : String(value))
1297
+ : !!value);
1298
+ }
1211
1299
  });
1212
1300
 
1213
1301
  addCleanup(el, cl);
1214
1302
  }
1215
1303
 
1216
- function applyClassBinding(el, value) {
1217
- if (typeof value === 'string') {
1218
- el.className = value;
1219
- } else if (Array.isArray(value)) {
1220
- el.className = value.filter(Boolean).join(' ');
1221
- } else if (typeof value === 'object' && value !== null) {
1222
- for (var cls in value) {
1223
- if (value.hasOwnProperty(cls)) {
1224
- el.classList.toggle(cls, !!value[cls]);
1225
- }
1226
- }
1304
+ // A `:class` value as { class: on }. Strings and arrays turn classes on; an
1305
+ // object entry sets its own, so a falsy one removes a class markup set.
1306
+ function classMap(v, out, on) {
1307
+ if (typeof v === 'string') {
1308
+ v.split(/\s+/).forEach(function(c) { if (c) out[c] = on; });
1309
+ } else if (Array.isArray(v)) {
1310
+ v.forEach(function(x) { if (x) classMap(x, out, true); });
1311
+ } else if (v && typeof v === 'object') {
1312
+ for (var k in v) classMap(k, out, !!v[k]);
1227
1313
  }
1314
+ return out;
1228
1315
  }
1229
1316
 
1230
- function applyStyleBinding(el, value) {
1231
- if (typeof value === 'string') {
1232
- el.style.cssText = value;
1233
- } else if (typeof value === 'object' && value !== null) {
1234
- for (var prop in value) {
1235
- if (value.hasOwnProperty(prop)) {
1236
- var cssProp = prop.replace(/[A-Z]/g, function(m) { return '-' + m.toLowerCase(); });
1237
- var val = value[prop];
1238
- if (val === null || val === undefined || val === false) {
1239
- el.style.removeProperty(cssProp);
1240
- } else {
1241
- el.style.setProperty(cssProp, String(val));
1242
- }
1243
- }
1317
+ function applyClassBinding(el, value, prev) {
1318
+ var next = classMap(value, {}, true), c;
1319
+ for (c in prev) if (prev[c] && !(c in next)) el.classList.remove(c);
1320
+ for (c in next) el.classList.toggle(c, next[c]);
1321
+ return next;
1322
+ }
1323
+
1324
+ // A `:style` value as { property: [value, priority] }, or null to remove it.
1325
+ // A string is parsed by a detached element's CSSOM, which handles custom
1326
+ // properties, `!important`, shorthands and `;` inside `url()`.
1327
+ var styleScratch;
1328
+ function styleMap(v, out) {
1329
+ if (typeof v === 'string') {
1330
+ var s = (styleScratch || (styleScratch = document.createElement('div'))).style;
1331
+ s.cssText = v;
1332
+ for (var i = 0; i < s.length; i++) {
1333
+ out[s[i]] = [s.getPropertyValue(s[i]), s.getPropertyPriority(s[i])];
1334
+ }
1335
+ } else if (Array.isArray(v)) {
1336
+ v.forEach(function(x) { styleMap(x, out); });
1337
+ } else if (v && typeof v === 'object') {
1338
+ for (var k in v) {
1339
+ out[/^--/.test(k) ? k : k.replace(/[A-Z]/g, '-$&').toLowerCase()] =
1340
+ v[k] == null || v[k] === false ? null : [String(v[k]), ''];
1244
1341
  }
1245
1342
  }
1343
+ return out;
1344
+ }
1345
+
1346
+ function applyStyleBinding(el, value, prev) {
1347
+ var next = styleMap(value, {}), p;
1348
+ for (p in prev) if (prev[p] && !(p in next)) el.style.removeProperty(p);
1349
+ for (p in next) {
1350
+ if (next[p]) el.style.setProperty(p, next[p][0], next[p][1]);
1351
+ else el.style.removeProperty(p);
1352
+ }
1353
+ return next;
1246
1354
  }
1247
1355
 
1248
1356
  // --- 3.9 l-on / @event ---
@@ -1344,13 +1452,17 @@
1344
1452
 
1345
1453
  function handleRef(el, dir, scope) {
1346
1454
  var name = dir.expression;
1347
- var root = findScopeRoot(el);
1348
- if (!root.__faqirRefs) root.__faqirRefs = {};
1349
- root.__faqirRefs[name] = el;
1455
+ // Written through the reactive proxy, so an effect that read `$refs.name`
1456
+ // re-runs when the ref appears or goes. [1.1F-01]
1457
+ var refs = reactive(getScopeRefs(scopeRoots.get(scope) || findScopeRoot(el)));
1458
+ refs[name] = el;
1350
1459
 
1351
1460
  addCleanup(el, function() {
1352
- if (root.__faqirRefs && root.__faqirRefs[name] === el) {
1353
- delete root.__faqirRefs[name];
1461
+ // `__target`: a tracked read here would subscribe whichever structural
1462
+ // effect is tearing this element down.
1463
+ if (refs.__target[name] === el) {
1464
+ refs[name] = undefined;
1465
+ delete refs.__target[name];
1354
1466
  }
1355
1467
  });
1356
1468
  }
@@ -1358,29 +1470,24 @@
1358
1470
  // --- 3.11 l-init ---
1359
1471
 
1360
1472
  function handleInit(el, dir, scope) {
1361
- evaluateAssignment(dir.expression, scope, el);
1473
+ runInit(function() { evaluateAssignment(dir.expression, scope, el); });
1362
1474
  }
1363
1475
 
1364
1476
  // --- 3.12 l-effect ---
1365
1477
 
1366
1478
  function handleEffect(el, dir, scope) {
1367
- var cl = effect(function() {
1368
- evaluateAssignment(dir.expression, scope, el);
1479
+ runInit(function() {
1480
+ addCleanup(el, effect(function() {
1481
+ evaluateAssignment(dir.expression, scope, el);
1482
+ }));
1369
1483
  });
1370
- addCleanup(el, cl);
1371
1484
  }
1372
1485
 
1373
1486
  // --- 3.13 l-cloak ---
1374
-
1375
- function injectCloakStyle() {
1376
- // Once per document. A second `Faqir.start()` used to append another
1377
- // identical <style> to <head>, and nothing ever removed either. [W3-1]
1378
- if (document.querySelector('style[data-faqir-cloak]')) return;
1379
- var style = document.createElement('style');
1380
- style.setAttribute('data-faqir-cloak', '');
1381
- style.textContent = '[l-cloak] { display: none !important; }';
1382
- document.head.appendChild(style);
1383
- }
1487
+ //
1488
+ // The engine injects no stylesheet. The `[l-cloak]` rule ships in
1489
+ // `base/reset.css` (so in every bundle); the injected copy it used to append
1490
+ // at boot was redundant, and the only inline style a CSP had to allow. [1.1F-07]
1384
1491
 
1385
1492
  /**
1386
1493
  * Strip `l-cloak` from `within` and everything under it (the whole document
@@ -1389,7 +1496,7 @@
1389
1496
  * The sweep used to run exactly once, at the end of bootstrap, against a
1390
1497
  * document-wide selector. Anything inserted afterwards — an `l-if` branch, an
1391
1498
  * `l-for` row, a fragment an application appended — kept the attribute, and
1392
- * the injected `[l-cloak] { display: none !important }` rule then hid it
1499
+ * the `[l-cloak] { display: none !important }` rule then hid it
1393
1500
  * permanently: content that had bound correctly and could never be seen. The
1394
1501
  * MutationObserver runs this over every node it is handed. [W3-1]
1395
1502
  */
@@ -1546,7 +1653,7 @@
1546
1653
  if (isFaqirSwitch) {
1547
1654
  var cl = effect(function() {
1548
1655
  var value = evaluate(prop, scope, el);
1549
- el.checked = !!value;
1656
+ writeProp(el, 'checked', !!value);
1550
1657
  el.dataset.state = value ? 'on' : 'off';
1551
1658
  el.setAttribute('aria-checked', value ? 'true' : 'false');
1552
1659
  });
@@ -1558,11 +1665,9 @@
1558
1665
  } else if (tag === 'input' && type === 'checkbox') {
1559
1666
  var cl = effect(function() {
1560
1667
  var current = evaluate(prop, scope, el);
1561
- if (Array.isArray(current)) {
1562
- el.checked = current.indexOf(el.value) >= 0;
1563
- } else {
1564
- el.checked = !!current;
1565
- }
1668
+ writeProp(el, 'checked', Array.isArray(current)
1669
+ ? current.indexOf(el.value) >= 0
1670
+ : !!current);
1566
1671
  });
1567
1672
  bind('change', function() {
1568
1673
  var current = evaluate(prop, scope, el);
@@ -1580,7 +1685,7 @@
1580
1685
 
1581
1686
  } else if (tag === 'input' && type === 'radio') {
1582
1687
  var cl = effect(function() {
1583
- el.checked = evaluate(prop, scope, el) === el.value;
1688
+ writeProp(el, 'checked', evaluate(prop, scope, el) === el.value);
1584
1689
  });
1585
1690
  bind('change', function() {
1586
1691
  if (el.checked) {
@@ -1591,7 +1696,7 @@
1591
1696
 
1592
1697
  } else if (tag === 'select') {
1593
1698
  var cl = effect(function() {
1594
- el.value = evaluate(prop, scope, el) || '';
1699
+ writeProp(el, 'value', evaluate(prop, scope, el) || '');
1595
1700
  });
1596
1701
  bind('change', function() {
1597
1702
  writeModel(prop, el.value, scope, el);
@@ -1684,7 +1789,7 @@
1684
1789
  var value = evaluate(dir.expression, scope, el);
1685
1790
 
1686
1791
  if (value) {
1687
- if (insertedNodes.length === 0) {
1792
+ if (insertedNodes.length === 0) renderThenInit(function() {
1688
1793
  var fragment = el.content.cloneNode(true);
1689
1794
  var nodes = [].slice.call(fragment.childNodes);
1690
1795
 
@@ -1727,7 +1832,8 @@
1727
1832
  runEnterTransition(insertedNodes[i]);
1728
1833
  }
1729
1834
  }
1730
- }
1835
+ return insertedNodes;
1836
+ });
1731
1837
  } else {
1732
1838
  for (var i = 0; i < insertedNodes.length; i++) {
1733
1839
  var node = insertedNodes[i];
@@ -1843,6 +1949,8 @@
1843
1949
  var indexName = match[2] || 'index';
1844
1950
  var listExpr = match[3];
1845
1951
  var keyExpr = el.getAttribute('l-key');
1952
+ // Dev hint: a loop variable that shadows a name the scope already has. Only
1953
+ // names the author wrote; `in` reads no reactive dependency. [1.1F-05]
1846
1954
 
1847
1955
  var anchor = document.createComment('l-for');
1848
1956
  el.parentNode.insertBefore(anchor, el);
@@ -1877,6 +1985,7 @@
1877
1985
  own[itemName] = item;
1878
1986
  own[indexName] = i;
1879
1987
  var childScope = createForItemScope(own, scope);
1988
+ scopeRoots.set(childScope, scopeRoots.get(scope));
1880
1989
  for (var j = 0; j < nodes.length; j++) {
1881
1990
  if (nodes[j].nodeType !== 1) continue;
1882
1991
  // A row whose top node declares `l-data` is a scope root: build it with
@@ -1901,7 +2010,9 @@
1901
2010
 
1902
2011
  var currentEntries = [];
1903
2012
 
1904
- var cl = effect(function() {
2013
+ // One reconciliation. Returns the rows it built, for `renderThenInit` to
2014
+ // start their controllers and run their held inits. [1.1F-03]
2015
+ function renderList() {
1905
2016
  var list = evaluate(listExpr, scope, el);
1906
2017
  var items = Array.isArray(list) ? list :
1907
2018
  typeof list === 'number' ? Array.from({ length: list }, function(_, i) { return i + 1; }) :
@@ -1974,7 +2085,12 @@
1974
2085
  }
1975
2086
 
1976
2087
  currentEntries = newEntries;
1977
- });
2088
+ var fresh = [];
2089
+ for (var i = 0; i < n; i++) if (!source[i]) fresh.push.apply(fresh, newEntries[i].nodes);
2090
+ return fresh;
2091
+ }
2092
+
2093
+ var cl = effect(function() { renderThenInit(renderList); });
1978
2094
 
1979
2095
  addCleanup(el, cl, anchor);
1980
2096
  }
@@ -2113,41 +2229,20 @@
2113
2229
  // ═══════════════════════════════════════════════════════
2114
2230
 
2115
2231
  // --- From dom.js ---
2116
- var $ = function(selector, scope) { return (scope || document).querySelector(selector); };
2117
2232
  var $$ = function(selector, scope) { return [].slice.call((scope || document).querySelectorAll(selector)); };
2118
2233
 
2119
- function create(tag, attrs) {
2120
- var el = document.createElement(tag);
2121
- if (attrs) {
2122
- var keys = Object.keys(attrs);
2123
- for (var i = 0; i < keys.length; i++) {
2124
- el.setAttribute(keys[i], attrs[keys[i]]);
2125
- }
2126
- }
2127
- for (var j = 2; j < arguments.length; j++) {
2128
- var child = arguments[j];
2129
- el.append(typeof child === 'string' ? document.createTextNode(child) : child);
2130
- }
2131
- return el;
2132
- }
2133
-
2134
- // --- From events.js ---
2135
- function delegate(root, event, selector, handler) {
2136
- function listener(e) {
2137
- var target = e.target.closest(selector);
2138
- if (target && root.contains(target)) handler(e, target);
2139
- }
2140
- root.addEventListener(event, listener);
2141
- return function() { root.removeEventListener(event, listener); };
2142
- }
2234
+ // A part's owner is its nearest [data-ui] above it that is not a layout
2235
+ // primitive, starting from the parent since a part may carry data-ui itself.
2236
+ // Same selector as registry/core/dom.js `owns`.
2237
+ var OWNER = '[data-ui]:not([data-ui=stack],[data-ui=cluster],[data-ui=grid],[data-ui=container],[data-ui=surface],[data-ui=switcher],[data-ui=aspect-ratio])';
2143
2238
 
2144
- function once(el, event, handler) {
2145
- function listener(e) { cleanupOnce(); handler(e); }
2146
- function cleanupOnce() { el.removeEventListener(event, listener); }
2147
- el.addEventListener(event, listener);
2148
- return cleanupOnce;
2239
+ function ownParts(root, part) {
2240
+ return $$("[data-part='" + part + "']", root).filter(function(el) {
2241
+ return el.parentElement.closest(OWNER) === root;
2242
+ });
2149
2243
  }
2150
2244
 
2245
+ // --- From events.js ---
2151
2246
  function onOutsideClick(el, handler) {
2152
2247
  function listener(e) { if (!el.contains(e.target)) handler(e); }
2153
2248
  document.addEventListener('pointerdown', listener);
@@ -2192,25 +2287,14 @@
2192
2287
  return function() { container.removeEventListener('keydown', onKeyDown); };
2193
2288
  }
2194
2289
 
2195
- // --- From motion.js (prefersReducedMotion already defined in Section 3.14) ---
2196
- function waitForTransition(el) {
2197
- if (prefersReducedMotion()) return Promise.resolve();
2198
- var style = getComputedStyle(el);
2199
- var hasDuration = parseFloat(style.transitionDuration) > 0 ||
2200
- (style.animationName !== 'none' && parseFloat(style.animationDuration) > 0);
2201
- if (!hasDuration) return Promise.resolve();
2202
- return new Promise(function(resolve) {
2203
- function done(e) {
2204
- if (e.target !== el) return;
2205
- el.removeEventListener('transitionend', done);
2206
- el.removeEventListener('animationend', done);
2207
- resolve();
2208
- }
2209
- el.addEventListener('transitionend', done);
2210
- el.addEventListener('animationend', done);
2211
- });
2290
+ // Back to the opener once an overlay has closed, unless focus moved on while
2291
+ // it closed (`active` was read before the panel hid) or the opener is gone.
2292
+ function returnFocus(root, target, active) {
2293
+ if ((!active || active === document.body || root.contains(active)) &&
2294
+ target && target.isConnected && !target.closest('[hidden]')) target.focus();
2212
2295
  }
2213
2296
 
2297
+ // --- From motion.js (prefersReducedMotion already defined in Section 3.14) ---
2214
2298
  // The four events an exit can end with, in one list: bound and unbound
2215
2299
  // together, so neither half can drift from the other.
2216
2300
  var EXIT_EVENTS = ['transitionend', 'transitioncancel', 'animationend', 'animationcancel'];
@@ -2513,7 +2597,8 @@ function createAccordion(root) {
2513
2597
  // Prevent double-init
2514
2598
  if (root._faqirAccordion) return root._faqirAccordion;
2515
2599
 
2516
- const getItems = () => [...root.querySelectorAll("[data-part='item']")];
2600
+ // Direct children only: a nested accordion's items are its own.
2601
+ const getItems = () => [...root.querySelectorAll(":scope > [data-part='item']")];
2517
2602
  const isSingle = () => root.dataset.variant === "single";
2518
2603
 
2519
2604
  function expand(index) {
@@ -2573,26 +2658,30 @@ function createAccordion(root) {
2573
2658
  if (content) content.hidden = true;
2574
2659
  }
2575
2660
 
2576
- function onTriggerClick(e) {
2661
+ /**
2662
+ * The index of the item whose own trigger `e` came from, or -1. A trigger of
2663
+ * a component nested in the content (a dropdown, another accordion) is not
2664
+ * the item's: an item's own trigger is the first one in it, since it comes
2665
+ * before the content.
2666
+ */
2667
+ function triggerIndex(e) {
2577
2668
  const trigger = e.target.closest("[data-part='trigger']");
2578
- if (!trigger) return;
2579
- const item = trigger.closest("[data-part='item']");
2580
- if (!item) return;
2581
- const items = getItems();
2582
- const index = items.indexOf(item);
2669
+ const item = trigger?.closest("[data-part='item']");
2670
+ if (!item || item.querySelector("[data-part='trigger']") !== trigger) return -1;
2671
+ return getItems().indexOf(item);
2672
+ }
2673
+
2674
+ function onTriggerClick(e) {
2675
+ const index = triggerIndex(e);
2583
2676
  if (index >= 0) toggle(index);
2584
2677
  }
2585
2678
 
2586
2679
  function onKeyDown(e) {
2587
2680
  if (e.key === "Enter" || e.key === " ") {
2588
- const trigger = e.target.closest("[data-part='trigger']");
2589
- if (trigger) {
2681
+ const index = triggerIndex(e);
2682
+ if (index >= 0) {
2590
2683
  e.preventDefault();
2591
- const item = trigger.closest("[data-part='item']");
2592
- if (!item) return;
2593
- const items = getItems();
2594
- const index = items.indexOf(item);
2595
- if (index >= 0) toggle(index);
2684
+ toggle(index);
2596
2685
  }
2597
2686
  }
2598
2687
  }
@@ -3367,12 +3456,14 @@ function createCarousel(root) {
3367
3456
  if (!viewport) return null;
3368
3457
 
3369
3458
  const loop = root.hasAttribute("data-loop");
3370
- const slides = () => [...viewport.querySelectorAll("[data-part='slide']")];
3371
- const dots = () => [...root.querySelectorAll("[data-part='dot']")];
3372
- const prevBtn = root.querySelector("[data-part='prev']");
3373
- const nextBtn = root.querySelector("[data-part='next']");
3374
- const status = root.querySelector("[data-part='status']");
3375
- const enhanced = [...root.querySelectorAll("[data-part='controls'], [data-part='dots']")];
3459
+ // A carousel nested in a slide brings its own slides, buttons and dots:
3460
+ // slides are the viewport's children, the rest go through the owner guard.
3461
+ const slides = () => [...viewport.querySelectorAll(":scope > [data-part='slide']")];
3462
+ const dots = () => ownParts(root, "dot");
3463
+ const [prevBtn] = ownParts(root, "prev");
3464
+ const [nextBtn] = ownParts(root, "next");
3465
+ const [status] = ownParts(root, "status");
3466
+ const enhanced = [...ownParts(root, "controls"), ...ownParts(root, "dots")];
3376
3467
 
3377
3468
  let index = 0;
3378
3469
 
@@ -3468,12 +3559,13 @@ function createCarousel(root) {
3468
3559
 
3469
3560
  // ── events ──────────────────────────────────────────────────────────────────
3470
3561
  function onClick(e) {
3471
- const btn = e.target.closest && e.target.closest("[data-part]");
3472
- if (!btn || !root.contains(btn)) return;
3473
- const part = btn.dataset.part;
3474
- if (part === "next") next();
3475
- else if (part === "prev") prev();
3476
- else if (part === "dot") goTo(dots().indexOf(btn));
3562
+ // Matched by identity, so a nested carousel's buttons are not these. A
3563
+ // missing button is `undefined`, never equal to a `null` miss here.
3564
+ const btn = e.target.closest("[data-part]");
3565
+ const dot = dots().indexOf(btn);
3566
+ if (btn === nextBtn) next();
3567
+ else if (btn === prevBtn) prev();
3568
+ else if (dot >= 0) goTo(dot);
3477
3569
  }
3478
3570
 
3479
3571
  // One rAF-throttled measurement per scroll burst keeps tracking cheap.
@@ -3824,8 +3916,11 @@ function createCommandPalette(root) {
3824
3916
  }
3825
3917
 
3826
3918
  function close() {
3919
+ const active = document.activeElement;
3827
3920
  dismiss();
3828
- previouslyFocused?.focus();
3921
+ // The opener may have left with another overlay — the downstream palette
3922
+ // was opened from inside a dialog that closed underneath it.
3923
+ returnFocus(root, previouslyFocused, active);
3829
3924
  }
3830
3925
 
3831
3926
  /**
@@ -4019,14 +4114,29 @@ function createCommandPalette(root) {
4019
4114
  }
4020
4115
  }
4021
4116
 
4117
+ /**
4118
+ * Cmd/Ctrl+K — one owner per press.
4119
+ *
4120
+ * Every instance listens on `document`, so a page with three palettes used to
4121
+ * open three stacked focus traps. The first instance to act prevents default
4122
+ * and every later one (or one an app handler pre-empted) stands down;
4123
+ * `data-no-shortcut` opts an instance out entirely, read live, and a palette
4124
+ * whose root has left the document without `destroy()` (an SPA route change)
4125
+ * no longer claims the press from the live one. The key is
4126
+ * matched case-insensitively (Caps Lock sends "K"), falling back to the
4127
+ * physical key on a non-Latin layout; Shift is left alone because Cmd+Shift+K
4128
+ * is a browser and app chord of its own.
4129
+ */
4022
4130
  function onGlobalKeyDown(e) {
4023
- if ((e.metaKey || e.ctrlKey) && e.key === "k") {
4024
- e.preventDefault();
4025
- if (root.dataset.state === "open") {
4026
- close();
4027
- } else {
4028
- open();
4029
- }
4131
+ if (e.defaultPrevented || !root.isConnected || root.hasAttribute("data-no-shortcut")) return;
4132
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey || e.repeat || e.isComposing) return;
4133
+ const k = (e.key || "").toLowerCase();
4134
+ if (k !== "k" && (/^[a-z]$/.test(k) || e.code !== "KeyK")) return;
4135
+ e.preventDefault();
4136
+ if (root.dataset.state === "open") {
4137
+ close();
4138
+ } else {
4139
+ open();
4030
4140
  }
4031
4141
  }
4032
4142
 
@@ -4062,8 +4172,10 @@ function createCommandPalette(root) {
4062
4172
  function createContextMenu(root) {
4063
4173
  if (root._faqirContextMenu) return root._faqirContextMenu;
4064
4174
 
4065
- const target = root.querySelector("[data-part='target']");
4066
- const menu = root.querySelector("[data-part='menu']");
4175
+ // Matched by owner: a dropdown inside the target has a `menu` part too, and
4176
+ // it comes first in document order.
4177
+ const [target] = ownParts(root, "target");
4178
+ const [menu] = ownParts(root, "menu");
4067
4179
 
4068
4180
  // Same as `dropdown`: a missing part must name itself rather than throwing a
4069
4181
  // TypeError out of `createMenuNavigation(null)`. [W3-2]
@@ -4367,17 +4479,24 @@ function createDialog(root) {
4367
4479
  // Prevent double-init
4368
4480
  if (root._faqirDialog) return root._faqirDialog;
4369
4481
 
4370
- const trigger = root.querySelector("[data-part='trigger']");
4371
- const overlay = root.querySelector("[data-part='overlay']");
4372
- const panel = root.querySelector("[data-part='panel']");
4373
- const closeButtons = root.querySelectorAll("[data-part='close']");
4374
- const confirmButtons = root.querySelectorAll("[data-part='confirm']");
4375
- const cancelButtons = root.querySelectorAll("[data-part='cancel']");
4482
+ // Overlay and panel are direct children. Trigger and actions may be wrapped
4483
+ // in layout primitives, so they are matched by owner: a popover's close
4484
+ // button inside the panel belongs to the popover and must not close this.
4485
+ const [trigger] = ownParts(root, "trigger");
4486
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
4487
+ const panel = root.querySelector(":scope > [data-part='panel']");
4488
+ const closeButtons = ownParts(root, "close");
4489
+ const confirmButtons = ownParts(root, "confirm");
4490
+ const cancelButtons = ownParts(root, "cancel");
4376
4491
 
4377
4492
  // The role is the seam between `dialog` and `alert-dialog` — read it from the
4378
4493
  // markup so a single controller serves both recipes.
4379
4494
  const isAlert = !!panel && panel.getAttribute("role") === "alertdialog";
4380
4495
 
4496
+ // `panel.focus()` on open does nothing without a tabindex: focus stayed on
4497
+ // `body`, out of reach of the trap and the root's Escape listener.
4498
+ if (panel && !panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
4499
+
4381
4500
  let focusCleanup = null;
4382
4501
  let previouslyFocused = null;
4383
4502
 
@@ -4400,10 +4519,7 @@ function createDialog(root) {
4400
4519
  /** On open, focus the least-destructive action for an alert, else the panel. */
4401
4520
  function focusInitial() {
4402
4521
  if (isAlert) {
4403
- const target =
4404
- root.querySelector("[data-part='cancel']") ||
4405
- root.querySelector("[data-part='close']") ||
4406
- panel;
4522
+ const target = cancelButtons[0] || closeButtons[0] || panel;
4407
4523
  target?.focus?.();
4408
4524
  } else {
4409
4525
  panel?.focus?.();
@@ -4437,12 +4553,13 @@ function createDialog(root) {
4437
4553
 
4438
4554
  const onEnd = () => {
4439
4555
  cancelExitWait = null;
4556
+ const active = document.activeElement;
4440
4557
  root.dataset.state = "closed";
4441
4558
  overlay.hidden = true;
4442
4559
  panel.hidden = true;
4443
4560
  if (focusCleanup) focusCleanup();
4444
4561
  focusCleanup = null;
4445
- previouslyFocused?.focus();
4562
+ returnFocus(root, previouslyFocused, active);
4446
4563
  };
4447
4564
 
4448
4565
  // The panel's own exit motion. A footer button's `background` transitionend
@@ -4575,10 +4692,16 @@ function createDrawer(root) {
4575
4692
  // Prevent double-init
4576
4693
  if (root._faqirDrawer) return root._faqirDrawer;
4577
4694
 
4578
- const trigger = root.querySelector("[data-part='trigger']");
4579
- const overlay = root.querySelector("[data-part='overlay']");
4580
- const panel = root.querySelector("[data-part='panel']");
4581
- const closeButtons = root.querySelectorAll("[data-part='close']");
4695
+ // Overlay and panel are direct children; trigger and close buttons are
4696
+ // matched by owner, so a nested component's close leaves this one alone.
4697
+ const [trigger] = ownParts(root, "trigger");
4698
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
4699
+ const panel = root.querySelector(":scope > [data-part='panel']");
4700
+ const closeButtons = ownParts(root, "close");
4701
+
4702
+ // `panel.focus()` on open does nothing without a tabindex: focus stayed on
4703
+ // `body`, out of reach of the trap and the root's Escape listener.
4704
+ if (panel && !panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
4582
4705
 
4583
4706
  let focusCleanup = null;
4584
4707
  let previouslyFocused = null;
@@ -4622,13 +4745,14 @@ function createDrawer(root) {
4622
4745
 
4623
4746
  const onEnd = () => {
4624
4747
  cancelExitWait = null;
4748
+ const active = document.activeElement;
4625
4749
  root.dataset.state = "closed";
4626
4750
  overlay.hidden = true;
4627
4751
  panel.hidden = true;
4628
4752
  unlockScroll();
4629
4753
  if (focusCleanup) focusCleanup();
4630
4754
  focusCleanup = null;
4631
- previouslyFocused?.focus();
4755
+ returnFocus(root, previouslyFocused, active);
4632
4756
  };
4633
4757
 
4634
4758
  // The panel's OWN `transform` — not the close button's `background`, which
@@ -6103,7 +6227,8 @@ function createPopover(root) {
6103
6227
 
6104
6228
  const trigger = root.querySelector("[data-part='trigger']");
6105
6229
  const content = root.querySelector("[data-part='content']");
6106
- const closeBtn = root.querySelector("[data-part='close']");
6230
+ // Not a close button belonging to a dialog or popover inside the content.
6231
+ const [closeBtn] = ownParts(root, "close");
6107
6232
 
6108
6233
  let outsideClickCleanup = null;
6109
6234
 
@@ -6995,10 +7120,16 @@ function createSheet(root) {
6995
7120
  // Prevent double-init
6996
7121
  if (root._faqirSheet) return root._faqirSheet;
6997
7122
 
6998
- const trigger = root.querySelector("[data-part='trigger']");
6999
- const overlay = root.querySelector("[data-part='overlay']");
7000
- const panel = root.querySelector("[data-part='panel']");
7001
- const closeButtons = root.querySelectorAll("[data-part='close']");
7123
+ // Overlay and panel are direct children; trigger and close buttons are
7124
+ // matched by owner, so a nested component's close leaves this one alone.
7125
+ const [trigger] = ownParts(root, "trigger");
7126
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
7127
+ const panel = root.querySelector(":scope > [data-part='panel']");
7128
+ const closeButtons = ownParts(root, "close");
7129
+
7130
+ // `panel.focus()` on open does nothing without a tabindex: focus stayed on
7131
+ // `body`, out of reach of the trap and the root's Escape listener.
7132
+ if (panel && !panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
7002
7133
 
7003
7134
  let focusCleanup = null;
7004
7135
  let previouslyFocused = null;
@@ -7042,13 +7173,14 @@ function createSheet(root) {
7042
7173
 
7043
7174
  const onEnd = () => {
7044
7175
  cancelExitWait = null;
7176
+ const active = document.activeElement;
7045
7177
  root.dataset.state = "closed";
7046
7178
  overlay.hidden = true;
7047
7179
  panel.hidden = true;
7048
7180
  unlockScroll();
7049
7181
  if (focusCleanup) focusCleanup();
7050
7182
  focusCleanup = null;
7051
- previouslyFocused?.focus();
7183
+ returnFocus(root, previouslyFocused, active);
7052
7184
  };
7053
7185
 
7054
7186
  // The panel's OWN `transform` — not a close button's `background`, which
@@ -7158,12 +7290,14 @@ function createSidebar(root) {
7158
7290
  // Prevent double-init.
7159
7291
  if (root._faqirSidebar) return root._faqirSidebar;
7160
7292
 
7161
- const overlay = root.querySelector("[data-part='overlay']");
7162
- const panel = root.querySelector("[data-part='panel']") || root;
7293
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
7294
+ const panel = root.querySelector(":scope > [data-part='panel']") || root;
7163
7295
 
7164
- // Toggle buttons: any `[data-part='trigger']` inside the sidebar, plus external
7165
- // triggers in the app shell that point at this sidebar by id. Both call toggle().
7166
- const internalTriggers = [...root.querySelectorAll("[data-part='trigger']")];
7296
+ // Toggle buttons: the sidebar's own `[data-part='trigger']`s, plus external
7297
+ // triggers in the app shell that point at this sidebar by id. Both call
7298
+ // toggle(). A dropdown's trigger in the footer belongs to the dropdown: it
7299
+ // used to have its aria-expanded forced and its click rail the sidebar.
7300
+ const internalTriggers = ownParts(root, "trigger");
7167
7301
  const externalTriggers = root.id
7168
7302
  ? [...document.querySelectorAll(`[data-sidebar-toggle="${root.id}"]`)]
7169
7303
  : [];
@@ -7773,10 +7907,13 @@ function createTable(root) {
7773
7907
  // Prevent double-init
7774
7908
  if (root._faqirTable) return root._faqirTable;
7775
7909
 
7910
+ // A table nested in a detail row keeps its own parts and events: `tbody`
7911
+ // contains it, so every deep lookup and delegated handler checks `mine`.
7912
+ const mine = (el) => el.closest("[data-ui='table']") === root;
7776
7913
  const table = root.querySelector("[data-part='table']");
7777
7914
  const thead = root.querySelector("[data-part='thead']");
7778
7915
  const tbody = root.querySelector("[data-part='tbody']");
7779
- const tfoot = root.querySelector("[data-part='tfoot']");
7916
+ const tfoot = [...root.querySelectorAll("[data-part='tfoot']")].find(mine);
7780
7917
  const headerCheckbox = thead?.querySelector("[data-part='checkbox']");
7781
7918
 
7782
7919
  // ── Options (read once from the root's opt-in attributes) ──
@@ -7996,23 +8133,14 @@ function createTable(root) {
7996
8133
 
7997
8134
  // ── Intl formatting ──
7998
8135
  function formatValue(n, fmt) {
8136
+ const o =
8137
+ fmt === "currency"
8138
+ ? { style: fmt, currency: opts.currency, currencySign: accounting ? "accounting" : "standard" }
8139
+ : fmt === "percent"
8140
+ ? { style: fmt, maximumFractionDigits: 2 }
8141
+ : fmt === "number" && { maximumFractionDigits: 2 };
7999
8142
  try {
8000
- if (fmt === "currency") {
8001
- return new Intl.NumberFormat(opts.locale, {
8002
- style: "currency",
8003
- currency: opts.currency,
8004
- currencySign: accounting ? "accounting" : "standard",
8005
- }).format(n);
8006
- }
8007
- if (fmt === "percent") {
8008
- return new Intl.NumberFormat(opts.locale, {
8009
- style: "percent",
8010
- maximumFractionDigits: 2,
8011
- }).format(n / 100);
8012
- }
8013
- if (fmt === "number") {
8014
- return new Intl.NumberFormat(opts.locale, { maximumFractionDigits: 2 }).format(n);
8015
- }
8143
+ if (o) return new Intl.NumberFormat(opts.locale, o).format(fmt === "percent" ? n / 100 : n);
8016
8144
  } catch {
8017
8145
  /* unknown locale/currency — fall through to raw */
8018
8146
  }
@@ -8253,13 +8381,17 @@ function createTable(root) {
8253
8381
  refreshStripes();
8254
8382
  }
8255
8383
 
8384
+ function sortChanged() {
8385
+ applySorts();
8386
+ emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8387
+ scheduleStateSave();
8388
+ }
8389
+
8256
8390
  /** Legacy API: sort one column with an explicit direction. */
8257
8391
  function sort(columnIndex, direction) {
8258
8392
  if (!tbody || bodyRows().length === 0) return;
8259
8393
  sorts = [{ index: columnIndex, dir: direction }];
8260
- applySorts();
8261
- emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8262
- scheduleStateSave();
8394
+ sortChanged();
8263
8395
  }
8264
8396
 
8265
8397
  /** Multi-sort API: sortBy([{ column, direction }...]). */
@@ -8271,17 +8403,13 @@ function createTable(root) {
8271
8403
  dir: s.direction || s.dir || "ascending",
8272
8404
  }))
8273
8405
  .filter((s) => Number.isInteger(s.index) && s.index >= 0 && s.index < columnCount());
8274
- applySorts();
8275
- emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8276
- scheduleStateSave();
8406
+ sortChanged();
8277
8407
  }
8278
8408
 
8279
8409
  function clearSort() {
8280
8410
  if (!sorts.length) return;
8281
8411
  sorts = [];
8282
- applySorts();
8283
- emit("sort", { sorts: [] });
8284
- scheduleStateSave();
8412
+ sortChanged();
8285
8413
  }
8286
8414
 
8287
8415
  function handleSortRequest(idx, additive) {
@@ -8298,9 +8426,7 @@ function createTable(root) {
8298
8426
  } else {
8299
8427
  sorts = dir === null ? [] : [{ index: idx, dir }];
8300
8428
  }
8301
- applySorts();
8302
- emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8303
- scheduleStateSave();
8429
+ sortChanged();
8304
8430
  }
8305
8431
 
8306
8432
  // ── Selection ──
@@ -8316,8 +8442,7 @@ function createTable(root) {
8316
8442
  const row = rows[index];
8317
8443
  setRowSelected(row, !row.hasAttribute("data-selected"));
8318
8444
  lastSelectedIndex = index;
8319
- updateHeaderCheckbox();
8320
- emit("selection-change", { selected: getSelected() });
8445
+ selectionChanged();
8321
8446
  }
8322
8447
 
8323
8448
  function selectAll() {
@@ -8326,15 +8451,13 @@ function createTable(root) {
8326
8451
  if (filtered && row.hasAttribute("data-filtered")) continue;
8327
8452
  setRowSelected(row, true);
8328
8453
  }
8329
- updateHeaderCheckbox();
8330
- emit("selection-change", { selected: getSelected() });
8454
+ selectionChanged();
8331
8455
  }
8332
8456
 
8333
8457
  function deselectAll() {
8334
8458
  for (const row of bodyRows()) setRowSelected(row, false);
8335
8459
  lastSelectedIndex = -1;
8336
- updateHeaderCheckbox();
8337
- emit("selection-change", { selected: getSelected() });
8460
+ selectionChanged();
8338
8461
  }
8339
8462
 
8340
8463
  function getSelected() {
@@ -8351,23 +8474,19 @@ function createTable(root) {
8351
8474
  for (let i = start; i <= end; i++) {
8352
8475
  if (i >= 0 && i < rows.length && isRowVisible(rows[i])) setRowSelected(rows[i], true);
8353
8476
  }
8477
+ }
8478
+
8479
+ function selectionChanged() {
8354
8480
  updateHeaderCheckbox();
8481
+ emit("selection-change", { selected: getSelected() });
8355
8482
  }
8356
8483
 
8357
8484
  function updateHeaderCheckbox() {
8358
8485
  if (!headerCheckbox) return;
8359
8486
  const rows = visibleBodyRows();
8360
- const selected = rows.filter((r) => r.hasAttribute("data-selected"));
8361
- if (selected.length === 0) {
8362
- headerCheckbox.checked = false;
8363
- headerCheckbox.indeterminate = false;
8364
- } else if (selected.length === rows.length) {
8365
- headerCheckbox.checked = true;
8366
- headerCheckbox.indeterminate = false;
8367
- } else {
8368
- headerCheckbox.checked = false;
8369
- headerCheckbox.indeterminate = true;
8370
- }
8487
+ const n = rows.filter((r) => r.hasAttribute("data-selected")).length;
8488
+ headerCheckbox.checked = n > 0 && n === rows.length;
8489
+ headerCheckbox.indeterminate = n > 0 && n < rows.length;
8371
8490
  }
8372
8491
 
8373
8492
  // ── Filtering ──
@@ -8456,6 +8575,7 @@ function createTable(root) {
8456
8575
  refreshStripes();
8457
8576
  updateHeaderCheckbox();
8458
8577
  computeAggregates();
8578
+ setupNavigability();
8459
8579
  emit("filter", { query: globalFilter, visible, total: rows.length });
8460
8580
  scheduleStateSave();
8461
8581
  }
@@ -8476,7 +8596,7 @@ function createTable(root) {
8476
8596
  applyFilters();
8477
8597
  }
8478
8598
  function syncFilterInputs() {
8479
- const g = root.querySelector("[data-part='filter']");
8599
+ const g = [...root.querySelectorAll("[data-part='filter']")].find(mine);
8480
8600
  if (g && g.value !== globalFilter) g.value = globalFilter;
8481
8601
  const fr = filterRowEl();
8482
8602
  if (!fr) return;
@@ -8499,6 +8619,7 @@ function createTable(root) {
8499
8619
  for (const m of groupMembers(gh)) toggleAttr(m, "data-collapsed", !expand);
8500
8620
  refreshStripes();
8501
8621
  updateHeaderCheckbox();
8622
+ setupNavigability();
8502
8623
  emit("group-toggle", { group: gh, expanded: expand });
8503
8624
  }
8504
8625
 
@@ -8527,6 +8648,7 @@ function createTable(root) {
8527
8648
  }
8528
8649
  refreshStripes();
8529
8650
  updateHeaderCheckbox();
8651
+ setupNavigability();
8530
8652
  emit("tree-toggle", { row, expanded: expand });
8531
8653
  }
8532
8654
 
@@ -8541,20 +8663,15 @@ function createTable(root) {
8541
8663
  emit("row-expand", { row, detail, expanded: expand });
8542
8664
  }
8543
8665
 
8544
- function expandAll() {
8545
- mutedRun(() => {
8546
- for (const gh of groupHeaders()) toggleGroup(gh, true);
8547
- if (opts.tree) for (const r of bodyRows()) if (isTreeParent(r)) toggleRow(r, true);
8548
- });
8549
- refreshStripes();
8550
- }
8551
- function collapseAll() {
8666
+ function setAllExpanded(expand) {
8552
8667
  mutedRun(() => {
8553
- for (const gh of groupHeaders()) toggleGroup(gh, false);
8554
- if (opts.tree) for (const r of bodyRows()) if (isTreeParent(r)) toggleRow(r, false);
8668
+ for (const gh of groupHeaders()) toggleGroup(gh, expand);
8669
+ if (opts.tree) for (const r of bodyRows()) if (isTreeParent(r)) toggleRow(r, expand);
8555
8670
  });
8556
8671
  refreshStripes();
8557
8672
  }
8673
+ const expandAll = () => setAllExpanded(true);
8674
+ const collapseAll = () => setAllExpanded(false);
8558
8675
 
8559
8676
  // ── Aggregates (tfoot, group headers, tree parents) ──
8560
8677
  function writeAggregate(cell, rows, ownIndex) {
@@ -8589,7 +8706,7 @@ function createTable(root) {
8589
8706
  const fmt =
8590
8707
  fn === "count"
8591
8708
  ? cell.getAttribute("data-format")
8592
- : cell.getAttribute("data-format") || headerCells()[col]?.getAttribute("data-format") || "number";
8709
+ : cellFormatOf(cell, col) || "number";
8593
8710
  cell.textContent = fmt ? formatValue(out, fmt) : String(out);
8594
8711
  applyNegativeFlag(cell);
8595
8712
  }
@@ -8773,17 +8890,15 @@ function createTable(root) {
8773
8890
 
8774
8891
  function onEditorKeydown(e) {
8775
8892
  if (!editing) return;
8893
+ e.stopPropagation(); // keep grid navigation away from typing
8776
8894
  if (e.key === "Enter") {
8777
8895
  e.preventDefault();
8778
- e.stopPropagation();
8779
8896
  commitEdit(true);
8780
8897
  } else if (e.key === "Escape") {
8781
8898
  e.preventDefault();
8782
- e.stopPropagation();
8783
8899
  cancelEdit(true);
8784
8900
  } else if (e.key === "Tab") {
8785
8901
  e.preventDefault();
8786
- e.stopPropagation();
8787
8902
  const from = editing.cell;
8788
8903
  if (commitEdit()) {
8789
8904
  const next = nextEditableCell(from, e.shiftKey ? -1 : 1);
@@ -8791,7 +8906,6 @@ function createTable(root) {
8791
8906
  }
8792
8907
  } else {
8793
8908
  e.target.removeAttribute("data-invalid");
8794
- e.stopPropagation(); // keep grid navigation away from typing
8795
8909
  }
8796
8910
  }
8797
8911
  function onEditorBlur(e) {
@@ -8832,6 +8946,7 @@ function createTable(root) {
8832
8946
  if (!th) return;
8833
8947
  forEachColumnCell(i, (cell) => toggleAttr(cell, "data-col-hidden", hidden));
8834
8948
  refreshPins();
8949
+ setupNavigability();
8835
8950
  emit("col-visibility", { column: i, hidden: !!hidden });
8836
8951
  scheduleStateSave();
8837
8952
  }
@@ -8943,14 +9058,11 @@ function createTable(root) {
8943
9058
  }
8944
9059
 
8945
9060
  function elementAt(e) {
8946
- if (document.elementFromPoint) {
8947
- try {
8948
- return document.elementFromPoint(e.clientX, e.clientY);
8949
- } catch {
8950
- return null;
8951
- }
9061
+ try {
9062
+ return document.elementFromPoint(e.clientX, e.clientY);
9063
+ } catch {
9064
+ return null; // elementFromPoint missing or throwing (headless)
8952
9065
  }
8953
- return null;
8954
9066
  }
8955
9067
 
8956
9068
  function resolveRowAt(e) {
@@ -8959,16 +9071,13 @@ function createTable(root) {
8959
9071
  return row && tbody?.contains(row) ? row : null;
8960
9072
  }
8961
9073
 
8962
- function dropPos(e, el) {
8963
- const rect = el.getBoundingClientRect?.();
8964
- if (!rect || !rect.height) return "after";
8965
- return e.clientY < rect.top + rect.height / 2 ? "before" : "after";
8966
- }
8967
- function dropPosX(e, el) {
9074
+ /** "before"/"after" the pointer's half of el: vertical for rows, horizontal (RTL-aware) for columns. */
9075
+ function dropPos(e, el, horizontal) {
8968
9076
  const rect = el.getBoundingClientRect?.();
8969
- if (!rect || !rect.width) return "after";
8970
- const before = e.clientX < rect.left + rect.width / 2;
8971
- return before !== isRTL ? "before" : "after";
9077
+ const size = rect && (horizontal ? rect.width : rect.height);
9078
+ if (!size) return "after";
9079
+ const before = horizontal ? e.clientX < rect.left + size / 2 !== isRTL : e.clientY < rect.top + size / 2;
9080
+ return before ? "before" : "after";
8972
9081
  }
8973
9082
 
8974
9083
  function bindDocDrag() {
@@ -9068,7 +9177,7 @@ function createTable(root) {
9068
9177
  if (!th) th = e.target?.closest?.("[data-part='th']");
9069
9178
  if (!th || th === drag.th || !headerRowEl()?.contains(th)) return;
9070
9179
  th.setAttribute("data-drop-target", "");
9071
- th.setAttribute("data-drop-pos", dropPosX(e, th));
9180
+ th.setAttribute("data-drop-pos", dropPos(e, th, true));
9072
9181
  }
9073
9182
  }
9074
9183
 
@@ -9290,15 +9399,11 @@ function createTable(root) {
9290
9399
  existing.remove();
9291
9400
  existing = null;
9292
9401
  }
9293
- let el;
9294
- if (isParent) {
9295
- el = document.createElement("button");
9296
- el.type = "button";
9297
- el.setAttribute("data-part", "expander");
9298
- el.setAttribute("aria-label", "Toggle");
9299
- } else {
9300
- el = document.createElement("span");
9301
- el.setAttribute("data-part", "expander");
9402
+ const el = document.createElement(isParent ? "button" : "span");
9403
+ if (isParent) el.type = "button";
9404
+ el.setAttribute("data-part", "expander");
9405
+ if (isParent) el.setAttribute("aria-label", "Toggle");
9406
+ else {
9302
9407
  el.setAttribute("data-leaf", "");
9303
9408
  el.setAttribute("aria-hidden", "true");
9304
9409
  }
@@ -9394,14 +9499,35 @@ function createTable(root) {
9394
9499
  return rows;
9395
9500
  }
9396
9501
 
9502
+ // APG grid: links, buttons and fields inside a navigable grid's cells leave the
9503
+ // Tab order (Enter/F2 reaches them, Escape comes back). The table's own controls
9504
+ // stay focusable. destroy() puts the original tabindex back.
9505
+ const demoted = new WeakMap();
9506
+ const cellControls = (cell) =>
9507
+ [...cell.querySelectorAll("input,select,textarea,button,a[href],[tabindex]")].filter(
9508
+ (el) => mine(el) && !el.closest("[data-part='checkbox'],[data-part='drag-handle'],[data-part='expander'],[data-part='row-toggle']")
9509
+ );
9510
+
9511
+ // Re-run after anything that can take the Tab stop away (refresh, filter,
9512
+ // collapse, column hide): only the Tab stop moves, never focus.
9397
9513
  function setupNavigability() {
9398
9514
  if (opts.navigable) {
9515
+ const rows = navMatrix();
9516
+ // A stale stop — its row removed, filtered or collapsed, or its column hidden —
9517
+ // is dropped so the first visible cell takes over.
9518
+ if (activeCell && (!rows.includes(activeCell.parentElement) || activeCell.hasAttribute("data-col-hidden"))) {
9519
+ activeCell.setAttribute("tabindex", "-1");
9520
+ activeCell = null;
9521
+ }
9399
9522
  let first = null;
9400
- for (const row of navMatrix()) {
9523
+ for (const row of rows) {
9401
9524
  for (const cell of cellsOf(row)) {
9402
- if (cell.hasAttribute("data-col-hidden")) continue;
9403
- if (!first && !activeCell) first = cell;
9525
+ if (!first && !activeCell && !cell.hasAttribute("data-col-hidden")) first = cell;
9404
9526
  cell.setAttribute("tabindex", cell === (activeCell || first) ? "0" : "-1");
9527
+ for (const el of cellControls(cell)) {
9528
+ if (!demoted.has(el)) demoted.set(el, el.getAttribute("tabindex"));
9529
+ el.setAttribute("tabindex", "-1");
9530
+ }
9405
9531
  }
9406
9532
  }
9407
9533
  if (!activeCell) activeCell = first;
@@ -9416,11 +9542,7 @@ function createTable(root) {
9416
9542
  if (activeCell && activeCell !== cell) activeCell.setAttribute("tabindex", "-1");
9417
9543
  activeCell = cell;
9418
9544
  cell.setAttribute("tabindex", "0");
9419
- try {
9420
- cell.focus();
9421
- } catch {
9422
- /* unfocusable */
9423
- }
9545
+ cell.focus();
9424
9546
  }
9425
9547
 
9426
9548
  function moveFocus(rowDelta, colDelta, edge) {
@@ -9460,7 +9582,7 @@ function createTable(root) {
9460
9582
  if (!opts.navigable) return;
9461
9583
  const cell = e.target.closest("[data-part='td'],[data-part='th']");
9462
9584
  if (!cell || cell === activeCell || !root.contains(cell)) return;
9463
- if (e.target !== cell) return; // focus landed on inner control, not the cell
9585
+ if (e.target !== cell && !demoted.has(e.target)) return; // the table's own control, not the cell
9464
9586
  if (activeCell) activeCell.setAttribute("tabindex", "-1");
9465
9587
  activeCell = cell;
9466
9588
  cell.setAttribute("tabindex", "0");
@@ -9495,13 +9617,12 @@ function createTable(root) {
9495
9617
  if (e.shiftKey && lastSelectedIndex >= 0) {
9496
9618
  e.preventDefault();
9497
9619
  selectRange(lastSelectedIndex, index);
9498
- emit("selection-change", { selected: getSelected() });
9620
+ selectionChanged();
9499
9621
  return;
9500
9622
  }
9501
9623
  toggleAttr(row, "data-selected", checkbox.checked);
9502
9624
  lastSelectedIndex = index;
9503
- updateHeaderCheckbox();
9504
- emit("selection-change", { selected: getSelected() });
9625
+ selectionChanged();
9505
9626
  }
9506
9627
 
9507
9628
  function onRootClick(e) {
@@ -9541,7 +9662,7 @@ function createTable(root) {
9541
9662
  if (index < 0) return;
9542
9663
  if (e.shiftKey && lastSelectedIndex >= 0) {
9543
9664
  selectRange(lastSelectedIndex, index);
9544
- emit("selection-change", { selected: getSelected() });
9665
+ selectionChanged();
9545
9666
  return;
9546
9667
  }
9547
9668
  if (opts.selectable != null) {
@@ -9553,8 +9674,7 @@ function createTable(root) {
9553
9674
  setRowSelected(row, !row.hasAttribute("data-selected"));
9554
9675
  }
9555
9676
  lastSelectedIndex = index;
9556
- updateHeaderCheckbox();
9557
- emit("selection-change", { selected: getSelected() });
9677
+ selectionChanged();
9558
9678
  }
9559
9679
  }
9560
9680
 
@@ -9614,10 +9734,18 @@ function createTable(root) {
9614
9734
  return;
9615
9735
  }
9616
9736
 
9737
+ const cell = target.closest?.("[data-part='td'],[data-part='th']");
9738
+
9739
+ // Escape in a cell's control returns to the cell.
9740
+ if (e.key === "Escape" && demoted.has(target)) {
9741
+ e.preventDefault();
9742
+ focusCell(cell);
9743
+ return;
9744
+ }
9745
+
9617
9746
  // Never hijack typing or native control activation (buttons own Enter/Space via click).
9618
9747
  if (target.closest?.("input,select,textarea,button,a")) return;
9619
9748
 
9620
- const cell = target.closest?.("[data-part='td'],[data-part='th']");
9621
9749
  const inHeader = cell && thead?.contains(cell);
9622
9750
 
9623
9751
  // Sortable header keyboard activation.
@@ -9708,6 +9836,12 @@ function createTable(root) {
9708
9836
  if (isCellEditable(cell)) {
9709
9837
  e.preventDefault();
9710
9838
  startEdit(cell);
9839
+ return;
9840
+ }
9841
+ const ctl = cellControls(cell)[0];
9842
+ if (ctl) {
9843
+ e.preventDefault();
9844
+ ctl.focus();
9711
9845
  }
9712
9846
  return;
9713
9847
  }
@@ -9755,7 +9889,7 @@ function createTable(root) {
9755
9889
 
9756
9890
  function measureSticky() {
9757
9891
  const needsHeaderVar =
9758
- root.hasAttribute("data-sticky-header") || !!tbody?.querySelector("[data-part='tr'][data-pin='top']");
9892
+ root.hasAttribute("data-sticky-header") || bodyRows().some((r) => r.dataset.pin === "top");
9759
9893
  if (needsHeaderVar && thead && thead.offsetHeight) {
9760
9894
  root.style.setProperty("--table-thead-h", thead.offsetHeight + "px");
9761
9895
  }
@@ -9794,6 +9928,12 @@ function createTable(root) {
9794
9928
  cleanups.length = 0;
9795
9929
  observer?.disconnect();
9796
9930
  observer = null;
9931
+ for (const el of root.querySelectorAll("[tabindex]")) {
9932
+ if (demoted.has(el)) {
9933
+ const t = demoted.get(el);
9934
+ t == null ? el.removeAttribute("tabindex") : el.setAttribute("tabindex", t);
9935
+ }
9936
+ }
9797
9937
  for (const t of timers) clearTimeout(t);
9798
9938
  timers.clear();
9799
9939
  clearTimeout(saveTimer);
@@ -9803,13 +9943,14 @@ function createTable(root) {
9803
9943
  // ── Wire up ──
9804
9944
  if (thead) on(thead, "click", onHeaderClick);
9805
9945
  if (headerCheckbox) on(headerCheckbox, "change", onHeaderCheckboxChange);
9806
- if (tbody) on(tbody, "change", onRowCheckboxChange);
9807
- on(root, "click", onRootClick);
9808
- on(root, "dblclick", onDblClick);
9809
- on(root, "keydown", onKeydown);
9810
- on(root, "input", onFilterInput);
9811
- on(root, "pointerdown", onPointerDown);
9812
- on(root, "focusin", onFocusIn);
9946
+ const onOwn = (target, ev, fn) => on(target, ev, (e) => mine(e.target) && fn(e));
9947
+ if (tbody) onOwn(tbody, "change", onRowCheckboxChange);
9948
+ onOwn(root, "click", onRootClick);
9949
+ onOwn(root, "dblclick", onDblClick);
9950
+ onOwn(root, "keydown", onKeydown);
9951
+ onOwn(root, "input", onFilterInput);
9952
+ onOwn(root, "pointerdown", onPointerDown);
9953
+ onOwn(root, "focusin", onFocusIn);
9813
9954
  if (typeof window !== "undefined") {
9814
9955
  let resizeTimer = null;
9815
9956
  const onWinResize = () => {
@@ -9894,9 +10035,14 @@ function createTabs(root) {
9894
10035
  // Prevent double-init
9895
10036
  if (root._faqirTabs) return root._faqirTabs;
9896
10037
 
9897
- const list = root.querySelector("[data-part='list']");
9898
- const triggers = () => [...root.querySelectorAll("[data-part='trigger']")];
9899
- const panels = () => [...root.querySelectorAll("[data-part='panel']")];
10038
+ // Direct children only: a component nested in a panel (a collapsible's
10039
+ // summary, another tabs) has parts of the same names, and a deep query took
10040
+ // them for this component's tabs and shifted the trigger↔panel pairing.
10041
+ const list = root.querySelector(":scope > [data-part='list']");
10042
+ const triggers = () => [
10043
+ ...root.querySelectorAll(":scope > [data-part='list'] > [data-part='trigger']"),
10044
+ ];
10045
+ const panels = () => [...root.querySelectorAll(":scope > [data-part='panel']")];
9900
10046
 
9901
10047
  function activate(index) {
9902
10048
  const allTriggers = triggers();
@@ -10565,8 +10711,11 @@ function createToastContainer(root) {
10565
10711
  * exclusivity when the controls are used standalone (no `l-model`), the
10566
10712
  * `[data-state]` styling hook, and a `faqir:change` notification.
10567
10713
  *
10568
- * Every state mutation funnels through a native `change` event on the affected
10714
+ * Every user mutation funnels through a native `change` event on the affected
10569
10715
  * input, so the engine's `l-model` listener and this controller stay in lockstep.
10716
+ * Store-driven `l-model` writes arrive as the engine's `faqir:model` event (and
10717
+ * a form `reset`); both re-sync state without a `faqir:change`. A plain script
10718
+ * write (`input.checked = x`) is silent and is not observed — use `setValue()`.
10570
10719
  */
10571
10720
  function createToggleGroup(root) {
10572
10721
  // Prevent double-init.
@@ -10727,13 +10876,23 @@ function createToggleGroup(root) {
10727
10876
  if (el) select(el);
10728
10877
  }
10729
10878
 
10879
+ // A store-driven `l-model` write sets `.checked` silently and announces it
10880
+ // with `faqir:model`; follow it without re-emitting `faqir:change` (the user
10881
+ // did not act). A form reset restores defaults silently the same way.
10882
+ const form = root.closest("form");
10883
+ const onReset = () => setTimeout(syncState, 0);
10884
+
10730
10885
  root.addEventListener("change", onChange);
10886
+ root.addEventListener("faqir:model", syncState);
10731
10887
  root.addEventListener("keydown", onKeyDown);
10888
+ if (form) form.addEventListener("reset", onReset);
10732
10889
  syncState();
10733
10890
 
10734
10891
  function destroy() {
10735
10892
  root.removeEventListener("change", onChange);
10893
+ root.removeEventListener("faqir:model", syncState);
10736
10894
  root.removeEventListener("keydown", onKeyDown);
10895
+ if (form) form.removeEventListener("reset", onReset);
10737
10896
  delete root._faqirToggleGroup;
10738
10897
  }
10739
10898
 
@@ -10754,7 +10913,8 @@ function createTooltip(root) {
10754
10913
  if (root._faqirTooltip) return root._faqirTooltip;
10755
10914
 
10756
10915
  const trigger = root.querySelector("[data-part='trigger']");
10757
- const content = root.querySelector("[data-part='content']");
10916
+ // Not the content of a collapsible or popover used as the trigger.
10917
+ const [content] = ownParts(root, "content");
10758
10918
 
10759
10919
  let showTimer = null;
10760
10920
  let hideTimer = null;
@@ -11352,7 +11512,9 @@ function createTreeView(root) {
11352
11512
  /** One value, deep-copied into something plain, finite and printable. */
11353
11513
  function snapshotValue(value, stack, depth) {
11354
11514
  if (typeof value === 'function') {
11355
- return '[Function' + (value.name ? ' ' + value.name : '') + ']';
11515
+ // Scope methods are bound [1.1F-05]; report the name the author wrote.
11516
+ var name = String(value.name || '').replace(/^bound /, '');
11517
+ return '[Function' + (name ? ' ' + name : '') + ']';
11356
11518
  }
11357
11519
  if (value === null || typeof value !== 'object') return value;
11358
11520
  if (value.nodeType === 1 && value.tagName) {
@@ -11596,22 +11758,35 @@ function createTreeView(root) {
11596
11758
  }
11597
11759
  }
11598
11760
 
11761
+ // Every controller inside `root` (a document or an element), `root` included.
11762
+ // Controllers are double-init guarded, so a second call is a no-op.
11763
+ function startControllers(root) {
11764
+ var own = root.getAttribute && root.getAttribute('data-ui');
11765
+ if (own && controllerRegistry[own]) initController(own, root);
11766
+ var names = Object.keys(controllerRegistry);
11767
+ for (var n = 0; n < names.length; n++) {
11768
+ var els = root.querySelectorAll('[data-ui="' + names[n] + '"]');
11769
+ for (var e = 0; e < els.length; e++) initController(names[n], els[e]);
11770
+ }
11771
+ }
11772
+
11773
+ // `Faqir.initTree` — the same order bootstrap uses: a connected subtree's
11774
+ // controllers start first, so its `l-init` / first `l-effect` reach them
11775
+ // through `$ui`. A detached subtree's controllers wait for the
11776
+ // MutationObserver, as before. [1.1F-03]
11777
+ function publicInitTree(root, parentScope) {
11778
+ if (root.isConnected) startControllers(root);
11779
+ initTree(root, parentScope);
11780
+ }
11781
+
11599
11782
  // The document's one MutationObserver, or null before the first bootstrap,
11600
11783
  // and the <body> it is currently watching.
11601
11784
  var mutationObserver = null;
11602
11785
  var observedBody = null;
11603
11786
 
11604
11787
  function bootstrap() {
11605
- injectCloakStyle();
11606
-
11607
11788
  // Auto-init controllers for all [data-ui] elements
11608
- var names = Object.keys(controllerRegistry);
11609
- for (var n = 0; n < names.length; n++) {
11610
- var els = document.querySelectorAll('[data-ui="' + names[n] + '"]');
11611
- for (var e = 0; e < els.length; e++) {
11612
- initController(names[n], els[e]);
11613
- }
11614
- }
11789
+ startControllers(document);
11615
11790
 
11616
11791
  // Find all scope roots.
11617
11792
  // l-data elements always create a scope.
@@ -11632,7 +11807,7 @@ function createTreeView(root) {
11632
11807
  // to call `start()` again. [W3-1]
11633
11808
  if (rootEl.__faqirScope) {
11634
11809
  processed.add(rootEl);
11635
- var seen = rootEl.querySelectorAll('[l-data]');
11810
+ var seen = rootEl.querySelectorAll('[l-data], [data-ui]');
11636
11811
  for (var sd = 0; sd < seen.length; sd++) processed.add(seen[sd]);
11637
11812
  continue;
11638
11813
  }
@@ -11664,7 +11839,13 @@ function createTreeView(root) {
11664
11839
 
11665
11840
  if (!skipThis) {
11666
11841
  initTree(rootEl, null);
11667
- var descendants = rootEl.querySelectorAll('[l-data]');
11842
+ // `initTree` walked the whole subtree, so every root candidate inside it
11843
+ // — nested `[l-data]` scopes AND nested `[data-ui]` components — is
11844
+ // bound already. Claiming only the `[l-data]` ones left a `[data-ui]`
11845
+ // inside a standalone `[data-ui]` looking standalone too: it became a
11846
+ // root of its own and re-bound its subtree, so a button four components
11847
+ // deep fired its `@click` four times.
11848
+ var descendants = rootEl.querySelectorAll('[l-data], [data-ui]');
11668
11849
  for (var d = 0; d < descendants.length; d++) processed.add(descendants[d]);
11669
11850
  processed.add(rootEl);
11670
11851
  }
@@ -11678,12 +11859,7 @@ function createTreeView(root) {
11678
11859
  // after the first sweep ran and before the MutationObserver below starts.
11679
11860
  // Controllers are double-init guarded, so re-invoking is a no-op for
11680
11861
  // elements initialized by the first sweep.
11681
- for (var n2 = 0; n2 < names.length; n2++) {
11682
- var els2 = document.querySelectorAll('[data-ui="' + names[n2] + '"]');
11683
- for (var e2 = 0; e2 < els2.length; e2++) {
11684
- initController(names[n2], els2[e2]);
11685
- }
11686
- }
11862
+ startControllers(document);
11687
11863
 
11688
11864
  removeCloaks();
11689
11865
 
@@ -11709,14 +11885,14 @@ function createTreeView(root) {
11709
11885
  if (node.nodeType !== 1) continue;
11710
11886
 
11711
11887
  // Content that arrives after bootstrap has already bound by the time
11712
- // it is here; leaving `l-cloak` on it means the injected rule hides
11713
- // it forever. [W3-1]
11888
+ // it is here; leaving `l-cloak` on it means the cloak rule hides it
11889
+ // forever. [W3-1]
11714
11890
  removeCloaks(node);
11715
11891
 
11716
- var uiName = node.getAttribute ? node.getAttribute('data-ui') : null;
11717
- if (uiName && controllerRegistry[uiName]) {
11718
- initController(uiName, node);
11719
- }
11892
+ // Controllers first — the node's own and every one inside it — so
11893
+ // the `l-init` / first `l-effect` that `initTree` runs below reach
11894
+ // them through `$ui`. They used to start after it. [1.1F-03]
11895
+ startControllers(node);
11720
11896
 
11721
11897
  // Already initialized — by `Faqir.start()` / `Faqir.initTree()` in the
11722
11898
  // same task that inserted it (the record is only delivered on the
@@ -11733,11 +11909,6 @@ function createTreeView(root) {
11733
11909
  }
11734
11910
 
11735
11911
  if (node.querySelectorAll) {
11736
- var cNames = Object.keys(controllerRegistry);
11737
- for (var cn = 0; cn < cNames.length; cn++) {
11738
- var cEls = node.querySelectorAll('[data-ui="' + cNames[cn] + '"]');
11739
- for (var ce = 0; ce < cEls.length; ce++) initController(cNames[cn], cEls[ce]);
11740
- }
11741
11912
  var scopeEls = node.querySelectorAll('[l-data]');
11742
11913
  for (var se = 0; se < scopeEls.length; se++) {
11743
11914
  if (!scopeEls[se].__faqirScope) {
@@ -11766,14 +11937,35 @@ function createTreeView(root) {
11766
11937
  }
11767
11938
 
11768
11939
  // Auto-start logic
11940
+ //
11941
+ // `data-manual` opts out. A classic script is its own `currentScript`; a
11942
+ // module (or anything a bundler emitted) has none, so there the attribute is
11943
+ // honoured on the page's module `<script>` instead.
11769
11944
  var currentScript = typeof document !== 'undefined' ? document.currentScript : null;
11770
- var isManual = currentScript && currentScript.hasAttribute('data-manual');
11945
+ var isManual = currentScript
11946
+ ? currentScript.hasAttribute('data-manual')
11947
+ : typeof document !== 'undefined' && !!document.querySelector('script[type="module"][data-manual]');
11948
+
11949
+ // Once the document is parsed — always true for a module, a `defer` script
11950
+ // or a bundler import — booting right here ran before the importing code
11951
+ // got past its `import`, so every `Faqir.data()` / `Faqir.directive()` it
11952
+ // registered next was never seen. The boot waits one task instead: long
11953
+ // enough for the rest of the module graph and any `import().then(...)`, and
11954
+ // `DOMContentLoaded` still wins if it has not fired yet. `bootstrap` is
11955
+ // re-entry safe, so an explicit `Faqir.start()` before then costs nothing.
11956
+ var autoStarted = false;
11957
+ function autoStart() {
11958
+ if (autoStarted) return;
11959
+ autoStarted = true;
11960
+ bootstrap();
11961
+ }
11771
11962
 
11772
11963
  if (!isManual && typeof document !== 'undefined') {
11773
11964
  if (document.readyState === 'loading') {
11774
- document.addEventListener('DOMContentLoaded', bootstrap);
11965
+ document.addEventListener('DOMContentLoaded', autoStart);
11775
11966
  } else {
11776
- bootstrap();
11967
+ if (document.readyState === 'interactive') document.addEventListener('DOMContentLoaded', autoStart);
11968
+ setTimeout(autoStart, 0);
11777
11969
  }
11778
11970
  }
11779
11971
 
@@ -11787,7 +11979,7 @@ function createTreeView(root) {
11787
11979
  // one time it was hand-maintained (0.1.0 in a package versioned 0.2.4), so
11788
11980
  // the built artifact is now the only place the number is authoritative and
11789
11981
  // the build fails if this marker goes missing.
11790
- version: '1.1.0', // @faqir:version
11982
+ version: '1.1.2', // @faqir:version
11791
11983
  reactive: reactive,
11792
11984
  effect: effect,
11793
11985
  batch: batch,
@@ -11798,11 +11990,14 @@ function createTreeView(root) {
11798
11990
  data: function(name, factory) { dataRegistry.set(name, factory); },
11799
11991
  store: function(name, obj) { globalStores[name] = reactive(obj); },
11800
11992
  directive: function(name, handler) { customDirectives.set(name, handler); },
11801
- magic: function(name, callback) { customMagics.set(name, callback); },
11993
+ magic: function(name, callback) {
11994
+ if (name === 'this') throw new Error('[Faqir] "this" is reserved: $this is the directive element');
11995
+ customMagics.set(name, callback);
11996
+ },
11802
11997
  plugin: function(fn) { fn(Faqir); },
11803
11998
  controller: function(name, factory) { controllerRegistry[name] = factory; },
11804
11999
  start: bootstrap,
11805
- initTree: initTree,
12000
+ initTree: publicInitTree,
11806
12001
  // Snapshot of Faqir's view of one element — scope, directives, controller,
11807
12002
  // protocol attributes. [0.7-12]
11808
12003
  inspect: inspect,