@jsswift/ui 1.0.52 → 1.0.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsswift/ui",
3
- "version": "1.0.52",
3
+ "version": "1.0.54",
4
4
  "description": "JSswift UI layer built on top of @jsswift/core",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,8 @@
23
23
  "./img/svg/tabler-icons-sprite.svg": "./dist/img/svg/tabler-icons-sprite.svg",
24
24
  "./css/base.css": "./dist/css/base.css",
25
25
  "./css/ui.css": "./dist/css/ui.css",
26
+ "./css/ui-light.css": "./dist/css/ui-light.css",
27
+ "./css/ui-light.min.css": "./dist/css/ui-light.min.css",
26
28
  "./css/min-ui.css": "./dist/css/min-ui.css",
27
29
  "./css/ui.min.css": "./dist/css/ui.min.css",
28
30
  "./css/ui-components.css": "./dist/css/ui-components.css",
@@ -38,6 +40,6 @@
38
40
  "src"
39
41
  ],
40
42
  "peerDependencies": {
41
- "@jsswift/core": "^1.0.52"
43
+ "@jsswift/core": "^1.0.54"
42
44
  }
43
45
  }
@@ -1310,7 +1310,8 @@
1310
1310
  || typeof noteSlot === "function"
1311
1311
  || typeof hintSlot === "function";
1312
1312
  if (canReact) {
1313
- JSswift.reactive.effect(() => { renderMessage(); }, "UI.FormField:message");
1313
+ const stopMessage = JSswift.reactive.effect(() => { renderMessage(); }, "UI.FormField:message");
1314
+ JSswift._registerCleanup(wrap, stopMessage);
1314
1315
  } else {
1315
1316
  renderMessage();
1316
1317
  }
@@ -1333,6 +1334,16 @@
1333
1334
  value: props.value
1334
1335
  });
1335
1336
 
1337
+ let autofillDisposed = false;
1338
+ const autofillTimeouts = new Set();
1339
+ const autofillLater = (fn, delay) => {
1340
+ if (autofillDisposed) return;
1341
+ const id = setTimeout(() => {
1342
+ autofillTimeouts.delete(id);
1343
+ if (!autofillDisposed) fn();
1344
+ }, delay);
1345
+ autofillTimeouts.add(id);
1346
+ };
1336
1347
  let lastKnownValue = el.value ?? "";
1337
1348
  const syncAutofill = () => {
1338
1349
  const next = el.value ?? "";
@@ -1344,7 +1355,7 @@
1344
1355
  let autofillTimer = null;
1345
1356
  let autofillProbe = false;
1346
1357
  const scheduleAutofillProbe = () => {
1347
- if (autofillProbe) return;
1358
+ if (autofillDisposed || autofillProbe) return;
1348
1359
  autofillProbe = true;
1349
1360
  let count = 0;
1350
1361
  const tick = () => {
@@ -1354,12 +1365,12 @@
1354
1365
  autofillProbe = false;
1355
1366
  return;
1356
1367
  }
1357
- setTimeout(tick, 120);
1368
+ autofillLater(tick, 120);
1358
1369
  };
1359
- setTimeout(tick, 40);
1370
+ autofillLater(tick, 40);
1360
1371
  };
