dalila 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🐰 ✂️ Dalila
1
+ # 🐰✂️ Dalila
2
2
 
3
3
  **DOM-first reactivity without the re-renders.**
4
4
 
@@ -58,7 +58,7 @@ bind(document.getElementById('app')!, ctx);
58
58
 
59
59
  ### Runtime
60
60
 
61
- - [Template Binding](./docs/runtime/bind.md) — `bind()`, `mount()`, `configure()`, text interpolation, events
61
+ - [Template Binding](./docs/runtime/bind.md) — `bind()`, `mount()`, `configure()`, transitions, portal, text interpolation, events
62
62
  - [Components](./docs/runtime/component.md) — `defineComponent`, typed props/emits/refs, slots
63
63
  - [FOUC Prevention](./docs/runtime/fouc-prevention.md) — Automatic token hiding
64
64
 
@@ -108,7 +108,7 @@ Firefox extension workflows:
108
108
 
109
109
  ```
110
110
  dalila → signal, computed, effect, batch, ...
111
- dalila/runtime → bind(), mount(), configure(), defineComponent()
111
+ dalila/runtime → bind(), mount(), configure(), createPortalTarget(), defineComponent()
112
112
  dalila/context → createContext, provide, inject
113
113
  dalila/http → createHttpClient with XSRF protection
114
114
  ```
@@ -143,6 +143,23 @@ const dispose = mount('.app', { count: signal(0) });
143
143
  dispose();
144
144
  ```
145
145
 
146
+ ### Transitions and Portal
147
+
148
+ ```html
149
+ <div d-when="open" d-transition="fade">Panel</div>
150
+ <div d-portal="showModal ? '#modal-root' : null">Modal content</div>
151
+ ```
152
+
153
+ ```ts
154
+ import { configure, createPortalTarget } from 'dalila/runtime';
155
+
156
+ const modalTarget = createPortalTarget('modal-root');
157
+
158
+ configure({
159
+ transitions: [{ name: 'fade', duration: 250 }],
160
+ });
161
+ ```
162
+
146
163
  ### Scopes
147
164
 
148
165
  ```ts
package/dist/cli/check.js CHANGED
@@ -520,7 +520,7 @@ function extractTemplateIdentifiers(html) {
520
520
  i++;
521
521
  }
522
522
  // --- 2. Directive scanning (supports single and double quotes) ---
