@faqir-ui/core 1.1.1 → 1.2.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.
Files changed (44) hide show
  1. package/README.md +9 -8
  2. package/cdn.json +41 -36
  3. package/dist/faqir-core.js +568 -394
  4. package/dist/faqir-core.min.js +3 -3
  5. package/dist/faqir-core.min.js.map +3 -3
  6. package/dist/faqir-core.mjs +651 -532
  7. package/dist/faqir.aurora.css +6436 -1401
  8. package/dist/faqir.brutalist.css +6436 -1401
  9. package/dist/faqir.candy.css +6436 -1401
  10. package/dist/faqir.clay.css +19164 -0
  11. package/dist/faqir.clinical.css +6436 -1401
  12. package/dist/faqir.contrast.css +6436 -1401
  13. package/dist/faqir.deco.css +19247 -0
  14. package/dist/faqir.default.css +6436 -1401
  15. package/dist/faqir.document-serif.css +6436 -1401
  16. package/dist/faqir.document.css +6437 -1401
  17. package/dist/faqir.editorial-document.css +6436 -1401
  18. package/dist/faqir.editorial.css +6436 -1401
  19. package/dist/faqir.fintech.css +6436 -1401
  20. package/dist/faqir.glass.css +6436 -1401
  21. package/dist/faqir.ink-document.css +6436 -1401
  22. package/dist/faqir.ink.css +6436 -1401
  23. package/dist/faqir.luxe.css +6436 -1401
  24. package/dist/faqir.memphis.css +19163 -0
  25. package/dist/faqir.midnight.css +6436 -1401
  26. package/dist/faqir.monolith.css +19162 -0
  27. package/dist/faqir.neo.css +6436 -1401
  28. package/dist/faqir.neumorph.css +6436 -1401
  29. package/dist/faqir.nordic.css +6436 -1401
  30. package/dist/faqir.organic.css +6436 -1401
  31. package/dist/faqir.paper.css +6436 -1401
  32. package/dist/faqir.slate.css +6436 -1401
  33. package/dist/faqir.soft.css +6436 -1401
  34. package/dist/faqir.sunset.css +6436 -1401
  35. package/dist/faqir.swiss-document.css +6436 -1401
  36. package/dist/faqir.swiss.css +6436 -1401
  37. package/dist/faqir.terminal.css +6436 -1401
  38. package/dist/plugins/faqir-mask.js +1 -2
  39. package/dist/plugins/faqir-rules.js +1 -1
  40. package/dist/plugins/faqir-tweak.js +304 -0
  41. package/dist/plugins/faqir-validate.js +12 -4
  42. package/dist/sri.json +39 -34
  43. package/faqir-core.d.ts +52 -2
  44. 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.1
13
+ // Package version: 1.2.0
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
 
@@ -504,8 +541,10 @@
504
541
  for (var i = 0; i < children.length; i++) {
505
542
  var child = children[i];
506
543
 
507
- // Skip elements already initialized by l-for or l-if
508
- if (child.__faqirScope && !child.hasAttribute('l-data')) {
544
+ // Skip elements already initialized by l-for or l-if — or teleported here
545
+ // by an earlier sibling's l-teleport, which bound them (and, with an
546
+ // l-data of their own, scoped them) on the way out.
547
+ if (child.__faqirStray || (child.__faqirScope && !child.hasAttribute('l-data'))) {
509
548
  continue;
510
549
  }
511
550
 
@@ -558,6 +597,17 @@
558
597
  // at `<body>`. Created once, on first use, so a page with no stray directive
559
598
  // pays nothing for it.
560
599
  var strayScope = null;
600
+
601
+ // The element a scope's `$refs` live on, keyed by the scope object. Not
602
+ // `findScopeRoot`: `__faqirScope` also marks "already initialised", so an
603
+ // `l-if` clone or an `l-for` row carries it too, and a ref resolved by walking
604
+ // up landed on the clone while `$refs` read the real root — invisible. A
605
+ // teleported element has left the root's subtree, and a stray element has no
606
+ // stamped ancestor at all. A WeakMap rather than a scope property, because a
607
+ // read through the reactive scope inside a structural effect would subscribe
608
+ // it. [1.1F-01]
609
+ var scopeRoots = new WeakMap();
610
+
561
611
  function getStrayScope() {
562
612
  if (!strayScope) {
563
613
  if (!document.body.__scopeId) document.body.__scopeId = ++scopeCounter;
@@ -567,6 +617,7 @@
567
617
  // MutationObserver would stop giving standalone `[data-ui]` islands their
568
618
  // own scope.
569
619
  strayScope = createScopeWithMagics({}, document.body, document.body);
620
+ scopeRoots.set(strayScope, document.body);
570
621
  }
571
622
  return strayScope;
572
623
  }
@@ -619,6 +670,7 @@
619
670
 
620
671
  var scopeId = ++scopeCounter;
621
672
  var scope = createScopeWithMagics(userData, root, root);
673
+ scopeRoots.set(scope, root);
622
674
  root.__faqirScope = scope;
623
675
  root.__scopeId = scopeId;
624
676
  root.__faqirCleanups = [];
@@ -643,7 +695,7 @@
643
695
 
644
696
  var initExpr = root.getAttribute('l-init');
645
697
  if (initExpr) {
646
- evaluateAssignment(initExpr, scope, root);
698
+ runInit(function() { evaluateAssignment(initExpr, scope, root); });
647
699
  }
648
700
 
649
701
  return scope;
@@ -733,6 +785,15 @@
733
785
  Object.defineProperties(target, descriptors);
734
786
 
735
787
  var scope = reactive(target);
788
+ // A bare call `pick()` inside `with(rowScope)` gets the l-for row scope as
789
+ // `this`, and that scope owns the loop variable and index: `this.item = x`
790
+ // wrote the row instead of the component. Bind each own method to the
791
+ // scope. Getters keep their receiver; functions assigned later stay
792
+ // unbound. Written to the target, so nothing is triggered. [1.1F-05]
793
+ for (var k in descriptors) {
794
+ var d = descriptors[k];
795
+ if (d.writable && typeof d.value === 'function') target[k] = d.value.bind(scope);
796
+ }
736
797
  return scope;
737
798
  }
738
799
 
@@ -751,8 +812,10 @@
751
812
 
752
813
  // --- Scope utilities ---
753
814
 
815
+ // Created on first read, so an effect that reads a ref before it exists
816
+ // subscribes to the same object `handleRef` later writes through.
754
817
  function getScopeRefs(root) {
755
- return root.__faqirRefs || {};
818
+ return root.__faqirRefs || (root.__faqirRefs = {});
756
819
  }
757
820
 
758
821
  function findScopeRoot(el) {
@@ -1185,16 +1248,38 @@
1185
1248
  'open', 'novalidate', 'formnovalidate', 'inert'
1186
1249
  ]);
1187
1250
 
1251
+ // Properties whose live state stops following the attribute once the user
1252
+ // has touched the control (the "dirty" flag), so an attribute-only write
1253
+ // leaves a stale control. `muted` and `indeterminate` have no attribute at all.
1254
+ var PROPS = new Set(['checked', 'selected', 'muted', 'indeterminate', 'value']);
1255
+
1256
+ // Write a DOM property only when it differs. `checked`/`selected` writes
1257
+ // notify with a bubbling `faqir:model` — never a native `change`, which would
1258
+ // re-enter l-model's own listener and fire user `@change` handlers on a
1259
+ // store-driven write.
1260
+ function writeProp(el, name, value) {
1261
+ if (!(name in el) || el[name] === value) return;
1262
+ el[name] = value;
1263
+ if (name === 'checked' || name === 'selected') {
1264
+ el.dispatchEvent(new CustomEvent('faqir:model', { bubbles: true }));
1265
+ }
1266
+ }
1267
+
1188
1268
  function handleBind(el, dir, scope) {
1189
1269
  var attrName = dir.arg;
1270
+ // What this binding's last run set on `class`/`style`. Both merge: a run
1271
+ // applies its own classes/declarations and removes only the ones its
1272
+ // previous run set and this one dropped, so static markup, `l-show` and
1273
+ // controller writes survive a re-run. [1.1F-04]
1274
+ var prev = {};
1190
1275
 
1191
1276
  var cl = effect(function() {
1192
1277
  var value = evaluate(dir.expression, scope, el);
1193
1278
 
1194
1279
  if (attrName === 'class') {
1195
- applyClassBinding(el, value);
1280
+ prev = applyClassBinding(el, value, prev);
1196
1281
  } else if (attrName === 'style') {
1197
- applyStyleBinding(el, value);
1282
+ prev = applyStyleBinding(el, value, prev);
1198
1283
  } else if (BOOLEAN_ATTRS.has(attrName)) {
1199
1284
  if (value) {
1200
1285
  el.setAttribute(attrName, '');
@@ -1208,41 +1293,66 @@
1208
1293
  el.setAttribute(attrName, String(value));
1209
1294
  }
1210
1295
  }
1296
+ if (PROPS.has(attrName)) {
1297
+ writeProp(el, attrName, attrName === 'value'
1298
+ ? (value == null || value === false ? '' : String(value))
1299
+ : !!value);
1300
+ }
1211
1301
  });
1212
1302
 
1213
1303
  addCleanup(el, cl);
1214
1304
  }
1215
1305
 
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
- }
1306
+ // A `:class` value as { class: on }. Strings and arrays turn classes on; an
1307
+ // object entry sets its own, so a falsy one removes a class markup set.
1308
+ function classMap(v, out, on) {
1309
+ if (typeof v === 'string') {
1310
+ v.split(/\s+/).forEach(function(c) { if (c) out[c] = on; });
1311
+ } else if (Array.isArray(v)) {
1312
+ v.forEach(function(x) { if (x) classMap(x, out, true); });
1313
+ } else if (v && typeof v === 'object') {
1314
+ for (var k in v) classMap(k, out, !!v[k]);
1227
1315
  }
1316
+ return out;
1228
1317
  }
