twico-ui 1.41.0 → 1.41.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to Twico UI are documented here (newest first). This file is generated automatically on each release by [semantic-release](https://semantic-release.gitbook.io/) from [Conventional Commits](https://www.conventionalcommits.org/) — do not edit it by hand. The same notes appear on each [GitHub Release](https://github.com/EncryptionL/twico-ui/releases).
4
4
 
5
+ ## [1.41.1](https://github.com/EncryptionL/twico-ui/compare/v1.41.0...v1.41.1) (2026-09-24)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **button:** make the neutral pressed tone legible in dark mode ([7cd0cf0](https://github.com/EncryptionL/twico-ui/commit/7cd0cf019b44be2246b2dde870325fffc2c9228c)), closes [#411](https://github.com/EncryptionL/twico-ui/issues/411) [#0b1222](https://github.com/EncryptionL/twico-ui/issues/0b1222) [#0f172a](https://github.com/EncryptionL/twico-ui/issues/0f172a) [#411](https://github.com/EncryptionL/twico-ui/issues/411)
11
+ * **datatable:** apply per-row editable to batch write ([#428](https://github.com/EncryptionL/twico-ui/issues/428)) + reveal once per activeCell ([#429](https://github.com/EncryptionL/twico-ui/issues/429)) ([363044f](https://github.com/EncryptionL/twico-ui/commit/363044f10c4dafcc90caf68b5d8464a7ccff7e41)), closes [#424](https://github.com/EncryptionL/twico-ui/issues/424)
12
+ * **datatable:** make batch keys×patch safe for mixed columns ([#428](https://github.com/EncryptionL/twico-ui/issues/428)) + converge virtualized reveal ([#429](https://github.com/EncryptionL/twico-ui/issues/429)) ([394a79c](https://github.com/EncryptionL/twico-ui/commit/394a79caacc8136403fa6b5386a53cd6b1d64021))
13
+
5
14
  # [1.41.0](https://github.com/EncryptionL/twico-ui/compare/v1.40.0...v1.41.0) (2026-09-23)
6
15
 
7
16
 
package/dist/index.cjs CHANGED
@@ -441,7 +441,10 @@ var ICONBTN_CSS = `
441
441
  .twc-iconbtn[aria-pressed="true"][data-pressed-tone="warning"] { --_accent: var(--color-warning); --_accent-subtle: var(--color-warning-subtle); --_accent-subtle-fg: var(--color-warning-subtle-fg); }
442
442
  .twc-iconbtn[aria-pressed="true"][data-pressed-tone="info"] { --_accent: var(--color-info); --_accent-subtle: var(--color-info-subtle); --_accent-subtle-fg: var(--color-info-subtle-fg); }
443
443
  .twc-iconbtn[aria-pressed="true"][data-pressed-tone="danger"] { --_accent: var(--color-danger); --_accent-subtle: var(--color-danger-subtle); --_accent-subtle-fg: var(--color-danger-subtle-fg); }
444
- .twc-iconbtn[aria-pressed="true"][data-pressed-tone="neutral"] { --_accent: var(--color-border-strong); --_accent-subtle: var(--color-surface-sunken); --_accent-subtle-fg: var(--color-text); }
444
+ /* #430: color-surface-sunken steps toward the page background in dark mode, so a neutral pressed fill was
445
+ invisible on a surface panel. Fill with color-border (steps away from the background in both themes) + a
446
+ stronger color-text-subtle edge so the outline also carries the "on" state. Mirrors Button. */
447
+ .twc-iconbtn[aria-pressed="true"][data-pressed-tone="neutral"] { --_accent: var(--color-text-subtle); --_accent-subtle: var(--color-border); --_accent-subtle-fg: var(--color-text); }
445
448
  /* #418: keep the pressed look on hover (matching-specificity, after the variant hover rules). */
446
449
  .twc-iconbtn[aria-pressed="true"]:hover:not(:disabled) { background: var(--_accent-subtle); color: var(--_accent-subtle-fg); border-color: var(--_accent); filter: brightness(0.97); }
447
450
  .dark .twc-iconbtn[aria-pressed="true"]:hover:not(:disabled) { filter: brightness(1.25); }
@@ -1739,7 +1742,11 @@ var CSS2 = `
1739
1742
  .twc-btn[aria-pressed="true"][data-pressed-tone="warning"] { --_accent: var(--color-warning); --_accent-subtle: var(--color-warning-subtle); --_accent-subtle-fg: var(--color-warning-subtle-fg); }
1740
1743
  .twc-btn[aria-pressed="true"][data-pressed-tone="info"] { --_accent: var(--color-info); --_accent-subtle: var(--color-info-subtle); --_accent-subtle-fg: var(--color-info-subtle-fg); }
1741
1744
  .twc-btn[aria-pressed="true"][data-pressed-tone="danger"] { --_accent: var(--color-danger); --_accent-subtle: var(--color-danger-subtle); --_accent-subtle-fg: var(--color-danger-subtle-fg); }
1742
- .twc-btn[aria-pressed="true"][data-pressed-tone="neutral"] { --_accent: var(--color-border-strong); --_accent-subtle: var(--color-surface-sunken); --_accent-subtle-fg: var(--color-text); }
1745
+ /* #430: color-surface-sunken steps TOWARD the page background in dark mode (0b1222 under 0f172a), so a neutral
1746
+ pressed fill was invisible on a surface panel. Fill with color-border instead \u2014 it steps AWAY from the
1747
+ background in both themes (lighter in dark, darker in light) \u2014 and strengthen the edge with color-text-subtle
1748
+ so the outline carries the "on" state too (neutral has no meaning-colour to recolour the label). */
1749
+ .twc-btn[aria-pressed="true"][data-pressed-tone="neutral"] { --_accent: var(--color-text-subtle); --_accent-subtle: var(--color-border); --_accent-subtle-fg: var(--color-text); }
1743
1750
  /* #418: keep the pressed look under the pointer \u2014 the variant hover rules (0,4,0) otherwise beat the base
1744
1751
  pressed rule (0,2,0). This matching-specificity rule, source-ordered after them, restores the fill/fg/border
1745
1752
  (resetting the text colour too, so solid stays legible \u2014 incl. tone="danger"). */
@@ -10483,7 +10490,7 @@ function Datatable({
10483
10490
  );
10484
10491
  const effectiveHidden = React53__namespace.default.useMemo(() => combineSources.size ? /* @__PURE__ */ new Set([...hidden, ...combineSources]) : hidden, [hidden, combineSources]);
10485
10492
  const effectiveWrapped = React53__namespace.default.useMemo(() => stackCombineTargets.size ? /* @__PURE__ */ new Set([...wrapped, ...stackCombineTargets]) : wrapped, [wrapped, stackCombineTargets]);
10486
- const visibleCols = cols.filter((c) => !effectiveHidden.has(c.field));
10493
+ const visibleCols = React53__namespace.default.useMemo(() => cols.filter((c) => !effectiveHidden.has(c.field)), [cols, effectiveHidden]);
10487
10494
  const colFieldsKey = JSON.stringify(
10488
10495
  cols.filter((c) => typeof c.field === "string" && c.type !== "actions").map((c) => c.field)
10489
10496
  );
@@ -11358,12 +11365,18 @@ function Datatable({
11358
11365
  const revealOn = !!scrollActiveCellIntoView;
11359
11366
  const revealBlock = scrollActiveCellIntoView && typeof scrollActiveCellIntoView === "object" && scrollActiveCellIntoView.block === "center" ? "center" : "nearest";
11360
11367
  const revealInline = scrollActiveCellIntoView && typeof scrollActiveCellIntoView === "object" && scrollActiveCellIntoView.inline === "center" ? "center" : "nearest";
11368
+ const revealRi = activeCellVal ? keyIndex.get(activeCellVal.key) : void 0;
11369
+ const revealCi = activeCellVal ? cellColIndex(activeCellVal.field) : -1;
11370
+ const revealOffsetsRef = React53__namespace.default.useRef(offsets);
11371
+ revealOffsetsRef.current = offsets;
11372
+ const revealMidRef = React53__namespace.default.useRef(keyIndexMid);
11373
+ revealMidRef.current = keyIndexMid;
11361
11374
  React53__namespace.default.useEffect(() => {
11362
11375
  if (!revealOn || !activeCellVal) return void 0;
11363
11376
  const block = revealBlock, inline = revealInline;
11364
- const ri = keyIndex.get(activeCellVal.key), ci = cellColIndex(activeCellVal.field);
11377
+ const ri = revealRi, ci = revealCi;
11365
11378
  if (ri == null || ci < 0) return void 0;
11366
- let raf = 0;
11379
+ let raf = 0, tries = 0;
11367
11380
  const reveal = () => {
11368
11381
  const sc = scrollRef.current;
11369
11382
  if (!sc) return;
@@ -11389,10 +11402,12 @@ function Datatable({
11389
11402
  } else if (tR.left < scR.left + leftInset) sc.scrollLeft -= scR.left + leftInset - tR.left;
11390
11403
  else if (tR.right > scR.right - rightInset) sc.scrollLeft += tR.right - (scR.right - rightInset);
11391
11404
  }
11392
- } else if (virtualizing && offsets) {
11393
- const mi = keyIndexMid.get(activeCellVal.key);
11394
- if (mi != null && offsets[mi] != null) {
11395
- sc.scrollTop = Math.max(0, offsets[mi] - (block === "center" ? sc.clientHeight / 2 : headH));
11405
+ } else if (virtualizing && revealOffsetsRef.current) {
11406
+ const off = revealOffsetsRef.current;
11407
+ const mi = revealMidRef.current.get(activeCellVal.key);
11408
+ if (mi != null && off[mi] != null && tries < 30) {
11409
+ tries += 1;
11410
+ sc.scrollTop = Math.max(0, off[mi] - (block === "center" ? sc.clientHeight / 2 : headH));
11396
11411
  raf = requestAnimationFrame(reveal);
11397
11412
  }
11398
11413
  }
@@ -11401,7 +11416,7 @@ function Datatable({
11401
11416
  return () => {
11402
11417
  if (raf) cancelAnimationFrame(raf);
11403
11418
  };
11404
- }, [activeCellVal?.key, activeCellVal?.field, revealOn, revealBlock, revealInline, keyIndex, cellColIndex, virtualizing, offsets, keyIndexMid, headH]);
11419
+ }, [activeCellVal?.key, activeCellVal?.field, revealOn, revealBlock, revealInline, revealRi, revealCi, virtualizing, headH]);
11405
11420
  React53__namespace.default.useEffect(() => {
11406
11421
  if (!activeCellControlled || !activeCellVal) {
11407
11422
  jumpAppliedRef.current = null;
@@ -11493,6 +11508,10 @@ function Datatable({
11493
11508
  [cols, editMode, batchEditKey]
11494
11509
  // eslint-disable-line react-hooks/exhaustive-deps
11495
11510
  );
11511
+ const batchEditableColsSel = React53__namespace.default.useMemo(
11512
+ () => batchEditableCols.filter((c) => typeof c.editable !== "function" || selectedRows.some((r2) => c.editable(r2, { field: c.field }))),
11513
+ [batchEditableCols, selectedRows]
11514
+ );
11496
11515
  const hasBatchEditor = showBatchEdit && batchEditableCols.length > 0;
11497
11516
  const [batchEdit, setBatchEdit] = React53__namespace.default.useState(null);
11498
11517
  const [batchEditPos, openBatchEdit, closeBatchEdit] = useFloating();
@@ -11527,7 +11546,7 @@ function Datatable({
11527
11546
  }, [batchEdit, selected.size, closeBatchEdit]);
11528
11547
  function applyBatchEdit() {
11529
11548
  if (!batchEdit) return;
11530
- const active = batchEditableCols.filter((c) => batchEdit.fields[c.field]);
11549
+ const active = batchEditableColsSel.filter((c) => batchEdit.fields[c.field]);
11531
11550
  if (!active.length) {
11532
11551
  setBatchEdit(null);
11533
11552
  closeBatchEdit();
@@ -11543,20 +11562,27 @@ function Datatable({
11543
11562
  patch[c.field] = v;
11544
11563
  });
11545
11564
  const selKeys2 = new Set(selected);
11565
+ const loadedSel = /* @__PURE__ */ new Set();
11546
11566
  const changedRows = [];
11567
+ const safeKeys = [];
11547
11568
  const nextAll = rows.map((r2, i) => {
11548
11569
  const k = keyOf(r2, i);
11549
11570
  if (!selKeys2.has(k)) return r2;
11571
+ loadedSel.add(k);
11550
11572
  const rowPatch = {};
11573
+ let allEditable = true;
11551
11574
  for (const c of active) {
11552
11575
  if (typeof c.editable !== "function" || c.editable(r2, { field: c.field })) rowPatch[c.field] = patch[c.field];
11576
+ else allEditable = false;
11553
11577
  }
11578
+ if (allEditable) safeKeys.push(k);
11554
11579
  if (!Object.keys(rowPatch).length) return r2;
11555
11580
  const updated = { ...r2, ...rowPatch };
11556
11581
  changedRows.push(updated);
11557
11582
  return updated;
11558
11583
  });
11559
- onBatchUpdate?.(changedRows, patch, [...selected]);
11584
+ for (const k of selKeys2) if (!loadedSel.has(k)) safeKeys.push(k);
11585
+ onBatchUpdate?.(changedRows, patch, safeKeys);
11560
11586
  onRowsChange?.(nextAll);
11561
11587
  setBatchEdit(null);
11562
11588
  closeBatchEdit();
@@ -13099,8 +13125,8 @@ function Datatable({
13099
13125
  }
13100
13126
  ) : null,
13101
13127
  batchEdit && batchEditPos ? (() => {
13102
- const pickedCols = Object.keys(batchEdit.fields).filter((f) => batchEdit.fields[f]).map((f) => batchEditableCols.find((c) => c.field === f)).filter(Boolean);
13103
- const unpicked = batchEditableCols.filter((c) => !batchEdit.fields[c.field]);
13128
+ const pickedCols = Object.keys(batchEdit.fields).filter((f) => batchEdit.fields[f]).map((f) => batchEditableColsSel.find((c) => c.field === f)).filter(Boolean);
13129
+ const unpicked = batchEditableColsSel.filter((c) => !batchEdit.fields[c.field]);
13104
13130
  const addField = (f) => {
13105
13131
  if (f) setBatchEdit((b) => ({ ...b, fields: { ...b.fields, [f]: true }, values: { ...b.values, [f]: b.values[f] ?? "" } }));
13106
13132
  };
@@ -13132,7 +13158,11 @@ function Datatable({
13132
13158
  onChange: addField
13133
13159
  }
13134
13160
  ) }) : null,
13135
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twc-dt__cfg-list", children: pickedCols.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twc-dt__be-empty", children: "Pick a column to set its value on all selected rows." }) : pickedCols.map((c, i) => {
13161
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twc-dt__cfg-list", children: pickedCols.length === 0 ? (
13162
+ // #428: when a predicate leaves no column editable for the current selection, say so instead of
13163
+ // inviting a pick that can't be made (the "Add a column…" picker is hidden when unpicked is empty).
13164
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "twc-dt__be-empty", children: unpicked.length ? "Pick a column to set its value on all selected rows." : "No columns are editable for the current selection." })
13165
+ ) : pickedCols.map((c, i) => {
13136
13166
  const opts = c.valueOptions ? c.valueOptions.map((o) => typeof o === "string" ? { value: o, label: o } : o) : null;
13137
13167
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "twc-dt__be-row", children: [
13138
13168
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "twc-dt__be-name", title: colLabel(c), children: [