1361
1372
  const startAutofillWatch = () => {
1362
- if (autofillTimer) return;
1373
+ if (autofillDisposed || autofillTimer) return;
1363
1374
  autofillTimer = setInterval(() => {
1364
1375
  if (document.activeElement !== el) {
1365
1376
  clearInterval(autofillTimer);
@@ -1385,27 +1396,33 @@
1385
1396
  el.addEventListener("focus", () => {
1386
1397
  startAutofillWatch();
1387
1398
  scheduleAutofillProbe();
1388
- setTimeout(syncAutofill, 50);
1399
+ autofillLater(syncAutofill, 50);
1389
1400
  });
1390
1401
  el.addEventListener("blur", () => {
1391
1402
  stopAutofillWatch();
1392
- setTimeout(syncAutofill, 0);
1403
+ autofillLater(syncAutofill, 0);
1393
1404
  });
1394
- setTimeout(syncAutofill, 0);
1405
+ autofillLater(syncAutofill, 0);
1395
1406
  scheduleAutofillProbe();
1396
1407
 
1408
+ JSswift._registerCleanup(el, () => {
1409
+ autofillDisposed = true;
1410
+ stopAutofillWatch();
1411
+ autofillTimeouts.forEach(clearTimeout);
1412
+ autofillTimeouts.clear();
1413
+ });
1414
+
1397
1415
  // model binding
1398
1416
  const model = props.model;
1399
1417
  if (model) {
1400
1418
  // Supporta rod direttamente
1401
1419
  if (typeof model === "object" && typeof model._bind === "function") {
1402
- app.rodModel(el, model);
1420
+ JSswift._registerCleanup(el, app.rodModel(el, model));
1403
1421
  }
1404
1422
  // Supporta [get,set] signal -> creiamo rodFromSignal
1405
1423
  else if (Array.isArray(model) && typeof model[0] === "function" && typeof model[1] === "function") {
1406
- const r = app.rodFromSignal(model[0], model[1]);
1407
- app.rodModel(el, r);
1408
- // NOTE: se vuoi cleanup automatico qui, lo facciamo nel layer component (v2)
1424
+ const stop = JSswift.reactive.untracked(() => app.signalModel(el, model[0], model[1]));
1425
+ JSswift._registerCleanup(el, stop);
1409
1426
  }
1410
1427
  }
1411
1428
  uiRegisterShortcode(el, props, {
@@ -1422,7 +1439,7 @@
1422
1439
  if (value == null || value === false) control.removeAttribute(name);
1423
1440
  else control.setAttribute(name, String(value));
1424
1441
  };
1425
- JSswift.reactive.effect(() => {
1442
+ const stopAttributes = JSswift.reactive.effect(() => {
1426
1443
  const label = uiUnwrap(props.ariaLabel) ?? (typeof props.label === "string" ? props.label : null);
1427
1444
  set("aria-label", label);
1428
1445
  set("aria-labelledby", uiUnwrap(props.ariaLabelledBy));
@@ -1437,6 +1454,7 @@
1437
1454
  set(name.toLowerCase(), uiUnwrap(props[name]));
1438
1455
  }
1439
1456
  }, "UI.Field:controlAttributes");
1457
+ JSswift._registerCleanup(field, stopAttributes);
1440
1458
  }
1441
1459
 
1442
1460
  UI.Input = (props = {}) => {
@@ -1456,6 +1474,16 @@
1456
1474
  readOnly: !!props.readonly
1457
1475
  });
1458
1476
 
1477
+ let autofillDisposed = false;
1478
+ const autofillTimeouts = new Set();
1479
+ const autofillLater = (fn, delay) => {
1480
+ if (autofillDisposed) return;
1481
+ const id = setTimeout(() => {
1482
+ autofillTimeouts.delete(id);
1483
+ if (!autofillDisposed) fn();
1484
+ }, delay);
1485
+ autofillTimeouts.add(id);
1486
+ };
1459
1487
  let lastKnownValue = input.value ?? "";
1460
1488
  const syncAutofill = () => {
1461
1489
  const next = input.value ?? "";
@@ -1467,7 +1495,7 @@
1467
1495
  let autofillTimer = null;
1468
1496
  let autofillProbe = false;
1469
1497
  const scheduleAutofillProbe = () => {
1470
- if (autofillProbe) return;
1498
+ if (autofillDisposed || autofillProbe) return;
1471
1499
  autofillProbe = true;
1472
1500
  let count = 0;
1473
1501
  const tick = () => {
@@ -1477,12 +1505,12 @@
1477
1505
  autofillProbe = false;
1478
1506
  return;
1479
1507
  }
1480
- setTimeout(tick, 120);
1508
+ autofillLater(tick, 120);
1481
1509
  };
1482
- setTimeout(tick, 40);
1510
+ autofillLater(tick, 40);
1483
1511
  };
1484
1512
  const startAutofillWatch = () => {
1485
- if (autofillTimer) return;
1513
+ if (autofillDisposed || autofillTimer) return;
1486
1514
  autofillTimer = setInterval(() => {
1487
1515
  if (document.activeElement !== input) {
1488
1516
  clearInterval(autofillTimer);
@@ -1511,12 +1539,12 @@
1511
1539
  props.onFocus?.(e);
1512
1540
  startAutofillWatch();
1513
1541
  scheduleAutofillProbe();
1514
- setTimeout(syncAutofill, 50);
1542
+ autofillLater(syncAutofill, 50);
1515
1543
  });
1516
1544
  input.addEventListener("blur", (e) => {
1517
1545
  props.onBlur?.(e);
1518
1546
  stopAutofillWatch();
1519
- setTimeout(syncAutofill, 0);
1547
+ autofillLater(syncAutofill, 0);
1520
1548
  });
1521
1549
  input.addEventListener("animationstart", (e) => {
1522
1550
  if (e.animationName !== "cms-autofill") return;
@@ -1524,9 +1552,16 @@
1524
1552
  startAutofillWatch();
1525
1553
  scheduleAutofillProbe();
1526
1554
  });
1527
- setTimeout(syncAutofill, 0);
1555
+ autofillLater(syncAutofill, 0);
1528
1556
  scheduleAutofillProbe();
1529
1557
 
1558
+ JSswift._registerCleanup(input, () => {
1559
+ autofillDisposed = true;
1560
+ stopAutofillWatch();
1561
+ autofillTimeouts.forEach(clearTimeout);
1562
+ autofillTimeouts.clear();
1563
+ });
1564
+
1530
1565
  // model binding
1531
1566
  const model = props.model;
1532
1567
  if (model) {
@@ -1540,11 +1575,12 @@
1540
1575
  lastKnownValue = next;
1541
1576
  };
1542
1577
 
1543
- rod.action((v) => {
1578
+ const onValue = (v) => {
1544
1579
  if (syncing) return;
1545
1580
  syncing = true;
1546
1581
  try { updateFromRod(v); } finally { syncing = false; }
1547
- });
1582
+ };
1583
+ rod.action(onValue);
1548
1584
 
1549
1585
  // Component setup may run inside a dynamic parent render. This initial
1550
1586
  // hydration must not make that parent subscribe to the control model.
@@ -1561,18 +1597,26 @@
1561
1597
  input.addEventListener("input", onInput);
1562
1598
  input.addEventListener("compositionend", onInput);
1563
1599
 
1564
- if (typeof rod.onDispose === "function") {
1565
- rod.onDispose(() => {
1566
- input.removeEventListener("input", onInput);
1567
- input.removeEventListener("compositionend", onInput);
1568
- });
1569
- }
1600
+ let disposed = false;
1601
+ const stop = () => {
1602
+ if (disposed) return;
1603
+ disposed = true;
1604
+ input.removeEventListener("input", onInput);
1605
+ input.removeEventListener("compositionend", onInput);
1606
+ const actionIndex = rod._actions?.indexOf(onValue) ?? -1;
1607
+ if (actionIndex >= 0) rod._actions.splice(actionIndex, 1);
1608
+ const hookIndex = rod._disposeHooks?.indexOf(stop) ?? -1;
1609
+ if (hookIndex >= 0 && !rod._disposed) rod._disposeHooks.splice(hookIndex, 1);
1610
+ };
1611
+ rod.onDispose?.(stop);
1612
+ JSswift._registerCleanup(input, stop);
1570
1613
  };
1571
1614
  if (typeof model === "object" && typeof model._bind === "function") {
1572
1615
  bindInputRod(model);
1573
1616
  } else if (Array.isArray(model) && typeof model[0] === "function" && typeof model[1] === "function") {
1574
1617
  const r = JSswift.reactive.untracked(() => JSswift.rodFromSignal(model[0], model[1]));
1575
1618
  bindInputRod(r);
1619
+ JSswift._registerCleanup(input, () => r.dispose());
1576
1620
  }
1577
1621
  }
1578
1622
 
@@ -1656,11 +1700,12 @@
1656
1700
  textarea.value = next;
1657
1701
  };
1658
1702
 
1659
- rod.action((v) => {
1703
+ const onValue = (v) => {
1660
1704
  if (syncing) return;
1661
1705
  syncing = true;
1662
1706
  try { updateFromRod(v); } finally { syncing = false; }
1663
- });
1707
+ };
1708
+ rod.action(onValue);
1664
1709
 
1665
1710
  JSswift.reactive.untracked(() => updateFromRod(rod.value));
1666
1711
 
@@ -1675,18 +1720,26 @@
1675
1720
  textarea.addEventListener("input", onInput);
1676
1721
  textarea.addEventListener("compositionend", onInput);
1677
1722
 
1678
- if (typeof rod.onDispose === "function") {
1679
- rod.onDispose(() => {
1680
- textarea.removeEventListener("input", onInput);
1681
- textarea.removeEventListener("compositionend", onInput);
1682
- });
1683
- }
1723
+ let disposed = false;
1724
+ const stop = () => {
1725
+ if (disposed) return;
1726
+ disposed = true;
1727
+ textarea.removeEventListener("input", onInput);
1728
+ textarea.removeEventListener("compositionend", onInput);
1729
+ const actionIndex = rod._actions?.indexOf(onValue) ?? -1;
1730
+ if (actionIndex >= 0) rod._actions.splice(actionIndex, 1);
1731
+ const hookIndex = rod._disposeHooks?.indexOf(stop) ?? -1;
1732
+ if (hookIndex >= 0 && !rod._disposed) rod._disposeHooks.splice(hookIndex, 1);
1733
+ };
1734
+ rod.onDispose?.(stop);
1735
+ JSswift._registerCleanup(textarea, stop);
1684
1736
  };
1685
1737
  if (typeof model === "object" && typeof model._bind === "function") {
1686
1738
  bindTextareaRod(model);
1687
1739
  } else if (Array.isArray(model) && typeof model[0] === "function" && typeof model[1] === "function") {
1688
1740
  const r = JSswift.reactive.untracked(() => JSswift.rodFromSignal(model[0], model[1]));
1689
1741
  bindTextareaRod(r);
1742
+ JSswift._registerCleanup(textarea, () => r.dispose());
1690
1743
  }
1691
1744
  }
1692
1745
 
@@ -1722,7 +1775,22 @@
1722
1775
  };