1229
1318
 
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
- }
1319
+ function applyClassBinding(el, value, prev) {
1320
+ var next = classMap(value, {}, true), c;
1321
+ for (c in prev) if (prev[c] && !(c in next)) el.classList.remove(c);
1322
+ for (c in next) el.classList.toggle(c, next[c]);
1323
+ return next;
1324
+ }
1325
+
1326
+ // A `:style` value as { property: [value, priority] }, or null to remove it.
1327
+ // A string is parsed by a detached element's CSSOM, which handles custom
1328
+ // properties, `!important`, shorthands and `;` inside `url()`.
1329
+ var styleScratch;
1330
+ function styleMap(v, out) {
1331
+ if (typeof v === 'string') {
1332
+ var s = (styleScratch || (styleScratch = document.createElement('div'))).style;
1333
+ s.cssText = v;
1334
+ for (var i = 0; i < s.length; i++) {
1335
+ out[s[i]] = [s.getPropertyValue(s[i]), s.getPropertyPriority(s[i])];
1244
1336
  }
1337
+ } else if (Array.isArray(v)) {
1338
+ v.forEach(function(x) { styleMap(x, out); });
1339
+ } else if (v && typeof v === 'object') {
1340
+ for (var k in v) {
1341
+ out[/^--/.test(k) ? k : k.replace(/[A-Z]/g, '-$&').toLowerCase()] =
1342
+ v[k] == null || v[k] === false ? null : [String(v[k]), ''];
1343
+ }
1344
+ }
1345
+ return out;
1346
+ }
1347
+
1348
+ function applyStyleBinding(el, value, prev) {
1349
+ var next = styleMap(value, {}), p;
1350
+ for (p in prev) if (prev[p] && !(p in next)) el.style.removeProperty(p);
1351
+ for (p in next) {
1352
+ if (next[p]) el.style.setProperty(p, next[p][0], next[p][1]);
1353
+ else el.style.removeProperty(p);
1245
1354
  }
1355
+ return next;
1246
1356
  }
1247
1357
 
1248
1358
  // --- 3.9 l-on / @event ---
@@ -1344,13 +1454,17 @@
1344
1454
 
