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 +20 -3
- package/dist/cli/check.js +1 -1
- package/dist/cli/index.js +5 -5
- package/dist/runtime/bind.d.ts +12 -0
- package/dist/runtime/bind.js +418 -38
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +1 -1
- package/package.json +1 -1
- package/scripts/dev-server.cjs +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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('
|
|
246
|
+
console.log('🐰✂️ Dalila Routes Watch');
|
|
247
247
|
console.log(` app: ${appDir}`);
|
|
248
248
|
console.log(` output: ${outputPath}`);
|
|
249
249
|
console.log('');
|
package/dist/runtime/bind.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/runtime/bind.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2152
|
+
transitions.enter();
|
|
2153
|
+
elseTransitions?.leave(() => {
|
|
2154
|
+
if (capturedElseEl.parentNode) {
|
|
2155
|
+
capturedElseEl.parentNode.removeChild(capturedElseEl);
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
1903
2158
|
}
|
|
1904
2159
|
else {
|
|
1905
|
-
|
|
1906
|
-
htmlEl.parentNode
|
|
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
|
-
|
|
1924
|
-
htmlEl.parentNode
|
|
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
|
|
2072
|
-
* Two-way
|
|
2073
|
-
*
|
|
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 = [
|
|
2077
|
-
|
|
2078
|
-
|
|
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(
|
|
2411
|
+
warn(`${attr}: "${bindingName}" must be a signal`);
|
|
2087
2412
|
continue;
|
|
2088
2413
|
}
|
|
2089
2414
|
const writable = isWritableSignal(binding);
|
|
2090
|
-
if (!writable) {
|
|
2091
|
-
warn(
|
|
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
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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' ||
|
|
2441
|
+
if (directive.twoWay && writable) {
|
|
2442
|
+
const eventName = el.tagName === 'SELECT' || directive.prop === 'checked'
|
|
2443
|
+
? 'change'
|
|
2444
|
+
: 'input';
|
|
2108
2445
|
const handler = () => {
|
|
2109
|
-
const
|
|
2110
|
-
|
|
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-
|
|
3153
|
-
|
|
3530
|
+
// 17. d-portal — move 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.
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
package/scripts/dev-server.cjs
CHANGED
|
@@ -1047,7 +1047,7 @@ startKeepalive();
|
|
|
1047
1047
|
|
|
1048
1048
|
server.listen(port, () => {
|
|
1049
1049
|
console.log('');
|
|
1050
|
-
console.log('
|
|
1050
|
+
console.log(' 🐰✂️ Dalila dev server');
|
|
1051
1051
|
console.log(` http://localhost:${port}`);
|
|
1052
1052
|
console.log('');
|
|
1053
1053
|
});
|