dalila 1.9.2 → 1.9.4

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.
@@ -9,6 +9,7 @@
9
9
  import { effect, createScope, withScope, isInDevMode, signal, computeVirtualRange } from '../core/index.js';
10
10
  import { WRAPPED_HANDLER } from '../form/form.js';
11
11
  import { linkScopeToDom, withDevtoolsDomTarget } from '../core/devtools.js';
12
+ import { isComponent, normalizePropDef, coercePropValue, kebabToCamel, camelToKebab } from './component.js';
12
13
  // ============================================================================
13
14
  // Utilities
14
15
  // ============================================================================
@@ -18,6 +19,20 @@ import { linkScopeToDom, withDevtoolsDomTarget } from '../core/devtools.js';
18
19
  function isSignal(value) {
19
20
  return typeof value === 'function' && 'set' in value && 'update' in value;
20
21
  }
22
+ function isWritableSignal(value) {
23
+ if (!isSignal(value))
24
+ return false;
25
+ // `computed()` exposes set/update that always throw. Probe with a no-op write
26
+ // (same value) to detect read-only signals without mutating state.
27
+ try {
28
+ const current = value.peek();
29
+ value.set(current);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
21
36
  /**
22
37
  * Resolve a value from ctx - handles signals, functions, and plain values.
23
38
  * Only zero-arity functions are called (getters/computed). Functions with
@@ -749,6 +764,8 @@ function expressionDependsOnReactiveSource(node, ctx) {
749
764
  // ============================================================================
750
765
  const DEFAULT_EVENTS = ['click', 'input', 'change', 'submit', 'keydown', 'keyup'];
751
766
  const DEFAULT_RAW_TEXT_SELECTORS = 'pre, code';
767
+ const COMPONENT_REGISTRY_KEY = '__dalila_component_registry__';
768
+ const COMPONENT_EMIT_KEY = '__dalila_component_emit__';
752
769
  // ============================================================================
753
770
  // Text Interpolation
754
771
  // ============================================================================
@@ -1054,6 +1071,71 @@ function bindEvents(root, ctx, events, cleanups) {
1054
1071
  }
1055
1072
  }
1056
1073
  // ============================================================================
1074
+ // d-emit-<event> Directive
1075
+ // ============================================================================
1076
+ /**
1077
+ * Bind all [d-emit-<event>] directives within root.
1078
+ * Only active inside component contexts (where COMPONENT_EMIT_KEY exists).
1079
+ * Works inside d-each because child contexts inherit via prototype chain.
1080
+ */
1081
+ function bindEmit(root, ctx, cleanups) {
1082
+ const emitFn = ctx[COMPONENT_EMIT_KEY];
1083
+ if (typeof emitFn !== 'function')
1084
+ return;
1085
+ const elements = qsaIncludingRoot(root, '*');
1086
+ for (const el of elements) {
1087
+ for (const attrNode of Array.from(el.attributes)) {
1088
+ if (!attrNode.name.startsWith('d-emit-'))
1089
+ continue;
1090
+ if (attrNode.name === 'd-emit-value')
1091
+ continue;
1092
+ const eventName = attrNode.name.slice('d-emit-'.length).trim();
1093
+ const attr = attrNode.name;
1094
+ if (!eventName) {
1095
+ warn(`${attr}: missing DOM event name`);
1096
+ continue;
1097
+ }
1098
+ const emitName = normalizeBinding(attrNode.value);
1099
+ if (!emitName) {
1100
+ warn(`${attr}: empty value ignored`);
1101
+ continue;
1102
+ }
1103
+ const payloadExpr = el.getAttribute('d-emit-value');
1104
+ const payloadRaw = payloadExpr?.trim();
1105
+ let payloadAst = null;
1106
+ if (payloadRaw) {
1107
+ try {
1108
+ payloadAst = parseExpression(payloadRaw);
1109
+ }
1110
+ catch (err) {
1111
+ warn(`${attr}: invalid d-emit-value="${payloadRaw}" (${err.message})`);
1112
+ continue;
1113
+ }
1114
+ }
1115
+ else if (payloadExpr !== null) {
1116
+ warn(`${attr}: d-emit-value is empty; emitting DOM Event instead`);
1117
+ }
1118
+ if (emitName.includes(':')) {
1119
+ warn(`${attr}: ":" syntax is no longer supported. Use d-emit-value instead.`);
1120
+ continue;
1121
+ }
1122
+ const handler = (e) => {
1123
+ if (payloadAst) {
1124
+ const eventCtx = Object.create(ctx);
1125
+ eventCtx.$event = e;
1126
+ const result = evalExpressionAst(payloadAst, eventCtx);
1127
+ emitFn(emitName, result.ok ? result.value : undefined);
1128
+ }
1129
+ else {
1130
+ emitFn(emitName, e);
1131
+ }
1132
+ };
1133
+ el.addEventListener(eventName, handler);
1134
+ cleanups.push(() => el.removeEventListener(eventName, handler));
1135
+ }
1136
+ }
1137
+ }
1138
+ // ============================================================================
1057
1139
  // d-when Directive
1058
1140
  // ============================================================================
1059
1141
  /**
@@ -1532,7 +1614,17 @@ function bindEach(root, ctx, cleanups) {
1532
1614
  const elements = qsaIncludingRoot(root, '[d-each]')
1533
1615
  .filter(el => !el.parentElement?.closest('[d-each], [d-virtual-each]'));
1534
1616
  for (const el of elements) {
1535
- const bindingName = normalizeBinding(el.getAttribute('d-each'));
1617
+ const rawValue = el.getAttribute('d-each')?.trim() ?? '';
1618
+ let bindingName;
1619
+ let alias = 'item'; // default
1620
+ const asMatch = rawValue.match(/^(\S+)\s+as\s+(\S+)$/);
1621
+ if (asMatch) {
1622
+ bindingName = normalizeBinding(asMatch[1]);
1623
+ alias = asMatch[2];
1624
+ }
1625
+ else {
1626
+ bindingName = normalizeBinding(rawValue);
1627
+ }
1536
1628
  if (!bindingName)
1537
1629
  continue;
1538
1630
  let binding = ctx[bindingName];
@@ -1588,7 +1680,7 @@ function bindEach(root, ctx, cleanups) {
1588
1680
  if (keyBinding) {
1589
1681
  if (keyBinding === '$index')
1590
1682
  return index;
1591
- if (keyBinding === 'item')
1683
+ if (keyBinding === alias || keyBinding === 'item')
1592
1684
  return item;
1593
1685
  if (typeof item === 'object' && item !== null && keyBinding in item) {
1594
1686
  return item[keyBinding];
@@ -1627,8 +1719,11 @@ function bindEach(root, ctx, cleanups) {
1627
1719
  };
1628
1720
  metadataByKey.set(key, metadata);
1629
1721
  itemsByKey.set(key, item);
1630
- // Expose item + positional / collection helpers.
1631
- itemCtx.item = item;
1722
+ // Expose item under the alias name + positional / collection helpers.
1723
+ itemCtx[alias] = item;
1724
+ if (alias !== 'item') {
1725
+ itemCtx.item = item; // backward compat
1726
+ }
1632
1727
  itemCtx.key = key;
1633
1728
  itemCtx.$index = metadata.$index;
1634
1729
  itemCtx.$count = metadata.$count;
@@ -1758,6 +1853,7 @@ function bindEach(root, ctx, cleanups) {
1758
1853
  */
1759
1854
  function bindIf(root, ctx, cleanups) {
1760
1855
  const elements = qsaIncludingRoot(root, '[d-if]');
1856
+ const processedElse = new Set();
1761
1857
  for (const el of elements) {
1762
1858
  const bindingName = normalizeBinding(el.getAttribute('d-if'));
1763
1859
  if (!bindingName)
@@ -1767,29 +1863,69 @@ function bindIf(root, ctx, cleanups) {
1767
1863
  warn(`d-if: "${bindingName}" not found in context`);
1768
1864
  continue;
1769
1865
  }
1866
+ // Detect d-else sibling BEFORE removing from DOM
1867
+ const elseEl = el.nextElementSibling?.hasAttribute('d-else') ? el.nextElementSibling : null;
1770
1868
  const comment = document.createComment('d-if');
1771
1869
  el.parentNode?.replaceChild(comment, el);
1772
1870
  el.removeAttribute('d-if');
1773
1871
  const htmlEl = el;
1872
+ // Handle d-else branch
1873
+ let elseHtmlEl = null;
1874
+ let elseComment = null;
1875
+ if (elseEl) {
1876
+ processedElse.add(elseEl);
1877
+ elseComment = document.createComment('d-else');
1878
+ elseEl.parentNode?.replaceChild(elseComment, elseEl);
1879
+ elseEl.removeAttribute('d-else');
1880
+ elseHtmlEl = elseEl;
1881
+ }
1774
1882
  // Apply initial state synchronously to avoid FOUC
1775
1883
  const initialValue = !!resolve(binding);
1776
1884
  if (initialValue) {
1777
1885
  comment.parentNode?.insertBefore(htmlEl, comment);
1778
1886
  }
1887
+ else if (elseHtmlEl && elseComment) {
1888
+ elseComment.parentNode?.insertBefore(elseHtmlEl, elseComment);
1889
+ }
1779
1890
  // Then create reactive effect to keep it updated
1780
- bindEffect(htmlEl, () => {
1781
- const value = !!resolve(binding);
1782
- if (value) {
1783
- if (!htmlEl.parentNode) {
1784
- comment.parentNode?.insertBefore(htmlEl, comment);
1891
+ if (elseHtmlEl && elseComment) {
1892
+ const capturedElseEl = elseHtmlEl;
1893
+ const capturedElseComment = elseComment;
1894
+ bindEffect(htmlEl, () => {
1895
+ const value = !!resolve(binding);
1896
+ if (value) {
1897
+ if (!htmlEl.parentNode) {
1898
+ comment.parentNode?.insertBefore(htmlEl, comment);
1899
+ }
1900
+ if (capturedElseEl.parentNode) {
1901
+ capturedElseEl.parentNode.removeChild(capturedElseEl);
1902
+ }
1785
1903
  }
1786
- }
1787
- else {
1788
- if (htmlEl.parentNode) {
1789
- htmlEl.parentNode.removeChild(htmlEl);
1904
+ else {
1905
+ if (htmlEl.parentNode) {
1906
+ htmlEl.parentNode.removeChild(htmlEl);
1907
+ }
1908
+ if (!capturedElseEl.parentNode) {
1909
+ capturedElseComment.parentNode?.insertBefore(capturedElseEl, capturedElseComment);
1910
+ }
1790
1911
  }
1791
- }
1792
- });
1912
+ });
1913
+ }
1914
+ else {
1915
+ bindEffect(htmlEl, () => {
1916
+ const value = !!resolve(binding);
1917
+ if (value) {
1918
+ if (!htmlEl.parentNode) {
1919
+ comment.parentNode?.insertBefore(htmlEl, comment);
1920
+ }
1921
+ }
1922
+ else {
1923
+ if (htmlEl.parentNode) {
1924
+ htmlEl.parentNode.removeChild(htmlEl);
1925
+ }
1926
+ }
1927
+ });
1928
+ }
1793
1929
  }
1794
1930
  }
1795
1931
  // ============================================================================
@@ -1833,6 +1969,38 @@ function bindHtml(root, ctx, cleanups) {
1833
1969
  }
1834
1970
  }
1835
1971
  // ============================================================================
1972
+ // d-text Directive
1973
+ // ============================================================================
1974
+ /**
1975
+ * Bind all [d-text] directives within root.
1976
+ * Sets textContent — safe from XSS by design (no HTML parsing).
1977
+ * Counterpart to d-html which renders raw HTML.
1978
+ */
1979
+ function bindText(root, ctx, cleanups) {
1980
+ const elements = qsaIncludingRoot(root, '[d-text]');
1981
+ for (const el of elements) {
1982
+ const bindingName = normalizeBinding(el.getAttribute('d-text'));
1983
+ if (!bindingName)
1984
+ continue;
1985
+ const binding = ctx[bindingName];
1986
+ if (binding === undefined) {
1987
+ warn(`d-text: "${bindingName}" not found in context`);
1988
+ continue;
1989
+ }
1990
+ const htmlEl = el;
1991
+ // Sync initial render
1992
+ const initial = resolve(binding);
1993
+ htmlEl.textContent = initial == null ? '' : String(initial);
1994
+ // Reactive effect only if needed
1995
+ if (isSignal(binding) || (typeof binding === 'function' && binding.length === 0)) {
1996
+ bindEffect(htmlEl, () => {
1997
+ const v = resolve(binding);
1998
+ htmlEl.textContent = v == null ? '' : String(v);
1999
+ });
2000
+ }
2001
+ }
2002
+ }
2003
+ // ============================================================================
1836
2004
  // d-attr Directive
1837
2005
  // ============================================================================
1838
2006
  /**
@@ -1897,6 +2065,57 @@ function bindAttrs(root, ctx, cleanups) {
1897
2065
  }
1898
2066
  }
1899
2067
  // ============================================================================
2068
+ // d-bind-* Directive (Two-way Binding)
2069
+ // ============================================================================
2070
+ /**
2071
+ * Bind all [d-bind-value] and [d-bind-checked] directives within root.
2072
+ * Two-way binding: signal → DOM (outbound) and DOM → signal (inbound).
2073
+ * Only works with signals — logs a warning otherwise.
2074
+ */
2075
+ function bindTwoWay(root, ctx, cleanups) {
2076
+ const SUPPORTED = ['value', 'checked'];
2077
+ for (const prop of SUPPORTED) {
2078
+ const attr = `d-bind-${prop}`;
2079
+ const elements = qsaIncludingRoot(root, `[${attr}]`);
2080
+ for (const el of elements) {
2081
+ const bindingName = normalizeBinding(el.getAttribute(attr));
2082
+ if (!bindingName)
2083
+ continue;
2084
+ const binding = ctx[bindingName];
2085
+ if (!isSignal(binding)) {
2086
+ warn(`d-bind-${prop}: "${bindingName}" must be a signal`);
2087
+ continue;
2088
+ }
2089
+ const writable = isWritableSignal(binding);
2090
+ if (!writable) {
2091
+ warn(`d-bind-${prop}: "${bindingName}" is read-only (inbound updates disabled)`);
2092
+ }
2093
+ el.removeAttribute(attr);
2094
+ // Outbound: signal → DOM
2095
+ const isBoolean = prop === 'checked';
2096
+ bindEffect(el, () => {
2097
+ const val = binding();
2098
+ if (isBoolean) {
2099
+ el[prop] = !!val;
2100
+ }
2101
+ else {
2102
+ el[prop] = val == null ? '' : String(val);
2103
+ }
2104
+ });
2105
+ // Inbound: DOM → signal
2106
+ if (writable) {
2107
+ const eventName = el.tagName === 'SELECT' || isBoolean ? 'change' : 'input';
2108
+ const handler = () => {
2109
+ const val = isBoolean ? el.checked : el.value;
2110
+ binding.set(val);
2111
+ };
2112
+ el.addEventListener(eventName, handler);
2113
+ cleanups.push(() => el.removeEventListener(eventName, handler));
2114
+ }
2115
+ }
2116
+ }
2117
+ }
2118
+ // ============================================================================
1900
2119
  // Form Directives
1901
2120
  // ============================================================================
1902
2121
  /**
@@ -2445,6 +2664,344 @@ function bindArrayOperations(container, fieldArray, cleanups) {
2445
2664
  }
2446
2665
  }
2447
2666
  // ============================================================================
2667
+ // d-ref — declarative element references
2668
+ // ============================================================================
2669
+ function bindRef(root, refs) {
2670
+ const elements = qsaIncludingRoot(root, '[d-ref]');
2671
+ for (const el of elements) {
2672
+ const name = el.getAttribute('d-ref');
2673
+ if (!name || !name.trim()) {
2674
+ warn('d-ref: empty ref name ignored');
2675
+ continue;
2676
+ }
2677
+ const trimmed = name.trim();
2678
+ if (refs.has(trimmed)) {
2679
+ warn(`d-ref: duplicate ref name "${trimmed}" in the same scope`);
2680
+ }
2681
+ refs.set(trimmed, el);
2682
+ }
2683
+ }
2684
+ // ============================================================================
2685
+ // Component System
2686
+ // ============================================================================
2687
+ function extractSlots(el) {
2688
+ const getSlotName = (node) => {
2689
+ const raw = node.getAttribute('d-slot') ?? node.getAttribute('slot');
2690
+ if (!raw)
2691
+ return null;
2692
+ const name = raw.trim();
2693
+ return name || null;
2694
+ };
2695
+ const namedSlots = new Map();
2696
+ const defaultSlot = document.createDocumentFragment();
2697
+ for (const child of Array.from(el.childNodes)) {
2698
+ if (child instanceof Element && child.tagName === 'TEMPLATE') {
2699
+ const name = getSlotName(child);
2700
+ if (name) {
2701
+ const frag = namedSlots.get(name) ?? document.createDocumentFragment();
2702
+ frag.append(...Array.from(child.content.childNodes));
2703
+ namedSlots.set(name, frag);
2704
+ }
2705
+ else {
2706
+ defaultSlot.appendChild(child);
2707
+ }
2708
+ }
2709
+ else if (child instanceof Element) {
2710
+ const name = getSlotName(child);
2711
+ if (name) {
2712
+ const frag = namedSlots.get(name) ?? document.createDocumentFragment();
2713
+ child.removeAttribute('d-slot');
2714
+ child.removeAttribute('slot');
2715
+ frag.appendChild(child);
2716
+ namedSlots.set(name, frag);
2717
+ }
2718
+ else {
2719
+ defaultSlot.appendChild(child);
2720
+ }
2721
+ }
2722
+ else {
2723
+ defaultSlot.appendChild(child);
2724
+ }
2725
+ }
2726
+ return { defaultSlot, namedSlots };
2727
+ }
2728
+ function fillSlots(root, defaultSlot, namedSlots) {
2729
+ for (const slotEl of Array.from(root.querySelectorAll('slot[name]'))) {
2730
+ const name = slotEl.getAttribute('name');
2731
+ const content = namedSlots.get(name);
2732
+ if (content && content.childNodes.length > 0)
2733
+ slotEl.replaceWith(content);
2734
+ }
2735
+ const defaultSlotEl = root.querySelector('slot:not([name])');
2736
+ if (defaultSlotEl && defaultSlot.childNodes.length > 0)
2737
+ defaultSlotEl.replaceWith(defaultSlot);
2738
+ }
2739
+ function bindSlotFragments(defaultSlot, namedSlots, parentCtx, events, cleanups) {
2740
+ const bindFrag = (frag) => {
2741
+ if (frag.childNodes.length === 0)
2742
+ return;
2743
+ const container = document.createElement('div');
2744
+ container.setAttribute('data-dalila-internal-bound', '');
2745
+ container.appendChild(frag);
2746
+ const handle = bind(container, parentCtx, { events, _skipLifecycle: true, _internal: true });
2747
+ cleanups.push(handle);
2748
+ while (container.firstChild)
2749
+ frag.appendChild(container.firstChild);
2750
+ };
2751
+ bindFrag(defaultSlot);
2752
+ for (const frag of namedSlots.values())
2753
+ bindFrag(frag);
2754
+ }
2755
+ function resolveComponentProps(el, parentCtx, def) {
2756
+ const props = {};
2757
+ const schema = def.props ?? {};
2758
+ const hasSchema = Object.keys(schema).length > 0;
2759
+ const PREFIX = 'd-props-';
2760
+ for (const attr of Array.from(el.attributes)) {
2761
+ if (!attr.name.startsWith(PREFIX))
2762
+ continue;
2763
+ const kebab = attr.name.slice(PREFIX.length);
2764
+ const propName = kebabToCamel(kebab);
2765
+ if (hasSchema && !(propName in schema)) {
2766
+ warn(`Component <${def.tag}>: d-props-${kebab} is not declared in props schema`);
2767
+ }
2768
+ const bindingName = normalizeBinding(attr.value);
2769
+ if (!bindingName)
2770
+ continue;
2771
+ const parentValue = parentCtx[bindingName];
2772
+ if (parentValue === undefined) {
2773
+ warn(`d-props-${kebab}: "${bindingName}" not found in parent context`);
2774
+ }
2775
+ // Read raw value — signals and zero-arity functions (getters/computed-like) are
2776
+ // unwrapped reactively; everything else passes through as-is.
2777
+ const isGetter = !isSignal(parentValue) && typeof parentValue === 'function' && parentValue.length === 0;
2778
+ const raw = isSignal(parentValue) ? parentValue() : isGetter ? parentValue() : parentValue;
2779
+ const propSignal = signal(raw);
2780
+ // Reactive sync: signals and zero-arity getters
2781
+ if (isSignal(parentValue) || isGetter) {
2782
+ effect(() => { propSignal.set(isSignal(parentValue) ? parentValue() : parentValue()); });
2783
+ }
2784
+ props[propName] = propSignal;
2785
+ }
2786
+ for (const [propName, propOption] of Object.entries(schema)) {
2787
+ if (props[propName])
2788
+ continue;
2789
+ const propDef = normalizePropDef(propOption);
2790
+ const kebabPropName = camelToKebab(propName);
2791
+ const attrName = el.hasAttribute(propName)
2792
+ ? propName
2793
+ : (el.hasAttribute(kebabPropName) ? kebabPropName : null);
2794
+ if (attrName) {
2795
+ const raw = el.getAttribute(attrName);
2796
+ // Dev warning: Array/Object props should use d-props-*
2797
+ if (propDef.type === Array || propDef.type === Object) {
2798
+ warn(`Component <${def.tag}>: prop "${propName}" has type ${propDef.type === Array ? 'Array' : 'Object'} ` +
2799
+ `but received a static string attribute. Use d-props-${camelToKebab(propName)} to pass reactive data.`);
2800
+ }
2801
+ props[propName] = signal(coercePropValue(raw, propDef.type));
2802
+ }
2803
+ else {
2804
+ if (propDef.default !== undefined) {
2805
+ const defaultValue = typeof propDef.default === 'function'
2806
+ ? propDef.default()
2807
+ : propDef.default;
2808
+ props[propName] = signal(defaultValue);
2809
+ }
2810
+ else {
2811
+ if (propDef.required) {
2812
+ warn(`Component <${def.tag}>: required prop "${propName}" was not provided`);
2813
+ }
2814
+ props[propName] = signal(undefined);
2815
+ }
2816
+ }
2817
+ }
2818
+ return props;
2819
+ }
2820
+ function getComponentRegistry(ctx) {
2821
+ const reg = ctx[COMPONENT_REGISTRY_KEY];
2822
+ return reg instanceof Map ? reg : null;
2823
+ }
2824
+ function bindComponents(root, ctx, events, cleanups, onMountError) {
2825
+ const registry = getComponentRegistry(ctx);
2826
+ if (!registry || registry.size === 0)
2827
+ return;
2828
+ const tagSelector = Array.from(registry.keys()).join(', ');
2829
+ const elements = qsaIncludingRoot(root, tagSelector);
2830
+ const boundary = root.closest('[data-dalila-internal-bound]');
2831
+ for (const el of elements) {
2832
+ // Skip stale entries from the initial snapshot.
2833
+ // Earlier iterations may replace/move nodes (e.g. slot projection),
2834
+ // so this element might no longer belong to the current bind boundary.
2835
+ if (!root.contains(el))
2836
+ continue;
2837
+ if (el.closest('[data-dalila-internal-bound]') !== boundary)
2838
+ continue;
2839
+ const tag = el.tagName.toLowerCase();
2840
+ const component = registry.get(tag);
2841
+ if (!component)
2842
+ continue;
2843
+ const def = component.definition;
2844
+ // 1. Extract slots
2845
+ const { defaultSlot, namedSlots } = extractSlots(el);
2846
+ // 2. Create component DOM
2847
+ const templateEl = document.createElement('template');
2848
+ templateEl.innerHTML = def.template.trim();
2849
+ const content = templateEl.content;
2850
+ // Dev-mode template validation
2851
+ if (isInDevMode()) {
2852
+ if (!def.template.trim()) {
2853
+ warn(`Component <${def.tag}>: template is empty`);
2854
+ }
2855
+ else if (content.childNodes.length === 0) {
2856
+ warn(`Component <${def.tag}>: template produced no DOM nodes`);
2857
+ }
2858
+ }
2859
+ // Single-root optimization: no wrapper needed
2860
+ // A d-each on the sole element will clone siblings at runtime, so it needs a container.
2861
+ const elementChildren = Array.from(content.children);
2862
+ const hasOnlyOneElement = elementChildren.length === 1
2863
+ && !elementChildren[0].hasAttribute('d-each')
2864
+ && Array.from(content.childNodes).every(n => n === elementChildren[0] || (n.nodeType === 3 && !n.textContent.trim()));
2865
+ let componentRoot;
2866
+ if (hasOnlyOneElement) {
2867
+ componentRoot = elementChildren[0];
2868
+ content.removeChild(componentRoot);
2869
+ }
2870
+ else {
2871
+ componentRoot = document.createElement('dalila-c');
2872
+ componentRoot.style.display = 'contents';
2873
+ componentRoot.appendChild(content);
2874
+ }
2875
+ // 3. Create component scope (child of current template scope)
2876
+ const componentScope = createScope();
2877
+ const pendingMountCallbacks = [];
2878
+ // 4. Within component scope: resolve props, run setup, bind
2879
+ let componentHandle = null;
2880
+ // Collect d-on-* event handlers from the component tag for ctx.emit()
2881
+ const componentEventHandlers = {};
2882
+ for (const attr of Array.from(el.attributes)) {
2883
+ if (!attr.name.startsWith('d-on-'))
2884
+ continue;
2885
+ const eventName = attr.name.slice(5); // "d-on-select" → "select"
2886
+ const handlerName = normalizeBinding(attr.value);
2887
+ if (!handlerName)
2888
+ continue;
2889
+ const handler = ctx[handlerName];
2890
+ if (typeof handler === 'function') {
2891
+ componentEventHandlers[eventName] = handler;
2892
+ }
2893
+ else if (handler !== undefined) {
2894
+ warn(`Component <${def.tag}>: d-on-${eventName}="${handlerName}" is not a function`);
2895
+ }
2896
+ }
2897
+ withScope(componentScope, () => {
2898
+ // 4a. Resolve props
2899
+ const propSignals = resolveComponentProps(el, ctx, def);
2900
+ // 4b. Create ref accessor + emit
2901
+ const setupCtx = {
2902
+ ref: (name) => componentHandle?.getRef(name) ?? null,
2903
+ refs: () => componentHandle?.getRefs() ?? Object.freeze({}),
2904
+ emit: (event, ...args) => {
2905
+ const handler = componentEventHandlers[event];
2906
+ if (typeof handler === 'function')
2907
+ handler(...args);
2908
+ },
2909
+ onMount: (fn) => {
2910
+ pendingMountCallbacks.push(fn);
2911
+ },
2912
+ onCleanup: (fn) => {
2913
+ componentScope.onCleanup(fn);
2914
+ },
2915
+ };
2916
+ // 4c. Run setup
2917
+ let setupReturn = {};
2918
+ if (def.setup) {
2919
+ setupReturn = def.setup(propSignals, setupCtx);
2920
+ for (const key of Object.keys(setupReturn)) {
2921
+ if (key in propSignals) {
2922
+ warn(`Component <${def.tag}>: setup() returned "${key}" which overrides a prop binding`);
2923
+ }
2924
+ }
2925
+ }
2926
+ // 4d. Build component bind context (propagate registry for nested components)
2927
+ const componentCtx = { ...propSignals, ...setupReturn };
2928
+ const parentRegistry = getComponentRegistry(ctx);
2929
+ if (parentRegistry) {
2930
+ componentCtx[COMPONENT_REGISTRY_KEY] = parentRegistry;
2931
+ }
2932
+ // 4d'. Store emit function for d-emit-* directives
2933
+ componentCtx[COMPONENT_EMIT_KEY] = (event, ...args) => {
2934
+ const handler = componentEventHandlers[event];
2935
+ if (typeof handler === 'function')
2936
+ handler(...args);
2937
+ };
2938
+ // 4e. Bind slot content with PARENT context/scope
2939
+ const parentScope = componentScope.parent;
2940
+ if (parentScope) {
2941
+ withScope(parentScope, () => {
2942
+ bindSlotFragments(defaultSlot, namedSlots, ctx, events, cleanups);
2943
+ });
2944
+ }
2945
+ // 4f. Fill slots
2946
+ fillSlots(componentRoot, defaultSlot, namedSlots);
2947
+ // 4g. Mark as bound boundary
2948
+ componentRoot.setAttribute('data-dalila-internal-bound', '');
2949
+ // 4h. Bind component template
2950
+ componentHandle = bind(componentRoot, componentCtx, { events, _skipLifecycle: true, _internal: true });
2951
+ cleanups.push(componentHandle);
2952
+ });
2953
+ // 5. Replace original tag with component DOM
2954
+ el.replaceWith(componentRoot);
2955
+ // 6. Run component onMount callbacks after the DOM swap.
2956
+ if (pendingMountCallbacks.length > 0) {
2957
+ withScope(componentScope, () => {
2958
+ for (const cb of pendingMountCallbacks) {
2959
+ if (onMountError === 'throw') {
2960
+ cb();
2961
+ }
2962
+ else {
2963
+ try {
2964
+ cb();
2965
+ }
2966
+ catch (err) {
2967
+ console.error(`[Dalila] Component <${def.tag}> onMount() threw:`, err);
2968
+ }
2969
+ }
2970
+ }
2971
+ });
2972
+ }
2973
+ // 7. Register scope cleanup
2974
+ cleanups.push(() => componentScope.dispose());
2975
+ }
2976
+ }
2977
+ // ============================================================================
2978
+ // Global Configuration
2979
+ // ============================================================================
2980
+ let globalConfig = {};
2981
+ /**
2982
+ * Set global defaults for all `bind()` / `mount()` calls.
2983
+ *
2984
+ * Options set here are merged with per-call options (per-call wins).
2985
+ * Call with an empty object to reset.
2986
+ *
2987
+ * @example
2988
+ * ```ts
2989
+ * import { configure } from 'dalila/runtime';
2990
+ *
2991
+ * configure({
2992
+ * components: [FruitPicker],
2993
+ * onMountError: 'log',
2994
+ * });
2995
+ * ```
2996
+ */
2997
+ export function configure(config) {
2998
+ if (Object.keys(config).length === 0) {
2999
+ globalConfig = {};
3000
+ return;
3001
+ }
3002
+ globalConfig = { ...globalConfig, ...config };
3003
+ }
3004
+ // ============================================================================
2448
3005
  // Main bind() Function
2449
3006
  // ============================================================================
2450
3007
  /**
@@ -2471,7 +3028,76 @@ function bindArrayOperations(container, fieldArray, cleanups) {
2471
3028
  * ```
2472
3029
  */
2473
3030
  export function bind(root, ctx, options = {}) {
3031
+ // ── Merge global config with per-call options ──
3032
+ if (Object.keys(globalConfig).length > 0) {
3033
+ const { components: globalComponents, ...globalRest } = globalConfig;
3034
+ const { components: localComponents, ...localRest } = options;
3035
+ const mergedOpts = { ...globalRest, ...localRest };
3036
+ // Combine component registries: local takes precedence over global
3037
+ if (globalComponents || localComponents) {
3038
+ const combined = {};
3039
+ const mergeComponents = (src) => {
3040
+ if (!src)
3041
+ return;
3042
+ if (Array.isArray(src)) {
3043
+ for (const comp of src) {
3044
+ if (isComponent(comp))
3045
+ combined[comp.definition.tag] = comp;
3046
+ }
3047
+ }
3048
+ else {
3049
+ for (const [key, comp] of Object.entries(src)) {
3050
+ if (isComponent(comp))
3051
+ combined[comp.definition.tag] = comp;
3052
+ }
3053
+ }
3054
+ };
3055
+ mergeComponents(globalComponents);
3056
+ mergeComponents(localComponents); // local wins
3057
+ mergedOpts.components = combined;
3058
+ }
3059
+ options = mergedOpts;
3060
+ }
3061
+ // ── Resolve string selector ──
3062
+ if (typeof root === 'string') {
3063
+ const found = document.querySelector(root);
3064
+ if (!found)
3065
+ throw new Error(`[Dalila] bind: element not found: ${root}`);
3066
+ root = found;
3067
+ }
3068
+ // ── Component registry propagation via context ──
3069
+ if (options.components) {
3070
+ const existing = ctx[COMPONENT_REGISTRY_KEY];
3071
+ const merged = new Map(existing instanceof Map ? existing : []);
3072
+ if (Array.isArray(options.components)) {
3073
+ for (const comp of options.components) {
3074
+ if (!isComponent(comp)) {
3075
+ warn('bind: components[] contains an invalid component entry');
3076
+ continue;
3077
+ }
3078
+ merged.set(comp.definition.tag, comp);
3079
+ }
3080
+ }
3081
+ else {
3082
+ for (const [key, comp] of Object.entries(options.components)) {
3083
+ if (!isComponent(comp)) {
3084
+ warn(`bind: components["${key}"] is not a valid component`);
3085
+ continue;
3086
+ }
3087
+ const tag = comp.definition.tag;
3088
+ if (key !== tag) {
3089
+ warn(`bind: components key "${key}" differs from component tag "${tag}" (using "${tag}")`);
3090
+ }
3091
+ merged.set(tag, comp);
3092
+ }
3093
+ }
3094
+ // Preserve prototype/inherited lookups from the original context.
3095
+ const ctxWithRegistry = Object.create(ctx);
3096
+ ctxWithRegistry[COMPONENT_REGISTRY_KEY] = merged;
3097
+ ctx = ctxWithRegistry;
3098
+ }
2474
3099
  const events = options.events ?? DEFAULT_EVENTS;
3100
+ const onMountError = options.onMountError ?? 'log';
2475
3101
  const rawTextSelectors = options.rawTextSelectors ?? DEFAULT_RAW_TEXT_SELECTORS;
2476
3102
  const templatePlanCacheConfig = resolveTemplatePlanCacheConfig(options);
2477
3103
  const benchSession = createBindBenchSession();
@@ -2484,6 +3110,7 @@ export function bind(root, ctx, options = {}) {
2484
3110
  // Create a scope for this template binding
2485
3111
  const templateScope = createScope();
2486
3112
  const cleanups = [];
3113
+ const refs = new Map();
2487
3114
  linkScopeToDom(templateScope, root, describeBindRoot(root));
2488
3115
  // Run all bindings within the template scope
2489
3116
  withScope(templateScope, () => {
@@ -2495,24 +3122,34 @@ export function bind(root, ctx, options = {}) {
2495
3122
  bindVirtualEach(root, ctx, cleanups);
2496
3123
  // 4. d-each — must run early: removes templates before TreeWalker visits them
2497
3124
  bindEach(root, ctx, cleanups);
2498
- // 5. Text interpolation (template plan cache + lazy parser fallback)
3125
+ // 5. Components must run after d-each but before d-ref / text interpolation
3126
+ bindComponents(root, ctx, events, cleanups, onMountError);
3127
+ // 6. d-ref — collect element references (after d-each removes templates)
3128
+ bindRef(root, refs);
3129
+ // 6.5. d-text — safe textContent binding (before text interpolation)
3130
+ bindText(root, ctx, cleanups);
3131
+ // 7. Text interpolation (template plan cache + lazy parser fallback)
2499
3132
  bindTextInterpolation(root, ctx, rawTextSelectors, templatePlanCacheConfig, benchSession);
2500
- // 6. d-attr bindings
3133
+ // 8. d-attr bindings
2501
3134
  bindAttrs(root, ctx, cleanups);
2502
- // 7. d-html bindings
3135
+ // 9. d-bind-* two-way bindings
3136
+ bindTwoWay(root, ctx, cleanups);
3137
+ // 10. d-html bindings
2503
3138
  bindHtml(root, ctx, cleanups);
2504
- // 8. Form fields — register fields with form instances
3139
+ // 11. Form fields — register fields with form instances
2505
3140
  bindField(root, ctx, cleanups);
2506
- // 9. Event bindings
3141
+ // 12. Event bindings
2507
3142
  bindEvents(root, ctx, events, cleanups);
2508
- // 10. d-when directive
3143
+ // 13. d-emit-* bindings (component template → parent)
3144
+ bindEmit(root, ctx, cleanups);
3145
+ // 14. d-when directive
2509
3146
  bindWhen(root, ctx, cleanups);
2510
- // 11. d-match directive
3147
+ // 15. d-match directive
2511
3148
  bindMatch(root, ctx, cleanups);
2512
- // 12. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
3149
+ // 16. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
2513
3150
  bindError(root, ctx, cleanups);
2514
3151
  bindFormError(root, ctx, cleanups);
2515
- // 13. d-if — must run last: elements are fully bound before conditional removal
3152
+ // 17. d-if — must run last: elements are fully bound before conditional removal
2516
3153
  bindIf(root, ctx, cleanups);
2517
3154
  });
2518
3155
  // Bindings complete: remove loading state and mark as ready.
@@ -2524,8 +3161,8 @@ export function bind(root, ctx, options = {}) {
2524
3161
  });
2525
3162
  }
2526
3163
  flushBindBenchSession(benchSession);
2527
- // Return dispose function
2528
- return () => {
3164
+ // Return BindHandle (callable dispose + ref accessors)
3165
+ const dispose = () => {
2529
3166
  // Run manual cleanups (event listeners)
2530
3167
  for (const cleanup of cleanups) {
2531
3168
  if (typeof cleanup === 'function') {
@@ -2540,6 +3177,7 @@ export function bind(root, ctx, options = {}) {
2540
3177
  }
2541
3178
  }
2542
3179
  cleanups.length = 0;
3180
+ refs.clear();
2543
3181
  // Dispose template scope (stops all effects)
2544
3182
  try {
2545
3183
  templateScope.dispose();
@@ -2550,6 +3188,15 @@ export function bind(root, ctx, options = {}) {
2550
3188
  }
2551
3189
  }
2552
3190
  };
3191
+ const handle = Object.assign(dispose, {
3192
+ getRef(name) {
3193
+ return refs.get(name) ?? null;
3194
+ },
3195
+ getRefs() {
3196
+ return Object.freeze(Object.fromEntries(refs));
3197
+ },
3198
+ });
3199
+ return handle;
2553
3200
  }
2554
3201
  // ============================================================================
2555
3202
  // Convenience: Auto-bind on DOMContentLoaded
@@ -2584,3 +3231,32 @@ export function autoBind(selector, ctx, options) {
2584
3231
  }
2585
3232
  });
2586
3233
  }
3234
+ export function mount(first, second, third) {
3235
+ // Overload 1: mount(selector, vm, options?)
3236
+ if (typeof first === 'string' && !isComponent(first)) {
3237
+ return bind(first, second, (third ?? {}));
3238
+ }
3239
+ // Overload 2: mount(component, target, props?)
3240
+ const component = first;
3241
+ const target = second;
3242
+ const props = third;
3243
+ const def = component.definition;
3244
+ const el = document.createElement(def.tag);
3245
+ if (props) {
3246
+ for (const key of Object.keys(props)) {
3247
+ el.setAttribute(`d-props-${camelToKebab(key)}`, key);
3248
+ }
3249
+ }
3250
+ target.appendChild(el);
3251
+ const parentCtx = {};
3252
+ if (props) {
3253
+ for (const [key, value] of Object.entries(props)) {
3254
+ parentCtx[key] = isSignal(value) ? value : signal(value);
3255
+ }
3256
+ }
3257
+ return bind(el, parentCtx, {
3258
+ components: { [def.tag]: component },
3259
+ _skipLifecycle: true,
3260
+ _internal: true,
3261
+ });
3262
+ }