UNPKG

protvista-datatable

Version:

[![Published on NPM](https://img.shields.io/npm/v/protvista-datatable.svg)](https://www.npmjs.com/package/protvista-datatable)

455 lines (399 loc) 13.5 kB
import { LitElement, html, TemplateResult, CSSResult, PropertyDeclarations, css, } from "lit-element"; import { ScrollFilter } from "protvista-utils"; import { isOutside, isWithinRange, parseColumnFilters } from "./utils"; import { ProtvistaManager } from "./types/manager"; import lightDOMstyles, { ACTIVE, EXPANDED, HIDDEN, OVERLAPPED, TRANSPARENT, } from "./styles"; class ProtvistaDatatable extends LitElement { private height: string; private columns: NodeListOf<HTMLTableHeaderCellElement>; private rows: NodeListOf<HTMLTableRowElement>; private filterMap: Map<string, Set<string>>; private selectedFilters: Map<string, string>; private mutationObserver: MutationObserver; // This will eventually be an array of tuples private highlight: [start: number, end: number]; private displayStart?: number; private displayEnd?: number; private selectedid?: string; private visibleChildren: string[]; private noScrollToRow: boolean; private noDeselect: boolean; private expandTable?: boolean; private scrollFilter: any; // to replace with type definition from utils when exists private wheelListener: (e: WheelEvent) => any; private manager: ProtvistaManager; static get is(): string { return "protvista-datatable"; } constructor() { super(); this.height = "25rem"; this.visibleChildren = []; this.noScrollToRow = false; this.noDeselect = false; this.expandTable = false; this.scrollFilter = new ScrollFilter(this); this.wheelListener = (event) => this.scrollFilter.wheel(event); this.eventHandler = this.eventHandler.bind(this); } connectedCallback(): void { super.connectedCallback(); const tbody = this.querySelector("table tbody"); // Return early if no structure if (!tbody) { return; } // The content of the table is dynamically set by the consumer // so we need to lookout for changes this.mutationObserver = new MutationObserver(() => { this.init(); }); // Observe the table body for any changes (e.g. dynamic data) this.mutationObserver.observe(tbody, { characterData: true, childList: true, subtree: true, }); // Add style to light DOM to style slot content const styleTag = document.createElement("style"); styleTag.innerHTML = lightDOMstyles.toString(); document.querySelector("head").appendChild(styleTag); if (this.closest("protvista-manager")) { this.manager = this.closest("protvista-manager"); this.manager.register(this); } if (!this.noDeselect) { document.addEventListener("click", this.eventHandler); } // this makes sure the protvista-zoomable event listener doesn't reset this.classList.add("feature"); if (this.hasAttribute("filter-scroll")) { document.addEventListener("wheel", this.wheelListener); } this.init(); } disconnectedCallback(): void { super.disconnectedCallback(); if (this.manager) { this.manager.unregister(this); } document.removeEventListener("click", this.eventHandler); document.removeEventListener("wheel", this.wheelListener); this.mutationObserver.disconnect(); } init(): void { this.columns = this.querySelectorAll<HTMLTableHeaderCellElement>("table thead th"); // Add blank column to header for (+/-) if not there alread // Check if added already, otherwise, ∞ loop!! if (!this.querySelector(".pd-group-column-header")) { // Can't use insertCell with "th" const additionalTH = document.createElement("th"); additionalTH.classList.add("pd-group-column-header"); const headerTR = this.querySelector<HTMLTableRowElement>("table thead tr"); headerTR.insertBefore(additionalTH, headerTR.firstChild); } this.rows = this.querySelectorAll<HTMLTableRowElement>("table tbody tr"); this.rows.forEach((row) => { // Add extra (+/-) cell - only if it hasn't got it already!!! if (!row.dataset.groupFor && !row.querySelector(".pd-group-trigger")) { const plusMinusCell = row.insertCell(0); plusMinusCell.classList.add("pd-group-trigger"); if (this.querySelector("[data-group-for]")) { const plusMinusButton = document.createElement("button"); plusMinusButton.dataset.triggerId = row.dataset.id; plusMinusCell.appendChild(plusMinusButton); // Add row click handler plusMinusButton.addEventListener("click", (e) => this.handleGroupToggle(e) ); } } // Add row click handler row.addEventListener("click", (e) => this.handleClick(e, row)); }); this.updateRowStyling(); this.selectedFilters = new Map(); this.filterMap = this.parseDataForFilters(); this.addFilterOptions(); } parseDataForFilters(): Map<string, Set<string>> { // Initialise map by looking at Column headers const filterMap = parseColumnFilters(this.columns); // Populate map with values this.rows.forEach((row) => { const tableCells = row.childNodes as NodeListOf<HTMLTableDataCellElement>; tableCells.forEach((cell) => { if (cell.dataset?.filter) { const filterSet = filterMap.get(cell.dataset.filter); filterSet.add(cell.dataset.filterValue); } }); }); return filterMap; } addFilterOptions(): void { this.columns.forEach((column) => { if (column.dataset.filter) { let select: HTMLSelectElement; let wrapper: HTMLSpanElement; // Has this column already been modified? if (column.querySelector(".filter-wrap")) { select = column.querySelector("select"); wrapper = column.querySelector(".filter-wrap"); } else { wrapper = document.createElement("span"); wrapper.className = "filter-wrap"; wrapper.innerHTML = column.innerHTML; select = document.createElement("select"); select.dataset.testid = "select"; select.onchange = (e: Event) => this.handleFilterChange(e, column.dataset.filter); } select.innerHTML = "<option selected value>-- Select --</option>"; this.filterMap.get(column.dataset.filter).forEach((optionValue) => { const option = document.createElement("option"); option.value = optionValue; option.label = optionValue; option.dataset.testid = "select-option"; select.appendChild(option); }); // eslint-disable-next-line no-param-reassign column.innerHTML = ""; wrapper.appendChild(select); column.appendChild(wrapper); } }); } eventHandler(e: MouseEvent): void { const target = e.target as HTMLElement; if (!target.closest("protvista-datatable") && !target.closest(".feature")) { this.selectedid = null; this.highlight = null; } } static get properties(): PropertyDeclarations { return { highlight: { converter: (value: string) => { if (value && value !== "null") { try { const splitArray = value.split(":").map((d) => Number(d)); if (splitArray.length !== 2) { throw new Error( "Highlight should be only 2 values separated by ':'." ); } return [splitArray[0], splitArray[1]]; } catch (e) { console.error("Invalid highlight coordinates:", e); } } return null; }, }, height: { type: String }, displayStart: { type: Number }, displayEnd: { type: Number }, visibleChildren: { type: Array }, selectedid: { type: String }, noScrollToRow: { type: Boolean }, noDeselect: { type: Boolean }, expandTable: { type: Boolean }, }; } static get styles(): CSSResult { return css` :host { display: block; } .protvista-datatable-container { overflow-y: auto; // Note: overflow-x was set to 'hidden' but changing // to 'auto' doesn't seem to be an issue. overflow-x: auto; scrollbar-gutter: stable; } :host([scrollable="true"]) .protvista-datatable-container { overflow-y: auto; } :host([scrollable="false"]) .protvista-datatable-container { overflow-y: hidden; } `; } handleGroupToggle(e: MouseEvent): void { const { triggerId } = (e.target as HTMLButtonElement).dataset; if (this.visibleChildren.includes(triggerId)) { this.visibleChildren = this.visibleChildren.filter( (childId) => childId !== triggerId ); (e.target as HTMLButtonElement).classList.remove(EXPANDED.cssText); } else { this.visibleChildren = [...this.visibleChildren, triggerId]; (e.target as HTMLButtonElement).classList.add(EXPANDED.cssText); } } handleClick(e: MouseEvent, row: HTMLTableRowElement): void { // Don't select transparent row if (row.classList.contains("transparent")) { return; } const { id, start, end } = row.dataset; this.selectedid = id; const detail: { [key: string]: string } = {}; if (start && end) detail.highlight = `${start}:${end}`; if (this.selectedid) detail.selectedid = this.selectedid; this.dispatchEvent( new CustomEvent("change", { detail, bubbles: true, cancelable: true, }) ); } handleFilterChange(e: Event, filterName: string): void { const { selectedOptions } = e.target as HTMLSelectElement; // Only 1 can be selected const { value } = selectedOptions.item(0); if (value) { this.selectedFilters.set(filterName, value); } else { this.selectedFilters.delete(filterName); } this.updateRowStyling(); } isRowVisible(row: HTMLTableRowElement): boolean { // Handle show/hide groups const isExpandedGroup = !row.dataset.groupFor || (row.dataset.groupFor && this.visibleChildren.includes(row.dataset.groupFor)); // Handle filters // If no filters are selected, consider it a match if (!this.selectedFilters || this.selectedFilters.size === 0) { return isExpandedGroup; } for (const [filterName, value] of this.selectedFilters) { let column; if (row.dataset.groupFor) { // If group, get group row const groupRow = this.querySelector( `[data-id="${row.dataset.groupFor}"]` ); column = groupRow.querySelector<HTMLTableCellElement>( `[data-filter="${filterName}"]` ); } else { column = row.querySelector<HTMLTableCellElement>( `[data-filter="${filterName}"]` ); } if (column && column.dataset.filterValue !== value) { return false; } } return isExpandedGroup; } updateRowStyling(): void { let oddOrEvenCount = 0; this.rows?.forEach((row) => { // Filter visibility const isRowVisible = this.isRowVisible(row); if (isRowVisible) { row.classList.remove(HIDDEN.cssText); } else { row.classList.add(HIDDEN.cssText); } // Only increment if non grouped row if (!row.dataset.groupFor) { oddOrEvenCount++; } const { start, end } = row.dataset; row.classList.add(oddOrEvenCount % 2 === 0 ? "even" : "odd"); // Is the row selected? if ( this.selectedid && (this.selectedid === row.dataset.id || row.dataset.groupFor === this.selectedid) ) { row.classList.add(ACTIVE.cssText); } else { // Note: if too expensive, check before row.classList.remove(ACTIVE.cssText); } // Is the row not within ProtVista track range? if ( isOutside( this.displayStart, this.displayEnd, Number(start), Number(end) ) ) { row.classList.add(TRANSPARENT.cssText); } else { // Note: if too expensive, check before row.classList.remove(TRANSPARENT.cssText); } // Is the row part of the selected range? if ( this.highlight && isWithinRange( this.highlight[0], this.highlight[1], Number(start), Number(end) ) ) { row.classList.add(OVERLAPPED.cssText); } else { row.classList.remove(OVERLAPPED.cssText); } if (row.dataset.groupFor) { const collSpan = this.columns.length + 1; // Add 1 for the +/- button // eslint-disable-next-line no-param-reassign row.cells[0].colSpan = collSpan - row.cells.length + 1; // Add 1 for column } }); } scrollIntoView(): void { if (!this.selectedid) { return; } const element = this.querySelector(`[data-id="${this.selectedid}"]`); element?.scrollIntoView({ behavior: "smooth", block: "center" }); } render(): TemplateResult { const style = this.expandTable || this.hasAttribute("expand-table") ? "height: auto" : `max-height:${this.height}`; return html` <div class="protvista-datatable-container" style=${style}> <slot></slot> </div> `; } updated(): void { this.updateRowStyling(); if (!this.noScrollToRow) { this.scrollIntoView(); } } } export default ProtvistaDatatable;