Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5948fc83ea | |||
| 72e3d6a09e | |||
| de6f4a3ac5 | |||
| eecdc51557 | |||
| c841c49e1e | |||
| 2595d822d0 | |||
| 3ae0541065 | |||
| 4b735b768a | |||
| 9422edbfa1 | |||
| 37c5e92d6d | |||
| c7503de11e |
31
changelog.md
31
changelog.md
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-08 - 3.70.0 - feat(dees-table)
|
||||
add opt-in flash highlighting for updated table cells
|
||||
|
||||
- introduces highlight-updates and highlight-duration properties for diff-based cell update highlighting
|
||||
- adds a warning banner when flash highlighting is enabled without rowKey
|
||||
- keeps selection stable across data refreshes and avoids flashing user-edited cells
|
||||
- includes a live demo showcasing flashing updates and reduced-motion support
|
||||
|
||||
## 2026-04-08 - 3.69.1 - fix(ui)
|
||||
refine heading emphasis and animate app dashboard subview expansion
|
||||
|
||||
- Adjust heading color hierarchy so h1-h2 use primary text while h3-h6 use secondary text, and reduce h1 font weight for better visual balance
|
||||
- Replace app dashboard subview conditional rendering with animated expand/collapse behavior using grid transitions and inert state handling
|
||||
|
||||
## 2026-04-08 - 3.69.0 - feat(dees-heading)
|
||||
add numeric aliases for horizontal rule heading levels and refine heading spacing styles
|
||||
|
||||
- Support level="7" as an alias for "hr" and level="8" as an alias for "hr-small".
|
||||
- Update heading and hr variant styles to use design tokens for spacing and colors, with per-level margin tuning.
|
||||
- Extend the demo to show both named and numeric hr heading level variants.
|
||||
|
||||
## 2026-04-08 - 3.68.0 - feat(dees-simple-appdash)
|
||||
add nested sidebar subviews and preserve submit labels from slotted text
|
||||
|
||||
- support grouped navigation items with expandable subviews and parent-to-first-subview fallback in the app dashboard
|
||||
- allow dees-form-submit to derive its button text from light DOM content when no explicit text property is set
|
||||
|
||||
## 2026-04-07 - 3.67.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-07 - 3.67.0 - feat(dees-table)
|
||||
improve inline cell editors with integrated input styling and auto-open dropdowns
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "3.67.0",
|
||||
"version": "3.70.0",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '3.67.0',
|
||||
version: '3.70.0',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ITableAction } from './dees-table.js';
|
||||
import * as plugins from '../../00plugins.js';
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
interface ITableDemoData {
|
||||
date: string;
|
||||
@@ -742,6 +743,71 @@ export const demoFunc = () => html`
|
||||
] as ITableAction[]}
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const tableEl = elementArg.querySelector('#demoLiveFlash') as any;
|
||||
if (!tableEl) return;
|
||||
// Guard against double-start if runAfterRender fires more than once
|
||||
// (e.g. across hot-reload cycles).
|
||||
if (tableEl.__liveFlashTimerId) {
|
||||
window.clearInterval(tableEl.__liveFlashTimerId);
|
||||
}
|
||||
const tick = () => {
|
||||
if (!Array.isArray(tableEl.data) || tableEl.data.length === 0) return;
|
||||
const next = tableEl.data.map((r: any) => ({ ...r }));
|
||||
const count = 1 + Math.floor(Math.random() * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = Math.floor(Math.random() * next.length);
|
||||
const delta = +((Math.random() * 2 - 1) * 3).toFixed(2);
|
||||
const newPrice = Math.max(1, +(next[idx].price + delta).toFixed(2));
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
price: newPrice,
|
||||
change: delta,
|
||||
updatedAt: new Date().toLocaleTimeString(),
|
||||
};
|
||||
}
|
||||
tableEl.data = next;
|
||||
};
|
||||
tableEl.__liveFlashTimerId = window.setInterval(tick, 1500);
|
||||
}}>
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Live Updates with Flash Highlighting</h2>
|
||||
<p class="demo-description">
|
||||
Opt-in cell-flash via <code>highlight-updates="flash"</code>. The ticker below mutates
|
||||
random rows every 1.5s and reassigns <code>.data</code>. Updated cells briefly flash
|
||||
amber and fade out. Requires <code>rowKey</code> (here <code>"symbol"</code>). Honors
|
||||
<code>prefers-reduced-motion</code>. Row selection persists across updates — click a
|
||||
row, then watch it stay selected as the data churns.
|
||||
</p>
|
||||
<dees-table
|
||||
id="demoLiveFlash"
|
||||
.rowKey=${'symbol'}
|
||||
highlight-updates="flash"
|
||||
.selectionMode=${'multi'}
|
||||
heading1="Live Market Feed"
|
||||
heading2="Flashing cells indicate updated values"
|
||||
.columns=${[
|
||||
{ key: 'symbol', header: 'Symbol', sortable: true },
|
||||
{ key: 'price', header: 'Price', sortable: true },
|
||||
{ key: 'change', header: 'Δ', sortable: true },
|
||||
{ key: 'updatedAt', header: 'Updated' },
|
||||
]}
|
||||
.data=${[
|
||||
{ symbol: 'AAPL', price: 182.52, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'MSFT', price: 414.18, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'GOOG', price: 168.74, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'AMZN', price: 186.13, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'TSLA', price: 248.50, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'NVDA', price: 877.35, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'META', price: 492.96, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'NFLX', price: 605.88, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'AMD', price: 165.24, change: 0, updatedAt: '—' },
|
||||
{ symbol: 'INTC', price: 42.15, change: 0, updatedAt: '—' },
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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``}
|
||||
@@ -768,7 +832,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
${effectiveColumns
|
||||
.filter((c) => !c.hidden)
|
||||
.map((col) => {
|
||||
const isSortable = !!col.sortable;
|
||||
const isSortable = col.sortable !== false;
|
||||
const ariaSort = this.getAriaSort(col);
|
||||
return html`
|
||||
<th
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1053,18 +1195,42 @@ export class DeesTable<T> extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Active when the table top is above the stick line and the table bottom
|
||||
// hasn't yet scrolled past it.
|
||||
const shouldBeActive =
|
||||
tableRect.top < stick.top && tableRect.bottom > stick.top + Math.min(headerHeight, 1);
|
||||
// Active when the table top is above the stick line and any pixel of the
|
||||
// table still sits below it. As the table's bottom edge approaches the
|
||||
// stick line we shrink the floating container and slide the cloned header
|
||||
// up inside it, so the header appears to scroll off with the table
|
||||
// instead of snapping away in one frame.
|
||||
const distance = tableRect.bottom - stick.top;
|
||||
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) {
|
||||
// Reset inline geometry so the next activation starts clean.
|
||||
fh.style.height = '';
|
||||
const ft = this.__floatingTableEl;
|
||||
if (ft) ft.style.transform = '';
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1100,15 +1266,28 @@ export class DeesTable<T> extends DeesElement {
|
||||
fh.style.left = `${clipLeft}px`;
|
||||
fh.style.width = `${clipWidth}px`;
|
||||
|
||||
// Exit animation: when the table's bottom edge is within `headerHeight`
|
||||
// pixels of the stick line, shrink the container and translate the
|
||||
// inner table up by the same amount. overflow:hidden on .floatingHeader
|
||||
// clips the overflow, producing a scroll-off effect.
|
||||
const visibleHeight = Math.min(headerHeight, distance);
|
||||
const exitOffset = headerHeight - visibleHeight;
|
||||
fh.style.height = `${visibleHeight}px`;
|
||||
|
||||
// The inner table is positioned so the visible region matches the real
|
||||
// table's left edge — shift it left when we clipped to the container.
|
||||
floatTable.style.width = `${tableRect.width}px`;
|
||||
floatTable.style.marginLeft = `${tableRect.left - clipLeft}px`;
|
||||
floatTable.style.transform = exitOffset > 0 ? `translateY(-${exitOffset}px)` : '';
|
||||
}
|
||||
|
||||
public async disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.teardownFloatingHeader();
|
||||
if (this.__flashClearTimer) {
|
||||
clearTimeout(this.__flashClearTimer);
|
||||
this.__flashClearTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
@@ -1116,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.
|
||||
@@ -1496,7 +1807,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
// Maximum exposed slot: one beyond the current cascade, capped at the
|
||||
// number of sortable columns. If the column is already in the cascade we
|
||||
// never need to grow the slot count.
|
||||
const sortableColumnCount = effectiveColumns.filter((c) => !!c.sortable).length;
|
||||
const sortableColumnCount = effectiveColumns.filter((c) => c.sortable !== false).length;
|
||||
const maxSlot = Math.min(
|
||||
Math.max(cascadeLen + (existing ? 0 : 1), 1),
|
||||
Math.max(sortableColumnCount, 1)
|
||||
@@ -1602,6 +1913,17 @@ export class DeesTable<T> extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
items.push({ divider: true });
|
||||
items.push({
|
||||
name: this.showColumnFilters ? 'Hide column filters' : 'Show column filters',
|
||||
iconName: this.showColumnFilters ? 'lucide:filterX' : 'lucide:filter',
|
||||
action: async () => {
|
||||
this.showColumnFilters = !this.showColumnFilters;
|
||||
this.requestUpdate();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -2061,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 },
|
||||
@@ -2074,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];
|
||||
|
||||
@@ -373,6 +373,72 @@ export const tableStyles: CSSResult[] = [
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* ---- Cell flash highlighting (opt-in via highlight-updates="flash") ----
|
||||
Bloomberg/TradingView-style: the text itself briefly takes an accent
|
||||
color then fades back to the default. No background tint, no layout
|
||||
shift, no weight change. Readable, modern, subtle.
|
||||
Consumers can override per instance:
|
||||
dees-table#myTable { --dees-table-flash-color: hsl(142 76% 40%); }
|
||||
*/
|
||||
:host {
|
||||
--dees-table-flash-color: ${cssManager.bdTheme(
|
||||
'hsl(32 95% 44%)',
|
||||
'hsl(45 93% 62%)'
|
||||
)};
|
||||
--dees-table-flash-easing: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.innerCellContainer.flashing {
|
||||
animation: dees-table-cell-flash
|
||||
var(--dees-table-flash-duration, 900ms)
|
||||
var(--dees-table-flash-easing);
|
||||
}
|
||||
|
||||
/* Hold the accent color briefly, then fade back to the theme's default
|
||||
text color. Inherits to child text and to SVG icons that use
|
||||
currentColor. Cells with explicit color overrides in renderers are
|
||||
intentionally unaffected. */
|
||||
@keyframes dees-table-cell-flash {
|
||||
0%,
|
||||
35% { color: var(--dees-table-flash-color); }
|
||||
100% { color: var(--dees-color-text-primary); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.innerCellContainer.flashing {
|
||||
animation: none;
|
||||
color: var(--dees-table-flash-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dev-time warning banner shown when highlight-updates="flash" but
|
||||
rowKey is missing. Consumers should never ship this to production. */
|
||||
.flashConfigWarning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 16px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 3px solid ${cssManager.bdTheme('hsl(38 92% 50%)', 'hsl(48 96% 63%)')};
|
||||
background: ${cssManager.bdTheme('hsl(48 96% 89% / 0.6)', 'hsl(48 96% 30% / 0.15)')};
|
||||
color: ${cssManager.bdTheme('hsl(32 81% 29%)', 'hsl(48 96% 80%)')};
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.flashConfigWarning dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.flashConfigWarning code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.6)', 'hsl(0 0% 0% / 0.3)')};
|
||||
font-family: ${cssGeistFontFamily};
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Editable cell affordances */
|
||||
td.editable {
|
||||
cursor: text;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Column<T = any> {
|
||||
header?: string | TemplateResult;
|
||||
value?: (row: T) => any;
|
||||
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
|
||||
/** Whether this column can be sorted by clicking its header. Defaults to `true`; set to `false` to disable. */
|
||||
sortable?: boolean;
|
||||
/** whether this column participates in per-column quick filtering (default: true) */
|
||||
filterable?: boolean;
|
||||
|
||||
@@ -75,12 +75,23 @@ export class DeesFormSubmit extends DeesElement {
|
||||
.text=${this.text}
|
||||
?disabled=${this.disabled}
|
||||
@clicked=${this.submit}
|
||||
>
|
||||
<slot></slot>
|
||||
</dees-button>
|
||||
></dees-button>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Capture light DOM text content as the button label. dees-button wipes
|
||||
// its own light DOM during extractLightDom(), so we cannot simply forward
|
||||
// a <slot> into it — we have to hoist the text onto the .text property
|
||||
// ourselves before handing it to dees-button.
|
||||
if (!this.text) {
|
||||
const slotText = this.textContent?.trim();
|
||||
if (slotText) {
|
||||
this.text = slotText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,9 @@ export function demoFunc() {
|
||||
<dees-heading level="4">This is a H4 heading</dees-heading>
|
||||
<dees-heading level="5">This is a H5 heading</dees-heading>
|
||||
<dees-heading level="6">This is a H6 heading</dees-heading>
|
||||
<dees-heading level="hr">This is an hr heading</dees-heading>
|
||||
<dees-heading level="hr-small">This is an hr small heading</dees-heading>
|
||||
<dees-heading level="hr">This is an hr heading (level="hr")</dees-heading>
|
||||
<dees-heading level="7">This is an hr heading (level="7")</dees-heading>
|
||||
<dees-heading level="hr-small">This is an hr-small heading (level="hr-small")</dees-heading>
|
||||
<dees-heading level="8">This is an hr-small heading (level="8")</dees-heading>
|
||||
`;
|
||||
}
|
||||
@@ -27,68 +27,104 @@ export class DeesHeading extends DeesElement {
|
||||
|
||||
// properties
|
||||
/**
|
||||
* Heading level: 1-6 for h1-h6, or 'hr' for horizontal rule style
|
||||
* Heading level:
|
||||
* '1'-'6' → <h1>..<h6>
|
||||
* '7'|'hr' → horizontal-rule style heading
|
||||
* '8'|'hr-small' → small horizontal-rule style heading
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
accessor level: '1' | '2' | '3' | '4' | '5' | '6' | 'hr' | 'hr-small' = '1';
|
||||
accessor level: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | 'hr' | 'hr-small' = '1';
|
||||
|
||||
// STATIC STYLES
|
||||
public static styles: CSSResult[] = [
|
||||
themeDefaultStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
/* TODO: Migrate hardcoded values to --dees-* CSS variables */
|
||||
/* Heading styles */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
h1 { font-size: 32px; font-family: ${cssCalSansFontFamily}; letter-spacing: 0.025em;}
|
||||
h2 { font-size: 28px; }
|
||||
h3 { font-size: 24px; }
|
||||
h4 { font-size: 20px; }
|
||||
h5 { font-size: 16px; }
|
||||
h6 { font-size: 14px; }
|
||||
|
||||
/* Heading styles.
|
||||
* Color hierarchy: h1-h2 stay prominent with text-primary; h3-h6 step
|
||||
* down to text-secondary so they read as subheadings instead of
|
||||
* mini-h1s. Keeps the visual loudness out of smaller headings. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
}
|
||||
h1, h2 {
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
h3, h4, h5, h6 {
|
||||
color: var(--dees-color-text-secondary);
|
||||
}
|
||||
|
||||
/* Per-level typography + spacing.
|
||||
* Margin scales with importance: h1 gets the most breathing room,
|
||||
* h6 the least. Top margin > bottom margin so headings group with
|
||||
* the content that follows them. */
|
||||
h1 {
|
||||
/* h1 uses weight 500, not 600: the Cal Sans display font is
|
||||
* already stylized enough that bold + max contrast + 32px stacks
|
||||
* too much emphasis. 500 keeps the typographic impact without
|
||||
* shouting. */
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
font-family: ${cssCalSansFontFamily};
|
||||
letter-spacing: 0.025em;
|
||||
margin: var(--dees-spacing-2xl) 0 var(--dees-spacing-lg);
|
||||
}
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
margin: var(--dees-spacing-xl) 0 var(--dees-spacing-md);
|
||||
}
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
margin: var(--dees-spacing-xl) 0 var(--dees-spacing-md);
|
||||
}
|
||||
h4 {
|
||||
font-size: 20px;
|
||||
margin: var(--dees-spacing-lg) 0 var(--dees-spacing-sm);
|
||||
}
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
margin: var(--dees-spacing-md) 0 var(--dees-spacing-sm);
|
||||
}
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
margin: var(--dees-spacing-md) 0 var(--dees-spacing-xs);
|
||||
}
|
||||
|
||||
/* Horizontal rule style heading */
|
||||
.heading-hr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
color: ${cssManager.bdTheme('#999', '#555')};
|
||||
margin: var(--dees-spacing-lg) 0;
|
||||
color: var(--dees-color-text-muted);
|
||||
}
|
||||
/* Fade lines toward and away from text for hr style */
|
||||
.heading-hr::before {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
/* fade in toward center */
|
||||
background: ${cssManager.bdTheme(
|
||||
'linear-gradient(to right, transparent, #ccc)',
|
||||
'linear-gradient(to right, transparent, #333)'
|
||||
)};
|
||||
margin: 0 8px;
|
||||
background: linear-gradient(to right, transparent, var(--dees-color-border-strong));
|
||||
margin: 0 var(--dees-spacing-sm);
|
||||
}
|
||||
.heading-hr::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
/* fade out away from center */
|
||||
background: ${cssManager.bdTheme(
|
||||
'linear-gradient(to right, #ccc, transparent)',
|
||||
'linear-gradient(to right, #333, transparent)'
|
||||
)};
|
||||
margin: 0 8px;
|
||||
background: linear-gradient(to right, var(--dees-color-border-strong), transparent);
|
||||
margin: 0 var(--dees-spacing-sm);
|
||||
}
|
||||
/* Small hr variant with reduced margins */
|
||||
.heading-hr.heading-hr-small {
|
||||
margin: 8px 0;
|
||||
margin: var(--dees-spacing-sm) 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.heading-hr.heading-hr-small::before,
|
||||
.heading-hr.heading-hr-small::after {
|
||||
margin: 0 8px;
|
||||
margin: 0 var(--dees-spacing-sm);
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -109,8 +145,10 @@ export class DeesHeading extends DeesElement {
|
||||
return html`<h5><slot></slot></h5>`;
|
||||
case '6':
|
||||
return html`<h6><slot></slot></h6>`;
|
||||
case '7':
|
||||
case 'hr':
|
||||
return html`<div class="heading-hr"><slot></slot></div>`;
|
||||
case '8':
|
||||
case 'hr-small':
|
||||
return html`<div class="heading-hr heading-hr-small"><slot></slot></div>`;
|
||||
default:
|
||||
|
||||
@@ -353,12 +353,35 @@ export const demoFunc = () => html`
|
||||
name: 'Analytics',
|
||||
iconName: 'lucide:lineChart',
|
||||
element: DemoViewAnalytics,
|
||||
subViews: [
|
||||
{
|
||||
name: 'Overview',
|
||||
iconName: 'lucide:activity',
|
||||
element: DemoViewAnalytics,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
iconName: 'lucide:fileText',
|
||||
element: DemoViewDashboard,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
element: DemoViewSettings,
|
||||
}
|
||||
subViews: [
|
||||
{
|
||||
name: 'Profile',
|
||||
iconName: 'lucide:user',
|
||||
element: DemoViewSettings,
|
||||
},
|
||||
{
|
||||
name: 'Billing',
|
||||
iconName: 'lucide:creditCard',
|
||||
element: DemoViewSettings,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as IView[]}
|
||||
@logout=${() => {
|
||||
console.log('Logout event triggered');
|
||||
|
||||
@@ -26,7 +26,8 @@ declare global {
|
||||
export interface IView {
|
||||
name: string;
|
||||
iconName?: string;
|
||||
element: DeesElement['constructor']['prototype'];
|
||||
element?: DeesElement['constructor']['prototype'];
|
||||
subViews?: IView[];
|
||||
}
|
||||
|
||||
export type TGlobalMessageType = 'info' | 'success' | 'warning' | 'error';
|
||||
@@ -250,6 +251,78 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewTab .chevron {
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.viewTab.hasSubs:hover .chevron {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.viewTab.hasSubs.groupActive .chevron {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.subViews {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
margin-left: 12px;
|
||||
position: relative;
|
||||
transition:
|
||||
grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
margin-top 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
margin-bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.subViews.expanded {
|
||||
grid-template-rows: 1fr;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subViews::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: var(--dees-color-border-default);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.subViews.expanded::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subViews-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.viewTab.sub {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.viewTab.sub dees-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.viewTab.sub.selected::before {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.appActions {
|
||||
padding: 12px 8px;
|
||||
border-top: 1px solid var(--dees-color-border-default);
|
||||
@@ -563,10 +636,12 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
<div class="viewTabs-container">
|
||||
<div class="section-label">Navigation</div>
|
||||
<div class="viewTabs">
|
||||
${this.viewTabs.map(
|
||||
(view) => html`
|
||||
${this.viewTabs.map((view) => {
|
||||
const hasSubs = !!view.subViews?.length;
|
||||
const groupActive = hasSubs && this.isGroupActive(view);
|
||||
return html`
|
||||
<div
|
||||
class="viewTab ${this.selectedView === view ? 'selected' : ''}"
|
||||
class="viewTab ${this.selectedView === view ? 'selected' : ''} ${hasSubs ? 'hasSubs' : ''} ${groupActive ? 'groupActive' : ''}"
|
||||
@click=${() => this.loadView(view)}
|
||||
>
|
||||
${view.iconName ? html`
|
||||
@@ -575,9 +650,39 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
<dees-icon .icon="${'lucide:file'}"></dees-icon>
|
||||
`}
|
||||
<span>${view.name}</span>
|
||||
${hasSubs ? html`
|
||||
<dees-icon class="chevron" .icon="${'lucide:chevronDown'}"></dees-icon>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
${hasSubs ? html`
|
||||
<div
|
||||
class="subViews ${groupActive ? 'expanded' : ''}"
|
||||
?inert=${!groupActive}
|
||||
>
|
||||
<div class="subViews-inner">
|
||||
${view.subViews!.map(
|
||||
(sub) => html`
|
||||
<div
|
||||
class="viewTab sub ${this.selectedView === sub ? 'selected' : ''}"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.loadView(sub);
|
||||
}}
|
||||
>
|
||||
${sub.iconName ? html`
|
||||
<dees-icon .icon="${sub.iconName.includes(':') ? sub.iconName : `lucide:${sub.iconName}`}"></dees-icon>
|
||||
` : html`
|
||||
<dees-icon .icon="${'lucide:dot'}"></dees-icon>
|
||||
`}
|
||||
<span>${sub.name}</span>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="appActions">
|
||||
@@ -771,8 +876,23 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
}
|
||||
|
||||
|
||||
private isGroupActive(view: IView): boolean {
|
||||
if (this.selectedView === view) return true;
|
||||
return view.subViews?.some((sv) => sv === this.selectedView) ?? false;
|
||||
}
|
||||
|
||||
private currentView!: DeesElement;
|
||||
public async loadView(viewArg: IView) {
|
||||
// Group-only parent: resolve to first sub view with an element
|
||||
if (!viewArg.element && viewArg.subViews?.length) {
|
||||
const firstNavigable = viewArg.subViews.find((sv) => sv.element);
|
||||
if (firstNavigable) {
|
||||
return this.loadView(firstNavigable);
|
||||
}
|
||||
return; // nothing navigable — ignore click
|
||||
}
|
||||
if (!viewArg.element) return; // safety: no element and no subs → no-op
|
||||
|
||||
const appcontent = this.shadowRoot!.querySelector('.appcontent')!;
|
||||
const view = new viewArg.element();
|
||||
if (this.currentView) {
|
||||
@@ -781,7 +901,7 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
appcontent.appendChild(view);
|
||||
this.currentView = view;
|
||||
this.selectedView = viewArg;
|
||||
|
||||
|
||||
// Emit view-select event
|
||||
this.dispatchEvent(new CustomEvent('view-select', {
|
||||
detail: { view: viewArg },
|
||||
|
||||
Reference in New Issue
Block a user