feat(dees-table): add floating header support with fixed-height table mode
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-07 - 3.62.0 - feat(dees-table)
|
||||||
add multi-column sorting with header menu controls and priority indicators
|
add multi-column sorting with header menu controls and priority indicators
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
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.'
|
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> = {};
|
accessor columnFilters: Record<string, string> = {};
|
||||||
@property({ type: Boolean, attribute: 'show-column-filters' })
|
@property({ type: Boolean, attribute: 'show-column-filters' })
|
||||||
accessor showColumnFilters: boolean = false;
|
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
|
// search row state
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
@@ -297,74 +306,7 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
<div class="tableScroll">
|
<div class="tableScroll">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
${this.renderHeaderRows(effectiveColumns)}
|
||||||
${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``}
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${viewData.map((itemArg, rowIndex) => {
|
${viewData.map((itemArg, rowIndex) => {
|
||||||
@@ -507,6 +449,13 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="floatingHeader" aria-hidden="true">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
${this.renderHeaderRows(effectiveColumns)}
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
: html` <div class="noDataSet">No data set!</div> `}
|
: html` <div class="noDataSet">No data set!</div> `}
|
||||||
<div slot="footer" class="footer">
|
<div slot="footer" class="footer">
|
||||||
@@ -545,13 +494,302 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async firstUpdated() {
|
/**
|
||||||
|
* 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> {
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
this.determineColumnWidths();
|
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) {
|
if (this.searchable) {
|
||||||
const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search');
|
const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search');
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|||||||
@@ -114,29 +114,48 @@ export const tableStyles: CSSResult[] = [
|
|||||||
border-bottom-width: 0px;
|
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 {
|
.tableScroll {
|
||||||
/* enable horizontal scroll only when content exceeds width */
|
position: relative;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
/* prevent vertical scroll inside the table container */
|
overflow-y: visible;
|
||||||
overflow-y: hidden;
|
scrollbar-gutter: stable;
|
||||||
/* avoid reserving extra space for classic scrollbars where possible */
|
|
||||||
scrollbar-gutter: stable both-edges;
|
|
||||||
}
|
}
|
||||||
/* Hide horizontal scrollbar entirely when not using sticky header */
|
/* Mode A, internal scroll: opt-in via fixed-height attribute.
|
||||||
:host(:not([sticky-header])) .tableScroll {
|
The table scrolls inside its own box and the header sticks via plain CSS sticky. */
|
||||||
-ms-overflow-style: none; /* IE/Edge */
|
:host([fixed-height]) .tableScroll {
|
||||||
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 {
|
|
||||||
max-height: var(--table-max-height, 360px);
|
max-height: var(--table-max-height, 360px);
|
||||||
overflow: auto;
|
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 {
|
table {
|
||||||
@@ -159,11 +178,20 @@ export const tableStyles: CSSResult[] = [
|
|||||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
||||||
border-bottom: 1px solid var(--dees-color-border-strong);
|
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;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
:host([fixed-height]) thead tr.filtersRow th {
|
||||||
|
top: 36px; /* matches th { height: 36px } below */
|
||||||
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
|||||||
Reference in New Issue
Block a user