Compare commits

...

4 Commits

Author SHA1 Message Date
2f95979cc6 v3.64.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 14:34:19 +00:00
b3f098b41e feat(dees-table): add file-manager style row selection and JSON copy support 2026-04-07 14:34:19 +00:00
a0d5462ff1 v3.63.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 13:55:43 +00:00
f1c204f790 feat(dees-table): add floating header support with fixed-height table mode 2026-04-07 13:55:43 +00:00
5 changed files with 561 additions and 120 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## 2026-04-07 - 3.64.0 - feat(dees-table)
add file-manager style row selection and JSON copy support
- adds optional selection checkbox rendering via the show-selection-checkbox property
- supports plain, ctrl/cmd, and shift-click row selection with range selection behavior
- adds Ctrl/Cmd+C and context menu actions to copy selected rows as formatted JSON
- updates row selection styling to prevent native text selection during range selection
## 2026-04-07 - 3.63.0 - feat(dees-table)
add floating header support with fixed-height table mode
- replace the sticky-header option with a fixed-height mode for internal scrolling
- add a JS-managed floating header so column headers remain visible when tables scroll inside ancestor containers
- sync floating header column widths and filter rows with the rendered table
## 2026-04-07 - 3.62.0 - feat(dees-table)
add multi-column sorting with header menu controls and priority indicators

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "3.62.0",
"version": "3.64.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",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.62.0',
version: '3.64.0',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}

View File

