feat(dees-table): add floating header support with fixed-height table mode
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user