1723
1776
 
1724
1777
 
1778
+ // Component-owned subscriptions; shared models remain caller-owned.
1779
+ function uiSubscriptionScope() {
1780
+ const stops = [];
1781
+ return {
1782
+ effect: (fn, name) => { const stop = JSswift.reactive.effect(fn, name); stops.push(stop); return stop; },
1783
+ action: (rod, fn) => {
1784
+ rod.action(fn);
1785
+ stops.push(() => { const i = rod._actions?.indexOf(fn) ?? -1; if (i >= 0) rod._actions.splice(i, 1); });
1786
+ },
1787
+ dispose: () => stops.splice(0).forEach(stop => stop())
1788
+ };
1789
+ }
1790
+
1725
1791
  UI.Search = (props = {}) => {
1792
+ const scope = uiSubscriptionScope();
1793
+ let disposed = false;
1726
1794
  const slots = props.slots || {};
1727
1795
  const isBoundValue = (v) => !!v && ((typeof v === "object" && typeof v._bind === "function") || uiIsSignal(v));
1728
1796
  const valueBinding = isBoundValue(props.model)
@@ -1835,6 +1903,7 @@
1835
1903
  setActive(-1);
1836
1904
  };
1837
1905
  const runSearch = async (query, options = {}) => {
1906
+ if (disposed) return;
1838
1907
  const q = query == null ? "" : String(query).trim();
1839
1908
  const id = ++requestId;
1840
1909
  const endpoint = uiUnwrap(props.endpoint ?? props.url);
@@ -1889,7 +1958,7 @@
1889
1958
  } else {
1890
1959
  raw = [];
1891
1960
  }
