/** * Copyright Aquera Inc 2025 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, CSSResultArray, TemplateResult } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { styles } from './nile-grid.css'; import NileElement from '../internal/nile-element'; import { syncStickyShadows, layout } from './nile-grid.layout'; import { listenToEventListeners, removeEventListeners, getHead, getBody, copyArrayValuesInPlace } from './nile-grid.utils'; import { computeNewWidth, resizeWithScroll, resizeNoScroll, updateResizeGuides } from './nile-grid.resize'; import { PrefixColumn } from './types/nile-grid.types'; /** * Nile grid component. * * @tag nile-grid * */ @customElement('nile-grid') export class NileGrid extends NileElement { /** * The styles for nile-grid * @remarks If you are extending this class you can extend the base styles with super. Eg `return [super(), myCustomStyles]` */ public static get styles(): CSSResultArray { return [styles]; } /** To store the column widths in the beginning and after resizing */ @state() public columnWidths: number[] = []; /** To get the slot element */ @query('slot') public slotEl!: HTMLSlotElement; /** To get the first resize line to show the resize handle */ @query('.nile-resize-start') resizeStart: HTMLElement; /** To get the last resize line to show the resize handle */ @query('.nile-resize-end') resizeEnd: HTMLElement; /** To get the last resize line to show the resize handle */ @query('.shadow-left') shadowLeft: HTMLElement; /** To get the last resize line to show the resize handle */ @query('.shadow-right') shadowRight: HTMLElement; /** To enable horizontal scrolling when user is resizing */ @property({ type: Boolean, attribute: true, reflect: true }) enableScroll: boolean = false; /** To enable hoverable rows */ @property({ type: Boolean, attribute: true, reflect: true }) hoverable: boolean = false; /** Controls vertical overscroll behavior when the grid is scrollable. */ @property({ type: String, attribute: true, reflect: true }) overflowScrollBehaviour: string = "none"; /** To store the mutation observer */ private mo?: MutationObserver; /** To store the resize observer */ private resizeObserver?: ResizeObserver; private resizeObserverStickyShadows?: ResizeObserver; /** To store the request animation frame */ private raf = 0; /** Minimum column width */ @property({ type: Number, reflect: true, attribute: true }) minColumnWidth = 40; /** To store the sticky index's */ @state() public stickyLeftIndexes: number[] = []; /** To store the sticky index's */ @state() public stickyRightIndexes: number[] = []; /** To calculate the rowspan height */ @state() public cellHeight: number | null = null; @state() public headHeight: number | null = null; /** To check if the widths are initialized */ @state() public widthsInitialized = false; /** To check if the widths are initialized */ @state() public lockedWidthColumns: boolean[] = []; /** prefix columns width */ @state() public prefixSumColumnsWidth: PrefixColumn[] = []; /** prefix columns width */ @state() public stickyColumns: number[] = []; /** body rows */ @state() public bodyRows: HTMLElement[] = []; /** head rows */ @state() public headRows: HTMLElement[] = []; @state() public needsStructureLayout = false; @state() public needsWidthLayout = false; @state() public needsSpanLayout = false; @state() public colCount = 0; public isGridReady = false; public shadowLeftVisibleSticky?: boolean; public shadowRightVisibleSticky?: boolean; public stickyLeftHeadEls: HTMLElement[] = []; public stickyRightHeadEls: HTMLElement[] = []; public stickyLeftBodyEls: HTMLElement[] = []; public stickyRightBodyEls: HTMLElement[] = []; private resizeTimer: any = 0; /** To throttle sticky shadow updates */ private shadowRaf = 0; public resizeBaseColumnWidths: number[] = []; constructor() { super(); } connectedCallback(): void { super.connectedCallback(); this.emit('nile-init'); } private syncStickyShadowsRaf = () => { if (this.shadowRaf) return; this.shadowRaf = requestAnimationFrame(() => { this.shadowRaf = 0; syncStickyShadows(this); }); }; private updateOverscrollBehavior() { const isVerticallyScrollable = this.scrollHeight > this.clientHeight; this.style.overscrollBehavior = isVerticallyScrollable ? this.overflowScrollBehaviour : ''; } private scheduleLayout() { if (this.raf) cancelAnimationFrame(this.raf); this.raf = requestAnimationFrame(() => { this.raf = 0; layout( this, this.slotEl, this.ensureWidths.bind(this), this.stickyLeftIndexes, this.stickyRightIndexes ); syncStickyShadows(this); }); } private ensureWidths(colCount: number) { if (this.columnWidths.length !== colCount) { this.columnWidths = Array(colCount).fill(0); this.lockedWidthColumns = Array(colCount).fill(false); } } private onResize = (e: Event) => { e.stopPropagation(); this.emit('nile-resize', { columnNumber: (e as CustomEvent).detail.col, columnWidth: (e as CustomEvent).detail.widthPx, }); const { col, widthPx } = (e as CustomEvent).detail as { col: number; widthPx: number; }; if (!this.resizeBaseColumnWidths) return; copyArrayValuesInPlace(this.columnWidths, this.resizeBaseColumnWidths); const { prevW, newW, delta0 } = computeNewWidth({ target: e.target as HTMLElement, col, widthPx, floor: this.minColumnWidth, columnWidths: this.columnWidths, }); if (delta0 === 0) { updateResizeGuides(this, col); return; } if (!this.enableScroll) { resizeNoScroll({ grid: this, col, prevW, newW, floor: this.minColumnWidth }); } else { resizeWithScroll({ grid: this, col, newW }); } updateResizeGuides(this, col); }; protected firstUpdated() { this.setAttribute('role', 'table'); listenToEventListeners( this, this.onResize, this.slotEl, this._attachObserverAndLayout.bind(this) ); this.addEventListener('scroll', this.syncStickyShadowsRaf); this.resizeObserverStickyShadows = new ResizeObserver(() => { syncStickyShadows(this); this.updateOverscrollBehavior(); }); this.resizeObserverStickyShadows.observe(this); this.syncStickyShadowsRaf(); } updated(changed: Map) { if (changed.has("overflowScrollBehaviour")) { this.updateOverscrollBehavior(); } } private _attachObserverAndLayout() { this.mo?.disconnect(); this.mo = new MutationObserver(() => { this.needsStructureLayout = true; this.needsWidthLayout = true; this.needsSpanLayout = true; this.scheduleLayout(); }); this.resizeObserver?.disconnect(); this.resizeObserver = new ResizeObserver(() => { if (this.resizeTimer) clearTimeout(this.resizeTimer); this.resizeTimer = setTimeout(() => { this.resizeTimer = 0; this.needsWidthLayout = true; this.scheduleLayout(); }, 16); }); const head = getHead(this.slotEl); const body = getBody(this.slotEl); if (!head && !body) return; const target = head ?? this; this.mo.observe(target, { childList: true, subtree: true, attributes: true, attributeFilter: ['colspan', 'rowspan', 'width', 'lockwidth', 'sticky'] }); this.resizeObserver?.observe(this); if (!this.colCount || !this.columnWidths?.length) { this.needsStructureLayout = true; this.needsSpanLayout = true; this.needsWidthLayout = true; this.scheduleLayout(); } } public render(): TemplateResult { return html`
`; } disconnectedCallback(): void { this.emit('nile-destroy'); super.disconnectedCallback?.(); this.mo?.disconnect(); this.resizeObserver?.disconnect(); this.resizeObserverStickyShadows?.disconnect(); if (this.raf) cancelAnimationFrame(this.raf); removeEventListeners( this, this.onResize, this.slotEl, this._attachObserverAndLayout.bind(this) ); } /* #endregion */ } export default NileGrid; declare global { interface HTMLElementTagNameMap { 'nile-grid': NileGrid; } }