1345
1455
  function handleRef(el, dir, scope) {
1346
1456
  var name = dir.expression;
1347
- var root = findScopeRoot(el);
1348
- if (!root.__faqirRefs) root.__faqirRefs = {};
1349
- root.__faqirRefs[name] = el;
1457
+ // Written through the reactive proxy, so an effect that read `$refs.name`
1458
+ // re-runs when the ref appears or goes. [1.1F-01]
1459
+ var refs = reactive(getScopeRefs(scopeRoots.get(scope) || findScopeRoot(el)));
1460
+ refs[name] = el;
1350
1461
 
1351
1462
  addCleanup(el, function() {
1352
- if (root.__faqirRefs && root.__faqirRefs[name] === el) {
1353
- delete root.__faqirRefs[name];
1463
+ // `__target`: a tracked read here would subscribe whichever structural
1464
+ // effect is tearing this element down.
1465
+ if (refs.__target[name] === el) {
1466
+ refs[name] = undefined;
1467
+ delete refs.__target[name];
1354
1468
  }
1355
1469
  });
1356
1470
  }
@@ -1358,29 +1472,24 @@
1358
1472
  // --- 3.11 l-init ---
1359
1473
 
1360
1474
  function handleInit(el, dir, scope) {
1361
- evaluateAssignment(dir.expression, scope, el);
1475
+ runInit(function() { evaluateAssignment(dir.expression, scope, el); });
1362
1476
  }
1363
1477
 
1364
1478
  // --- 3.12 l-effect ---
1365
1479
 
1366
1480
  function handleEffect(el, dir, scope) {
1367
- var cl = effect(function() {
1368
- evaluateAssignment(dir.expression, scope, el);
1481
+ runInit(function() {
1482
+ addCleanup(el, effect(function() {
1483
+ evaluateAssignment(dir.expression, scope, el);
1484
+ }));
1369
1485
  });
1370
- addCleanup(el, cl);
1371
1486
  }
1372
1487
 
1373
1488
  // --- 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
- }
1489
+ //
1490
+ // The engine injects no stylesheet. The `[l-cloak]` rule ships in
1491
+ // `base/reset.css` (so in every bundle); the injected copy it used to append
1492
+ // at boot was redundant, and the only inline style a CSP had to allow. [1.1F-07]
1384
1493
 
1385
1494
  /**
1386
1495
  * Strip `l-cloak` from `within` and everything under it (the whole document
@@ -1389,7 +1498,7 @@
1389
1498
  * The sweep used to run exactly once, at the end of bootstrap, against a
1390
1499
  * document-wide selector. Anything inserted afterwards — an `l-if` branch, an
1391
1500
  * `l-for` row, a fragment an application appended — kept the attribute, and
1392
- * the injected `[l-cloak] { display: none !important }` rule then hid it
1501
+ * the `[l-cloak] { display: none !important }` rule then hid it
1393
1502
  * permanently: content that had bound correctly and could never be seen. The
1394
1503
  * MutationObserver runs this over every node it is handed. [W3-1]
1395
1504
  */
@@ -1415,7 +1524,7 @@
1415
1524
 
1416
1525
  // Named presets shipped by motion-presets.css. Unknown names still animate
1417
1526
  // (the attribute is stamped) but have no styling, so we warn to catch typos.
1418
- var MOTION_PRESETS = { fade: 1, 'slide-up': 1, scale: 1 };
1527
+ var MOTION_PRESETS = { fade: 1, 'slide-up': 1, 'slide-down': 1, 'slide-start': 1, 'slide-end': 1, scale: 1, blur: 1, flip: 1 };
1419
1528
 
1420
1529
  function prefersReducedMotion() {
1421
1530
  return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -1469,7 +1578,7 @@
1469
1578
  var preset = el.getAttribute('l-transition') || 'fade';
1470
1579
  if (!MOTION_PRESETS[preset]) {
1471
1580
  console.warn('[Faqir] l-transition: unknown preset "' + preset +
1472
- '" — expected one of: fade, slide-up, scale');
1581
+ '" — expected one of: ' + Object.keys(MOTION_PRESETS).join(', '));
1473
1582
  }
1474
1583
 
1475
1584
  el.setAttribute('data-motion', phase); // from-state (no transition yet)
@@ -1546,23 +1655,22 @@
1546
1655
  if (isFaqirSwitch) {
1547
1656
  var cl = effect(function() {
1548
1657
  var value = evaluate(prop, scope, el);
1549
- el.checked = !!value;
1658
+ writeProp(el, 'checked', !!value);
1550
1659
  el.dataset.state = value ? 'on' : 'off';
1551
1660
  el.setAttribute('aria-checked', value ? 'true' : 'false');
1552
1661
  });
1553
- bind('change', function() {
1554
- writeModel(prop, el.checked, scope, el);
1662
+ // A switch is a <button role="switch">: no `checked`, and no `change`.
1663
+ bind('click', function() {
1664
+ writeModel(prop, !evaluate(prop, scope, el), scope, el);
1555
1665
  });
1556
1666
  addCleanup(el, cl);
1557
1667
 
1558
1668
  } else if (tag === 'input' && type === 'checkbox') {
1559
1669
  var cl = effect(function() {
1560
1670
  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
- }
1671
+ writeProp(el, 'checked', Array.isArray(current)
1672
+ ? current.indexOf(el.value) >= 0
1673
+ : !!current);
1566
1674
  });
1567
1675
  bind('change', function() {
1568
1676
  var current = evaluate(prop, scope, el);
@@ -1580,7 +1688,7 @@
1580
1688
 
1581
1689
  } else if (tag === 'input' && type === 'radio') {
1582
1690
  var cl = effect(function() {
1583
- el.checked = evaluate(prop, scope, el) === el.value;
1691
+ writeProp(el, 'checked', evaluate(prop, scope, el) === el.value);
1584
1692
  });
1585
1693
  bind('change', function() {
1586
1694
  if (el.checked) {
@@ -1591,7 +1699,7 @@
1591
1699
 
1592
1700
  } else if (tag === 'select') {
1593
1701
  var cl = effect(function() {
1594
- el.value = evaluate(prop, scope, el) || '';
1702
+ writeProp(el, 'value', evaluate(prop, scope, el) || '');
1595
1703
  });
1596
1704
  bind('change', function() {
1597
1705
  writeModel(prop, el.value, scope, el);
@@ -1684,7 +1792,7 @@
1684
1792
  var value = evaluate(dir.expression, scope, el);
1685
1793
 
1686
1794
  if (value) {
1687
- if (insertedNodes.length === 0) {
1795
+ if (insertedNodes.length === 0) renderThenInit(function() {
1688
1796
  var fragment = el.content.cloneNode(true);
1689
1797
  var nodes = [].slice.call(fragment.childNodes);
1690
1798
 
@@ -1727,7 +1835,8 @@
1727
1835
  runEnterTransition(insertedNodes[i]);
1728
1836
  }
1729
1837
  }
1730
- }
1838
+ return insertedNodes;
1839
+ });
1731
1840
  } else {
1732
1841
  for (var i = 0; i < insertedNodes.length; i++) {
1733
1842
  var node = insertedNodes[i];
@@ -1843,6 +1952,8 @@
1843
1952
  var indexName = match[2] || 'index';
1844
1953
  var listExpr = match[3];
1845
1954
  var keyExpr = el.getAttribute('l-key');
1955
+ // Dev hint: a loop variable that shadows a name the scope already has. Only
1956
+ // names the author wrote; `in` reads no reactive dependency. [1.1F-05]
1846
1957
 
1847
1958
  var anchor = document.createComment('l-for');
1848
1959
  el.parentNode.insertBefore(anchor, el);
@@ -1877,6 +1988,7 @@
1877
1988
  own[itemName] = item;
1878
1989
  own[indexName] = i;
1879
1990
  var childScope = createForItemScope(own, scope);
1991
+ scopeRoots.set(childScope, scopeRoots.get(scope));
1880
1992
  for (var j = 0; j < nodes.length; j++) {
1881
1993
  if (nodes[j].nodeType !== 1) continue;
1882
1994
  // A row whose top node declares `l-data` is a scope root: build it with
@@ -1901,7 +2013,9 @@
1901
2013
 
1902
2014
  var currentEntries = [];
1903
2015
 
1904
- var cl = effect(function() {
2016
+ // One reconciliation. Returns the rows it built, for `renderThenInit` to
2017
+ // start their controllers and run their held inits. [1.1F-03]
2018
+ function renderList() {
1905
2019
  var list = evaluate(listExpr, scope, el);
1906
2020
  var items = Array.isArray(list) ? list :
1907
2021
  typeof list === 'number' ? Array.from({ length: list }, function(_, i) { return i + 1; }) :
@@ -1974,7 +2088,12 @@
1974
2088
  }
1975
2089
 
1976
2090
  currentEntries = newEntries;
1977
- });
2091
+ var fresh = [];
2092
+ for (var i = 0; i < n; i++) if (!source[i]) fresh.push.apply(fresh, newEntries[i].nodes);
2093
+ return fresh;
2094
+ }
2095
+
2096
+ var cl = effect(function() { renderThenInit(renderList); });
1978
2097
 
1979
2098
  addCleanup(el, cl, anchor);
1980
2099
  }
@@ -2113,41 +2232,20 @@
2113
2232
  // ═══════════════════════════════════════════════════════
2114
2233
 
2115
2234
  // --- From dom.js ---
2116
- var $ = function(selector, scope) { return (scope || document).querySelector(selector); };
2117
2235
  var $$ = function(selector, scope) { return [].slice.call((scope || document).querySelectorAll(selector)); };
2118
2236
 
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
- }
2237
+ // A part's owner is its nearest [data-ui] above it that is not a layout
2238
+ // primitive, starting from the parent since a part may carry data-ui itself.
2239
+ // Same selector as registry/core/dom.js `owns`.
2240
+ 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
2241
 
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;
2242
+ function ownParts(root, part) {
2243
+ return $$("[data-part='" + part + "']", root).filter(function(el) {
2244
+ return el.parentElement.closest(OWNER) === root;
2245
+ });
2149
2246
  }
2150
2247
 
2248
+ // --- From events.js ---
2151
2249
  function onOutsideClick(el, handler) {
2152
2250
  function listener(e) { if (!el.contains(e.target)) handler(e); }
2153
2251
  document.addEventListener('pointerdown', listener);
@@ -2192,25 +2290,14 @@
2192
2290
  return function() { container.removeEventListener('keydown', onKeyDown); };
2193
2291
  }
2194
2292
 
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
- });
2293
+ // Back to the opener once an overlay has closed, unless focus moved on while
2294
+ // it closed (`active` was read before the panel hid) or the opener is gone.
2295
+ function returnFocus(root, target, active) {
2296
+ if ((!active || active === document.body || root.contains(active)) &&
2297
+ target && target.isConnected && !target.closest('[hidden]')) target.focus();
2212
2298
  }
2213
2299
 
2300
+ // --- From motion.js (prefersReducedMotion already defined in Section 3.14) ---
2214
2301
  // The four events an exit can end with, in one list: bound and unbound
2215
2302
  // together, so neither half can drift from the other.
2216
2303
  var EXIT_EVENTS = ['transitionend', 'transitioncancel', 'animationend', 'animationcancel'];
@@ -2513,7 +2600,8 @@ function createAccordion(root) {
2513
2600
  // Prevent double-init
2514
2601
  if (root._faqirAccordion) return root._faqirAccordion;
2515
2602
 
2516
- const getItems = () => [...root.querySelectorAll("[data-part='item']")];
2603
+ // Direct children only: a nested accordion's items are its own.
2604
+ const getItems = () => [...root.querySelectorAll(":scope > [data-part='item']")];
2517
2605
  const isSingle = () => root.dataset.variant === "single";
2518
2606
 
2519
2607
  function expand(index) {
@@ -2573,26 +2661,30 @@ function createAccordion(root) {
2573
2661
  if (content) content.hidden = true;
2574
2662
  }
2575
2663
 
2576
- function onTriggerClick(e) {
2664
+ /**
2665
+ * The index of the item whose own trigger `e` came from, or -1. A trigger of
2666
+ * a component nested in the content (a dropdown, another accordion) is not
2667
+ * the item's: an item's own trigger is the first one in it, since it comes
2668
+ * before the content.
2669
+ */
2670
+ function triggerIndex(e) {
2577
2671
  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);
2672
+ const item = trigger?.closest("[data-part='item']");
2673
+ if (!item || item.querySelector("[data-part='trigger']") !== trigger) return -1;
2674
+ return getItems().indexOf(item);
2675
+ }
2676
+
2677
+ function onTriggerClick(e) {
2678
+ const index = triggerIndex(e);
2583
2679
  if (index >= 0) toggle(index);
2584
2680
  }
2585
2681
 
2586
2682
  function onKeyDown(e) {
2587
2683
  if (e.key === "Enter" || e.key === " ") {
2588
- const trigger = e.target.closest("[data-part='trigger']");
2589
- if (trigger) {
2684
+ const index = triggerIndex(e);
2685
+ if (index >= 0) {
2590
2686
  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);
2687
+ toggle(index);
2596
2688
  }
2597
2689
  }
2598
2690
  }
@@ -3367,12 +3459,14 @@ function createCarousel(root) {
3367
3459
  if (!viewport) return null;
3368
3460
 
3369
3461
  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']")];
3462
+ // A carousel nested in a slide brings its own slides, buttons and dots:
3463
+ // slides are the viewport's children, the rest go through the owner guard.
3464
+ const slides = () => [...viewport.querySelectorAll(":scope > [data-part='slide']")];
3465
+ const dots = () => ownParts(root, "dot");
3466
+ const [prevBtn] = ownParts(root, "prev");
3467
+ const [nextBtn] = ownParts(root, "next");
3468
+ const [status] = ownParts(root, "status");
3469
+ const enhanced = [...ownParts(root, "controls"), ...ownParts(root, "dots")];
3376
3470
 
3377
3471
  let index = 0;
3378
3472
 
@@ -3468,12 +3562,13 @@ function createCarousel(root) {
3468
3562
 
3469
3563
  // ── events ──────────────────────────────────────────────────────────────────
3470
3564
  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));
3565
+ // Matched by identity, so a nested carousel's buttons are not these. A
3566
+ // missing button is `undefined`, never equal to a `null` miss here.
3567
+ const btn = e.target.closest("[data-part]");
3568
+ const dot = dots().indexOf(btn);
3569
+ if (btn === nextBtn) next();
3570
+ else if (btn === prevBtn) prev();
3571
+ else if (dot >= 0) goTo(dot);
3477
3572
  }
3478
3573
 
3479
3574
  // One rAF-throttled measurement per scroll burst keeps tracking cheap.
@@ -3824,8 +3919,11 @@ function createCommandPalette(root) {
3824
3919
  }
3825
3920
 
3826
3921
  function close() {
3922
+ const active = document.activeElement;
3827
3923
  dismiss();
3828
- previouslyFocused?.focus();
3924
+ // The opener may have left with another overlay — the downstream palette
3925
+ // was opened from inside a dialog that closed underneath it.
3926
+ returnFocus(root, previouslyFocused, active);
3829
3927
  }
3830
3928
 
3831
3929
  /**
@@ -4019,14 +4117,29 @@ function createCommandPalette(root) {
4019
4117
  }
4020
4118
  }
4021
4119
 
4120
+ /**
4121
+ * Cmd/Ctrl+K — one owner per press.
4122
+ *
4123
+ * Every instance listens on `document`, so a page with three palettes used to
4124
+ * open three stacked focus traps. The first instance to act prevents default
4125
+ * and every later one (or one an app handler pre-empted) stands down;
4126
+ * `data-no-shortcut` opts an instance out entirely, read live, and a palette
4127
+ * whose root has left the document without `destroy()` (an SPA route change)
4128
+ * no longer claims the press from the live one. The key is
4129
+ * matched case-insensitively (Caps Lock sends "K"), falling back to the
4130
+ * physical key on a non-Latin layout; Shift is left alone because Cmd+Shift+K
4131
+ * is a browser and app chord of its own.
4132
+ */
4022
4133
  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
- }
4134
+ if (e.defaultPrevented || !root.isConnected || root.hasAttribute("data-no-shortcut")) return;
4135
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey || e.repeat || e.isComposing) return;
4136
+ const k = (e.key || "").toLowerCase();
4137
+ if (k !== "k" && (/^[a-z]$/.test(k) || e.code !== "KeyK")) return;
4138
+ e.preventDefault();
4139
+ if (root.dataset.state === "open") {
4140
+ close();
4141
+ } else {
4142
+ open();
4030
4143
  }
4031
4144
  }
4032
4145
 
@@ -4062,8 +4175,10 @@ function createCommandPalette(root) {
4062
4175
  function createContextMenu(root) {
4063
4176
  if (root._faqirContextMenu) return root._faqirContextMenu;
4064
4177
 
4065
- const target = root.querySelector("[data-part='target']");
4066
- const menu = root.querySelector("[data-part='menu']");
4178
+ // Matched by owner: a dropdown inside the target has a `menu` part too, and
4179
+ // it comes first in document order.
4180
+ const [target] = ownParts(root, "target");
4181
+ const [menu] = ownParts(root, "menu");
4067
4182
 
4068
4183
  // Same as `dropdown`: a missing part must name itself rather than throwing a
4069
4184
  // TypeError out of `createMenuNavigation(null)`. [W3-2]
@@ -4367,17 +4482,24 @@ function createDialog(root) {
4367
4482
  // Prevent double-init
4368
4483
  if (root._faqirDialog) return root._faqirDialog;
4369
4484
 
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']");
4485
+ // Overlay and panel are direct children. Trigger and actions may be wrapped
4486
+ // in layout primitives, so they are matched by owner: a popover's close
4487
+ // button inside the panel belongs to the popover and must not close this.
4488
+ const [trigger] = ownParts(root, "trigger");
4489
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
4490
+ const panel = root.querySelector(":scope > [data-part='panel']");
4491
+ const closeButtons = ownParts(root, "close");
4492
+ const confirmButtons = ownParts(root, "confirm");
4493
+ const cancelButtons = ownParts(root, "cancel");
4376
4494
 
4377
4495
  // The role is the seam between `dialog` and `alert-dialog` — read it from the
4378
4496
  // markup so a single controller serves both recipes.
4379
4497
  const isAlert = !!panel && panel.getAttribute("role") === "alertdialog";
4380
4498
 
4499
+ // `panel.focus()` on open does nothing without a tabindex: focus stayed on
4500
+ // `body`, out of reach of the trap and the root's Escape listener.
4501
+ if (panel && !panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
4502
+
4381
4503
  let focusCleanup = null;
4382
4504
  let previouslyFocused = null;
4383
4505
 
@@ -4400,10 +4522,7 @@ function createDialog(root) {
4400
4522
  /** On open, focus the least-destructive action for an alert, else the panel. */
4401
4523
  function focusInitial() {
4402
4524
  if (isAlert) {
4403
- const target =
4404
- root.querySelector("[data-part='cancel']") ||
4405
- root.querySelector("[data-part='close']") ||
4406
- panel;
4525
+ const target = cancelButtons[0] || closeButtons[0] || panel;
4407
4526
  target?.focus?.();
4408
4527
  } else {
4409
4528
  panel?.focus?.();
@@ -4437,12 +4556,13 @@ function createDialog(root) {
4437
4556
 
4438
4557
  const onEnd = () => {
4439
4558
  cancelExitWait = null;
4559
+ const active = document.activeElement;
4440
4560
  root.dataset.state = "closed";
4441
4561
  overlay.hidden = true;
4442
4562
  panel.hidden = true;
4443
4563
  if (focusCleanup) focusCleanup();
4444
4564
  focusCleanup = null;
4445
- previouslyFocused?.focus();
4565
+ returnFocus(root, previouslyFocused, active);
4446
4566
  };
4447
4567
 
4448
4568
  // The panel's own exit motion. A footer button's `background` transitionend
@@ -4575,10 +4695,16 @@ function createDrawer(root) {
4575
4695
  // Prevent double-init
4576
4696
  if (root._faqirDrawer) return root._faqirDrawer;
4577
4697
 
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']");
4698
+ // Overlay and panel are direct children; trigger and close buttons are
4699
+ // matched by owner, so a nested component's close leaves this one alone.
4700
+ const [trigger] = ownParts(root, "trigger");
4701
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
4702
+ const panel = root.querySelector(":scope > [data-part='panel']");
4703
+ const closeButtons = ownParts(root, "close");
4704
+
4705
+ // `panel.focus()` on open does nothing without a tabindex: focus stayed on
4706
+ // `body`, out of reach of the trap and the root's Escape listener.
4707
+ if (panel && !panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
4582
4708
 
4583
4709
  let focusCleanup = null;
4584
4710
  let previouslyFocused = null;
@@ -4622,13 +4748,14 @@ function createDrawer(root) {
4622
4748
 
4623
4749
  const onEnd = () => {
4624
4750
  cancelExitWait = null;
4751
+ const active = document.activeElement;
4625
4752
  root.dataset.state = "closed";
4626
4753
  overlay.hidden = true;
4627
4754
  panel.hidden = true;
4628
4755
  unlockScroll();
4629
4756
  if (focusCleanup) focusCleanup();
4630
4757
  focusCleanup = null;
4631
- previouslyFocused?.focus();
4758
+ returnFocus(root, previouslyFocused, active);
4632
4759
  };
4633
4760
 
4634
4761
  // The panel's OWN `transform` — not the close button's `background`, which
@@ -6103,7 +6230,8 @@ function createPopover(root) {
6103
6230
 
6104
6231
  const trigger = root.querySelector("[data-part='trigger']");
6105
6232
  const content = root.querySelector("[data-part='content']");
6106
- const closeBtn = root.querySelector("[data-part='close']");
6233
+ // Not a close button belonging to a dialog or popover inside the content.
6234
+ const [closeBtn] = ownParts(root, "close");
6107
6235
 
6108
6236
  let outsideClickCleanup = null;
6109
6237
 
@@ -6238,41 +6366,37 @@ function rsEncode(data, ecLen) {
6238
6366
 
6239
6367
  const ECL_MAP = { L: 0, M: 1, Q: 2, H: 3 };
6240
6368
 
6241
- // [version][ecl] → { totalBytes, ecPerBlock, blocks }
6242
- const VERSION_TABLE = [
6243
- null, // index 0 unused
6244
- // v1
6245
- [{ dc: 19, ec: 7, b: 1 }, { dc: 16, ec: 10, b: 1 }, { dc: 13, ec: 13, b: 1 }, { dc: 9, ec: 17, b: 1 }],
6246
- // v2
6247
- [{ dc: 34, ec: 10, b: 1 }, { dc: 28, ec: 16, b: 1 }, { dc: 22, ec: 22, b: 1 }, { dc: 16, ec: 28, b: 1 }],
6248
- // v3
6249
- [{ dc: 55, ec: 15, b: 1 }, { dc: 44, ec: 26, b: 1 }, { dc: 34, ec: 18, b: 2 }, { dc: 26, ec: 22, b: 2 }],
6250
- // v4
6251
- [{ dc: 80, ec: 20, b: 1 }, { dc: 64, ec: 18, b: 2 }, { dc: 48, ec: 26, b: 2 }, { dc: 36, ec: 16, b: 4 }],
6252
- // v5
6253
- [{ dc: 108, ec: 26, b: 1 }, { dc: 86, ec: 24, b: 2 }, { dc: 62, ec: 18, b: 4 }, { dc: 46, ec: 22, b: 4 }],
6254
- // v6
6255
- [{ dc: 136, ec: 18, b: 2 }, { dc: 108, ec: 16, b: 4 }, { dc: 76, ec: 24, b: 4 }, { dc: 60, ec: 28, b: 4 }],
6256
- // v7
6257
- [{ dc: 156, ec: 20, b: 2 }, { dc: 124, ec: 18, b: 4 }, { dc: 88, ec: 18, b: 6 }, { dc: 66, ec: 26, b: 5 }],
6258
- // v8
6259
- [{ dc: 194, ec: 24, b: 2 }, { dc: 154, ec: 22, b: 4 }, { dc: 110, ec: 22, b: 6 }, { dc: 86, ec: 26, b: 6 }],
6260
- // v9
6261
- [{ dc: 232, ec: 30, b: 2 }, { dc: 182, ec: 22, b: 5 }, { dc: 132, ec: 20, b: 8 }, { dc: 98, ec: 24, b: 8 }],
6262
- // v10
6263
- [{ dc: 274, ec: 18, b: 4 }, { dc: 216, ec: 26, b: 5 }, { dc: 154, ec: 24, b: 8 }, { dc: 119, ec: 28, b: 8 }],
6369
+ // ISO/IEC 18004 Table 9, flattened as [(version - 1) * 4 + ecl]: EC codewords
6370
+ // per block, and the block count. Data codewords are what is left of the
6371
+ // version's total once the EC blocks are taken out.
6372
+ const EC_PER_BLOCK = [
6373
+ 7, 10, 13, 17, 10, 16, 22, 28, 15, 26, 18, 22, 20, 18, 26, 16, 26, 24, 18, 22,
6374
+ 18, 16, 24, 28, 20, 18, 18, 26, 24, 22, 22, 26, 30, 22, 20, 24, 18, 26, 24, 28,
6264
6375
  ];
6265
-
6266
- const ALIGNMENT_POSITIONS = [
6267
- null, [], [6, 18], [6, 22], [6, 26], [6, 30],
6268
- [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
6376
+ const BLOCKS = [
6377
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 2, 2, 4, 1, 2, 4, 4,
6378
+ 2, 4, 4, 4, 2, 4, 6, 5, 2, 4, 6, 6, 2, 5, 8, 8, 4, 5, 8, 8,
6269
6379
  ];
6380
+ const TOTAL_CODEWORDS = [26, 44, 70, 100, 134, 172, 196, 242, 292, 346];
6381
+
6382
+ function versionInfo(version, eclIdx) {
6383
+ const i = (version - 1) * 4 + eclIdx;
6384
+ const ec = EC_PER_BLOCK[i];
6385
+ const b = BLOCKS[i];
6386
+ return { dc: TOTAL_CODEWORDS[version - 1] - ec * b, ec, b };
6387
+ }
6388
+
6389
+ // Alignment centres: none for v1, two up to v6, three from v7 (evenly spaced).
6390
+ function alignmentPositions(v) {
6391
+ return v < 2 ? [] : v < 7 ? [6, 4 * v + 10] : [6, 2 * v + 8, 4 * v + 10];
6392
+ }
6270
6393
 
6271
6394
  function selectVersion(dataLen, ecl) {
6272
6395
  const eclIdx = ECL_MAP[ecl] ?? 1;
6273
6396
  for (let v = 1; v <= 10; v++) {
6274
- const info = VERSION_TABLE[v][eclIdx];
6275
- if (dataLen <= info.dc) return v;
6397
+ const info = versionInfo(v, eclIdx);
6398
+ // + 4-bit mode and 8-bit (v1–9) or 16-bit (v10) character count, rounded up.
6399
+ if (dataLen + (v < 10 ? 2 : 3) <= info.dc) return v;
6276
6400
  }
6277
6401
  return -1;
6278
6402
  }
@@ -6291,16 +6415,14 @@ function setModule(matrix, row, col, val, reserved) {
6291
6415
  }
6292
6416
  }
6293
6417
 
6418
+ // Both patterns are concentric squares, coloured by their ring (Chebyshev
6419
+ // distance from the centre): a finder is dark at rings 0, 1 and 3, light at 2 and
6420
+ // at its ring-4 separator; an alignment pattern is dark at 0 and 2, light at 1.
6294
6421
  function placeFinderPattern(matrix, reserved, row, col) {
6295
6422
  for (let r = -1; r <= 7; r++) {
6296
6423
  for (let c = -1; c <= 7; c++) {
6297
- const val =
6298
- (r >= 0 && r <= 6 && (c === 0 || c === 6)) ||
6299
- (c >= 0 && c <= 6 && (r === 0 || r === 6)) ||
6300
- (r >= 2 && r <= 4 && c >= 2 && c <= 4)
6301
- ? 1
6302
- : 0;
6303
- setModule(matrix, row + r, col + c, val, reserved);
6424
+ const d = Math.max(Math.abs(r - 3), Math.abs(c - 3));
6425
+ setModule(matrix, row + r, col + c, d !== 2 && d !== 4 ? 1 : 0, reserved);
6304
6426
  }
6305
6427
  }
6306
6428
  }
@@ -6308,9 +6430,8 @@ function placeFinderPattern(matrix, reserved, row, col) {
6308
6430
  function placeAlignmentPattern(matrix, reserved, row, col) {
6309
6431
  for (let r = -2; r <= 2; r++) {
6310
6432
  for (let c = -2; c <= 2; c++) {
6311
- const val =
6312
- Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0) ? 1 : 0;
6313
- setModule(matrix, row + r, col + c, val, reserved);
6433
+ const d = Math.max(Math.abs(r), Math.abs(c));
6434
+ setModule(matrix, row + r, col + c, d !== 1 ? 1 : 0, reserved);
6314
6435
  }
6315
6436
  }
6316
6437
  }
@@ -6336,23 +6457,34 @@ function reserveFormatArea(matrix, reserved) {
6336
6457
  setModule(matrix, s - 8, 8, 1, reserved); // dark module
6337
6458
  }
6338
6459
 
6460
+ // Versions 7+ carry an 18-bit version word (BCH 18,6) in two 6×3 blocks.
6461
+ function placeVersionInfo(matrix, reserved, version) {
6462
+ if (version < 7) return;
6463
+ const s = matrix.length;
6464
+ let rem = version;
6465
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
6466
+ const bits = (version << 12) | rem;
6467
+ for (let i = 0; i < 18; i++) {
6468
+ const bit = (bits >> i) & 1;
6469
+ const a = s - 11 + (i % 3);
6470
+ const b = (i / 3) | 0;
6471
+ setModule(matrix, b, a, bit, reserved);
6472
+ setModule(matrix, a, b, bit, reserved);
6473
+ }
6474
+ }
6475
+
6339
6476
  function placeData(matrix, reserved, bits) {
6340
6477
  const s = matrix.length;
6341
6478
  let bitIdx = 0;
6342
- let upward = true;
6343
- for (let col = s - 1; col >= 1; col -= 2) {
6479
+ for (let col = s - 1, upward = true; col >= 1; col -= 2, upward = !upward) {
6344
6480
  if (col === 6) col = 5; // skip timing column
6345
- const rows = upward
6346
- ? Array.from({ length: s }, (_, i) => s - 1 - i)
6347
- : Array.from({ length: s }, (_, i) => i);
6348
- for (const row of rows) {
6349
- for (let c = 0; c < 2; c++) {
6350
- const cc = col - c;
6351
- if (reserved[row][cc]) continue;
6352
- matrix[row][cc] = bitIdx < bits.length ? bits[bitIdx++] : 0;
6481
+ for (let i = 0; i < s; i++) {
6482
+ const row = upward ? s - 1 - i : i;
6483
+ for (let cc = col; cc > col - 2; cc--) {
6484
+ // Past the last bit the remainder modules are 0.
6485
+ if (!reserved[row][cc]) matrix[row][cc] = bits[bitIdx++] | 0;
6353
6486
  }
6354
6487
  }
6355
- upward = !upward;
6356
6488
  }
6357
6489
  }
6358
6490
 
@@ -6394,8 +6526,9 @@ function placeFormatInfo(matrix, ecl, maskId) {
6394
6526
  else if (i === 7) matrix[8][8] = bit;
6395
6527
  else if (i === 8) matrix[7][8] = bit;
6396
6528
  else matrix[14 - i][8] = bit;
6397
- // Around other finders
6398
- if (i < 8) matrix[s - 1 - i][8] = bit;
6529
+ // The second copy: bits 0–6 up column 8 from the bottom, 7–14 along row 8
6530
+ // to the right edge. (s - 8, 8) between them is the always-dark module.
6531
+ if (i < 7) matrix[s - 1 - i][8] = bit;
6399
6532
  else matrix[8][s - 15 + i] = bit;
6400
6533
  }
6401
6534
  }
@@ -6437,7 +6570,7 @@ function encodeQR(text, ecl = "M") {
6437
6570
  if (version < 0) throw new Error("Data too long for QR versions 1–10");
6438
6571
 
6439
6572
  const eclIdx = ECL_MAP[ecl] ?? 1;
6440
- const info = VERSION_TABLE[version][eclIdx];
6573
+ const info = versionInfo(version, eclIdx);
6441
6574
  const size = version * 4 + 17;
6442
6575
 
6443
6576
  // Build data codewords
@@ -6477,7 +6610,6 @@ function encodeQR(text, ecl = "M") {
6477
6610
  // Split into blocks and compute EC
6478
6611
  const blocks = [];
6479
6612
  const ecBlocks = [];
6480
- const ecPerBlock = info.ec / info.b;
6481
6613
  const dcPerBlock = Math.floor(info.dc / info.b);
6482
6614
  const remainder = info.dc % info.b;
6483
6615
  let offset = 0;
@@ -6487,7 +6619,7 @@ function encodeQR(text, ecl = "M") {
6487
6619
  const blockData = codewords.slice(offset, offset + blockDc);
6488
6620
  offset += blockDc;
6489
6621
  blocks.push(blockData);
6490
- ecBlocks.push(rsEncode(blockData, ecPerBlock));
6622
+ ecBlocks.push(rsEncode(blockData, info.ec));
6491
6623
  }
6492
6624
 
6493
6625
  // Interleave
@@ -6498,10 +6630,8 @@ function encodeQR(text, ecl = "M") {
6498
6630
  if (i < block.length) interleaved.push(block[i]);
6499
6631
  }
6500
6632
  }
6501
- for (let i = 0; i < ecPerBlock; i++) {
6502
- for (const ec of ecBlocks) {
6503
- if (i < ec.length) interleaved.push(ec[i]);
6504
- }
6633
+ for (let i = 0; i < info.ec; i++) {
6634
+ for (const ec of ecBlocks) interleaved.push(ec[i]);
6505
6635
  }
6506
6636
 
6507
6637
  // Convert to bit array
@@ -6518,7 +6648,7 @@ function encodeQR(text, ecl = "M") {
6518
6648
  placeFinderPattern(matrix, reserved, 0, size - 7);
6519
6649
  placeFinderPattern(matrix, reserved, size - 7, 0);
6520
6650
 
6521
- const alignPos = ALIGNMENT_POSITIONS[version] || [];
6651
+ const alignPos = alignmentPositions(version);
6522
6652
  for (const r of alignPos) {
6523
6653
  for (const c of alignPos) {
6524
6654
  if (reserved[r]?.[c]) continue;
@@ -6528,6 +6658,7 @@ function encodeQR(text, ecl = "M") {
6528
6658
 
6529
6659
  placeTimingPatterns(matrix, reserved);
6530
6660
  reserveFormatArea(matrix, reserved);
6661
+ placeVersionInfo(matrix, reserved, version);
6531
6662
 
6532
6663
  // Try all masks, pick lowest penalty
6533
6664
  let bestMask = 0;
@@ -6995,10 +7126,16 @@ function createSheet(root) {
6995
7126
  // Prevent double-init
6996
7127
  if (root._faqirSheet) return root._faqirSheet;
6997
7128
 
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']");
7129
+ // Overlay and panel are direct children; trigger and close buttons are
7130
+ // matched by owner, so a nested component's close leaves this one alone.
7131
+ const [trigger] = ownParts(root, "trigger");
7132
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
7133
+ const panel = root.querySelector(":scope > [data-part='panel']");
7134
+ const closeButtons = ownParts(root, "close");
7135
+
7136
+ // `panel.focus()` on open does nothing without a tabindex: focus stayed on
7137
+ // `body`, out of reach of the trap and the root's Escape listener.
7138
+ if (panel && !panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
7002
7139
 
7003
7140
  let focusCleanup = null;
7004
7141
  let previouslyFocused = null;
@@ -7042,13 +7179,14 @@ function createSheet(root) {
7042
7179
 
7043
7180
  const onEnd = () => {
7044
7181
  cancelExitWait = null;
7182
+ const active = document.activeElement;
7045
7183
  root.dataset.state = "closed";
7046
7184
  overlay.hidden = true;
7047
7185
  panel.hidden = true;
7048
7186
  unlockScroll();
7049
7187
  if (focusCleanup) focusCleanup();
7050
7188
  focusCleanup = null;
7051
- previouslyFocused?.focus();
7189
+ returnFocus(root, previouslyFocused, active);
7052
7190
  };
7053
7191
 
7054
7192
  // The panel's OWN `transform` — not a close button's `background`, which
@@ -7158,12 +7296,14 @@ function createSidebar(root) {
7158
7296
  // Prevent double-init.
7159
7297
  if (root._faqirSidebar) return root._faqirSidebar;
7160
7298
 
7161
- const overlay = root.querySelector("[data-part='overlay']");
7162
- const panel = root.querySelector("[data-part='panel']") || root;
7299
+ const overlay = root.querySelector(":scope > [data-part='overlay']");
7300
+ const panel = root.querySelector(":scope > [data-part='panel']") || root;
7163
7301
 
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']")];
7302
+ // Toggle buttons: the sidebar's own `[data-part='trigger']`s, plus external
7303
+ // triggers in the app shell that point at this sidebar by id. Both call
7304
+ // toggle(). A dropdown's trigger in the footer belongs to the dropdown: it
7305
+ // used to have its aria-expanded forced and its click rail the sidebar.
7306
+ const internalTriggers = ownParts(root, "trigger");
7167
7307
  const externalTriggers = root.id
7168
7308
  ? [...document.querySelectorAll(`[data-sidebar-toggle="${root.id}"]`)]
7169
7309
  : [];
@@ -7773,10 +7913,13 @@ function createTable(root) {
7773
7913
  // Prevent double-init
7774
7914
  if (root._faqirTable) return root._faqirTable;
7775
7915
 
7916
+ // A table nested in a detail row keeps its own parts and events: `tbody`
7917
+ // contains it, so every deep lookup and delegated handler checks `mine`.
7918
+ const mine = (el) => el.closest("[data-ui='table']") === root;
7776
7919
  const table = root.querySelector("[data-part='table']");
7777
7920
  const thead = root.querySelector("[data-part='thead']");
7778
7921
  const tbody = root.querySelector("[data-part='tbody']");
7779
- const tfoot = root.querySelector("[data-part='tfoot']");
7922
+ const tfoot = [...root.querySelectorAll("[data-part='tfoot']")].find(mine);
7780
7923
  const headerCheckbox = thead?.querySelector("[data-part='checkbox']");
7781
7924
 
7782
7925
  // ── Options (read once from the root's opt-in attributes) ──
@@ -7996,23 +8139,14 @@ function createTable(root) {
7996
8139
 
7997
8140
  // ── Intl formatting ──
7998
8141
  function formatValue(n, fmt) {
8142
+ const o =
8143
+ fmt === "currency"
8144
+ ? { style: fmt, currency: opts.currency, currencySign: accounting ? "accounting" : "standard" }
8145
+ : fmt === "percent"
8146
+ ? { style: fmt, maximumFractionDigits: 2 }
8147
+ : fmt === "number" && { maximumFractionDigits: 2 };
7999
8148
  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
- }
8149
+ if (o) return new Intl.NumberFormat(opts.locale, o).format(fmt === "percent" ? n / 100 : n);
8016
8150
  } catch {
8017
8151
  /* unknown locale/currency — fall through to raw */
8018
8152
  }
@@ -8253,13 +8387,17 @@ function createTable(root) {
8253
8387
  refreshStripes();
8254
8388
  }
8255
8389
 
8390
+ function sortChanged() {
8391
+ applySorts();
8392
+ emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8393
+ scheduleStateSave();
8394
+ }
8395
+
8256
8396
  /** Legacy API: sort one column with an explicit direction. */
8257
8397
  function sort(columnIndex, direction) {
8258
8398
  if (!tbody || bodyRows().length === 0) return;
8259
8399
  sorts = [{ index: columnIndex, dir: direction }];
8260
- applySorts();
8261
- emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8262
- scheduleStateSave();
8400
+ sortChanged();
8263
8401
  }
8264
8402
 
8265
8403
  /** Multi-sort API: sortBy([{ column, direction }...]). */
@@ -8271,17 +8409,13 @@ function createTable(root) {
8271
8409
  dir: s.direction || s.dir || "ascending",
8272
8410
  }))
8273
8411
  .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();
8412
+ sortChanged();
8277
8413
  }
8278
8414
 
8279
8415
  function clearSort() {
8280
8416
  if (!sorts.length) return;
8281
8417
  sorts = [];
8282
- applySorts();
8283
- emit("sort", { sorts: [] });
8284
- scheduleStateSave();
8418
+ sortChanged();
8285
8419
  }
8286
8420
 
8287
8421
  function handleSortRequest(idx, additive) {
@@ -8298,9 +8432,7 @@ function createTable(root) {
8298
8432
  } else {
8299
8433
  sorts = dir === null ? [] : [{ index: idx, dir }];
8300
8434
  }
8301
- applySorts();
8302
- emit("sort", { sorts: sorts.map((s) => ({ column: s.index, direction: s.dir })) });
8303
- scheduleStateSave();
8435
+ sortChanged();
8304
8436
  }
8305
8437
 
8306
8438
  // ── Selection ──
@@ -8316,8 +8448,7 @@ function createTable(root) {
8316
8448
  const row = rows[index];
8317
8449
  setRowSelected(row, !row.hasAttribute("data-selected"));
8318
8450
  lastSelectedIndex = index;
8319
- updateHeaderCheckbox();
8320
- emit("selection-change", { selected: getSelected() });
8451
+ selectionChanged();
8321
8452
  }
8322
8453
 
8323
8454
  function selectAll() {
@@ -8326,15 +8457,13 @@ function createTable(root) {
8326
8457
  if (filtered && row.hasAttribute("data-filtered")) continue;
8327
8458
  setRowSelected(row, true);
8328
8459
  }
8329
- updateHeaderCheckbox();
8330
- emit("selection-change", { selected: getSelected() });
8460
+ selectionChanged();
8331
8461
  }
8332
8462
 
8333
8463
  function deselectAll() {
8334
8464
  for (const row of bodyRows()) setRowSelected(row, false);
8335
8465
  lastSelectedIndex = -1;
8336
- updateHeaderCheckbox();
8337
- emit("selection-change", { selected: getSelected() });
8466
+ selectionChanged();
8338
8467
  }
8339
8468
 
8340
8469
  function getSelected() {
@@ -8351,23 +8480,19 @@ function createTable(root) {
8351
8480
  for (let i = start; i <= end; i++) {
8352
8481
  if (i >= 0 && i < rows.length && isRowVisible(rows[i])) setRowSelected(rows[i], true);
8353
8482
  }
8483
+ }
8484
+
8485
+ function selectionChanged() {
8354
8486
  updateHeaderCheckbox();
8487
+ emit("selection-change", { selected: getSelected() });
8355
8488
  }
8356
8489
 
8357
8490
  function updateHeaderCheckbox() {
8358
8491
  if (!headerCheckbox) return;
8359
8492
  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
- }
8493
+ const n = rows.filter((r) => r.hasAttribute("data-selected")).length;
8494
+ headerCheckbox.checked = n > 0 && n === rows.length;
8495
+ headerCheckbox.indeterminate = n > 0 && n < rows.length;
8371
8496
  }
8372
8497
 
8373
8498
  // ── Filtering ──
@@ -8456,6 +8581,7 @@ function createTable(root) {
8456
8581
  refreshStripes();
8457
8582
  updateHeaderCheckbox();
8458
8583
  computeAggregates();
8584
+ setupNavigability();
8459
8585
  emit("filter", { query: globalFilter, visible, total: rows.length });
8460
8586
  scheduleStateSave();
8461
8587
  }
@@ -8476,7 +8602,7 @@ function createTable(root) {
8476
8602
  applyFilters();
8477
8603
  }
8478
8604
  function syncFilterInputs() {
8479
- const g = root.querySelector("[data-part='filter']");
8605
+ const g = [...root.querySelectorAll("[data-part='filter']")].find(mine);
8480
8606
  if (g && g.value !== globalFilter) g.value = globalFilter;
8481
8607
  const fr = filterRowEl();
8482
8608
  if (!fr) return;
@@ -8499,6 +8625,7 @@ function createTable(root) {
8499
8625
  for (const m of groupMembers(gh)) toggleAttr(m, "data-collapsed", !expand);
8500
8626
  refreshStripes();
8501
8627
  updateHeaderCheckbox();
8628
+ setupNavigability();
8502
8629
  emit("group-toggle", { group: gh, expanded: expand });
8503
8630
  }
8504
8631
 
@@ -8527,6 +8654,7 @@ function createTable(root) {
8527
8654
  }
8528
8655
  refreshStripes();
8529
8656
  updateHeaderCheckbox();
8657
+ setupNavigability();
8530
8658
  emit("tree-toggle", { row, expanded: expand });
8531
8659
  }
8532
8660
 
@@ -8541,20 +8669,15 @@ function createTable(root) {
8541
8669
  emit("row-expand", { row, detail, expanded: expand });
8542
8670
  }
8543
8671
 
8544
- function expandAll() {
8672
+ function setAllExpanded(expand) {
8545
8673
  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() {
8552
- 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);
8674
+ for (const gh of groupHeaders()) toggleGroup(gh, expand);
8675
+ if (opts.tree) for (const r of bodyRows()) if (isTreeParent(r)) toggleRow(r, expand);
8555
8676
  });
8556
8677
  refreshStripes();
8557
8678
  }
8679
+ const expandAll = () => setAllExpanded(true);
8680
+ const collapseAll = () => setAllExpanded(false);
8558
8681
 
8559
8682
  // ── Aggregates (tfoot, group headers, tree parents) ──
8560
8683
  function writeAggregate(cell, rows, ownIndex) {
@@ -8589,7 +8712,7 @@ function createTable(root) {
8589
8712
  const fmt =
8590
8713
  fn === "count"
8591
8714
  ? cell.getAttribute("data-format")
8592
- : cell.getAttribute("data-format") || headerCells()[col]?.getAttribute("data-format") || "number";
8715
+ : cellFormatOf(cell, col) || "number";
8593
8716
  cell.textContent = fmt ? formatValue(out, fmt) : String(out);
8594
8717
  applyNegativeFlag(cell);
8595
8718
  }
@@ -8773,17 +8896,15 @@ function createTable(root) {
8773
8896
 
8774
8897
  function onEditorKeydown(e) {
8775
8898
  if (!editing) return;
8899
+ e.stopPropagation(); // keep grid navigation away from typing
8776
8900
  if (e.key === "Enter") {
8777
8901
  e.preventDefault();
8778
- e.stopPropagation();
8779
8902
  commitEdit(true);
8780
8903
  } else if (e.key === "Escape") {
8781
8904
  e.preventDefault();
8782
- e.stopPropagation();
8783
8905
  cancelEdit(true);
8784
8906
  } else if (e.key === "Tab") {
8785
8907
  e.preventDefault();
8786
- e.stopPropagation();
8787
8908
  const from = editing.cell;
8788
8909
  if (commitEdit()) {
8789
8910
  const next = nextEditableCell(from, e.shiftKey ? -1 : 1);
@@ -8791,7 +8912,6 @@ function createTable(root) {
8791
8912
  }
8792
8913
  } else {
8793
8914
  e.target.removeAttribute("data-invalid");
8794
- e.stopPropagation(); // keep grid navigation away from typing
8795
8915
  }
8796
8916
  }
8797
8917
  function onEditorBlur(e) {
@@ -8832,6 +8952,7 @@ function createTable(root) {
8832
8952
  if (!th) return;
8833
8953
  forEachColumnCell(i, (cell) => toggleAttr(cell, "data-col-hidden", hidden));
8834
8954
  refreshPins();
8955
+ setupNavigability();
8835
8956
  emit("col-visibility", { column: i, hidden: !!hidden });
8836
8957
  scheduleStateSave();
8837
8958
  }
@@ -8943,14 +9064,11 @@ function createTable(root) {
8943
9064
  }
8944
9065
 
8945
9066
  function elementAt(e) {
8946
- if (document.elementFromPoint) {
8947
- try {
8948
- return document.elementFromPoint(e.clientX, e.clientY);
8949
- } catch {
8950
- return null;
8951
- }
9067
+ try {
9068
+ return document.elementFromPoint(e.clientX, e.clientY);
9069
+ } catch {
9070
+ return null; // elementFromPoint missing or throwing (headless)
8952
9071
  }
8953
- return null;
8954
9072
  }
8955
9073
 
8956
9074
  function resolveRowAt(e) {
@@ -8959,16 +9077,13 @@ function createTable(root) {
8959
9077
  return row && tbody?.contains(row) ? row : null;
8960
9078
  }
8961
9079
 
8962
- function dropPos(e, el) {
9080
+ /** "before"/"after" the pointer's half of el: vertical for rows, horizontal (RTL-aware) for columns. */
9081
+ function dropPos(e, el, horizontal) {
8963
9082
  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) {
8968
- 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";
9083
+ const size = rect && (horizontal ? rect.width : rect.height);
9084
+ if (!size) return "after";
9085
+ const before = horizontal ? e.clientX < rect.left + size / 2 !== isRTL : e.clientY < rect.top + size / 2;
9086
+ return before ? "before" : "after";
8972
9087
  }
8973
9088
 
8974
9089
  function bindDocDrag() {
@@ -9068,7 +9183,7 @@ function createTable(root) {
9068
9183
  if (!th) th = e.target?.closest?.("[data-part='th']");
9069
9184
  if (!th || th === drag.th || !headerRowEl()?.contains(th)) return;
9070
9185
  th.setAttribute("data-drop-target", "");
9071
- th.setAttribute("data-drop-pos", dropPosX(e, th));
9186
+ th.setAttribute("data-drop-pos", dropPos(e, th, true));
9072
9187
  }
9073
9188
  }
9074
9189
 
@@ -9290,15 +9405,11 @@ function createTable(root) {
9290
9405
  existing.remove();
9291
9406
  existing = null;
9292
9407
  }
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");
9408
+ const el = document.createElement(isParent ? "button" : "span");
9409
+ if (isParent) el.type = "button";
9410
+ el.setAttribute("data-part", "expander");
9411
+ if (isParent) el.setAttribute("aria-label", "Toggle");
9412
+ else {
9302
9413
  el.setAttribute("data-leaf", "");
9303
9414
  el.setAttribute("aria-hidden", "true");
9304
9415
  }
@@ -9394,14 +9505,35 @@ function createTable(root) {
9394
9505
  return rows;
9395
9506
  }
9396
9507
 
9508
+ // APG grid: links, buttons and fields inside a navigable grid's cells leave the
9509
+ // Tab order (Enter/F2 reaches them, Escape comes back). The table's own controls
9510
+ // stay focusable. destroy() puts the original tabindex back.
9511
+ const demoted = new WeakMap();
9512
+ const cellControls = (cell) =>
9513
+ [...cell.querySelectorAll("input,select,textarea,button,a[href],[tabindex]")].filter(
9514
+ (el) => mine(el) && !el.closest("[data-part='checkbox'],[data-part='drag-handle'],[data-part='expander'],[data-part='row-toggle']")
9515
+ );
9516
+
9517
+ // Re-run after anything that can take the Tab stop away (refresh, filter,
9518
+ // collapse, column hide): only the Tab stop moves, never focus.
9397
9519
  function setupNavigability() {
9398
9520
  if (opts.navigable) {
9521
+ const rows = navMatrix();
9522
+ // A stale stop — its row removed, filtered or collapsed, or its column hidden —
9523
+ // is dropped so the first visible cell takes over.
9524
+ if (activeCell && (!rows.includes(activeCell.parentElement) || activeCell.hasAttribute("data-col-hidden"))) {
9525
+ activeCell.setAttribute("tabindex", "-1");
9526
+ activeCell = null;
9527
+ }
9399
9528
  let first = null;
9400
- for (const row of navMatrix()) {
9529
+ for (const row of rows) {
9401
9530
  for (const cell of cellsOf(row)) {
9402
- if (cell.hasAttribute("data-col-hidden")) continue;
9403
- if (!first && !activeCell) first = cell;
9531
+ if (!first && !activeCell && !cell.hasAttribute("data-col-hidden")) first = cell;
9404
9532
  cell.setAttribute("tabindex", cell === (activeCell || first) ? "0" : "-1");
9533
+ for (const el of cellControls(cell)) {
9534
+ if (!demoted.has(el)) demoted.set(el, el.getAttribute("tabindex"));
9535
+ el.setAttribute("tabindex", "-1");
9536
+ }
9405
9537
  }
9406
9538
  }
9407
9539
  if (!activeCell) activeCell = first;
@@ -9416,11 +9548,7 @@ function createTable(root) {
9416
9548
  if (activeCell && activeCell !== cell) activeCell.setAttribute("tabindex", "-1");
9417
9549
  activeCell = cell;
9418
9550
  cell.setAttribute("tabindex", "0");
9419
- try {
9420
- cell.focus();
9421
- } catch {
9422
- /* unfocusable */
9423
- }
9551
+ cell.focus();
9424
9552
  }
9425
9553
 
9426
9554
  function moveFocus(rowDelta, colDelta, edge) {
@@ -9460,7 +9588,7 @@ function createTable(root) {
9460
9588
  if (!opts.navigable) return;
9461
9589
  const cell = e.target.closest("[data-part='td'],[data-part='th']");
9462
9590
  if (!cell || cell === activeCell || !root.contains(cell)) return;
9463
- if (e.target !== cell) return; // focus landed on inner control, not the cell
9591
+ if (e.target !== cell && !demoted.has(e.target)) return; // the table's own control, not the cell
9464
9592
  if (activeCell) activeCell.setAttribute("tabindex", "-1");
9465
9593
  activeCell = cell;
9466
9594
  cell.setAttribute("tabindex", "0");
@@ -9495,13 +9623,12 @@ function createTable(root) {
9495
9623
  if (e.shiftKey && lastSelectedIndex >= 0) {
9496
9624
  e.preventDefault();
9497
9625
  selectRange(lastSelectedIndex, index);
9498
- emit("selection-change", { selected: getSelected() });
9626
+ selectionChanged();
9499
9627
  return;
9500
9628
  }
9501
9629
  toggleAttr(row, "data-selected", checkbox.checked);
9502
9630
  lastSelectedIndex = index;
9503
- updateHeaderCheckbox();
9504
- emit("selection-change", { selected: getSelected() });
9631
+ selectionChanged();
9505
9632
  }
9506
9633
 
9507
9634
  function onRootClick(e) {
@@ -9541,7 +9668,7 @@ function createTable(root) {
9541
9668
  if (index < 0) return;
9542
9669
  if (e.shiftKey && lastSelectedIndex >= 0) {
9543
9670
  selectRange(lastSelectedIndex, index);
9544
- emit("selection-change", { selected: getSelected() });
9671
+ selectionChanged();
9545
9672
  return;
9546
9673
  }
9547
9674
  if (opts.selectable != null) {
@@ -9553,8 +9680,7 @@ function createTable(root) {
9553
9680
  setRowSelected(row, !row.hasAttribute("data-selected"));
9554
9681
  }
9555
9682
  lastSelectedIndex = index;
9556
- updateHeaderCheckbox();
9557
- emit("selection-change", { selected: getSelected() });
9683
+ selectionChanged();
9558
9684
  }
9559
9685
  }
9560
9686
 
@@ -9614,10 +9740,18 @@ function createTable(root) {
9614
9740
  return;
9615
9741
  }
9616
9742
 
9743
+ const cell = target.closest?.("[data-part='td'],[data-part='th']");
9744
+
9745
+ // Escape in a cell's control returns to the cell.
9746
+ if (e.key === "Escape" && demoted.has(target)) {
9747
+ e.preventDefault();
9748
+ focusCell(cell);
9749
+ return;
9750
+ }
9751
+
9617
9752
  // Never hijack typing or native control activation (buttons own Enter/Space via click).
9618
9753
  if (target.closest?.("input,select,textarea,button,a")) return;
9619
9754
 
9620
- const cell = target.closest?.("[data-part='td'],[data-part='th']");
9621
9755
  const inHeader = cell && thead?.contains(cell);
9622
9756
 
9623
9757
  // Sortable header keyboard activation.
@@ -9708,6 +9842,12 @@ function createTable(root) {
9708
9842
  if (isCellEditable(cell)) {
9709
9843
  e.preventDefault();
9710
9844
  startEdit(cell);
9845
+ return;
9846
+ }
9847
+ const ctl = cellControls(cell)[0];
9848
+ if (ctl) {
9849
+ e.preventDefault();
9850
+ ctl.focus();
9711
9851
  }
9712
9852
  return;
9713
9853
  }
@@ -9755,7 +9895,7 @@ function createTable(root) {
9755
9895
 
9756
9896
  function measureSticky() {
9757
9897
  const needsHeaderVar =
9758
- root.hasAttribute("data-sticky-header") || !!tbody?.querySelector("[data-part='tr'][data-pin='top']");
9898
+ root.hasAttribute("data-sticky-header") || bodyRows().some((r) => r.dataset.pin === "top");
9759
9899
  if (needsHeaderVar && thead && thead.offsetHeight) {
9760
9900
  root.style.setProperty("--table-thead-h", thead.offsetHeight + "px");
9761
9901
  }
@@ -9794,6 +9934,12 @@ function createTable(root) {
9794
9934
  cleanups.length = 0;
9795
9935
  observer?.disconnect();
9796
9936
  observer = null;
9937
+ for (const el of root.querySelectorAll("[tabindex]")) {
9938
+ if (demoted.has(el)) {
9939
+ const t = demoted.get(el);
9940
+ t == null ? el.removeAttribute("tabindex") : el.setAttribute("tabindex", t);
9941
+ }
9942
+ }
9797
9943
  for (const t of timers) clearTimeout(t);
9798
9944
  timers.clear();
9799
9945
  clearTimeout(saveTimer);
@@ -9803,13 +9949,14 @@ function createTable(root) {
9803
9949
  // ── Wire up ──
9804
9950
  if (thead) on(thead, "click", onHeaderClick);
9805
9951
  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);
9952
+ const onOwn = (target, ev, fn) => on(target, ev, (e) => mine(e.target) && fn(e));
9953
+ if (tbody) onOwn(tbody, "change", onRowCheckboxChange);
9954
+ onOwn(root, "click", onRootClick);
9955
+ onOwn(root, "dblclick", onDblClick);
9956
+ onOwn(root, "keydown", onKeydown);
9957
+ onOwn(root, "input", onFilterInput);
9958
+ onOwn(root, "pointerdown", onPointerDown);
9959
+ onOwn(root, "focusin", onFocusIn);
9813
9960
  if (typeof window !== "undefined") {
9814
9961
  let resizeTimer = null;
9815
9962
  const onWinResize = () => {
@@ -9894,9 +10041,14 @@ function createTabs(root) {
9894
10041
  // Prevent double-init
9895
10042
  if (root._faqirTabs) return root._faqirTabs;
9896
10043
 
9897
- const list = root.querySelector("[data-part='list']");
9898
- const triggers = () => [...root.querySelectorAll("[data-part='trigger']")];
9899
- const panels = () => [...root.querySelectorAll("[data-part='panel']")];
10044
+ // Direct children only: a component nested in a panel (a collapsible's
10045
+ // summary, another tabs) has parts of the same names, and a deep query took
10046
+ // them for this component's tabs and shifted the trigger↔panel pairing.
10047
+ const list = root.querySelector(":scope > [data-part='list']");
10048
+ const triggers = () => [
10049
+ ...root.querySelectorAll(":scope > [data-part='list'] > [data-part='trigger']"),
10050
+ ];
10051
+ const panels = () => [...root.querySelectorAll(":scope > [data-part='panel']")];
9900
10052
 
9901
10053
  function activate(index) {
9902
10054
  const allTriggers = triggers();
@@ -10565,8 +10717,11 @@ function createToastContainer(root) {
10565
10717
  * exclusivity when the controls are used standalone (no `l-model`), the
10566
10718
  * `[data-state]` styling hook, and a `faqir:change` notification.
10567
10719
  *
10568
- * Every state mutation funnels through a native `change` event on the affected
10720
+ * Every user mutation funnels through a native `change` event on the affected
10569
10721
  * input, so the engine's `l-model` listener and this controller stay in lockstep.
10722
+ * Store-driven `l-model` writes arrive as the engine's `faqir:model` event (and
10723
+ * a form `reset`); both re-sync state without a `faqir:change`. A plain script
10724
+ * write (`input.checked = x`) is silent and is not observed — use `setValue()`.
10570
10725
  */
10571
10726
  function createToggleGroup(root) {
10572
10727
  // Prevent double-init.
@@ -10727,13 +10882,23 @@ function createToggleGroup(root) {
10727
10882
  if (el) select(el);
10728
10883
  }
10729
10884
 
10885
+ // A store-driven `l-model` write sets `.checked` silently and announces it
10886
+ // with `faqir:model`; follow it without re-emitting `faqir:change` (the user
10887
+ // did not act). A form reset restores defaults silently the same way.
10888
+ const form = root.closest("form");
10889
+ const onReset = () => setTimeout(syncState, 0);
10890
+
10730
10891
  root.addEventListener("change", onChange);
10892
+ root.addEventListener("faqir:model", syncState);
10731
10893
  root.addEventListener("keydown", onKeyDown);
10894
+ if (form) form.addEventListener("reset", onReset);
10732
10895
  syncState();
10733
10896
 
10734
10897
  function destroy() {
10735
10898
  root.removeEventListener("change", onChange);
10899
+ root.removeEventListener("faqir:model", syncState);
10736
10900
  root.removeEventListener("keydown", onKeyDown);
10901
+ if (form) form.removeEventListener("reset", onReset);
10737
10902
  delete root._faqirToggleGroup;
10738
10903
  }
10739
10904
 
@@ -10754,7 +10919,8 @@ function createTooltip(root) {
10754
10919
  if (root._faqirTooltip) return root._faqirTooltip;
10755
10920
 
10756
10921
  const trigger = root.querySelector("[data-part='trigger']");
10757
- const content = root.querySelector("[data-part='content']");
10922
+ // Not the content of a collapsible or popover used as the trigger.
10923
+ const [content] = ownParts(root, "content");
10758
10924
 
10759
10925
  let showTimer = null;
10760
10926
  let hideTimer = null;
@@ -11352,7 +11518,9 @@ function createTreeView(root) {
11352
11518
  /** One value, deep-copied into something plain, finite and printable. */
11353
11519
  function snapshotValue(value, stack, depth) {
11354
11520
  if (typeof value === 'function') {
11355
- return '[Function' + (value.name ? ' ' + value.name : '') + ']';
11521
+ // Scope methods are bound [1.1F-05]; report the name the author wrote.
11522
+ var name = String(value.name || '').replace(/^bound /, '');
11523
+ return '[Function' + (name ? ' ' + name : '') + ']';
11356
11524
  }
11357
11525
  if (value === null || typeof value !== 'object') return value;
11358
11526
  if (value.nodeType === 1 && value.tagName) {
@@ -11596,22 +11764,35 @@ function createTreeView(root) {
11596
11764
  }
11597
11765
  }
11598
11766
 
11767
+ // Every controller inside `root` (a document or an element), `root` included.
11768
+ // Controllers are double-init guarded, so a second call is a no-op.
11769
+ function startControllers(root) {
11770
+ var own = root.getAttribute && root.getAttribute('data-ui');
11771
+ if (own && controllerRegistry[own]) initController(own, root);
11772
+ var names = Object.keys(controllerRegistry);
11773
+ for (var n = 0; n < names.length; n++) {
11774
+ var els = root.querySelectorAll('[data-ui="' + names[n] + '"]');
11775
+ for (var e = 0; e < els.length; e++) initController(names[n], els[e]);
11776
+ }
11777
+ }
11778
+
11779
+ // `Faqir.initTree` — the same order bootstrap uses: a connected subtree's
11780
+ // controllers start first, so its `l-init` / first `l-effect` reach them
11781
+ // through `$ui`. A detached subtree's controllers wait for the
11782
+ // MutationObserver, as before. [1.1F-03]
11783
+ function publicInitTree(root, parentScope) {
11784
+ if (root.isConnected) startControllers(root);
11785
+ initTree(root, parentScope);
11786
+ }
11787
+
11599
11788
  // The document's one MutationObserver, or null before the first bootstrap,
11600
11789
  // and the <body> it is currently watching.
11601
11790
  var mutationObserver = null;
11602
11791
  var observedBody = null;
11603
11792
 
11604
11793
  function bootstrap() {
11605
- injectCloakStyle();
11606
-
11607
11794
  // 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
- }
11795
+ startControllers(document);
11615
11796
 
11616
11797
  // Find all scope roots.
11617
11798
  // l-data elements always create a scope.
@@ -11684,12 +11865,7 @@ function createTreeView(root) {
11684
11865
  // after the first sweep ran and before the MutationObserver below starts.
11685
11866
  // Controllers are double-init guarded, so re-invoking is a no-op for
11686
11867
  // elements initialized by the first sweep.
11687
- for (var n2 = 0; n2 < names.length; n2++) {
11688
- var els2 = document.querySelectorAll('[data-ui="' + names[n2] + '"]');
11689
- for (var e2 = 0; e2 < els2.length; e2++) {
11690
- initController(names[n2], els2[e2]);
11691
- }
11692
- }
11868
+ startControllers(document);
11693
11869
 
11694
11870
  removeCloaks();
11695
11871
 
@@ -11715,14 +11891,14 @@ function createTreeView(root) {
11715
11891
  if (node.nodeType !== 1) continue;
11716
11892
 
11717
11893
  // Content that arrives after bootstrap has already bound by the time
11718
- // it is here; leaving `l-cloak` on it means the injected rule hides
11719
- // it forever. [W3-1]
11894
+ // it is here; leaving `l-cloak` on it means the cloak rule hides it
11895
+ // forever. [W3-1]
11720
11896
  removeCloaks(node);
11721
11897
 
11722
- var uiName = node.getAttribute ? node.getAttribute('data-ui') : null;
11723
- if (uiName && controllerRegistry[uiName]) {
11724
- initController(uiName, node);
11725
- }
11898
+ // Controllers first — the node's own and every one inside it — so
11899
+ // the `l-init` / first `l-effect` that `initTree` runs below reach
11900
+ // them through `$ui`. They used to start after it. [1.1F-03]
11901
+ startControllers(node);
11726
11902
 
11727
11903
  // Already initialized — by `Faqir.start()` / `Faqir.initTree()` in the
11728
11904
  // same task that inserted it (the record is only delivered on the
@@ -11739,11 +11915,6 @@ function createTreeView(root) {
11739
11915
  }
11740
11916
 
11741
11917
  if (node.querySelectorAll) {
11742
- var cNames = Object.keys(controllerRegistry);
11743
- for (var cn = 0; cn < cNames.length; cn++) {
11744
- var cEls = node.querySelectorAll('[data-ui="' + cNames[cn] + '"]');
11745
- for (var ce = 0; ce < cEls.length; ce++) initController(cNames[cn], cEls[ce]);
11746
- }
11747
11918
  var scopeEls = node.querySelectorAll('[l-data]');
11748
11919
  for (var se = 0; se < scopeEls.length; se++) {
11749
11920
  if (!scopeEls[se].__faqirScope) {
@@ -11814,7 +11985,7 @@ function createTreeView(root) {
11814
11985
  // one time it was hand-maintained (0.1.0 in a package versioned 0.2.4), so
11815
11986
  // the built artifact is now the only place the number is authoritative and
11816
11987
  // the build fails if this marker goes missing.
11817
- version: '1.1.1', // @faqir:version
11988
+ version: '1.2.0', // @faqir:version
11818
11989
  reactive: reactive,
11819
11990
  effect: effect,
11820
11991
  batch: batch,
@@ -11825,11 +11996,14 @@ function createTreeView(root) {
11825
11996
  data: function(name, factory) { dataRegistry.set(name, factory); },
11826
11997
  store: function(name, obj) { globalStores[name] = reactive(obj); },
11827
11998
  directive: function(name, handler) { customDirectives.set(name, handler); },
11828
- magic: function(name, callback) { customMagics.set(name, callback); },
11999
+ magic: function(name, callback) {
12000
+ if (name === 'this') throw new Error('[Faqir] "this" is reserved: $this is the directive element');
12001
+ customMagics.set(name, callback);
12002
+ },
11829
12003
  plugin: function(fn) { fn(Faqir); },
11830
12004
  controller: function(name, factory) { controllerRegistry[name] = factory; },
11831
12005
  start: bootstrap,
11832
- initTree: initTree,
12006
+ initTree: publicInitTree,
11833
12007
  // Snapshot of Faqir's view of one element — scope, directives, controller,
11834
12008
  // protocol attributes. [0.7-12]
11835
12009
  inspect: inspect,