1892
- if (id !== requestId) return;
1961
+ if (disposed || id !== requestId) return;
1893
1962
  const mapped = typeof props.mapResponse === "function" ? props.mapResponse(raw, q) : normalizeResults(raw);
1894
1963
  const results = normalizeResults(mapped);
1895
1964
  if (cacheEnabled) cache.set(cacheKey, results);
@@ -1898,13 +1967,13 @@
1898
1967
  props.onResults?.(results, q);
1899
1968
  } catch (error) {
1900
1969
  if (error?.name === "AbortError") return;
1901
- if (id !== requestId) return;
1970
+ if (disposed || id !== requestId) return;
1902
1971
  setError(error);
1903
1972
  setResults([]);
1904
1973
  setActive(-1);
1905
1974
  props.onError?.(error, q);
1906
1975
  } finally {
1907
- if (id === requestId) {
1976
+ if (!disposed && id === requestId) {
1908
1977
  setLoading(false);
1909
1978
  activeController = null;
1910
1979
  scheduleMenuPosition();
@@ -1912,6 +1981,7 @@
1912
1981
  }
1913
1982
  };
1914
1983
  const scheduleSearch = (query, options = {}) => {
1984
+ if (disposed) return;
1915
1985
  if (searchTimer) clearTimeout(searchTimer);
1916
1986
  const delay = options.immediate ? 0 : debounceMs();
1917
1987
  searchTimer = setTimeout(() => {
@@ -2072,18 +2142,18 @@
2072
2142
 
2073
2143
  if (valueBinding) {
2074
2144
  if (typeof valueBinding === "object" && typeof valueBinding._bind === "function") {
2075
- valueBinding.action((v) => setQuery(v, { fromModel: true }));
2145
+ scope.action(valueBinding, (v) => setQuery(v, { fromModel: true }));
2076
2146
  modelSet = (v) => {
2077
2147
  if (valueBinding.value !== v) valueBinding.value = v;
2078
2148
  };
2079
2149
  } else if (Array.isArray(valueBinding) && typeof valueBinding[0] === "function" && typeof valueBinding[1] === "function") {
2080
2150
  const get = valueBinding[0];
2081
2151
  const set = valueBinding[1];
2082
- JSswift.reactive.effect(() => { setQuery(get(), { fromModel: true }); }, "UI.Search:model");
2152
+ scope.effect(() => { setQuery(get(), { fromModel: true }); }, "UI.Search:model");
2083
2153
  modelSet = (v) => set(v);
2084
2154
  }
2085
2155
  } else if (uiIsReactive(props.value)) {
2086
- JSswift.reactive.effect(() => { setQuery(uiUnwrap(props.value), { fromModel: true }); }, "UI.Search:value");
2156
+ scope.effect(() => { setQuery(uiUnwrap(props.value), { fromModel: true }); }, "UI.Search:value");
2087
2157
  }
2088
2158
 
2089
2159
  input.addEventListener("input", (event) => {
@@ -2157,7 +2227,7 @@
2157
2227
  };
2158
2228
  document.addEventListener("click", onDocClick, true);
2159
2229
 
2160
- JSswift.reactive.effect(() => {
2230
+ scope.effect(() => {
2161
2231
  const openNow = getOpen();
2162
2232
  root.classList.toggle("open", openNow);
2163
2233
  input.setAttribute("aria-expanded", openNow ? "true" : "false");
@@ -2165,7 +2235,7 @@
2165
2235
  else unmountMenuPortal();
2166
2236
  }, "UI.Search:open");
2167
2237
 
2168
- JSswift.reactive.effect(() => {
2238
+ scope.effect(() => {
2169
2239
  const d = isDisabled();
2170
2240
  const r = isReadonly();
2171
2241
  root.classList.toggle("disabled", d);
@@ -2174,7 +2244,7 @@
2174
2244
  input.readOnly = r;
2175
2245
  }, "UI.Search:disabled");
2176
2246
 
2177
- JSswift.reactive.effect(() => {
2247
+ scope.effect(() => {
2178
2248
  const loading = getLoading();
2179
2249
  const error = getError();
2180
2250
  const results = getResults();
@@ -2228,7 +2298,7 @@
2228
2298
  scheduleMenuPosition();
2229
2299
  }, "UI.Search:render");
2230
2300
 
2231
- JSswift.reactive.effect(() => {
2301
+ scope.effect(() => {
2232
2302
  const active = getActive();
2233
2303
  if (active === lastActive) return;
2234
2304
  if (resultNodes[lastActive]) {
@@ -2271,10 +2341,12 @@
2271
2341
  field._searchNow = () => runSearch(getQuery(), { immediate: true, open: true });
2272
2342
  field._getResults = getResults;
2273
2343
  field._setValue = (value) => setQuery(value, {});
2274
- let disposed = false;
2275
2344
  field._dispose = () => {
2276
2345
  if (disposed) return;
2277
2346
  disposed = true;
2347
+ scope.dispose();
2348
+ requestId++;
2349
+ cache.clear();
2278
2350
  document.removeEventListener("click", onDocClick, true);
2279
2351
  if (searchTimer) clearTimeout(searchTimer);
2280
2352
  if (activeController) activeController.abort();
@@ -2295,6 +2367,8 @@
2295
2367
 
2296
2368
 
2297
2369
  UI.Select = (props = {}) => {
2370
+ const scope = uiSubscriptionScope();
2371
+ let disposed = false;
2298
2372
  const fieldProps = {
2299
2373
  ...props,
2300
2374
  label: props.label ?? props.placeholder
@@ -2337,7 +2411,7 @@
2337
2411
  if (valueBinding) {
2338
2412
  if (typeof valueBinding === "object" && typeof valueBinding._bind === "function") {
2339
2413
  JSswift.reactive.untracked(() => setValue(normalizeValue(valueBinding.value)));
2340
- valueBinding.action((v) => setValue(normalizeValue(v)));
2414
+ scope.action(valueBinding, (v) => setValue(normalizeValue(v)));
2341
2415
  modelSet = (v) => {
2342
2416
  const next = normalizeValue(v);
2343
2417
  if (valueBinding.value !== next) valueBinding.value = next;
@@ -2346,7 +2420,7 @@
2346
2420
  const get = valueBinding[0];
2347
2421
  const set = valueBinding[1];
2348
2422
  JSswift.reactive.untracked(() => {
2349
- JSswift.reactive.effect(() => { setValue(normalizeValue(get())); }, "UI.Select:model");
2423
+ scope.effect(() => { setValue(normalizeValue(get())); }, "UI.Select:model");
2350
2424
  });
2351
2425
  modelSet = (v) => set(normalizeValue(v));
2352
2426
  }
@@ -2413,6 +2487,7 @@
2413
2487
 
2414
2488
  // supports async options
2415
2489
  async function loadOptions() {
2490
+ if (disposed) return;
2416
2491
  const src = props.options;
2417
2492
  let raw;
2418
2493
 
@@ -2426,12 +2501,13 @@
2426
2501
  } else {
2427
2502
  raw = v;
2428
2503
  }
2429
- buildLists(raw);
2504
+ if (!disposed) buildLists(raw);
2430
2505
  } catch (e) {
2506
+ if (disposed) return;
2431
2507
  console.error("[UI.Select] options load error:", e);
2432
2508
  buildLists([]);
2433
2509
  } finally {
2434
- setLoading(false);
2510
+ if (!disposed) setLoading(false);
2435
2511
  }
2436
2512
  }
2437
2513
 
@@ -2471,11 +2547,12 @@
2471
2547
  }
2472
2548
 
2473
2549
  async function open() {
2474
- if (isDisabled()) return;
2550
+ if (disposed || isDisabled()) return;
2475
2551
  setOpen(true);
2476
2552
 
2477
2553
  // load options (sync/async) on open
2478
2554
  await loadOptions();
2555
+ if (disposed) return;
2479
2556
 
2480
2557
  // set active to current selected, otherwise first enabled
2481
2558
  const current = getValue();
@@ -2513,7 +2590,7 @@
2513
2590
  const valueNode = _.div({
2514
2591
  class: uiClass(["cms-select-value", isMulti ? "cms-select-value-multi" : ""])
2515
2592
  });
2516
- JSswift.reactive.effect(() => {
2593
+ scope.effect(() => {
2517
2594
  const flat = getFlat();
2518
2595
  valueNode.innerHTML = "";
2519
2596
  if (isMulti) {
@@ -2560,7 +2637,7 @@
2560
2637
  }, "UI.Select:value");
2561
2638
 
2562
2639
  const arrowWrap = _.div({ class: "cms-select-arrow" });
2563
- JSswift.reactive.effect(() => {
2640
+ scope.effect(() => {
2564
2641
  const arrowNode = JSswift.ui.renderSlot(slots, "arrow", { open: getOpen() }, UI.Icon("#chevron-down"));
2565
2642
  arrowWrap.innerHTML = "";
2566
2643
  renderSlotToArray(null, "default", {}, arrowNode).forEach(n => arrowWrap.appendChild(n));
@@ -2602,7 +2679,7 @@
2602
2679
  if (filterInput) filterInput.value = getFilter() || "";
2603
2680
  };
2604
2681
  JSswift.reactive.untracked(renderFilterSlot);
2605
- JSswift.reactive.effect(() => {
2682
+ scope.effect(() => {
2606
2683
  const next = getFilter() || "";
2607
2684
  if (filterInput && "value" in filterInput && filterInput.value !== next) {
2608
2685
  filterInput.value = next;
@@ -2611,7 +2688,7 @@
2611
2688
 
2612
2689
  if (typeof filterable === "boolean") filterable ? filterWrap.classList.remove("cms-d-none") : filterWrap.classList.add("cms-d-none");
2613
2690
  else if (filterable?.action) {
2614
- filterable.action((v) => {
2691
+ scope.action(filterable, (v) => {
2615
2692
  v ? filterWrap.classList.remove("cms-d-none") : filterWrap.classList.add("cms-d-none");
2616
2693
  });
2617
2694
  filterable.value ? filterWrap.classList.remove("cms-d-none") : filterWrap.classList.add("cms-d-none");;
@@ -2712,7 +2789,7 @@
2712
2789
  root.appendChild(menu);
2713
2790
 
2714
2791
  // open class + aria
2715
- JSswift.reactive.effect(() => {
2792
+ scope.effect(() => {
2716
2793
  const o = getOpen();
2717
2794
  root.classList.toggle("open", o);
2718
2795
  root.setAttribute("aria-expanded", o ? "true" : "false");
@@ -2720,7 +2797,7 @@
2720
2797
  else unmountMenuPortal();
2721
2798
  }, "UI.Select:open");
2722
2799
 
2723
- JSswift.reactive.effect(() => {
2800
+ scope.effect(() => {
2724
2801
  const d = isDisabled();
2725
2802
  root.classList.toggle("disabled", d);
2726
2803
  root.tabIndex = d ? -1 : 0;
@@ -2742,7 +2819,7 @@
2742
2819
  if (active >= 0) optionNodes[active]?.scrollIntoView({ block: "nearest" });
2743
2820
  }
2744
2821
 
2745
- JSswift.reactive.effect(() => {
2822
+ scope.effect(() => {
2746
2823
  const display = getList();
2747
2824
  const flat = getFlat();
2748
2825
  const filter = (getFilter() || "").toLowerCase().trim();
@@ -2839,7 +2916,7 @@
2839
2916
  if (getOpen()) scheduleMenuPosition();
2840
2917
  }, "UI.Select:render");
2841
2918
 
2842
- JSswift.reactive.effect(() => {
2919
+ scope.effect(() => {
2843
2920
  paintActive();
2844
2921
  }, "UI.Select:paintActive");
2845
2922
 
@@ -2953,10 +3030,10 @@
2953
3030
  root.addEventListener("keydown", onKeyDown);
2954
3031
  filterWrap.addEventListener("keydown", onKeyDown);
2955
3032
 
2956
- let disposed = false;
2957
3033
  root._dispose = () => {
2958
3034
  if (disposed) return;
2959
3035
  disposed = true;
3036
+ scope.dispose();
2960
3037
  document.removeEventListener("click", onDocClick, true);
2961
3038
  root.removeEventListener("keydown", onKeyDown);
2962
3039
  filterWrap.removeEventListener("keydown", onKeyDown);
@@ -2996,7 +3073,7 @@
2996
3073
  });
2997
3074
 
2998
3075
  // refresh has-value when value changes
2999
- JSswift.reactive.effect(() => { field._refresh?.(); }, "UI.Select:fieldRefresh");
3076
+ scope.effect(() => { field._refresh?.(); }, "UI.Select:fieldRefresh");
3000
3077
 
3001
3078
  uiConfigureFieldControl(field, root, props);
3002
3079
  field._select = root;
@@ -3579,7 +3656,10 @@
3579
3656
  return {
3580
3657
  get: () => model.value,
3581
3658
  set: (v) => { if (model.value !== v) model.value = v; },
3582
- watch: (fn) => { if (typeof model.action === "function") model.action(fn); }
3659
+ watch: (fn) => {
3660
+ model.action?.(fn);
3661
+ return () => { const i = model._actions?.indexOf(fn) ?? -1; if (i >= 0) model._actions.splice(i, 1); };
3662
+ }
3583
3663
  };
3584
3664
  }
3585
3665
  if (Array.isArray(model) && typeof model[0] === "function" && typeof model[1] === "function") {
@@ -3757,7 +3837,7 @@
3757
3837
  if (getOpen() !== next) syncOpen(next);
3758
3838
  controlledDrawer?.set(next);
3759
3839
  };
3760
- controlledDrawer?.watch?.(() => {
3840
+ const stopDrawerWatch = controlledDrawer?.watch?.(() => {
3761
3841
  const next = !!controlledDrawer.get();
3762
3842
  if (getOpen() !== next) syncOpen(next);
3763
3843
  });
@@ -3772,7 +3852,7 @@
3772
3852
  if (getNavOpen() !== next) syncNavOpen(next);
3773
3853
  controlledNav?.set(next);
3774
3854
  };
3775
- controlledNav?.watch?.(() => {
3855
+ const stopNavWatch = controlledNav?.watch?.(() => {
3776
3856
  const next = !!controlledNav.get();
3777
3857
  if (getNavOpen() !== next) syncNavOpen(next);
3778
3858
  });
@@ -3860,6 +3940,8 @@
3860
3940
  JSswift.uiApplyResponsiveProps(p, props, JSswift.uiResponsiveStyleRules);
3861
3941
 
3862
3942
  const root = _.div(p);
3943
+ if (stopDrawerWatch) JSswift._registerCleanup(root, stopDrawerWatch);
3944
+ if (stopNavWatch) JSswift._registerCleanup(root, stopNavWatch);
3863
3945
  const tagPage = uiUnwrap(resolveLayoutProp("tagPage")) === true;
3864
3946
  const tags = tagPage
3865
3947
  ? { header: "header", aside: "aside", nav: "nav", page: "main", footer: "footer" }
package/src/80-data.js CHANGED
@@ -44,8 +44,9 @@
44
44
  };
45
45
 
46
46
  let searchInput = null;
47
+ let stopSearchWatch;
47
48
  if (searchModel) {
48
- searchModel.watch((value) => {
49
+ stopSearchWatch = searchModel.watch((value) => {
49
50
  const next = String(value ?? "");
50
51
  setQueryState(next);
51
52
  if (searchInput && searchInput.value !== next) searchInput.value = next;
@@ -255,7 +256,7 @@
255
256
  );
256
257
  };
257
258
 
258
- app.reactive.effect(() => {
259
+ const stopRender = app.reactive.effect(() => {
259
260
  const rows = tableToArray(props.rows);
260
261
  const query = String(getQuery() || "").trim();
261
262
  const currentSort = getSort();
@@ -429,5 +430,8 @@
429
430
  }, "UI.Table:render");
430
431
 
431
432
  wrapProps.body = shell;
432
- return UI.Card(wrapProps);
433
+ const card = UI.Card(wrapProps);
434
+ JSswift._registerCleanup(card, stopRender);
435
+ if (stopSearchWatch) JSswift._registerCleanup(card, stopSearchWatch);
436
+ return card;
433
437
  };
package/src/css/README.md CHANGED
@@ -15,3 +15,5 @@ Generated outputs:
15
15
  - `pages/_jsswift-fe/css/min-ui.css`
16
16
  - `pages/_jsswift-fe/css/ui.min.css`
17
17
 
18
+
19
+ `ui.css` remains the full compatible bundle. `ui-light.css` and `ui-light.min.css` omit breakpoint utilities but retain base tokens, component/mobile styles and unprefixed utilities generated by `cms-dev/make-responsive-css.mjs --base-only`. Add `responsive.css` after the light bundle for breakpoint props. All props remain available in JavaScript.