feat(dees-table): add floating header support with fixed-height table mode

This commit is contained in:
2026-04-07 13:55:43 +00:00
parent e806c9bce6
commit f1c204f790
4 changed files with 364 additions and 91 deletions

View File

@@ -1,5 +1,12 @@
# Changelog
## 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

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.62.0',
version: '3.63.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,17 @@ 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 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 })
@@ -297,74 +306,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) => {
@@ -507,6 +449,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 +494,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.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>`;
})}
${this.dataActions && this.dataActions.length > 0
? html`<th class="actionsCol">Actions</th>`
: html``}
</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>`;
})}
${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) {

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,11 +178,20 @@ 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;