webspresso 0.0.112 → 0.0.113

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.
@@ -24,7 +24,7 @@
24
24
  * external package is not installed in `node_modules`.
25
25
  */
26
26
 
27
- const { isArray, getByPath, setByPath } = require('../utils');
27
+ const { isArray, getByPath, setByPath, parseDelayMs } = require('../utils');
28
28
 
29
29
  const PluginType = {
30
30
  Attribute: 1,
@@ -703,20 +703,122 @@ const CLIENT_OWNED_TEXT_ATTR = 'data-w-text';
703
703
  const CLIENT_OWNED_SHOW_ATTR = 'data-w-show';
704
704
  const CLIENT_OWNED_DISABLED_ATTR = 'data-w-disabled';
705
705
  const CLIENT_OWNED_MODEL_ATTR = 'data-w-model';
706
+ const CLIENT_OWNED_CLASS_ATTR = 'data-w-class';
707
+
708
+ function hasClassBindingAttr(el) {
709
+ if (!el || !el.attributes) return false;
710
+ if (el.hasAttribute && el.hasAttribute(CLIENT_OWNED_CLASS_ATTR)) return true;
711
+ for (let i = 0; i < el.attributes.length; i++) {
712
+ if (el.attributes[i].name.startsWith('data-w-class:')) return true;
713
+ }
714
+ return false;
715
+ }
706
716
 
707
717
  /**
708
718
  * Determine whether a node has client bindings owning specific DOM properties.
709
719
  */
710
720
  function getClientOwnership(el) {
711
721
  if (!el || typeof el.hasAttribute !== 'function') {
712
- return { text: false, show: false, disabled: false, model: false };
722
+ return { text: false, show: false, disabled: false, model: false, classBinding: false };
713
723
  }
714
724
  return {
715
725
  text: el.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
716
726
  show: el.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
717
727
  disabled: el.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
718
728
  model: el.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
729
+ classBinding: hasClassBindingAttr(el),
730
+ };
731
+ }
732
+
733
+ /**
734
+ * Determine ignore mode for third-party widget DOM ownership (`data-w-ignore`).
735
+ * - `'children'` (default `data-w-ignore` or `data-w-ignore="children"`): sync root attributes, never morph inner DOM.
736
+ * - `'self'` (`data-w-ignore="self"`): never sync root attributes, morph inner DOM.
737
+ * - `'all'` (`data-w-ignore="all"`): never sync root attributes AND never morph inner DOM.
738
+ * Note: Parent removal/key-change STILL removes the node and triggers `onBeforeNodeRemove` cleanup.
739
+ */
740
+ function getIgnoreMode(el) {
741
+ if (!el || typeof el.hasAttribute !== 'function' || !el.hasAttribute('data-w-ignore')) {
742
+ return null;
743
+ }
744
+ const raw = (el.getAttribute('data-w-ignore') || '').trim().toLowerCase();
745
+ if (raw === 'self') return 'self';
746
+ if (raw === 'all' || raw === 'both') return 'all';
747
+ return 'children';
748
+ }
749
+
750
+ function emitOwnershipConflict(debug, el, property, token, message) {
751
+ const isDev = typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production';
752
+ if (!debug && !isDev) return;
753
+ const tag = el && el.tagName ? el.tagName.toLowerCase() : 'element';
754
+ const idPart = el && el.id ? `#${el.id}` : '';
755
+ const payload = {
756
+ element: `${tag}${idPart}`,
757
+ property,
758
+ token: token || null,
759
+ message,
719
760
  };
761
+ if (debug && typeof debug.recordEvent === 'function') {
762
+ debug.recordEvent('ownership:conflict', payload);
763
+ } else if (isDev && typeof console !== 'undefined' && typeof console.warn === 'function') {
764
+ console.warn(`[webspresso:ownership] ${message}`, payload);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Sync `class` attribute from `fromEl` onto `toEl`, preserving client-owned class tokens
770
+ * managed by `data-w-class`, `data-w-class:*`, and `data-w-loading-class`.
771
+ */
772
+ function syncClassAttribute(toEl, fromEl, debug) {
773
+ const ownedClassState = toEl.__wOwnedClassState;
774
+ const fromRaw = fromEl.getAttribute('class');
775
+
776
+ if (!ownedClassState || ownedClassState.size === 0) {
777
+ if (fromRaw === null) {
778
+ if (toEl.hasAttribute('class')) toEl.removeAttribute('class');
779
+ } else if (toEl.getAttribute('class') !== fromRaw) {
780
+ toEl.setAttribute('class', fromRaw);
781
+ }
782
+ return;
783
+ }
784
+
785
+ const serverTokens = (fromRaw || '').trim().split(/\s+/).filter(Boolean);
786
+ const merged = [];
787
+ const seen = new Set();
788
+
789
+ for (let i = 0; i < serverTokens.length; i++) {
790
+ const tok = serverTokens[i];
791
+ if (ownedClassState.has(tok)) {
792
+ emitOwnershipConflict(
793
+ debug,
794
+ toEl,
795
+ 'class',
796
+ tok,
797
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server class attribute and client data-w-class manage token "${tok}".`
798
+ );
799
+ continue;
800
+ }
801
+ if (!seen.has(tok)) {
802
+ seen.add(tok);
803
+ merged.push(tok);
804
+ }
805
+ }
806
+
807
+ for (const [tok, active] of ownedClassState.entries()) {
808
+ if (active && !seen.has(tok)) {
809
+ seen.add(tok);
810
+ merged.push(tok);
811
+ }
812
+ }
813
+
814
+ const nextClass = merged.join(' ');
815
+ if (nextClass) {
816
+ if (toEl.getAttribute('class') !== nextClass) {
817
+ toEl.setAttribute('class', nextClass);
818
+ }
819
+ } else if (toEl.hasAttribute('class')) {
820
+ toEl.removeAttribute('class');
821
+ }
720
822
  }
721
823
 
722
824
  /**
@@ -775,16 +877,62 @@ function getMorphNodeKey(node) {
775
877
  /**
776
878
  * Sync attributes from `fromEl` onto `toEl` while respecting client-owned properties
777
879
  * and preserving active focus / form draft states.
880
+ * When a client binding attribute (`data-w-show`, `data-w-disabled`, `data-w-class`, etc.)
881
+ * is removed in `fromEl`, ownership is immediately released so server HTML can reclaim it.
778
882
  */
779
- function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false) {
883
+ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false, debug = null) {
780
884
  const fromAttrs = fromEl.attributes;
781
885
  const toAttrs = Array.from(toEl.attributes);
782
886
 
887
+ const effectiveOwnership = {
888
+ text: ownership.text && fromEl.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
889
+ show: ownership.show && fromEl.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
890
+ disabled: ownership.disabled && fromEl.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
891
+ model: ownership.model && fromEl.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
892
+ classBinding: ownership.classBinding && hasClassBindingAttr(fromEl),
893
+ };
894
+
895
+ if (ownership.classBinding && !effectiveOwnership.classBinding && toEl.__wOwnedClassState) {
896
+ toEl.__wOwnedClassState.clear();
897
+ delete toEl.__wOwnedClassState;
898
+ }
899
+
900
+ const wasHiddenByShow = effectiveOwnership.show && toEl.hasAttribute('hidden');
901
+
902
+ if (effectiveOwnership.show && fromEl.hasAttribute('hidden')) {
903
+ emitOwnershipConflict(
904
+ debug,
905
+ toEl,
906
+ 'hidden',
907
+ null,
908
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "hidden" attribute and client data-w-show manage visibility.`
909
+ );
910
+ }
911
+ if (effectiveOwnership.disabled && fromEl.hasAttribute('disabled')) {
912
+ emitOwnershipConflict(
913
+ debug,
914
+ toEl,
915
+ 'disabled',
916
+ null,
917
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "disabled" attribute and client data-w-disabled manage disabled state.`
918
+ );
919
+ }
920
+ if (effectiveOwnership.model && fromEl.hasAttribute('value') && fromEl.getAttribute('value') !== '') {
921
+ emitOwnershipConflict(
922
+ debug,
923
+ toEl,
924
+ 'value',
925
+ null,
926
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "value" attribute and client data-w-model manage input value.`
927
+ );
928
+ }
929
+
783
930
  // Remove attributes no longer present on server HTML, except client-managed runtime state
784
931
  for (let i = 0; i < toAttrs.length; i++) {
785
932
  const attrName = toAttrs[i].name;
786
- if (ownership.disabled && attrName === 'disabled') continue;
787
- if (ownership.show && attrName === 'hidden') continue;
933
+ if (attrName === 'class') continue; // Handled by syncClassAttribute
934
+ if (effectiveOwnership.disabled && (attrName === 'disabled' || attrName === 'aria-disabled')) continue;
935
+ if (effectiveOwnership.show && attrName === 'hidden') continue;
788
936
  if (attrName.startsWith('data-w-bound-')) continue;
789
937
  if (
790
938
  attrName === 'data-w-fragment-input' ||
@@ -798,11 +946,17 @@ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false)
798
946
  }
799
947
  }
800
948
 
949
+ // If `data-w-show` was removed and server does not set `style`, clear inline `display: none`
950
+ if (ownership.show && !effectiveOwnership.show && !fromEl.hasAttribute('style') && toEl.style && toEl.style.display === 'none') {
951
+ toEl.style.display = '';
952
+ }
953
+
801
954
  // Set updated attributes from server HTML
802
955
  for (let i = 0; i < fromAttrs.length; i++) {
803
956
  const attr = fromAttrs[i];
804
- if (ownership.disabled && attr.name === 'disabled') continue;
805
- if (ownership.show && attr.name === 'hidden') continue;
957
+ if (attr.name === 'class') continue; // Handled by syncClassAttribute
958
+ if (effectiveOwnership.disabled && (attr.name === 'disabled' || attr.name === 'aria-disabled')) continue;
959
+ if (effectiveOwnership.show && attr.name === 'hidden') continue;
806
960
  if (attr.name === 'action' && toEl.tagName === 'FORM' && (toEl.__wCustomAction || insideDirtyForm)) {
807
961
  continue;
808
962
  }
@@ -810,6 +964,16 @@ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false)
810
964
  toEl.setAttribute(attr.name, attr.value);
811
965
  }
812
966
  }
967
+
968
+ // Sync `class` preserving client-owned class tokens
969
+ syncClassAttribute(toEl, fromEl, debug);
970
+
971
+ // Restore `style.display = 'none'` if server `style` overwrote it while `data-w-show` hides the element
972
+ if (wasHiddenByShow && toEl.style && toEl.style.display !== 'none') {
973
+ toEl.style.display = 'none';
974
+ }
975
+
976
+ return effectiveOwnership;
813
977
  }
814
978
 
815
979
  /**
@@ -862,11 +1026,19 @@ function syncFormControlValue(toEl, fromEl, ownership, preserveDraft) {
862
1026
  function morphDomTrees(toEl, fromEl, ctx = {}) {
863
1027
  if (!toEl || !fromEl) return toEl;
864
1028
 
865
- const ownership = getClientOwnership(toEl);
1029
+ const rawOwnership = getClientOwnership(toEl);
1030
+ const ignoreMode = getIgnoreMode(toEl);
866
1031
  const isForm = toEl.tagName === 'FORM';
867
1032
  const insideDirtyForm = Boolean(ctx.insideDirtyForm || (isForm && isFormDirtyOrPreserved(toEl)));
868
1033
 
869
- syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm);
1034
+ const ownership =
1035
+ ignoreMode === 'self' || ignoreMode === 'all'
1036
+ ? rawOwnership
1037
+ : syncElementAttributes(toEl, fromEl, rawOwnership, insideDirtyForm, ctx.debug);
1038
+
1039
+ if (ignoreMode === 'children' || ignoreMode === 'all') {
1040
+ return toEl;
1041
+ }
870
1042
 
871
1043
  if (toEl.tagName === 'INPUT' || toEl.tagName === 'TEXTAREA' || toEl.tagName === 'SELECT') {
872
1044
  syncFormControlValue(toEl, fromEl, ownership, insideDirtyForm);
@@ -1171,6 +1343,167 @@ function createDatastarAdapter(options = {}) {
1171
1343
  }
1172
1344
  }
1173
1345
 
1346
+ /**
1347
+ * Parse `{ 'is-active': expr1, isOpen: expr2 }` into `Array<{ classes: string[], expr: string }>`.
1348
+ */
1349
+ function parseClassMapExpression(rawExpr) {
1350
+ const trimmed = (rawExpr || '').trim();
1351
+ if (!trimmed) return [];
1352
+ const inner = trimmed.startsWith('{') && trimmed.endsWith('}')
1353
+ ? trimmed.slice(1, -1).trim()
1354
+ : trimmed;
1355
+ if (!inner) return [];
1356
+
1357
+ const entries = [];
1358
+ let current = '';
1359
+ let parenDepth = 0;
1360
+ let bracketDepth = 0;
1361
+ let inQuote = null;
1362
+
1363
+ for (let i = 0; i < inner.length; i++) {
1364
+ const ch = inner[i];
1365
+ if (inQuote) {
1366
+ current += ch;
1367
+ if (ch === inQuote && inner[i - 1] !== '\\') inQuote = null;
1368
+ continue;
1369
+ }
1370
+ if (ch === '"' || ch === "'") {
1371
+ inQuote = ch;
1372
+ current += ch;
1373
+ continue;
1374
+ }
1375
+ if (ch === '(') parenDepth++;
1376
+ else if (ch === ')' && parenDepth > 0) parenDepth--;
1377
+ else if (ch === '[') bracketDepth++;
1378
+ else if (ch === ']' && bracketDepth > 0) bracketDepth--;
1379
+
1380
+ if (ch === ',' && parenDepth === 0 && bracketDepth === 0) {
1381
+ if (current.trim()) entries.push(current.trim());
1382
+ current = '';
1383
+ } else {
1384
+ current += ch;
1385
+ }
1386
+ }
1387
+ if (current.trim()) entries.push(current.trim());
1388
+
1389
+ const result = [];
1390
+ for (const entry of entries) {
1391
+ let colonIdx = -1;
1392
+ let q = null;
1393
+ for (let i = 0; i < entry.length; i++) {
1394
+ const ch = entry[i];
1395
+ if (q) {
1396
+ if (ch === q && entry[i - 1] !== '\\') q = null;
1397
+ continue;
1398
+ }
1399
+ if (ch === '"' || ch === "'") {
1400
+ q = ch;
1401
+ continue;
1402
+ }
1403
+ if (ch === ':') {
1404
+ colonIdx = i;
1405
+ break;
1406
+ }
1407
+ }
1408
+ if (colonIdx <= 0) continue;
1409
+ let rawKey = entry.slice(0, colonIdx).trim();
1410
+ const expr = entry.slice(colonIdx + 1).trim();
1411
+ if ((rawKey.startsWith("'") && rawKey.endsWith("'")) || (rawKey.startsWith('"') && rawKey.endsWith('"'))) {
1412
+ rawKey = rawKey.slice(1, -1);
1413
+ }
1414
+ const classes = rawKey.split(/\s+/).filter(Boolean);
1415
+ if (classes.length > 0 && expr) {
1416
+ result.push({ classes, expr });
1417
+ }
1418
+ }
1419
+ return result;
1420
+ }
1421
+
1422
+ function parseEventScheduleModifiers(modifiers) {
1423
+ let mode = null;
1424
+ let waitMs = 250;
1425
+ let leading = null;
1426
+ let trailing = null;
1427
+
1428
+ const list = Array.from(modifiers);
1429
+ for (let i = 0; i < list.length; i++) {
1430
+ const mod = list[i];
1431
+ if (mod === 'debounce' || mod === 'throttle') {
1432
+ mode = mod;
1433
+ const next = list[i + 1];
1434
+ if (next && /^\d+(?:ms|s)?$/i.test(next)) {
1435
+ waitMs = parseDelayMs(next, 250);
1436
+ i++;
1437
+ }
1438
+ } else if (mod.startsWith('debounce-')) {
1439
+ mode = 'debounce';
1440
+ waitMs = parseDelayMs(mod.slice('debounce-'.length), 250);
1441
+ } else if (mod.startsWith('throttle-')) {
1442
+ mode = 'throttle';
1443
+ waitMs = parseDelayMs(mod.slice('throttle-'.length), 250);
1444
+ } else if (mod === 'leading') {
1445
+ leading = true;
1446
+ } else if (mod === 'trailing') {
1447
+ trailing = true;
1448
+ }
1449
+ }
1450
+
1451
+ if (!mode) return null;
1452
+ if (mode === 'debounce') {
1453
+ return {
1454
+ mode: 'debounce',
1455
+ waitMs,
1456
+ leading: leading === true,
1457
+ trailing: trailing !== false,
1458
+ };
1459
+ }
1460
+ return {
1461
+ mode: 'throttle',
1462
+ waitMs,
1463
+ leading: leading !== false,
1464
+ trailing: trailing !== false,
1465
+ };
1466
+ }
1467
+
1468
+ function getElementBindingSignature(el) {
1469
+ if (!el || !el.attributes) return '';
1470
+ const parts = [];
1471
+ for (let i = 0; i < el.attributes.length; i++) {
1472
+ const n = el.attributes[i].name;
1473
+ if (
1474
+ n === 'data-w-text' ||
1475
+ n === 'data-w-show' ||
1476
+ n === 'data-w-disabled' ||
1477
+ n === 'data-w-model' ||
1478
+ n === 'data-w-class' ||
1479
+ n.startsWith('data-w-class:') ||
1480
+ n === 'data-w-loading' ||
1481
+ n === 'data-w-loading-hide' ||
1482
+ n === 'data-w-loading-delay' ||
1483
+ n === 'data-w-loading-class' ||
1484
+ n === 'data-w-on' ||
1485
+ n.startsWith('data-w-on:') ||
1486
+ n === 'data-w-click'
1487
+ ) {
1488
+ parts.push(`${n}=${el.attributes[i].value}`);
1489
+ }
1490
+ }
1491
+ parts.sort();
1492
+ return parts.join('\n');
1493
+ }
1494
+
1495
+ function setOwnedClassToken(el, token, active) {
1496
+ if (!el) return;
1497
+ if (!el.__wOwnedClassState) {
1498
+ el.__wOwnedClassState = new Map();
1499
+ }
1500
+ el.__wOwnedClassState.set(token, Boolean(active));
1501
+ if (el.classList) {
1502
+ if (active) el.classList.add(token);
1503
+ else el.classList.remove(token);
1504
+ }
1505
+ }
1506
+
1174
1507
  /**
1175
1508
  * Bind V1 declarative attributes inside `rootEl` for a controller instance.
1176
1509
  */
@@ -1207,7 +1540,24 @@ function createDatastarAdapter(options = {}) {
1207
1540
 
1208
1541
  for (let i = 0; i < elements.length; i++) {
1209
1542
  const el = elements[i];
1210
- if (elementDisposersMap.has(el)) continue;
1543
+ const sig = getElementBindingSignature(el);
1544
+ if (elementDisposersMap.has(el)) {
1545
+ if (el.__wBindingSig === sig) continue;
1546
+ // Bindings on this preserved element changed (e.g. server added/removed data-w-show/class):
1547
+ // dispose old bindings and re-bind cleanly.
1548
+ const oldDisposers = elementDisposersMap.get(el);
1549
+ for (let d = 0; d < oldDisposers.length; d++) {
1550
+ try {
1551
+ oldDisposers[d]();
1552
+ } catch (_e) {}
1553
+ }
1554
+ elementDisposersMap.delete(el);
1555
+ if (el.__wOwnedClassState) {
1556
+ el.__wOwnedClassState.clear();
1557
+ delete el.__wOwnedClassState;
1558
+ }
1559
+ }
1560
+ el.__wBindingSig = sig;
1211
1561
  const elDisposers = [];
1212
1562
  elementDisposersMap.set(el, elDisposers);
1213
1563
 
@@ -1236,30 +1586,106 @@ function createDatastarAdapter(options = {}) {
1236
1586
  elDisposers.push(stop);
1237
1587
  }
1238
1588
 
1239
- // 2b. data-w-loading & data-w-loading-hide (declarative action/task busy indicators)
1589
+ // 2a. data-w-class & data-w-class:<token>
1590
+ if (el.hasAttribute('data-w-class')) {
1591
+ const rawClassExpr = el.getAttribute('data-w-class') || '';
1592
+ const classEntries = parseClassMapExpression(rawClassExpr);
1593
+ for (let c = 0; c < classEntries.length; c++) {
1594
+ const entry = classEntries[c];
1595
+ const stop = engine.effect(() => {
1596
+ if (!isConnected() || el.isConnected === false) return;
1597
+ const active = Boolean(evaluateStateExpression(state, entry.expr));
1598
+ for (let k = 0; k < entry.classes.length; k++) {
1599
+ setOwnedClassToken(el, entry.classes[k], active);
1600
+ }
1601
+ });
1602
+ elDisposers.push(stop);
1603
+ }
1604
+ }
1605
+ if (el.attributes) {
1606
+ for (let a = 0; a < el.attributes.length; a++) {
1607
+ const attr = el.attributes[a];
1608
+ if (attr.name.startsWith('data-w-class:')) {
1609
+ const token = attr.name.slice('data-w-class:'.length).trim();
1610
+ const expr = (attr.value || '').trim();
1611
+ if (token && expr) {
1612
+ const stop = engine.effect(() => {
1613
+ if (!isConnected() || el.isConnected === false) return;
1614
+ setOwnedClassToken(el, token, Boolean(evaluateStateExpression(state, expr)));
1615
+ });
1616
+ elDisposers.push(stop);
1617
+ }
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ // 2b. data-w-loading & data-w-loading-hide (with optional data-w-loading-delay)
1240
1623
  if (el.hasAttribute('data-w-loading') || el.hasAttribute('data-w-loading-hide')) {
1241
1624
  const isHide = el.hasAttribute('data-w-loading-hide');
1242
1625
  const rawSpec = el.getAttribute(isHide ? 'data-w-loading-hide' : 'data-w-loading') || '';
1243
1626
  const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1627
+ const delayMs = parseDelayMs(el.getAttribute('data-w-loading-delay'), 0);
1244
1628
  const loadingClasses = (el.getAttribute('data-w-loading-class') || '')
1245
1629
  .trim()
1246
1630
  .split(/\s+/)
1247
1631
  .filter(Boolean);
1632
+
1633
+ let delayTimer = null;
1634
+ let generation = 0;
1635
+ let visuallyActive = false;
1636
+
1637
+ const applyVisualLoading = (active) => {
1638
+ visuallyActive = active;
1639
+ toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1640
+ if (loadingClasses.length > 0 && el.classList) {
1641
+ for (let c = 0; c < loadingClasses.length; c++) {
1642
+ setOwnedClassToken(el, loadingClasses[c], active);
1643
+ }
1644
+ }
1645
+ };
1646
+
1647
+ // Initialize non-loading state synchronously when delay > 0
1648
+ if (delayMs > 0) {
1649
+ applyVisualLoading(false);
1650
+ }
1651
+
1248
1652
  const stop = engine.effect(() => {
1249
1653
  if (!isConnected() || el.isConnected === false) return;
1250
1654
  const active =
1251
1655
  typeof isLoadingTargetActive === 'function'
1252
1656
  ? Boolean(isLoadingTargetActive(rawSpec))
1253
1657
  : Boolean(evaluateStateExpression(state, rawSpec || '$busy'));
1254
- toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1255
- if (loadingClasses.length > 0 && el.classList) {
1256
- for (let c = 0; c < loadingClasses.length; c++) {
1257
- if (active) el.classList.add(loadingClasses[c]);
1258
- else el.classList.remove(loadingClasses[c]);
1658
+
1659
+ if (delayMs <= 0) {
1660
+ applyVisualLoading(active);
1661
+ return;
1662
+ }
1663
+
1664
+ if (active) {
1665
+ if (visuallyActive || delayTimer) return;
1666
+ const gen = ++generation;
1667
+ delayTimer = setTimeout(() => {
1668
+ delayTimer = null;
1669
+ if (gen !== generation || !isConnected() || el.isConnected === false) return;
1670
+ applyVisualLoading(true);
1671
+ }, delayMs);
1672
+ } else {
1673
+ generation++;
1674
+ if (delayTimer) {
1675
+ clearTimeout(delayTimer);
1676
+ delayTimer = null;
1259
1677
  }
1678
+ applyVisualLoading(false);
1260
1679
  }
1261
1680
  });
1262
- elDisposers.push(stop);
1681
+ elDisposers.push(() => {
1682
+ generation++;
1683
+ if (delayTimer) {
1684
+ clearTimeout(delayTimer);
1685
+ delayTimer = null;
1686
+ }
1687
+ stop();
1688
+ });
1263
1689
  }
1264
1690
 
1265
1691
  // 3. data-w-disabled
@@ -1401,15 +1827,149 @@ function createDatastarAdapter(options = {}) {
1401
1827
 
1402
1828
  // 5. Event bindings (data-w-on:<event>, data-w-on, data-w-click)
1403
1829
  const eventBindings = extractEventBindings(el);
1404
- for (const binding of eventBindings) {
1405
- const listener = (event) => {
1406
- if (!isConnected() || el.isConnected === false) return;
1407
- if (binding.modifiers.has('prevent')) event.preventDefault();
1408
- if (binding.modifiers.has('stop')) event.stopPropagation();
1409
- invokeAction(binding.actionName, event, el);
1410
- };
1411
- el.addEventListener(binding.eventName, listener);
1412
- elDisposers.push(() => el.removeEventListener(binding.eventName, listener));
1830
+ if (eventBindings.length > 0) {
1831
+ let isEventComposing = false;
1832
+ const hasScheduled = eventBindings.some((b) => parseEventScheduleModifiers(b.modifiers) !== null);
1833
+ if (hasScheduled) {
1834
+ const onCompStart = () => {
1835
+ isEventComposing = true;
1836
+ };
1837
+ const onCompEnd = () => {
1838
+ isEventComposing = false;
1839
+ };
1840
+ el.addEventListener('compositionstart', onCompStart);
1841
+ el.addEventListener('compositionend', onCompEnd);
1842
+ elDisposers.push(() => {
1843
+ try {
1844
+ el.removeEventListener('compositionstart', onCompStart);
1845
+ el.removeEventListener('compositionend', onCompEnd);
1846
+ } catch (_e) {}
1847
+ });
1848
+ }
1849
+
1850
+ const parentForm = typeof el.closest === 'function' ? el.closest('form') : el.form || null;
1851
+
1852
+ for (const binding of eventBindings) {
1853
+ const schedule = parseEventScheduleModifiers(binding.modifiers);
1854
+ if (!schedule) {
1855
+ const listener = (event) => {
1856
+ if (!isConnected() || el.isConnected === false) return;
1857
+ if (binding.modifiers.has('prevent')) event.preventDefault();
1858
+ if (binding.modifiers.has('stop')) event.stopPropagation();
1859
+ invokeAction(binding.actionName, event, el);
1860
+ };
1861
+ el.addEventListener(binding.eventName, listener);
1862
+ elDisposers.push(() => el.removeEventListener(binding.eventName, listener));
1863
+ continue;
1864
+ }
1865
+
1866
+ let timer = null;
1867
+ let lastExecTime = 0;
1868
+ let pendingEvent = null;
1869
+ let generation = 0;
1870
+
1871
+ const cancelPending = () => {
1872
+ generation++;
1873
+ if (timer) {
1874
+ clearTimeout(timer);
1875
+ timer = null;
1876
+ }
1877
+ pendingEvent = null;
1878
+ };
1879
+
1880
+ const fireScheduled = (capturedEvent) => {
1881
+ if (!isConnected() || el.isConnected === false) return;
1882
+ invokeAction(binding.actionName, capturedEvent, el);
1883
+ };
1884
+
1885
+ const listener = (event) => {
1886
+ if (!isConnected() || el.isConnected === false) return;
1887
+ if (binding.modifiers.has('prevent')) event.preventDefault();
1888
+ if (binding.modifiers.has('stop')) event.stopPropagation();
1889
+ if (
1890
+ (event.isComposing || isEventComposing) &&
1891
+ (binding.eventName === 'input' || binding.eventName === 'keydown' || binding.eventName === 'keyup')
1892
+ ) {
1893
+ return;
1894
+ }
1895
+
1896
+ // Capture a safe event reference (never rely on event.currentTarget after async delay)
1897
+ const capturedEvent = event;
1898
+ const gen = generation;
1899
+
1900
+ if (schedule.mode === 'debounce') {
1901
+ const callNow = schedule.leading && !timer;
1902
+ if (timer) clearTimeout(timer);
1903
+ pendingEvent = capturedEvent;
1904
+ timer = setTimeout(() => {
1905
+ timer = null;
1906
+ if (gen !== generation) return;
1907
+ const evtToPass = pendingEvent;
1908
+ pendingEvent = null;
1909
+ if (schedule.trailing && evtToPass) {
1910
+ fireScheduled(evtToPass);
1911
+ }
1912
+ }, schedule.waitMs);
1913
+ if (callNow) {
1914
+ pendingEvent = null;
1915
+ fireScheduled(capturedEvent);
1916
+ }
1917
+ } else {
1918
+ // Throttle: default leading + trailing
1919
+ const now = Date.now();
1920
+ const remaining = schedule.waitMs - (now - lastExecTime);
1921
+ if (remaining <= 0 || remaining > schedule.waitMs) {
1922
+ if (timer) {
1923
+ clearTimeout(timer);
1924
+ timer = null;
1925
+ }
1926
+ pendingEvent = null;
1927
+ if (schedule.leading) {
1928
+ lastExecTime = now;
1929
+ fireScheduled(capturedEvent);
1930
+ } else if (schedule.trailing) {
1931
+ pendingEvent = capturedEvent;
1932
+ timer = setTimeout(() => {
1933
+ timer = null;
1934
+ if (gen !== generation) return;
1935
+ lastExecTime = Date.now();
1936
+ const evtToPass = pendingEvent;
1937
+ pendingEvent = null;
1938
+ if (evtToPass) fireScheduled(evtToPass);
1939
+ }, schedule.waitMs);
1940
+ }
1941
+ } else if (schedule.trailing) {
1942
+ pendingEvent = capturedEvent;
1943
+ if (!timer) {
1944
+ timer = setTimeout(() => {
1945
+ timer = null;
1946
+ if (gen !== generation) return;
1947
+ lastExecTime = schedule.leading ? Date.now() : 0;
1948
+ const evtToPass = pendingEvent;
1949
+ pendingEvent = null;
1950
+ if (evtToPass) fireScheduled(evtToPass);
1951
+ }, remaining);
1952
+ }
1953
+ }
1954
+ }
1955
+ };
1956
+
1957
+ el.addEventListener(binding.eventName, listener);
1958
+ elDisposers.push(() => {
1959
+ cancelPending();
1960
+ el.removeEventListener(binding.eventName, listener);
1961
+ });
1962
+
1963
+ if (parentForm && typeof parentForm.addEventListener === 'function') {
1964
+ const onFormReset = () => cancelPending();
1965
+ parentForm.addEventListener('reset', onFormReset);
1966
+ elDisposers.push(() => {
1967
+ try {
1968
+ parentForm.removeEventListener('reset', onFormReset);
1969
+ } catch (_e) {}
1970
+ });
1971
+ }
1972
+ }
1413
1973
  }
1414
1974
  }
1415
1975
 
@@ -1460,6 +2020,7 @@ function createDatastarAdapter(options = {}) {
1460
2020
  const morphed = morphDomTrees(targetEl, parsed, {
1461
2021
  onBeforeNodeRemove: lifecycleCallbacks.onBeforeNodeRemove,
1462
2022
  onNodeAdded: lifecycleCallbacks.onNodeAdded,
2023
+ debug: options.debug || null,
1463
2024
  });
1464
2025
 
1465
2026
  // Restore focus & selection range if the focused element was preserved inside targetEl