feat(dees-table): add opt-in flash highlighting for updated table cells

This commit is contained in:
2026-04-08 14:51:49 +00:00
parent de6f4a3ac5
commit 72e3d6a09e
5 changed files with 464 additions and 9 deletions

View File

@@ -214,6 +214,30 @@ export class DeesTable<T> extends DeesElement {
@property({ type: Number, attribute: 'virtual-overscan' })
accessor virtualOverscan: number = 8;
/**
* Opt-in visual indication of cell-value changes across data updates.
*
* - `'none'` (default): no diffing, zero overhead.
* - `'flash'`: when `data` is reassigned to a new array reference, diff the
* new rows against the previous snapshot and briefly flash any cells
* whose resolved value changed. Equality is strict `===`; object-valued
* cells are compared by reference. The currently-edited cell is never
* flashed. User-initiated cell edits do not flash.
*
* Requires `rowKey` to be set — without it, the feature silently no-ops
* and renders a visible dev warning banner. Honors `prefers-reduced-motion`
* (fades are replaced with a static background hint of the same duration).
*/
@property({ type: String, attribute: 'highlight-updates' })
accessor highlightUpdates: 'none' | 'flash' = 'none';
/**
* Duration of the flash animation in milliseconds. Fed into the
* `--dees-table-flash-duration` CSS variable on the host.
*/
@property({ type: Number, attribute: 'highlight-duration' })
accessor highlightDuration: number = 900;
/**
* When set, the table renders inside a fixed-height scroll container
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -268,6 +292,23 @@ export class DeesTable<T> extends DeesElement {
@state()
private accessor __floatingActive: boolean = false;
// ─── Flash-on-update state (only populated when highlightUpdates === 'flash') ──
/** rowId → set of colKey strings currently flashing. */
@state()
private accessor __flashingCells: Map<string, Set<string>> = new Map();
/** rowId → (colKey → last-seen resolved cell value). Populated per diff pass. */
private __prevSnapshot?: Map<string, Map<string, unknown>>;
/** Single shared timer that clears __flashingCells after highlightDuration ms. */
private __flashClearTimer?: ReturnType<typeof setTimeout>;
/** Monotonic counter bumped each flash batch so directives.keyed recreates the cell node and restarts the animation. */
private __flashTick: number = 0;
/** One-shot console.warn gate for missing rowKey in flash mode. */
private __flashWarnedNoRowKey: boolean = false;
// ─── Render memoization ──────────────────────────────────────────────
// These caches let render() short-circuit when the relevant inputs
// (by reference) haven't changed. They are NOT @state — mutating them
@@ -557,6 +598,15 @@ export class DeesTable<T> extends DeesElement {
</div>
</div>
<div class="headingSeparation"></div>
${this.highlightUpdates === 'flash' && !this.rowKey
? html`<div class="flashConfigWarning" role="alert">
<dees-icon .icon=${'lucide:triangleAlert'}></dees-icon>
<span>
<code>highlight-updates="flash"</code> requires
<code>rowKey</code> to be set. Flash is disabled.
</span>
</div>`
: html``}
<div class="searchGrid hidden">
<dees-input-text
.label=${'lucene syntax search'}
@@ -606,9 +656,13 @@ export class DeesTable<T> extends DeesElement {
${useVirtual && topSpacerHeight > 0
? html`<tr aria-hidden="true" style="height:${topSpacerHeight}px"><td></td></tr>`
: html``}
${renderRows.map((itemArg, sliceIdx) => {
${directives.repeat(
renderRows,
(itemArg, sliceIdx) => `${this.getRowId(itemArg)}::${renderStart + sliceIdx}`,
(itemArg, sliceIdx) => {
const rowIndex = renderStart + sliceIdx;
const rowId = this.getRowId(itemArg);
const flashSet = this.__flashingCells.get(rowId);
return html`
<tr
data-row-idx=${rowIndex}
@@ -640,6 +694,7 @@ export class DeesTable<T> extends DeesElement {
const isEditing =
this.__editingCell?.rowId === rowId &&
this.__editingCell?.colKey === editKey;
const isFlashing = !!flashSet?.has(editKey);
const cellClasses = [
isEditable ? 'editable' : '',
isFocused && !isEditing ? 'focused' : '',
@@ -647,14 +702,22 @@ export class DeesTable<T> extends DeesElement {
]
.filter(Boolean)
.join(' ');
const innerHtml = html`<div
class=${isFlashing ? 'innerCellContainer flashing' : 'innerCellContainer'}
>
${isEditing ? this.renderCellEditor(itemArg, col) : content}
</div>`;
return html`
<td
class=${cellClasses}
data-col-key=${editKey}
>
<div class="innerCellContainer">
${isEditing ? this.renderCellEditor(itemArg, col) : content}
</div>
${isFlashing
? directives.keyed(
`${rowId}:${editKey}:${this.__flashTick}`,
innerHtml
)
: innerHtml}
</td>
`;
})}
@@ -685,7 +748,8 @@ export class DeesTable<T> extends DeesElement {
}
})()}
</tr>`;
})}
}
)}
${useVirtual && bottomSpacerHeight > 0
? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
: html``}
@@ -801,7 +865,7 @@ export class DeesTable<T> extends DeesElement {
const key = String(col.key);
if (col.filterable === false) return html`<th></th>`;
return html`<th>
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
<input type="text" placeholder="Filter..." data-col-key=${key} .value=${this.columnFilters[key] || ''}
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
</th>`;
})}
@@ -957,6 +1021,84 @@ export class DeesTable<T> extends DeesElement {
if (fh) fh.classList.remove('active');
}
/**
* If a filter `<input>` inside the floating-header clone currently has
* focus, copy its value, caret, and selection range onto the matching
* input in the real header, then focus that real input. This lets the
* user keep typing uninterrupted when filter input causes the table to
* shrink below the viewport stick line and the floating header has to
* unmount.
*
* Safe to call at any time — it is a no-op unless an input inside the
* floating header is focused and has a `data-col-key` attribute that
* matches a real-header input.
*/
private __transferFocusToRealHeader(): void {
const fh = this.__floatingHeaderEl;
if (!fh) return;
const active = this.shadowRoot?.activeElement as HTMLElement | null;
if (!active || !fh.contains(active)) return;
const colKey = active.getAttribute('data-col-key');
if (!colKey) return;
const fromInput = active as HTMLInputElement;
const real = this.shadowRoot?.querySelector(
`.tableScroll > table > thead input[data-col-key="${CSS.escape(colKey)}"]`
) as HTMLInputElement | null;
if (!real || real === fromInput) return;
const selStart = fromInput.selectionStart;
const selEnd = fromInput.selectionEnd;
const selDir = fromInput.selectionDirection as any;
real.focus({ preventScroll: true });
try {
if (selStart != null && selEnd != null) {
real.setSelectionRange(selStart, selEnd, selDir || undefined);
}
} catch {
/* setSelectionRange throws on unsupported input types — ignore */
}
}
/**
* Symmetric counterpart to `__transferFocusToRealHeader`. When the
* floating header has just activated and a real-header filter input
* was focused (and is now scrolled off-screen behind the floating
* clone), move focus to the clone's matching input so the user keeps
* typing in the visible one.
*
* Called from `__syncFloatingHeader` inside the post-activation
* `updateComplete` callback — by then the clone subtree exists in the
* DOM and can receive focus.
*/
private __transferFocusToFloatingHeader(): void {
const fh = this.__floatingHeaderEl;
if (!fh || !this.__floatingActive) return;
const active = this.shadowRoot?.activeElement as HTMLElement | null;
if (!active) return;
// Only handle focus that lives in the real header (not already in the clone).
const realThead = this.shadowRoot?.querySelector(
'.tableScroll > table > thead'
) as HTMLElement | null;
if (!realThead || !realThead.contains(active)) return;
const colKey = active.getAttribute('data-col-key');
if (!colKey) return;
const fromInput = active as HTMLInputElement;
const clone = fh.querySelector(
`input[data-col-key="${CSS.escape(colKey)}"]`
) as HTMLInputElement | null;
if (!clone || clone === fromInput) return;
const selStart = fromInput.selectionStart;
const selEnd = fromInput.selectionEnd;
const selDir = fromInput.selectionDirection as any;
clone.focus({ preventScroll: true });
try {
if (selStart != null && selEnd != null) {
clone.setSelectionRange(selStart, selEnd, selDir || undefined);
}
} catch {
/* ignore */
}
}
// ─── Virtualization ─────────────────────────────────────────────────
/**
@@ -1062,6 +1204,15 @@ export class DeesTable<T> extends DeesElement {
const shouldBeActive = tableRect.top < stick.top && distance > 0;
if (shouldBeActive !== this.__floatingActive) {
if (!shouldBeActive) {
// Before we flag the clone for unmount, hand off any focused
// filter input to its counterpart in the real header. This is the
// "user is typing in a sticky filter input, filter shrinks the
// table so the floating header hides" case — without this
// handoff the user's focus (and caret position) would be lost
// when the clone unmounts.
this.__transferFocusToRealHeader();
}
this.__floatingActive = shouldBeActive;
fh.classList.toggle('active', shouldBeActive);
if (!shouldBeActive) {
@@ -1072,8 +1223,14 @@ export class DeesTable<T> extends DeesElement {
}
if (shouldBeActive) {
// Clone subtree doesn't exist yet — wait for the next render to
// materialize it, then complete geometry sync.
this.updateComplete.then(() => this.__syncFloatingHeader());
// materialize it, then complete geometry sync. Additionally, if a
// real-header filter input was focused when we activated, hand
// off to the clone once it exists so the user keeps typing in
// the visible (floating) input.
this.updateComplete.then(() => {
this.__syncFloatingHeader();
this.__transferFocusToFloatingHeader();
});
return;
}
}
@@ -1127,6 +1284,10 @@ export class DeesTable<T> extends DeesElement {
public async disconnectedCallback() {
super.disconnectedCallback();
this.teardownFloatingHeader();
if (this.__flashClearTimer) {
clearTimeout(this.__flashClearTimer);
this.__flashClearTimer = undefined;
}
}
public async firstUpdated() {
@@ -1134,9 +1295,141 @@ export class DeesTable<T> extends DeesElement {
// table markup actually exists (it only renders when data.length > 0).
}
/**
* Runs before each render. Drives two independent concerns:
*
* 1. **Selection rebind** — when `data` is reassigned to a fresh array
* (typical live-data pattern), `selectedDataRow` still points at the
* stale row object from the old array. We re-resolve it by rowKey so
* consumers of `selectedDataRow` (footer indicator, header/footer
* actions, copy fallback) see the live reference. `selectedIds`,
* `__focusedCell`, `__editingCell`, `__selectionAnchorId` are all
* keyed by string rowId and persist automatically — no change needed.
* This runs regardless of `highlightUpdates` — it is a baseline
* correctness fix for live data.
*
* 2. **Flash diff** — when `highlightUpdates === 'flash'`, diff the new
* data against `__prevSnapshot` and populate `__flashingCells` with
* the (rowId, colKey) pairs whose resolved cell value changed. A
* single shared timer clears `__flashingCells` after
* `highlightDuration` ms. Skipped if `rowKey` is missing (with a
* one-shot console.warn; the render surface also shows a warning
* banner).
*/
public willUpdate(changedProperties: Map<string | number | symbol, unknown>): void {
// --- Phase 1: selection rebind (always runs) ---
if (changedProperties.has('data') && this.selectedDataRow && this.rowKey) {
const prevId = this.getRowId(this.selectedDataRow);
let found: T | undefined;
for (const row of this.data) {
if (this.getRowId(row) === prevId) {
found = row;
break;
}
}
if (found) {
if (found !== this.selectedDataRow) this.selectedDataRow = found;
} else {
this.selectedDataRow = undefined as unknown as T;
}
}
// --- Phase 2: flash diff ---
if (this.highlightUpdates !== 'flash') {
// Mode was toggled off (or never on) — drop any lingering state so
// re-enabling later starts with a clean slate.
if (this.__prevSnapshot || this.__flashingCells.size > 0) {
this.__prevSnapshot = undefined;
if (this.__flashingCells.size > 0) this.__flashingCells = new Map();
if (this.__flashClearTimer) {
clearTimeout(this.__flashClearTimer);
this.__flashClearTimer = undefined;
}
}
return;
}
if (!this.rowKey) {
if (!this.__flashWarnedNoRowKey) {
this.__flashWarnedNoRowKey = true;
console.warn(
'[dees-table] highlightUpdates="flash" requires `rowKey` to be set. Flash is disabled. ' +
'Set the rowKey property/attribute to a stable identifier on your row data (e.g. `rowKey="id"`).'
);
}
return;
}
if (!changedProperties.has('data')) return;
const effectiveColumns = this.__getEffectiveColumns();
const visibleCols = effectiveColumns.filter((c) => !c.hidden);
const nextSnapshot = new Map<string, Map<string, unknown>>();
const newlyFlashing = new Map<string, Set<string>>();
for (const row of this.data) {
const rowId = this.getRowId(row);
const cellMap = new Map<string, unknown>();
for (const col of visibleCols) {
cellMap.set(String(col.key), getCellValueFn(row, col, this.displayFunction));
}
nextSnapshot.set(rowId, cellMap);
const prevCells = this.__prevSnapshot?.get(rowId);
if (!prevCells) continue; // new row — not an "update"
for (const [colKey, nextVal] of cellMap) {
if (prevCells.get(colKey) !== nextVal) {
// Don't flash the cell the user is actively editing.
if (
this.__editingCell &&
this.__editingCell.rowId === rowId &&
this.__editingCell.colKey === colKey
) continue;
let set = newlyFlashing.get(rowId);
if (!set) {
set = new Set();
newlyFlashing.set(rowId, set);
}
set.add(colKey);
}
}
}
const hadPrev = !!this.__prevSnapshot;
this.__prevSnapshot = nextSnapshot;
if (!hadPrev) return; // first time seeing data — no flashes
if (newlyFlashing.size === 0) return;
// Merge with any in-flight flashes from a rapid second update so a cell
// that changes twice before its animation ends gets a single clean
// restart (via __flashTick / directives.keyed) instead of stacking.
for (const [rowId, cols] of newlyFlashing) {
const existing = this.__flashingCells.get(rowId);
if (existing) {
for (const c of cols) existing.add(c);
} else {
this.__flashingCells.set(rowId, cols);
}
}
this.__flashTick++;
// Reactivity nudge: we've mutated the Map in place, so give Lit a fresh
// reference so the @state change fires for render.
this.__flashingCells = new Map(this.__flashingCells);
if (this.__flashClearTimer) clearTimeout(this.__flashClearTimer);
this.__flashClearTimer = setTimeout(() => {
this.__flashingCells = new Map();
this.__flashClearTimer = undefined;
}, Math.max(0, this.highlightDuration));
}
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
// Feed highlightDuration into the CSS variable so JS and CSS stay in
// sync via a single source of truth.
if (changedProperties.has('highlightDuration')) {
this.style.setProperty('--dees-table-flash-duration', `${this.highlightDuration}ms`);
}
// Only re-measure column widths when the data or schema actually changed
// (or on first paint). `determineColumnWidths` is the single biggest
// first-paint cost — it forces multiple layout flushes per row.
@@ -2090,6 +2383,10 @@ export class DeesTable<T> extends DeesElement {
}
if (parsed !== oldValue) {
(item as any)[col.key] = parsed;
// Keep the flash-diff snapshot in sync so the next external update
// does not see this user edit as an external change (which would
// otherwise flash the cell the user just typed into).
this.__recordCellInSnapshot(item, col);
this.dispatchEvent(
new CustomEvent('cellEdit', {
detail: { row: item, key, oldValue, newValue: parsed },
@@ -2103,6 +2400,24 @@ export class DeesTable<T> extends DeesElement {
this.requestUpdate();
}
/**
* Updates the flash diff snapshot for a single cell to match its current
* resolved value. Called from `commitCellEdit` so a user-initiated edit
* does not register as an external change on the next diff pass.
* No-op when flash mode is off or no snapshot exists yet.
*/
private __recordCellInSnapshot(item: T, col: Column<T>): void {
if (this.highlightUpdates !== 'flash' || !this.__prevSnapshot) return;
if (!this.rowKey) return;
const rowId = this.getRowId(item);
let cellMap = this.__prevSnapshot.get(rowId);
if (!cellMap) {
cellMap = new Map();
this.__prevSnapshot.set(rowId, cellMap);
}
cellMap.set(String(col.key), getCellValueFn(item, col, this.displayFunction));
}
/** Renders the appropriate dees-input-* component for this column. */
private renderCellEditor(item: T, col: Column<T>): TemplateResult {
const raw = (item as any)[col.key];