@design.estate/dees-catalog 3.69.1 → 3.70.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design.estate/dees-catalog",
3
- "version": "3.69.1",
3
+ "version": "3.70.1",
4
4
  "private": false,
5
5
  "description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
6
6
  "main": "dist_ts_web/index.js",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@design.estate/dees-catalog',
6
- version: '3.69.1',
6
+ version: '3.70.1',
7
7
  description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
8
8
  }
@@ -1,6 +1,7 @@
1
1
  import { type ITableAction } from './dees-table.js';
2
2
  import * as plugins from '../../00plugins.js';
3
3
  import { html, css, cssManager } from '@design.estate/dees-element';
4
+ import '@design.estate/dees-wcctools/demotools';
4
5
 
5
6
  interface ITableDemoData {
6
7
  date: string;
@@ -742,6 +743,71 @@ export const demoFunc = () => html`
742
743
  ] as ITableAction[]}
743
744
  ></dees-table>
744
745
  </div>
746
+
747
+ <dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
748
+ const tableEl = elementArg.querySelector('#demoLiveFlash') as any;
749
+ if (!tableEl) return;
750
+ // Guard against double-start if runAfterRender fires more than once
751
+ // (e.g. across hot-reload cycles).
752
+ if (tableEl.__liveFlashTimerId) {
753
+ window.clearInterval(tableEl.__liveFlashTimerId);
754
+ }
755
+ const tick = () => {
756
+ if (!Array.isArray(tableEl.data) || tableEl.data.length === 0) return;
757
+ const next = tableEl.data.map((r: any) => ({ ...r }));
758
+ const count = 1 + Math.floor(Math.random() * 3);
759
+ for (let i = 0; i < count; i++) {
760
+ const idx = Math.floor(Math.random() * next.length);
761
+ const delta = +((Math.random() * 2 - 1) * 3).toFixed(2);
762
+ const newPrice = Math.max(1, +(next[idx].price + delta).toFixed(2));
763
+ next[idx] = {
764
+ ...next[idx],
765
+ price: newPrice,
766
+ change: delta,
767
+ updatedAt: new Date().toLocaleTimeString(),
768
+ };
769
+ }
770
+ tableEl.data = next;
771
+ };
772
+ tableEl.__liveFlashTimerId = window.setInterval(tick, 1500);
773
+ }}>
774
+ <div class="demo-section">
775
+ <h2 class="demo-title">Live Updates with Flash Highlighting</h2>
776
+ <p class="demo-description">
777
+ Opt-in cell-flash via <code>highlight-updates="flash"</code>. The ticker below mutates
778
+ random rows every 1.5s and reassigns <code>.data</code>. Updated cells briefly flash
779
+ amber and fade out. Requires <code>rowKey</code> (here <code>"symbol"</code>). Honors
780
+ <code>prefers-reduced-motion</code>. Row selection persists across updates — click a
781
+ row, then watch it stay selected as the data churns.
782
+ </p>
783
+ <dees-table
784
+ id="demoLiveFlash"
785
+ .rowKey=${'symbol'}
786
+ highlight-updates="flash"
787
+ .selectionMode=${'multi'}
788
+ heading1="Live Market Feed"
789
+ heading2="Flashing cells indicate updated values"
790
+ .columns=${[
791
+ { key: 'symbol', header: 'Symbol', sortable: true },
792
+ { key: 'price', header: 'Price', sortable: true },
793
+ { key: 'change', header: 'Δ', sortable: true },
794
+ { key: 'updatedAt', header: 'Updated' },
795
+ ]}
796
+ .data=${[
797
+ { symbol: 'AAPL', price: 182.52, change: 0, updatedAt: '—' },
798
+ { symbol: 'MSFT', price: 414.18, change: 0, updatedAt: '—' },
799
+ { symbol: 'GOOG', price: 168.74, change: 0, updatedAt: '—' },
800
+ { symbol: 'AMZN', price: 186.13, change: 0, updatedAt: '—' },
801
+ { symbol: 'TSLA', price: 248.50, change: 0, updatedAt: '—' },
802
+ { symbol: 'NVDA', price: 877.35, change: 0, updatedAt: '—' },
803
+ { symbol: 'META', price: 492.96, change: 0, updatedAt: '—' },
804
+ { symbol: 'NFLX', price: 605.88, change: 0, updatedAt: '—' },
805
+ { symbol: 'AMD', price: 165.24, change: 0, updatedAt: '—' },
806
+ { symbol: 'INTC', price: 42.15, change: 0, updatedAt: '—' },
807
+ ]}
808
+ ></dees-table>
809
+ </div>
810
+ </dees-demowrapper>
745
811
  </div>
746
812
  </div>
747
813
  `;
@@ -214,6 +214,30 @@ export class DeesTable<T> extends DeesElement {
214
214
  @property({ type: Number, attribute: 'virtual-overscan' })
215
215
  accessor virtualOverscan: number = 8;
216
216
 
217
+ /**
218
+ * Opt-in visual indication of cell-value changes across data updates.
219
+ *
220
+ * - `'none'` (default): no diffing, zero overhead.
221
+ * - `'flash'`: when `data` is reassigned to a new array reference, diff the
222
+ * new rows against the previous snapshot and briefly flash any cells
223
+ * whose resolved value changed. Equality is strict `===`; object-valued
224
+ * cells are compared by reference. The currently-edited cell is never
225
+ * flashed. User-initiated cell edits do not flash.
226
+ *
227
+ * Requires `rowKey` to be set — without it, the feature silently no-ops
228
+ * and renders a visible dev warning banner. Honors `prefers-reduced-motion`
229
+ * (fades are replaced with a static background hint of the same duration).
230
+ */
231
+ @property({ type: String, attribute: 'highlight-updates' })
232
+ accessor highlightUpdates: 'none' | 'flash' = 'none';
233
+
234
+ /**
235
+ * Duration of the flash animation in milliseconds. Fed into the
236
+ * `--dees-table-flash-duration` CSS variable on the host.
237
+ */
238
+ @property({ type: Number, attribute: 'highlight-duration' })
239
+ accessor highlightDuration: number = 900;
240
+
217
241
  /**
218
242
  * When set, the table renders inside a fixed-height scroll container
219
243
  * (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -268,6 +292,23 @@ export class DeesTable<T> extends DeesElement {
268
292
  @state()
269
293
  private accessor __floatingActive: boolean = false;
270
294
 
295
+ // ─── Flash-on-update state (only populated when highlightUpdates === 'flash') ──
296
+ /** rowId → set of colKey strings currently flashing. */
297
+ @state()
298
+ private accessor __flashingCells: Map<string, Set<string>> = new Map();
299
+
300
+ /** rowId → (colKey → last-seen resolved cell value). Populated per diff pass. */
301
+ private __prevSnapshot?: Map<string, Map<string, unknown>>;
302
+
303
+ /** Single shared timer that clears __flashingCells after highlightDuration ms. */
304
+ private __flashClearTimer?: ReturnType<typeof setTimeout>;
305
+
306
+ /** Monotonic counter bumped each flash batch so directives.keyed recreates the cell node and restarts the animation. */
307
+ private __flashTick: number = 0;
308
+
309
+ /** One-shot console.warn gate for missing rowKey in flash mode. */
310
+ private __flashWarnedNoRowKey: boolean = false;
311
+
271
312
  // ─── Render memoization ──────────────────────────────────────────────
272
313
  // These caches let render() short-circuit when the relevant inputs
273
314
  // (by reference) haven't changed. They are NOT @state — mutating them
@@ -557,6 +598,15 @@ export class DeesTable<T> extends DeesElement {
557
598
  </div>
558
599
  </div>
559
600
  <div class="headingSeparation"></div>
601
+ ${this.highlightUpdates === 'flash' && !this.rowKey
602
+ ? html`<div class="flashConfigWarning" role="alert">
603
+ <dees-icon .icon=${'lucide:triangleAlert'}></dees-icon>
604
+ <span>
605
+ <code>highlight-updates="flash"</code> requires
606
+ <code>rowKey</code> to be set. Flash is disabled.
607
+ </span>
608
+ </div>`
609
+ : html``}
560
610
  <div class="searchGrid hidden">
561
611
  <dees-input-text
562
612
  .label=${'lucene syntax search'}
@@ -606,9 +656,13 @@ export class DeesTable<T> extends DeesElement {
606
656
  ${useVirtual && topSpacerHeight > 0
607
657
  ? html`<tr aria-hidden="true" style="height:${topSpacerHeight}px"><td></td></tr>`
608
658
  : html``}
609
- ${renderRows.map((itemArg, sliceIdx) => {
659
+ ${directives.repeat(
660
+ renderRows,
661
+ (itemArg, sliceIdx) => `${this.getRowId(itemArg)}::${renderStart + sliceIdx}`,
662
+ (itemArg, sliceIdx) => {
610
663
  const rowIndex = renderStart + sliceIdx;
611
664
  const rowId = this.getRowId(itemArg);
665
+ const flashSet = this.__flashingCells.get(rowId);
612
666
  return html`
613
667
  <tr
614
668
  data-row-idx=${rowIndex}
@@ -640,6 +694,7 @@ export class DeesTable<T> extends DeesElement {
640
694
  const isEditing =
641
695
  this.__editingCell?.rowId === rowId &&
642
696
  this.__editingCell?.colKey === editKey;
697
+ const isFlashing = !!flashSet?.has(editKey);
643
698
  const cellClasses = [
644
699
  isEditable ? 'editable' : '',
645
700
  isFocused && !isEditing ? 'focused' : '',
@@ -647,14 +702,22 @@ export class DeesTable<T> extends DeesElement {
647
702
  ]
648
703
  .filter(Boolean)
649
704
  .join(' ');
705
+ const innerHtml = html`<div
706
+ class=${isFlashing ? 'innerCellContainer flashing' : 'innerCellContainer'}
707
+ >
708
+ ${isEditing ? this.renderCellEditor(itemArg, col) : content}
709
+ </div>`;
650
710
  return html`
651
711
  <td
652
712
  class=${cellClasses}
653
713
  data-col-key=${editKey}
654
714
  >
655
- <div class="innerCellContainer">
656
- ${isEditing ? this.renderCellEditor(itemArg, col) : content}
657
- </div>
715
+ ${isFlashing
716
+ ? directives.keyed(
717
+ `${rowId}:${editKey}:${this.__flashTick}`,
718
+ innerHtml
719
+ )
720
+ : innerHtml}
658
721
  </td>
659
722
  `;
660
723
  })}
@@ -685,7 +748,8 @@ export class DeesTable<T> extends DeesElement {
685
748
  }
686
749
  })()}
687
750
  </tr>`;
688
- })}
751
+ }
752
+ )}
689
753
  ${useVirtual && bottomSpacerHeight > 0
690
754
  ? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
691
755
  : html``}
@@ -801,7 +865,7 @@ export class DeesTable<T> extends DeesElement {
801
865
  const key = String(col.key);
802
866
  if (col.filterable === false) return html`<th></th>`;
803
867
  return html`<th>
804
- <input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
868
+ <input type="text" placeholder="Filter..." data-col-key=${key} .value=${this.columnFilters[key] || ''}
805
869
  @input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
806
870
  </th>`;
807
871
  })}
@@ -957,6 +1021,84 @@ export class DeesTable<T> extends DeesElement {
957
1021
  if (fh) fh.classList.remove('active');
958
1022
  }
959
1023
 
1024
+ /**
1025
+ * If a filter `<input>` inside the floating-header clone currently has
1026
+ * focus, copy its value, caret, and selection range onto the matching
1027
+ * input in the real header, then focus that real input. This lets the
1028
+ * user keep typing uninterrupted when filter input causes the table to
1029
+ * shrink below the viewport stick line and the floating header has to
1030
+ * unmount.
1031
+ *
1032
+ * Safe to call at any time — it is a no-op unless an input inside the
1033
+ * floating header is focused and has a `data-col-key` attribute that
1034
+ * matches a real-header input.
1035
+ */
1036
+ private __transferFocusToRealHeader(): void {
1037
+ const fh = this.__floatingHeaderEl;
1038
+ if (!fh) return;
1039
+ const active = this.shadowRoot?.activeElement as HTMLElement | null;
1040
+ if (!active || !fh.contains(active)) return;
1041
+ const colKey = active.getAttribute('data-col-key');
1042
+ if (!colKey) return;
1043
+ const fromInput = active as HTMLInputElement;
1044
+ const real = this.shadowRoot?.querySelector(
1045
+ `.tableScroll > table > thead input[data-col-key="${CSS.escape(colKey)}"]`
1046
+ ) as HTMLInputElement | null;
1047
+ if (!real || real === fromInput) return;
1048
+ const selStart = fromInput.selectionStart;
1049
+ const selEnd = fromInput.selectionEnd;
1050
+ const selDir = fromInput.selectionDirection as any;
1051
+ real.focus({ preventScroll: true });
1052
+ try {
1053
+ if (selStart != null && selEnd != null) {
1054
+ real.setSelectionRange(selStart, selEnd, selDir || undefined);
1055
+ }
1056
+ } catch {
1057
+ /* setSelectionRange throws on unsupported input types — ignore */
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Symmetric counterpart to `__transferFocusToRealHeader`. When the
1063
+ * floating header has just activated and a real-header filter input
1064
+ * was focused (and is now scrolled off-screen behind the floating
1065
+ * clone), move focus to the clone's matching input so the user keeps
1066
+ * typing in the visible one.
1067
+ *
1068
+ * Called from `__syncFloatingHeader` inside the post-activation
1069
+ * `updateComplete` callback — by then the clone subtree exists in the
1070
+ * DOM and can receive focus.
1071
+ */
1072
+ private __transferFocusToFloatingHeader(): void {
1073
+ const fh = this.__floatingHeaderEl;
1074
+ if (!fh || !this.__floatingActive) return;
1075
+ const active = this.shadowRoot?.activeElement as HTMLElement | null;
1076
+ if (!active) return;
1077
+ // Only handle focus that lives in the real header (not already in the clone).
1078
+ const realThead = this.shadowRoot?.querySelector(
1079
+ '.tableScroll > table > thead'
1080
+ ) as HTMLElement | null;
1081
+ if (!realThead || !realThead.contains(active)) return;
1082
+ const colKey = active.getAttribute('data-col-key');
1083
+ if (!colKey) return;
1084
+ const fromInput = active as HTMLInputElement;
1085
+ const clone = fh.querySelector(
1086
+ `input[data-col-key="${CSS.escape(colKey)}"]`
1087
+ ) as HTMLInputElement | null;
1088
+ if (!clone || clone === fromInput) return;
1089
+ const selStart = fromInput.selectionStart;
1090
+ const selEnd = fromInput.selectionEnd;
1091
+ const selDir = fromInput.selectionDirection as any;
1092
+ clone.focus({ preventScroll: true });
1093
+ try {
1094
+ if (selStart != null && selEnd != null) {
1095
+ clone.setSelectionRange(selStart, selEnd, selDir || undefined);
1096
+ }
1097
+ } catch {
1098
+ /* ignore */
1099
+ }
1100
+ }
1101
+
960
1102
  // ─── Virtualization ─────────────────────────────────────────────────
961
1103
 
962
1104
  /**
@@ -1062,6 +1204,15 @@ export class DeesTable<T> extends DeesElement {
1062
1204
  const shouldBeActive = tableRect.top < stick.top && distance > 0;
1063
1205
 
1064
1206
  if (shouldBeActive !== this.__floatingActive) {
1207
+ if (!shouldBeActive) {
1208
+ // Before we flag the clone for unmount, hand off any focused
1209
+ // filter input to its counterpart in the real header. This is the
1210
+ // "user is typing in a sticky filter input, filter shrinks the
1211
+ // table so the floating header hides" case — without this
1212
+ // handoff the user's focus (and caret position) would be lost
1213
+ // when the clone unmounts.
1214
+ this.__transferFocusToRealHeader();
1215
+ }
1065
1216
  this.__floatingActive = shouldBeActive;
1066
1217
  fh.classList.toggle('active', shouldBeActive);
1067
1218
  if (!shouldBeActive) {
@@ -1072,8 +1223,14 @@ export class DeesTable<T> extends DeesElement {
1072
1223
  }
1073
1224
  if (shouldBeActive) {
1074
1225
  // Clone subtree doesn't exist yet — wait for the next render to
1075
- // materialize it, then complete geometry sync.
1076
- this.updateComplete.then(() => this.__syncFloatingHeader());
1226
+ // materialize it, then complete geometry sync. Additionally, if a
1227
+ // real-header filter input was focused when we activated, hand
1228
+ // off to the clone once it exists so the user keeps typing in
1229
+ // the visible (floating) input.
1230
+ this.updateComplete.then(() => {
1231
+ this.__syncFloatingHeader();
1232
+ this.__transferFocusToFloatingHeader();
1233
+ });
1077
1234
  return;
1078
1235
  }
1079
1236
  }
@@ -1127,6 +1284,10 @@ export class DeesTable<T> extends DeesElement {
1127
1284
  public async disconnectedCallback() {
1128
1285
  super.disconnectedCallback();
1129
1286
  this.teardownFloatingHeader();
1287
+ if (this.__flashClearTimer) {
1288
+ clearTimeout(this.__flashClearTimer);
1289
+ this.__flashClearTimer = undefined;
1290
+ }
1130
1291
  }
1131
1292
 
1132
1293
  public async firstUpdated() {
@@ -1134,9 +1295,141 @@ export class DeesTable<T> extends DeesElement {
1134
1295
  // table markup actually exists (it only renders when data.length > 0).
1135
1296
  }
1136
1297
 
1298
+ /**
1299
+ * Runs before each render. Drives two independent concerns:
1300
+ *
1301
+ * 1. **Selection rebind** — when `data` is reassigned to a fresh array
1302
+ * (typical live-data pattern), `selectedDataRow` still points at the
1303
+ * stale row object from the old array. We re-resolve it by rowKey so
1304
+ * consumers of `selectedDataRow` (footer indicator, header/footer
1305
+ * actions, copy fallback) see the live reference. `selectedIds`,
1306
+ * `__focusedCell`, `__editingCell`, `__selectionAnchorId` are all
1307
+ * keyed by string rowId and persist automatically — no change needed.
1308
+ * This runs regardless of `highlightUpdates` — it is a baseline
1309
+ * correctness fix for live data.
1310
+ *
1311
+ * 2. **Flash diff** — when `highlightUpdates === 'flash'`, diff the new
1312
+ * data against `__prevSnapshot` and populate `__flashingCells` with
1313
+ * the (rowId, colKey) pairs whose resolved cell value changed. A
1314
+ * single shared timer clears `__flashingCells` after
1315
+ * `highlightDuration` ms. Skipped if `rowKey` is missing (with a
1316
+ * one-shot console.warn; the render surface also shows a warning
1317
+ * banner).
1318
+ */
1319
+ public willUpdate(changedProperties: Map<string | number | symbol, unknown>): void {
1320
+ // --- Phase 1: selection rebind (always runs) ---
1321
+ if (changedProperties.has('data') && this.selectedDataRow && this.rowKey) {
1322
+ const prevId = this.getRowId(this.selectedDataRow);
1323
+ let found: T | undefined;
1324
+ for (const row of this.data) {
1325
+ if (this.getRowId(row) === prevId) {
1326
+ found = row;
1327
+ break;
1328
+ }
1329
+ }
1330
+ if (found) {
1331
+ if (found !== this.selectedDataRow) this.selectedDataRow = found;
1332
+ } else {
1333
+ this.selectedDataRow = undefined as unknown as T;
1334
+ }
1335
+ }
1336
+
1337
+ // --- Phase 2: flash diff ---
1338
+ if (this.highlightUpdates !== 'flash') {
1339
+ // Mode was toggled off (or never on) — drop any lingering state so
1340
+ // re-enabling later starts with a clean slate.
1341
+ if (this.__prevSnapshot || this.__flashingCells.size > 0) {
1342
+ this.__prevSnapshot = undefined;
1343
+ if (this.__flashingCells.size > 0) this.__flashingCells = new Map();
1344
+ if (this.__flashClearTimer) {
1345
+ clearTimeout(this.__flashClearTimer);
1346
+ this.__flashClearTimer = undefined;
1347
+ }
1348
+ }
1349
+ return;
1350
+ }
1351
+ if (!this.rowKey) {
1352
+ if (!this.__flashWarnedNoRowKey) {
1353
+ this.__flashWarnedNoRowKey = true;
1354
+ console.warn(
1355
+ '[dees-table] highlightUpdates="flash" requires `rowKey` to be set. Flash is disabled. ' +
1356
+ 'Set the rowKey property/attribute to a stable identifier on your row data (e.g. `rowKey="id"`).'
1357
+ );
1358
+ }
1359
+ return;
1360
+ }
1361
+ if (!changedProperties.has('data')) return;
1362
+
1363
+ const effectiveColumns = this.__getEffectiveColumns();
1364
+ const visibleCols = effectiveColumns.filter((c) => !c.hidden);
1365
+ const nextSnapshot = new Map<string, Map<string, unknown>>();
1366
+ const newlyFlashing = new Map<string, Set<string>>();
1367
+
1368
+ for (const row of this.data) {
1369
+ const rowId = this.getRowId(row);
1370
+ const cellMap = new Map<string, unknown>();
1371
+ for (const col of visibleCols) {
1372
+ cellMap.set(String(col.key), getCellValueFn(row, col, this.displayFunction));
1373
+ }
1374
+ nextSnapshot.set(rowId, cellMap);
1375
+
1376
+ const prevCells = this.__prevSnapshot?.get(rowId);
1377
+ if (!prevCells) continue; // new row — not an "update"
1378
+ for (const [colKey, nextVal] of cellMap) {
1379
+ if (prevCells.get(colKey) !== nextVal) {
1380
+ // Don't flash the cell the user is actively editing.
1381
+ if (
1382
+ this.__editingCell &&
1383
+ this.__editingCell.rowId === rowId &&
1384
+ this.__editingCell.colKey === colKey
1385
+ ) continue;
1386
+ let set = newlyFlashing.get(rowId);
1387
+ if (!set) {
1388
+ set = new Set();
1389
+ newlyFlashing.set(rowId, set);
1390
+ }
1391
+ set.add(colKey);
1392
+ }
1393
+ }
1394
+ }
1395
+
1396
+ const hadPrev = !!this.__prevSnapshot;
1397
+ this.__prevSnapshot = nextSnapshot;
1398
+ if (!hadPrev) return; // first time seeing data — no flashes
1399
+
1400
+ if (newlyFlashing.size === 0) return;
1401
+
1402
+ // Merge with any in-flight flashes from a rapid second update so a cell
1403
+ // that changes twice before its animation ends gets a single clean
1404
+ // restart (via __flashTick / directives.keyed) instead of stacking.
1405
+ for (const [rowId, cols] of newlyFlashing) {
1406
+ const existing = this.__flashingCells.get(rowId);
1407
+ if (existing) {
1408
+ for (const c of cols) existing.add(c);
1409
+ } else {
1410
+ this.__flashingCells.set(rowId, cols);
1411
+ }
1412
+ }
1413
+ this.__flashTick++;
1414
+ // Reactivity nudge: we've mutated the Map in place, so give Lit a fresh
1415
+ // reference so the @state change fires for render.
1416
+ this.__flashingCells = new Map(this.__flashingCells);
1417
+ if (this.__flashClearTimer) clearTimeout(this.__flashClearTimer);
1418
+ this.__flashClearTimer = setTimeout(() => {
1419
+ this.__flashingCells = new Map();
1420
+ this.__flashClearTimer = undefined;
1421
+ }, Math.max(0, this.highlightDuration));
1422
+ }
1423
+
1137
1424
  public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
1138
1425
  super.updated(changedProperties);
1139
1426
 
1427
+ // Feed highlightDuration into the CSS variable so JS and CSS stay in
1428
+ // sync via a single source of truth.
1429
+ if (changedProperties.has('highlightDuration')) {
1430
+ this.style.setProperty('--dees-table-flash-duration', `${this.highlightDuration}ms`);
1431
+ }
1432
+
1140
1433
  // Only re-measure column widths when the data or schema actually changed
1141
1434
  // (or on first paint). `determineColumnWidths` is the single biggest
1142
1435
  // first-paint cost — it forces multiple layout flushes per row.
@@ -2090,6 +2383,10 @@ export class DeesTable<T> extends DeesElement {
2090
2383
  }
2091
2384
  if (parsed !== oldValue) {
2092
2385
  (item as any)[col.key] = parsed;
2386
+ // Keep the flash-diff snapshot in sync so the next external update
2387
+ // does not see this user edit as an external change (which would
2388
+ // otherwise flash the cell the user just typed into).
2389
+ this.__recordCellInSnapshot(item, col);
2093
2390
  this.dispatchEvent(
2094
2391
  new CustomEvent('cellEdit', {
2095
2392
  detail: { row: item, key, oldValue, newValue: parsed },
@@ -2103,6 +2400,24 @@ export class DeesTable<T> extends DeesElement {
2103
2400
  this.requestUpdate();
2104
2401
  }
2105
2402
 
2403
+ /**
2404
+ * Updates the flash diff snapshot for a single cell to match its current
2405
+ * resolved value. Called from `commitCellEdit` so a user-initiated edit
2406
+ * does not register as an external change on the next diff pass.
2407
+ * No-op when flash mode is off or no snapshot exists yet.
2408
+ */
2409
+ private __recordCellInSnapshot(item: T, col: Column<T>): void {
2410
+ if (this.highlightUpdates !== 'flash' || !this.__prevSnapshot) return;
2411
+ if (!this.rowKey) return;
2412
+ const rowId = this.getRowId(item);
2413
+ let cellMap = this.__prevSnapshot.get(rowId);
2414
+ if (!cellMap) {
2415
+ cellMap = new Map();
2416
+ this.__prevSnapshot.set(rowId, cellMap);
2417
+ }
2418
+ cellMap.set(String(col.key), getCellValueFn(item, col, this.displayFunction));
2419
+ }
2420
+
2106
2421
  /** Renders the appropriate dees-input-* component for this column. */
2107
2422
  private renderCellEditor(item: T, col: Column<T>): TemplateResult {
2108
2423
  const raw = (item as any)[col.key];
@@ -373,6 +373,72 @@ export const tableStyles: CSSResult[] = [
373
373
  line-height: 24px;
374
374
  }
375
375
 
376
+ /* ---- Cell flash highlighting (opt-in via highlight-updates="flash") ----
377
+ Bloomberg/TradingView-style: the text itself briefly takes an accent
378
+ color then fades back to the default. No background tint, no layout
379
+ shift, no weight change. Readable, modern, subtle.
380
+ Consumers can override per instance:
381
+ dees-table#myTable { --dees-table-flash-color: hsl(142 76% 40%); }
382
+ */
383
+ :host {
384
+ --dees-table-flash-color: ${cssManager.bdTheme(
385
+ 'hsl(32 95% 44%)',
386
+ 'hsl(45 93% 62%)'
387
+ )};
388
+ --dees-table-flash-easing: cubic-bezier(0.22, 0.61, 0.36, 1);
389
+ }
390
+
391
+ .innerCellContainer.flashing {
392
+ animation: dees-table-cell-flash
393
+ var(--dees-table-flash-duration, 900ms)
394
+ var(--dees-table-flash-easing);
395
+ }
396
+
397
+ /* Hold the accent color briefly, then fade back to the theme's default
398
+ text color. Inherits to child text and to SVG icons that use
399
+ currentColor. Cells with explicit color overrides in renderers are
400
+ intentionally unaffected. */
401
+ @keyframes dees-table-cell-flash {
402
+ 0%,
403
+ 35% { color: var(--dees-table-flash-color); }
404
+ 100% { color: var(--dees-color-text-primary); }
405
+ }
406
+
407
+ @media (prefers-reduced-motion: reduce) {
408
+ .innerCellContainer.flashing {
409
+ animation: none;
410
+ color: var(--dees-table-flash-color);
411
+ }
412
+ }
413
+
414
+ /* Dev-time warning banner shown when highlight-updates="flash" but
415
+ rowKey is missing. Consumers should never ship this to production. */
416
+ .flashConfigWarning {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 8px;
420
+ margin: 8px 16px 0;
421
+ padding: 8px 12px;
422
+ border-left: 3px solid ${cssManager.bdTheme('hsl(38 92% 50%)', 'hsl(48 96% 63%)')};
423
+ background: ${cssManager.bdTheme('hsl(48 96% 89% / 0.6)', 'hsl(48 96% 30% / 0.15)')};
424
+ color: ${cssManager.bdTheme('hsl(32 81% 29%)', 'hsl(48 96% 80%)')};
425
+ font-size: 12px;
426
+ line-height: 1.4;
427
+ border-radius: 4px;
428
+ }
429
+ .flashConfigWarning dees-icon {
430
+ width: 14px;
431
+ height: 14px;
432
+ flex: 0 0 auto;
433
+ }
434
+ .flashConfigWarning code {
435
+ padding: 1px 4px;
436
+ border-radius: 3px;
437
+ background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.6)', 'hsl(0 0% 0% / 0.3)')};
438
+ font-family: ${cssGeistFontFamily};
439
+ font-size: 11px;
440
+ }
441
+
376
442
  /* Editable cell affordances */
377
443
  td.editable {
378
444
  cursor: text;
@@ -268,9 +268,7 @@ export class DeesModal extends DeesElement {
268
268
  }
269
269
 
270
270
  .heading .header-button dees-icon {
271
- width: 14px;
272
- height: 14px;
273
- display: block;
271
+ font-size: 14px;
274
272
  }
275
273
 
276
274
  .content {