523
- const DIRECTIVE_RE = /\b(d-each|d-virtual-each|d-virtual-height|d-virtual-item-height|d-virtual-overscan|d-if|d-when|d-match|d-html|d-attr-[a-zA-Z][\w-]*|d-on-[a-zA-Z][\w-]*|d-form-error|d-form|d-array)\s*=\s*(['"])([\s\S]*?)\2/g;
523
+ const DIRECTIVE_RE = /\b(d-each|d-virtual-each|d-virtual-height|d-virtual-item-height|d-virtual-overscan|d-if|d-when|d-match|d-portal|d-html|d-attr-[a-zA-Z][\w-]*|d-bind-[a-zA-Z][\w-]*|d-on-[a-zA-Z][\w-]*|d-form-error|d-form|d-array)\s*=\s*(['"])([\s\S]*?)\2/g;
524
524
  DIRECTIVE_RE.lastIndex = 0;
525
525
  let match;
526
526
  while ((match = DIRECTIVE_RE.exec(html))) {
package/dist/cli/index.js CHANGED
@@ -9,7 +9,7 @@ const routeArgs = args.slice(2);
9
9
  const WATCH_DEBOUNCE_MS = 120;
10
10
  function showHelp() {
11
11
  console.log(`
12
- 🐰 ✂️ Dalila CLI
12
+ 🐰✂️ Dalila CLI
13
13
 
14
14
  Usage:
15
15
  dalila routes generate [options] Generate routes + manifest from app file structure
@@ -32,7 +32,7 @@ Examples:
32
32
  }
33
33
  function showRoutesHelp() {
34
34
  console.log(`
35
- 🐰 ✂️ Dalila CLI - Routes
35
+ 🐰✂️ Dalila CLI - Routes
36
36
 
37
37
  Usage:
38
38
  dalila routes generate [options] Generate routes + manifest from app file structure
@@ -52,7 +52,7 @@ Examples:
52
52
  }
53
53
  function showCheckHelp() {
54
54
  console.log(`
55
- 🐰 ✂️ Dalila CLI - Check
55
+ 🐰✂️ Dalila CLI - Check
56
56
 
57
57
  Usage:
58
58
  dalila check [path] [options] Static analysis of HTML templates
@@ -182,7 +182,7 @@ function resolveGenerateConfig(cliArgs, cwd = process.cwd()) {
182
182
  async function generateRoutes(cliArgs) {
183
183
  const { appDir, outputPath } = resolveGenerateConfig(cliArgs);
184
184
  console.log('');
185
- console.log('🐰 ✂️ Dalila Routes Generator');
185
+ console.log('🐰✂️ Dalila Routes Generator');
186
186
  console.log('');
187
187
  try {
188
188
  await generateRoutesFile(appDir, outputPath);
@@ -243,7 +243,7 @@ function watchRoutes(cliArgs) {
243
243
  process.exit(1);
244
244
  }
245
245
  console.log('');
246
- console.log('🐰 ✂️ Dalila Routes Watch');
246
+ console.log('🐰✂️ Dalila Routes Watch');
247
247
  console.log(` app: ${appDir}`);
248
248
  console.log(` output: ${outputPath}`);
249
249
  console.log('');
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
+ import { Signal } from '../core/index.js';
9
10
  import type { Component } from './component.js';
10
11
  export interface BindOptions {
11
12
  /**
@@ -30,6 +31,10 @@ export interface BindOptions {
30
31
  components?: Record<string, Component> | Component[];
31
32
  /** Error policy for component `ctx.onMount()` callbacks. Default: 'log'. */
32
33
  onMountError?: 'log' | 'throw';
34
+ /**
35
+ * Optional runtime transition registry used by `d-transition`.
36
+ */
37
+ transitions?: TransitionConfig[];
33
38
  /**
34
39
  * Internal flag — set by fromHtml for router/template rendering.
35
40
  * Skips HMR context registration but KEEPS d-ready/d-loading lifecycle.
@@ -59,6 +64,13 @@ export interface BindHandle {
59
64
  getRef(name: string): Element | null;
60
65
  getRefs(): Readonly<Record<string, Element>>;
61
66
  }
67
+ export interface TransitionConfig {
68
+ name: string;
69
+ enter?: (el: HTMLElement) => void;
70
+ leave?: (el: HTMLElement) => void;
71
+ duration?: number;
72
+ }
73
+ export declare function createPortalTarget(id: string): Signal<Element | null>;
62
74
  /**
63
75
  * Set global defaults for all `bind()` / `mount()` calls.
64
76
  *
@@ -97,6 +97,7 @@ function warn(message) {
97
97
  console.warn(`[Dalila] ${message}`);
98
98
  }
99
99
  }
100
+ const portalSyncByElement = new WeakMap();
100
101
  function describeBindRoot(root) {
101
102
  const explicit = root.getAttribute('data-component') ||
102
103
  root.getAttribute('data-devtools-label') ||
@@ -1135,13 +1136,231 @@ function bindEmit(root, ctx, cleanups) {
1135
1136
  }
1136
1137
  }
1137
1138
  }
1139
+ function createTransitionRegistry(transitions) {
1140
+ const registry = new Map();
1141
+ if (!transitions)
1142
+ return registry;
1143
+ for (const cfg of transitions) {
1144
+ if (!cfg || typeof cfg !== 'object')
1145
+ continue;
1146
+ const name = typeof cfg.name === 'string' ? cfg.name.trim() : '';
1147
+ if (!name) {
1148
+ warn('configure({ transitions }): each transition must have a non-empty "name"');
1149
+ continue;
1150
+ }
1151
+ registry.set(name, cfg);
1152
+ }
1153
+ return registry;
1154
+ }
1155
+ function readTransitionNames(el) {
1156
+ const raw = el.getAttribute('d-transition');
1157
+ if (!raw)
1158
+ return [];
1159
+ return raw
1160
+ .split(/\s+/)
1161
+ .map(v => v.trim())
1162
+ .filter(Boolean);
1163
+ }
1164
+ function parseCssTimeToMs(value) {
1165
+ const token = value.trim();
1166
+ if (!token)
1167
+ return 0;
1168
+ if (token.endsWith('ms')) {
1169
+ const ms = Number(token.slice(0, -2));
1170
+ return Number.isFinite(ms) ? Math.max(0, ms) : 0;
1171
+ }
1172
+ if (token.endsWith('s')) {
1173
+ const seconds = Number(token.slice(0, -1));
1174
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : 0;
1175
+ }
1176
+ const fallback = Number(token);
1177
+ return Number.isFinite(fallback) ? Math.max(0, fallback) : 0;
1178
+ }
1179
+ function getTransitionDurationMs(el, names, registry) {
1180
+ let durationFromRegistry = 0;
1181
+ for (const name of names) {
1182
+ const cfg = registry.get(name);
1183
+ if (!cfg)
1184
+ continue;
1185
+ if (typeof cfg.duration === 'number' && Number.isFinite(cfg.duration)) {
1186
+ durationFromRegistry = Math.max(durationFromRegistry, Math.max(0, cfg.duration));
1187
+ }
1188
+ }
1189
+ let durationFromCss = 0;
1190
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
1191
+ const style = window.getComputedStyle(el);
1192
+ const durations = style.transitionDuration.split(',');
1193
+ const delays = style.transitionDelay.split(',');
1194
+ const total = Math.max(durations.length, delays.length);
1195
+ for (let i = 0; i < total; i++) {
1196
+ const duration = parseCssTimeToMs(durations[Math.min(i, durations.length - 1)] ?? '0ms');
1197
+ const delay = parseCssTimeToMs(delays[Math.min(i, delays.length - 1)] ?? '0ms');
1198
+ durationFromCss = Math.max(durationFromCss, duration + delay);
1199
+ }
1200
+ }
1201
+ return Math.max(durationFromRegistry, durationFromCss);
1202
+ }
1203
+ function runTransitionHook(phase, el, names, registry) {
1204
+ for (const name of names) {
1205
+ const cfg = registry.get(name);
1206
+ const hook = phase === 'enter' ? cfg?.enter : cfg?.leave;
1207
+ if (typeof hook !== 'function')
1208
+ continue;
1209
+ try {
1210
+ hook(el);
1211
+ }
1212
+ catch (err) {
1213
+ warn(`d-transition (${name}): ${phase} hook failed (${err.message || String(err)})`);
1214
+ }
1215
+ }
1216
+ }
1217
+ function syncPortalElement(el) {
1218
+ const sync = portalSyncByElement.get(el);
1219
+ sync?.();
1220
+ }
1221
+ function createTransitionController(el, registry, cleanups) {
1222
+ const names = readTransitionNames(el);
1223
+ const hasTransition = names.length > 0;
1224
+ let token = 0;
1225
+ let timeoutId = null;
1226
+ const cancelPending = () => {
1227
+ token++;
1228
+ if (timeoutId != null) {
1229
+ clearTimeout(timeoutId);
1230
+ timeoutId = null;
1231
+ }
1232
+ };
1233
+ cleanups.push(cancelPending);
1234
+ const enter = () => {
1235
+ cancelPending();
1236
+ if (!hasTransition)
1237
+ return;
1238
+ el.removeAttribute('data-leave');
1239
+ el.setAttribute('data-enter', '');
1240
+ runTransitionHook('enter', el, names, registry);
1241
+ };
1242
+ const leave = (onDone) => {
1243
+ cancelPending();
1244
+ if (!hasTransition) {
1245
+ onDone();
1246
+ return;
1247
+ }
1248
+ const current = ++token;
1249
+ el.removeAttribute('data-enter');
1250
+ el.setAttribute('data-leave', '');
1251
+ runTransitionHook('leave', el, names, registry);
1252
+ const durationMs = getTransitionDurationMs(el, names, registry);
1253
+ if (durationMs <= 0) {
1254
+ if (current === token)
1255
+ onDone();
1256
+ return;
1257
+ }
1258
+ timeoutId = setTimeout(() => {
1259
+ timeoutId = null;
1260
+ if (current !== token)
1261
+ return;
1262
+ onDone();
1263
+ }, durationMs);
1264
+ };
1265
+ return { hasTransition, enter, leave };
1266
+ }
1267
+ function bindPortal(root, ctx, cleanups) {
1268
+ const elements = qsaIncludingRoot(root, '[d-portal]');
1269
+ for (const el of elements) {
1270
+ const rawExpression = el.getAttribute('d-portal')?.trim();
1271
+ if (!rawExpression)
1272
+ continue;
1273
+ let expressionAst = null;
1274
+ let fallbackSelector = null;
1275
+ try {
1276
+ expressionAst = parseExpression(rawExpression);
1277
+ }
1278
+ catch {
1279
+ // Allow selector shorthand: d-portal="#modal-root"
1280
+ fallbackSelector = rawExpression;
1281
+ }
1282
+ const htmlEl = el;
1283
+ const anchor = document.createComment('d-portal');
1284
+ htmlEl.parentNode?.insertBefore(anchor, htmlEl);
1285
+ const coerceTarget = (value) => {
1286
+ const resolved = resolve(value);
1287
+ if (resolved == null || resolved === false)
1288
+ return null;
1289
+ if (typeof resolved === 'string') {
1290
+ const selector = resolved.trim();
1291
+ if (!selector)
1292
+ return null;
1293
+ if (typeof document === 'undefined')
1294
+ return null;
1295
+ const target = document.querySelector(selector);
1296
+ if (!target) {
1297
+ warn(`d-portal: target "${selector}" not found`);
1298
+ return null;
1299
+ }
1300
+ return target;
1301
+ }
1302
+ if (typeof Element !== 'undefined' && resolved instanceof Element) {
1303
+ return resolved;
1304
+ }
1305
+ warn('d-portal: expression must resolve to selector string, Element, or null');
1306
+ return null;
1307
+ };
1308
+ const restoreToAnchor = () => {
1309
+ const hostParent = anchor.parentNode;
1310
+ if (!hostParent)
1311
+ return;
1312
+ if (htmlEl.parentNode === hostParent)
1313
+ return;
1314
+ const next = anchor.nextSibling;
1315
+ if (next)
1316
+ hostParent.insertBefore(htmlEl, next);
1317
+ else
1318
+ hostParent.appendChild(htmlEl);
1319
+ };
1320
+ const syncPortal = () => {
1321
+ let target = null;
1322
+ if (expressionAst) {
1323
+ const result = evalExpressionAst(expressionAst, ctx);
1324
+ if (!result.ok) {
1325
+ if (result.reason === 'missing_identifier') {
1326
+ warn(`d-portal: ${result.message}`);
1327
+ }
1328
+ else {
1329
+ warn(`d-portal: invalid expression "${rawExpression}"`);
1330
+ }
1331
+ target = null;
1332
+ }
1333
+ else {
1334
+ target = coerceTarget(result.value);
1335
+ }
1336
+ }
1337
+ else {
1338
+ target = coerceTarget(fallbackSelector);
1339
+ }
1340
+ if (!target) {
1341
+ restoreToAnchor();
1342
+ return;
1343
+ }
1344
+ if (htmlEl.parentNode !== target) {
1345
+ target.appendChild(htmlEl);
1346
+ }
1347
+ };
1348
+ portalSyncByElement.set(htmlEl, syncPortal);
1349
+ bindEffect(htmlEl, syncPortal);
1350
+ cleanups.push(() => {
1351
+ portalSyncByElement.delete(htmlEl);
1352
+ restoreToAnchor();
1353
+ anchor.remove();
1354
+ });
1355
+ }
1356
+ }
1138
1357
  // ============================================================================
1139
1358
  // d-when Directive
1140
1359
  // ============================================================================
1141
1360
  /**
1142
1361
  * Bind all [d-when] directives within root
1143
1362
  */
1144
- function bindWhen(root, ctx, cleanups) {
1363
+ function bindWhen(root, ctx, cleanups, transitionRegistry) {
1145
1364
  const elements = qsaIncludingRoot(root, '[when], [d-when]');
1146
1365
  for (const el of elements) {
1147
1366
  const attrName = el.hasAttribute('when') ? 'when' : 'd-when';
@@ -1154,13 +1373,32 @@ function bindWhen(root, ctx, cleanups) {
1154
1373
  continue;
1155
1374
  }
1156
1375
  const htmlEl = el;
1376
+ const transitions = createTransitionController(htmlEl, transitionRegistry, cleanups);
1157
1377
  // Apply initial state synchronously to avoid FOUC (flash of unstyled content)
1158
1378
  const initialValue = !!resolve(binding);
1159
- htmlEl.style.display = initialValue ? '' : 'none';
1379
+ if (initialValue) {
1380
+ htmlEl.style.display = '';
1381
+ if (transitions.hasTransition) {
1382
+ htmlEl.removeAttribute('data-leave');
1383
+ htmlEl.setAttribute('data-enter', '');
1384
+ }
1385
+ }
1386
+ else {
1387
+ htmlEl.style.display = 'none';
1388
+ htmlEl.removeAttribute('data-enter');
1389
+ htmlEl.removeAttribute('data-leave');
1390
+ }
1160
1391
  // Then create reactive effect to keep it updated
1161
1392
  bindEffect(htmlEl, () => {
1162
1393
  const value = !!resolve(binding);
1163
- htmlEl.style.display = value ? '' : 'none';
1394
+ if (value) {
1395
+ htmlEl.style.display = '';
1396
+ transitions.enter();
1397
+ return;
1398
+ }
1399
+ transitions.leave(() => {
1400
+ htmlEl.style.display = 'none';
1401
+ });
1164
1402
  });
1165
1403
  }
1166
1404
  }
@@ -1851,7 +2089,7 @@ function bindEach(root, ctx, cleanups) {
1851
2089
  * Unlike [d-when] which toggles display, d-if adds/removes the element from
1852
2090
  * the DOM entirely. A comment node is left as placeholder for insertion position.
1853
2091
  */
1854
- function bindIf(root, ctx, cleanups) {
2092
+ function bindIf(root, ctx, cleanups, transitionRegistry) {
1855
2093
  const elements = qsaIncludingRoot(root, '[d-if]');
1856
2094
  const processedElse = new Set();
1857
2095
  for (const el of elements) {
@@ -1869,23 +2107,36 @@ function bindIf(root, ctx, cleanups) {
1869
2107
  el.parentNode?.replaceChild(comment, el);
1870
2108
  el.removeAttribute('d-if');
1871
2109
  const htmlEl = el;
2110
+ const transitions = createTransitionController(htmlEl, transitionRegistry, cleanups);
1872
2111
  // Handle d-else branch
1873
2112
  let elseHtmlEl = null;
1874
2113
  let elseComment = null;
2114
+ let elseTransitions = null;
1875
2115
  if (elseEl) {
1876
2116
  processedElse.add(elseEl);
1877
2117
  elseComment = document.createComment('d-else');
1878
2118
  elseEl.parentNode?.replaceChild(elseComment, elseEl);
1879
2119
  elseEl.removeAttribute('d-else');
1880
2120
  elseHtmlEl = elseEl;
2121
+ elseTransitions = createTransitionController(elseHtmlEl, transitionRegistry, cleanups);
1881
2122
  }
1882
2123
  // Apply initial state synchronously to avoid FOUC
1883
2124
  const initialValue = !!resolve(binding);
1884
2125
  if (initialValue) {
1885
2126
  comment.parentNode?.insertBefore(htmlEl, comment);
2127
+ syncPortalElement(htmlEl);
2128
+ if (transitions.hasTransition) {
2129
+ htmlEl.removeAttribute('data-leave');
2130
+ htmlEl.setAttribute('data-enter', '');
2131
+ }
1886
2132
  }
1887
2133
  else if (elseHtmlEl && elseComment) {
1888
2134
  elseComment.parentNode?.insertBefore(elseHtmlEl, elseComment);
2135
+ syncPortalElement(elseHtmlEl);
2136
+ if (elseTransitions?.hasTransition) {
2137
+ elseHtmlEl.removeAttribute('data-leave');
2138
+ elseHtmlEl.setAttribute('data-enter', '');
2139
+ }
1889
2140
  }
1890
2141
  // Then create reactive effect to keep it updated
1891
2142
  if (elseHtmlEl && elseComment) {
@@ -1896,18 +2147,26 @@ function bindIf(root, ctx, cleanups) {
1896
2147
  if (value) {
1897
2148
  if (!htmlEl.parentNode) {
1898
2149
  comment.parentNode?.insertBefore(htmlEl, comment);
2150
+ syncPortalElement(htmlEl);
1899
2151
  }
1900
- if (capturedElseEl.parentNode) {
1901
- capturedElseEl.parentNode.removeChild(capturedElseEl);
1902
- }
2152
+ transitions.enter();
2153
+ elseTransitions?.leave(() => {
2154
+ if (capturedElseEl.parentNode) {
2155
+ capturedElseEl.parentNode.removeChild(capturedElseEl);
2156
+ }
2157
+ });
1903
2158
  }
1904
2159
  else {
1905
- if (htmlEl.parentNode) {
1906
- htmlEl.parentNode.removeChild(htmlEl);
1907
- }
2160
+ transitions.leave(() => {
2161
+ if (htmlEl.parentNode) {
2162
+ htmlEl.parentNode.removeChild(htmlEl);
2163
+ }
2164
+ });
1908
2165
  if (!capturedElseEl.parentNode) {
1909
2166
  capturedElseComment.parentNode?.insertBefore(capturedElseEl, capturedElseComment);
2167
+ syncPortalElement(capturedElseEl);
1910
2168
  }
2169
+ elseTransitions?.enter();
1911
2170
  }
1912
2171
  });
1913
2172
  }
@@ -1917,12 +2176,16 @@ function bindIf(root, ctx, cleanups) {
1917
2176
  if (value) {
1918
2177
  if (!htmlEl.parentNode) {
1919
2178
  comment.parentNode?.insertBefore(htmlEl, comment);
2179
+ syncPortalElement(htmlEl);
1920
2180
  }
2181
+ transitions.enter();
1921
2182
  }
1922
2183
  else {
1923
- if (htmlEl.parentNode) {
1924
- htmlEl.parentNode.removeChild(htmlEl);
1925
- }
2184
+ transitions.leave(() => {
2185
+ if (htmlEl.parentNode) {
2186
+ htmlEl.parentNode.removeChild(htmlEl);
2187
+ }
2188
+ });
1926
2189
  }
1927
2190
  });
1928
2191
  }
@@ -2068,14 +2331,76 @@ function bindAttrs(root, ctx, cleanups) {
2068
2331
  // d-bind-* Directive (Two-way Binding)
2069
2332
  // ============================================================================
2070
2333
  /**
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.
2334
+ * Bind all [d-bind-*] directives within root.
2335
+ * Two-way bindings:
2336
+ * - d-bind-value
2337
+ * - d-bind-checked
2338
+ *
2339
+ * One-way reactive property bindings:
2340
+ * - d-bind-readonly
2341
+ * - d-bind-disabled
2342
+ * - d-bind-maxlength
2343
+ * - d-bind-placeholder
2344
+ * - d-bind-pattern
2345
+ * - d-bind-multiple
2346
+ *
2347
+ * Optional transform/parse hooks:
2348
+ * - d-bind-transform="fnName" (signal -> view)
2349
+ * - d-bind-parse="fnName" (view -> signal)
2074
2350
  */
2075
2351
  function bindTwoWay(root, ctx, cleanups) {
2076
- const SUPPORTED = ['value', 'checked'];
2077
- for (const prop of SUPPORTED) {
2078
- const attr = `d-bind-${prop}`;
2352
+ const SUPPORTED = [
2353
+ { attr: 'd-bind-value', prop: 'value', twoWay: true },
2354
+ { attr: 'd-bind-checked', prop: 'checked', twoWay: true },
2355
+ { attr: 'd-bind-readonly', prop: 'readOnly', twoWay: false },
2356
+ { attr: 'd-bind-disabled', prop: 'disabled', twoWay: false },
2357
+ { attr: 'd-bind-maxlength', prop: 'maxLength', twoWay: false },
2358
+ { attr: 'd-bind-placeholder', prop: 'placeholder', twoWay: false },
2359
+ { attr: 'd-bind-pattern', prop: 'pattern', twoWay: false },
2360
+ { attr: 'd-bind-multiple', prop: 'multiple', twoWay: false },
2361
+ ];
2362
+ const BOOLEAN_PROPS = new Set(['checked', 'readOnly', 'disabled', 'multiple']);
2363
+ const STRING_PROPS = new Set(['value', 'placeholder', 'pattern']);
2364
+ const applyBoundProp = (el, prop, value) => {
2365
+ if (!(prop in el))
2366
+ return;
2367
+ if (BOOLEAN_PROPS.has(prop)) {
2368
+ el[prop] = !!value;
2369
+ return;
2370
+ }
2371
+ if (STRING_PROPS.has(prop)) {
2372
+ el[prop] = value == null ? '' : String(value);
2373
+ return;
2374
+ }
2375
+ if (prop === 'maxLength') {
2376
+ if (value == null || value === '') {
2377
+ el.maxLength = -1;
2378
+ return;
2379
+ }
2380
+ const parsed = Number(value);
2381
+ el.maxLength = Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : -1;
2382
+ return;
2383
+ }
2384
+ el[prop] = value;
2385
+ };
2386
+ const resolveFunctionBinding = (el, attrName) => {
2387
+ const fnBindingName = normalizeBinding(el.getAttribute(attrName));
2388
+ if (!fnBindingName)
2389
+ return null;
2390
+ const fnBinding = ctx[fnBindingName];
2391
+ if (fnBinding === undefined) {
2392
+ warn(`${attrName}: "${fnBindingName}" not found in context`);
2393
+ return null;
2394
+ }
2395
+ const resolved = isSignal(fnBinding) ? fnBinding() : fnBinding;
2396
+ if (typeof resolved !== 'function') {
2397
+ warn(`${attrName}: "${fnBindingName}" must be a function (or signal-of-function)`);
2398
+ return null;
2399
+ }
2400
+ return resolved;
2401
+ };
2402
+ for (const directive of SUPPORTED) {
2403
+ const attr = directive.attr;
2079
2404
  const elements = qsaIncludingRoot(root, `[${attr}]`);
2080
2405
  for (const el of elements) {
2081
2406
  const bindingName = normalizeBinding(el.getAttribute(attr));
@@ -2083,31 +2408,55 @@ function bindTwoWay(root, ctx, cleanups) {
2083
2408
  continue;
2084
2409
  const binding = ctx[bindingName];
2085
2410
  if (!isSignal(binding)) {
2086
- warn(`d-bind-${prop}: "${bindingName}" must be a signal`);
2411
+ warn(`${attr}: "${bindingName}" must be a signal`);
2087
2412
  continue;
2088
2413
  }
2089
2414
  const writable = isWritableSignal(binding);
2090
- if (!writable) {
2091
- warn(`d-bind-${prop}: "${bindingName}" is read-only (inbound updates disabled)`);
2415
+ if (directive.twoWay && !writable) {
2416
+ warn(`${attr}: "${bindingName}" is read-only (inbound updates disabled)`);
2092
2417
  }
2093
2418
  el.removeAttribute(attr);
2419
+ const transformFn = resolveFunctionBinding(el, 'd-bind-transform');
2420
+ const parseFn = resolveFunctionBinding(el, 'd-bind-parse');
2421
+ if (directive.twoWay) {
2422
+ el.removeAttribute('d-bind-transform');
2423
+ el.removeAttribute('d-bind-parse');
2424
+ }
2094
2425
  // Outbound: signal → DOM
2095
- const isBoolean = prop === 'checked';
2096
2426
  bindEffect(el, () => {
2097
- const val = binding();
2098
- if (isBoolean) {
2099
- el[prop] = !!val;
2100
- }
2101
- else {
2102
- el[prop] = val == null ? '' : String(val);
2427
+ const rawValue = binding();
2428
+ let value = rawValue;
2429
+ if (transformFn) {
2430
+ try {
2431
+ value = transformFn(rawValue, el);
2432
+ }
2433
+ catch (err) {
2434
+ warn(`d-bind-transform: "${bindingName}" failed (${err.message || String(err)})`);
2435
+ value = rawValue;
2436
+ }
2103
2437
  }
2438
+ applyBoundProp(el, directive.prop, value);
2104
2439
  });
2105
2440
  // Inbound: DOM → signal
2106
- if (writable) {
2107
- const eventName = el.tagName === 'SELECT' || isBoolean ? 'change' : 'input';
2441
+ if (directive.twoWay && writable) {
2442
+ const eventName = el.tagName === 'SELECT' || directive.prop === 'checked'
2443
+ ? 'change'
2444
+ : 'input';
2108
2445
  const handler = () => {
2109
- const val = isBoolean ? el.checked : el.value;
2110
- binding.set(val);
2446
+ const rawValue = directive.prop === 'checked'
2447
+ ? el.checked
2448
+ : el.value;
2449
+ let nextValue = rawValue;
2450
+ if (parseFn) {
2451
+ try {
2452
+ nextValue = parseFn(rawValue, el);
2453
+ }
2454
+ catch (err) {
2455
+ warn(`d-bind-parse: "${bindingName}" failed (${err.message || String(err)})`);
2456
+ nextValue = rawValue;
2457
+ }
2458
+ }
2459
+ binding.set(nextValue);
2111
2460
  };
2112
2461
  el.addEventListener(eventName, handler);
2113
2462
  cleanups.push(() => el.removeEventListener(eventName, handler));
@@ -2978,6 +3327,20 @@ function bindComponents(root, ctx, events, cleanups, onMountError) {
2978
3327
  // Global Configuration
2979
3328
  // ============================================================================
2980
3329
  let globalConfig = {};
3330
+ export function createPortalTarget(id) {
3331
+ const targetSignal = signal(null);
3332
+ if (typeof document === 'undefined') {
3333
+ return targetSignal;
3334
+ }
3335
+ let target = document.getElementById(id);
3336
+ if (!target) {
3337
+ target = document.createElement('div');
3338
+ target.id = id;
3339
+ document.body.appendChild(target);
3340
+ }
3341
+ targetSignal.set(target);
3342
+ return targetSignal;
3343
+ }
2981
3344
  /**
2982
3345
  * Set global defaults for all `bind()` / `mount()` calls.
2983
3346
  *
@@ -3030,8 +3393,8 @@ export function configure(config) {
3030
3393
  export function bind(root, ctx, options = {}) {
3031
3394
  // ── Merge global config with per-call options ──
3032
3395
  if (Object.keys(globalConfig).length > 0) {
3033
- const { components: globalComponents, ...globalRest } = globalConfig;
3034
- const { components: localComponents, ...localRest } = options;
3396
+ const { components: globalComponents, transitions: globalTransitions, ...globalRest } = globalConfig;
3397
+ const { components: localComponents, transitions: localTransitions, ...localRest } = options;
3035
3398
  const mergedOpts = { ...globalRest, ...localRest };
3036
3399
  // Combine component registries: local takes precedence over global
3037
3400
  if (globalComponents || localComponents) {
@@ -3056,6 +3419,20 @@ export function bind(root, ctx, options = {}) {
3056
3419
  mergeComponents(localComponents); // local wins
3057
3420
  mergedOpts.components = combined;
3058
3421
  }
3422
+ if (globalTransitions || localTransitions) {
3423
+ const byName = new Map();
3424
+ for (const item of globalTransitions ?? []) {
3425
+ if (!item || typeof item.name !== 'string')
3426
+ continue;
3427
+ byName.set(item.name, item);
3428
+ }
3429
+ for (const item of localTransitions ?? []) {
3430
+ if (!item || typeof item.name !== 'string')
3431
+ continue;
3432
+ byName.set(item.name, item);
3433
+ }
3434
+ mergedOpts.transitions = Array.from(byName.values());
3435
+ }
3059
3436
  options = mergedOpts;
3060
3437
  }
3061
3438
  // ── Resolve string selector ──
@@ -3100,6 +3477,7 @@ export function bind(root, ctx, options = {}) {
3100
3477
  const onMountError = options.onMountError ?? 'log';
3101
3478
  const rawTextSelectors = options.rawTextSelectors ?? DEFAULT_RAW_TEXT_SELECTORS;
3102
3479
  const templatePlanCacheConfig = resolveTemplatePlanCacheConfig(options);
3480
+ const transitionRegistry = createTransitionRegistry(options.transitions);
3103
3481
  const benchSession = createBindBenchSession();
3104
3482
  const htmlRoot = root;
3105
3483
  // HMR support: Register binding context globally in dev mode.
@@ -3143,14 +3521,16 @@ export function bind(root, ctx, options = {}) {
3143
3521
  // 13. d-emit-* bindings (component template → parent)
3144
3522
  bindEmit(root, ctx, cleanups);
3145
3523
  // 14. d-when directive
3146
- bindWhen(root, ctx, cleanups);
3524
+ bindWhen(root, ctx, cleanups, transitionRegistry);
3147
3525
  // 15. d-match directive
3148
3526
  bindMatch(root, ctx, cleanups);
3149
3527
  // 16. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
3150
3528
  bindError(root, ctx, cleanups);
3151
3529
  bindFormError(root, ctx, cleanups);
3152
- // 17. d-ifmust run last: elements are fully bound before conditional removal
3153
- bindIf(root, ctx, cleanups);
3530
+ // 17. d-portalmove already-bound elements to external targets
3531
+ bindPortal(root, ctx, cleanups);
3532
+ // 18. d-if — must run last: elements are fully bound before conditional removal
3533
+ bindIf(root, ctx, cleanups, transitionRegistry);
3154
3534
  });
3155
3535
  // Bindings complete: remove loading state and mark as ready.
3156
3536
  // Only the top-level bind owns this lifecycle — d-each clones skip it.
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
- export { bind, autoBind, mount, configure } from './bind.js';
10
- export type { BindOptions, BindContext, BindData, DisposeFunction, BindHandle } from './bind.js';
9
+ export { bind, autoBind, mount, configure, createPortalTarget } from './bind.js';
10
+ export type { BindOptions, BindContext, BindData, DisposeFunction, BindHandle, TransitionConfig } from './bind.js';
11
11
  export { fromHtml } from './fromHtml.js';
12
12
  export type { FromHtmlOptions } from './fromHtml.js';
13
13
  export { defineComponent } from './component.js';
@@ -6,6 +6,6 @@
6
6
  *
7
7
  * @module dalila/runtime
8
8
  */
9
- export { bind, autoBind, mount, configure } from './bind.js';
9
+ export { bind, autoBind, mount, configure, createPortalTarget } from './bind.js';
10
10
  export { fromHtml } from './fromHtml.js';
11
11
  export { defineComponent } from './component.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1047,7 +1047,7 @@ startKeepalive();
1047
1047
 
1048
1048
  server.listen(port, () => {
1049
1049
  console.log('');
1050
- console.log(' 🐰 ✂️ Dalila dev server');
1050
+ console.log(' 🐰✂️ Dalila dev server');
1051
1051
  console.log(` http://localhost:${port}`);
1052
1052
  console.log('');
1053
1053
  });