@@ -184,8 +184,25 @@ export class DeesTable<T> extends DeesElement {
accessor columnFilters: Record<string, string> = {};
@property({ type: Boolean, attribute: 'show-column-filters' })
accessor showColumnFilters: boolean = false;
@property({ type: Boolean, reflect: true, attribute: 'sticky-header' })
accessor stickyHeader: boolean = false;
/**
* When true, the table renders a leftmost checkbox column for click-driven
* (de)selection. Row selection by mouse (plain/shift/ctrl click) is always
* available regardless of this flag.
*/
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
accessor showSelectionCheckbox: boolean = false;
/**
* When set, the table renders inside a fixed-height scroll container
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
* within that box via plain CSS sticky.
*
* When unset (the default), the table flows naturally and a JS-managed
* floating header keeps the column headers visible while the table is
* scrolled past in any ancestor scroll container (page or otherwise).
*/
@property({ type: Boolean, reflect: true, attribute: 'fixed-height' })
accessor fixedHeight: boolean = false;
// search row state
@property({ type: String })
@@ -200,9 +217,72 @@ export class DeesTable<T> extends DeesElement {
accessor selectedIds: Set<string> = new Set();
private _rowIdMap = new WeakMap<object, string>();
private _rowIdCounter = 0;
/**
* Anchor row id for shift+click range selection. Set whenever the user
* makes a non-range click (plain or cmd/ctrl) so the next shift+click
* can compute a contiguous range from this anchor.
*/
private __selectionAnchorId?: string;
constructor() {
super();
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
this.addEventListener('keydown', this.__handleHostKeydown);
}
/**
* Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls
* back to copying the focused-row (`selectedDataRow`) if no multi
* selection exists. No-op if a focused input/textarea would normally
* receive the copy.
*/
private __handleHostKeydown = (eventArg: KeyboardEvent) => {
const isCopy = (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
if (!isCopy) return;
// Don't hijack copy when the user is selecting text in an input/textarea.
const path = (eventArg.composedPath?.() || []) as EventTarget[];
for (const t of path) {
const tag = (t as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if ((t as HTMLElement)?.isContentEditable) return;
}
const rows: T[] = [];
if (this.selectedIds.size > 0) {
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
} else if (this.selectedDataRow) {
rows.push(this.selectedDataRow);
}
if (rows.length === 0) return;
eventArg.preventDefault();
this.__writeRowsAsJson(rows);
};
/**
* Copies the current selection as a JSON array. If `fallbackRow` is given
* and there is no multi-selection, that row is copied instead. Used both
* by the Ctrl/Cmd+C handler and by the default context-menu action.
*/
public copySelectionAsJson(fallbackRow?: T) {
const rows: T[] = [];
if (this.selectedIds.size > 0) {
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
} else if (fallbackRow) {
rows.push(fallbackRow);
} else if (this.selectedDataRow) {
rows.push(this.selectedDataRow);
}
if (rows.length === 0) return;
this.__writeRowsAsJson(rows);
}
private __writeRowsAsJson(rows: T[]) {
try {
const json = JSON.stringify(rows, null, 2);
navigator.clipboard?.writeText(json);
} catch {
/* ignore — clipboard may be unavailable */
}
}
public static styles = tableStyles;
@@ -297,74 +377,7 @@ export class DeesTable<T> extends DeesElement {
<div class="tableScroll">
<table>
<thead>
<tr>
${this.selectionMode !== 'none'
? html`
<th style="width:42px; text-align:center;">
${this.selectionMode === 'multi'
? html`
<dees-input-checkbox
.value=${this.areAllVisibleSelected()}
.indeterminate=${this.isVisibleSelectionIndeterminate()}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.setSelectVisible(e.detail === true);
}}
></dees-input-checkbox>
`
: html``}
</th>
`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
const isSortable = !!col.sortable;
const ariaSort = this.getAriaSort(col);
return html`
<th
role="columnheader"
aria-sort=${ariaSort}
style="${isSortable ? 'cursor: pointer;' : ''}"
@click=${(eventArg: MouseEvent) =>
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
@contextmenu=${(eventArg: MouseEvent) =>
isSortable
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
: null}
>
${col.header ?? (col.key as any)}
${this.renderSortIndicator(col)}
</th>`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html` <th class="actionsCol">Actions</th> `;
}
})()}
</tr>
${this.showColumnFilters
? html`<tr class="filtersRow">
${this.selectionMode !== 'none'
? html`<th style="width:42px;"></th>`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
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=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
</th>`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html` <th></th> `;
}
})()}
</tr>`
: html``}
${this.renderHeaderRows(effectiveColumns)}
</thead>
<tbody>
${viewData.map((itemArg, rowIndex) => {
@@ -377,15 +390,11 @@ export class DeesTable<T> extends DeesElement {
};
return html`
<tr
@click=${() => {
this.selectedDataRow = itemArg;
if (this.selectionMode === 'single') {
const id = this.getRowId(itemArg);
this.selectedIds.clear();
this.selectedIds.add(id);
this.emitSelectionChange();
this.requestUpdate();
}
@click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
@mousedown=${(e: MouseEvent) => {
// Prevent the browser's native shift-click text
// selection so range-select doesn't highlight text.
if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
}}
@dragenter=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
@@ -420,27 +429,51 @@ export class DeesTable<T> extends DeesElement {
}
}}
@contextmenu=${async (eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(
eventArg,
this.getActionsForType('contextmenu').map((action) => {
const menuItem: plugins.tsclass.website.IMenuItem = {
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({
item: itemArg,
table: this,
});
return null;
},
};
return menuItem;
})
);
// If the right-clicked row isn't part of the
// current selection, treat it like a plain click
// first so the context menu acts on a sensible
// selection (matches file-manager behavior).
if (!this.isRowSelected(itemArg)) {
this.selectedDataRow = itemArg;
this.selectedIds.clear();
this.selectedIds.add(this.getRowId(itemArg));
this.__selectionAnchorId = this.getRowId(itemArg);
this.emitSelectionChange();
this.requestUpdate();
}
const userItems: plugins.tsclass.website.IMenuItem[] =
this.getActionsForType('contextmenu').map((action) => ({
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({
item: itemArg,
table: this,
});
return null;
},
}));
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
{
name:
this.selectedIds.size > 1
? `Copy ${this.selectedIds.size} rows as JSON`
: 'Copy row as JSON',
iconName: 'lucide:Copy' as any,
action: async () => {
this.copySelectionAsJson(itemArg);
return null;
},
},
];
DeesContextmenu.openContextMenuWithOptions(eventArg, [
...userItems,
...defaultItems,
]);
}}
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
>
${this.selectionMode !== 'none'
${this.showSelectionCheckbox
? html`<td style="width:42px; text-align:center;">
<dees-input-checkbox
.value=${this.isRowSelected(itemArg)}
@@ -507,6 +540,13 @@ export class DeesTable<T> extends DeesElement {
</tbody>
</table>
</div>
<div class="floatingHeader" aria-hidden="true">
<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>
</div>
`
: html` <div class="noDataSet">No data set!</div> `}
<div slot="footer" class="footer">
@@ -545,13 +585,302 @@ export class DeesTable<T> extends DeesElement {
`;
}
/**
* Renders the header rows. Used twice per render: once inside the real
* `<thead>` and once inside the floating-header clone, so sort indicators
* and filter inputs stay in sync automatically.
*/
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
return html`
<tr>
${this.showSelectionCheckbox
? html`
<th style="width:42px; text-align:center;">
${this.selectionMode === 'multi'
? html`
<dees-input-checkbox
.value=${this.areAllVisibleSelected()}
.indeterminate=${this.isVisibleSelectionIndeterminate()}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.setSelectVisible(e.detail === true);
}}
></dees-input-checkbox>
`
: html``}
</th>
`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
const isSortable = !!col.sortable;
const ariaSort = this.getAriaSort(col);
return html`
<th
role="columnheader"
aria-sort=${ariaSort}
style="${isSortable ? 'cursor: pointer;' : ''}"
@click=${(eventArg: MouseEvent) =>
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
@contextmenu=${(eventArg: MouseEvent) =>
isSortable
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
: null}
>
${col.header ?? (col.key as any)}
${this.renderSortIndicator(col)}
</th>`;
})}
${this.dataActions && this.dataActions.length > 0
? html`<th class="actionsCol">Actions</th>`
: html``}
</tr>
${this.showColumnFilters
? html`<tr class="filtersRow">
${this.showSelectionCheckbox
? html`<th style="width:42px;"></th>`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
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=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
</th>`;
})}
${this.dataActions && this.dataActions.length > 0
? html`<th></th>`
: html``}
</tr>`
: html``}
`;
}
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
private __floatingResizeObserver?: ResizeObserver;
private __floatingScrollHandler?: () => void;
private __floatingActive = false;
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
private get __floatingHeaderEl(): HTMLDivElement | null {
return this.shadowRoot?.querySelector('.floatingHeader') ?? null;
}
private get __realTableEl(): HTMLTableElement | null {
return this.shadowRoot?.querySelector('.tableScroll > table') ?? null;
}
private get __floatingTableEl(): HTMLTableElement | null {
return this.shadowRoot?.querySelector('.floatingHeader > table') ?? null;
}
/**
* Walks up the DOM (and through shadow roots) collecting every ancestor
* element whose computed `overflow-y` makes it a scroll container, plus
* `window` at the end. We listen for scroll on all of them so the floating
* header reacts whether the user scrolls the page or any nested container.
*/
private __collectScrollAncestors(): Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> {
const result: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
let node: Node | null = this as unknown as Node;
const scrollish = (v: string) => v === 'auto' || v === 'scroll' || v === 'overlay';
while (node) {
if (node instanceof Element) {
const style = getComputedStyle(node);
const sy = scrollish(style.overflowY);
const sx = scrollish(style.overflowX);
if (sy || sx) {
result.push({ target: node, scrollsY: sy, scrollsX: sx });
}
}
const parent = (node as any).assignedSlot
? (node as any).assignedSlot
: node.parentNode;
if (parent) {
node = parent;
} else if ((node as ShadowRoot).host) {
node = (node as ShadowRoot).host;
} else {
node = null;
}
}
result.push({ target: window, scrollsY: true, scrollsX: true });
return result;
}
/**
* Returns the "stick line" — the y-coordinate (in viewport space) at which
* the floating header should appear. Defaults to 0 (page top), but if the
* table is inside a scroll container we use that container's content-box
* top so the header sits inside the container's border/padding instead of
* floating over it.
*/
private __getStickContext(): { top: number; left: number; right: number } {
let top = 0;
let left = 0;
let right = window.innerWidth;
for (const a of this.__scrollAncestors) {
if (a.target === window) continue;
const el = a.target as Element;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
// Only constrain top from ancestors that actually scroll vertically —
// a horizontal-only scroll container (like .tableScroll) must not push
// the stick line down to its own top.
if (a.scrollsY) {
const bt = parseFloat(cs.borderTopWidth) || 0;
top = Math.max(top, r.top + bt);
}
// Same for horizontal clipping.
if (a.scrollsX) {
const bl = parseFloat(cs.borderLeftWidth) || 0;
const br = parseFloat(cs.borderRightWidth) || 0;
left = Math.max(left, r.left + bl);
right = Math.min(right, r.right - br);
}
}
return { top, left, right };
}
private setupFloatingHeader() {
this.teardownFloatingHeader();
if (this.fixedHeight) return;
const realTable = this.__realTableEl;
if (!realTable) return;
this.__scrollAncestors = this.__collectScrollAncestors();
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
// so the upward walk above misses it. Add it explicitly so horizontal
// scrolling inside the table re-syncs the floating header.
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
if (tableScrollEl) {
this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
}
// Track resize of the real table so we can mirror its width and column widths.
this.__floatingResizeObserver = new ResizeObserver(() => {
this.__syncFloatingHeader();
});
this.__floatingResizeObserver.observe(realTable);
this.__floatingScrollHandler = () => this.__syncFloatingHeader();
for (const a of this.__scrollAncestors) {
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
}
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
this.__syncFloatingHeader();
}
private teardownFloatingHeader() {
this.__floatingResizeObserver?.disconnect();
this.__floatingResizeObserver = undefined;
if (this.__floatingScrollHandler) {
for (const a of this.__scrollAncestors) {
a.target.removeEventListener('scroll', this.__floatingScrollHandler);
}
window.removeEventListener('resize', this.__floatingScrollHandler);
this.__floatingScrollHandler = undefined;
}
this.__scrollAncestors = [];
this.__floatingActive = false;
const fh = this.__floatingHeaderEl;
if (fh) fh.classList.remove('active');
}
/**
* Single function that drives both activation and geometry of the floating
* header. Called on scroll, resize, table-resize, and after each render.
*/
private __syncFloatingHeader() {
const fh = this.__floatingHeaderEl;
const realTable = this.__realTableEl;
const floatTable = this.__floatingTableEl;
if (!fh || !realTable || !floatTable) return;
const tableRect = realTable.getBoundingClientRect();
const stick = this.__getStickContext();
// Mirror table layout + per-cell widths so columns line up.
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
const realHeadRows = realTable.tHead?.rows;
const floatHeadRows = floatTable.tHead?.rows;
let headerHeight = 0;
if (realHeadRows && floatHeadRows) {
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
headerHeight += realHeadRows[r].getBoundingClientRect().height;
const realCells = realHeadRows[r].cells;
const floatCells = floatHeadRows[r].cells;
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
const w = realCells[c].getBoundingClientRect().width;
(floatCells[c] as HTMLElement).style.width = `${w}px`;
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
}
}
}
// 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);
if (shouldBeActive !== this.__floatingActive) {
this.__floatingActive = shouldBeActive;
fh.classList.toggle('active', shouldBeActive);
}
if (!shouldBeActive) return;
// Position the floating header. Clip horizontally to the scroll context
// so a horizontally-scrolled inner container's header doesn't bleed
// outside the container's border.
const clipLeft = Math.max(tableRect.left, stick.left);
const clipRight = Math.min(tableRect.right, stick.right);
const clipWidth = Math.max(0, clipRight - clipLeft);
fh.style.top = `${stick.top}px`;
fh.style.left = `${clipLeft}px`;
fh.style.width = `${clipWidth}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`;
}
public async disconnectedCallback() {
super.disconnectedCallback();
this.teardownFloatingHeader();
}
public async firstUpdated() {
// Floating-header observers are wired up in `updated()` once the
// table markup actually exists (it only renders when data.length > 0).
}
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
this.determineColumnWidths();
// (Re)wire the floating header whenever the relevant props change or
// the table markup may have appeared/disappeared.
if (
changedProperties.has('fixedHeight') ||
changedProperties.has('data') ||
changedProperties.has('columns') ||
!this.__floatingScrollHandler
) {
if (!this.fixedHeight && this.data.length > 0) {
this.setupFloatingHeader();
} else {
this.teardownFloatingHeader();
}
}
// Keep the floating header in sync after any re-render
// (column widths may have changed).
if (!this.fixedHeight && this.data.length > 0) {
this.__syncFloatingHeader();
}
if (this.searchable) {
const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search');
if (!existing) {
@@ -1064,6 +1393,74 @@ export class DeesTable<T> extends DeesElement {
this.requestUpdate();
}
/**
* Handles row clicks with file-manager style selection semantics:
* - plain click: select only this row, set anchor
* - cmd/ctrl+click: toggle this row in/out, set anchor
* - shift+click: select the contiguous range from the anchor to this row
*
* Multi-row click selection is always available (`selectionMode === 'none'`
* and `'multi'` both behave this way) so consumers can always copy a set
* of rows. Only `selectionMode === 'single'` restricts to one row.
*/
private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) {
const id = this.getRowId(item);
if (this.selectionMode === 'single') {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
this.emitSelectionChange();
this.requestUpdate();
return;
}
// multi
const isToggle = eventArg.metaKey || eventArg.ctrlKey;
const isRange = eventArg.shiftKey;
if (isRange && this.__selectionAnchorId !== undefined) {
// Clear any text selection the browser may have created.
window.getSelection?.()?.removeAllRanges();
const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId);
if (anchorIdx >= 0) {
const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx];
this.selectedIds.clear();
for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i]));
} else {
// Anchor no longer in view (filter changed, etc.) — fall back to single select.
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
}
this.selectedDataRow = item;
} else if (isToggle) {
const wasSelected = this.selectedIds.has(id);
if (wasSelected) {
this.selectedIds.delete(id);
// If we just deselected the focused row, move focus to another
// selected row (or clear it) so the highlight goes away.
if (this.selectedDataRow === item) {
const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r)));
this.selectedDataRow = remaining as T;
}
} else {
this.selectedIds.add(id);
this.selectedDataRow = item;
}
this.__selectionAnchorId = id;
} else {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
}
this.emitSelectionChange();
this.requestUpdate();
}
private setRowSelected(row: T, checked: boolean) {
const id = this.getRowId(row);
if (this.selectionMode === 'single') {

View File

@@ -114,29 +114,48 @@ export const tableStyles: CSSResult[] = [
border-bottom-width: 0px;
}
/* Default mode (Mode B, page sticky): horizontal scroll lives on
.tableScroll (so wide tables don't get clipped by an ancestor
overflow:hidden such as dees-tile). Vertical sticky is handled by
a JS-managed floating header (.floatingHeader, position:fixed),
which is unaffected by ancestor overflow. */
.tableScroll {
/* enable horizontal scroll only when content exceeds width */
position: relative;
overflow-x: auto;
/* prevent vertical scroll inside the table container */
overflow-y: hidden;
/* avoid reserving extra space for classic scrollbars where possible */
scrollbar-gutter: stable both-edges;
overflow-y: visible;
scrollbar-gutter: stable;
}
/* Hide horizontal scrollbar entirely when not using sticky header */
:host(:not([sticky-header])) .tableScroll {
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: none; /* Firefox (hides both axes) */
}
:host(:not([sticky-header])) .tableScroll::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* In sticky-header mode, hide only the horizontal scrollbar in WebKit/Blink */
:host([sticky-header]) .tableScroll::-webkit-scrollbar:horizontal {
height: 0px;
}
:host([sticky-header]) .tableScroll {
/* Mode A, internal scroll: opt-in via fixed-height attribute.
The table scrolls inside its own box and the header sticks via plain CSS sticky. */
:host([fixed-height]) .tableScroll {
max-height: var(--table-max-height, 360px);
overflow: auto;
scrollbar-gutter: stable both-edges;
}
:host([fixed-height]) .tableScroll::-webkit-scrollbar:horizontal {
height: 0px;
}
/* Floating header overlay (Mode B). Position is managed by JS so it
escapes any ancestor overflow:hidden (position:fixed is not clipped
by overflow ancestors). */
.floatingHeader {
position: fixed;
top: 0;
left: 0;
z-index: 100;
visibility: hidden;
overflow: hidden;
pointer-events: none;
}
.floatingHeader.active {
visibility: visible;
}
.floatingHeader table {
margin: 0;
}
.floatingHeader th {
pointer-events: auto;
}
table {
@@ -159,15 +178,25 @@ export const tableStyles: CSSResult[] = [
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-bottom: 1px solid var(--dees-color-border-strong);
}
:host([sticky-header]) thead th {
/* th needs its own background so sticky cells paint over scrolled rows
(browsers don't paint the <thead> box behind a sticky <th>). */
th {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
}
/* Mode A — internal scroll sticky */
:host([fixed-height]) thead th {
position: sticky;
top: 0;
z-index: 2;
}
:host([fixed-height]) thead tr.filtersRow th {
top: 36px; /* matches th { height: 36px } below */
}
tbody tr {
transition: background-color 0.15s ease;
position: relative;
user-select: none;
}
/* Default horizontal lines (bottom border only) */