import { ariaAttribute__boolean, Codec, contentAttribute__boolean, contentAttribute__maybeNumber, contentAttribute__number, } from "@hesxenon/prelude/Codec.js"; import { create, createEvent, dispatch, isEventInLeftHalf, } from "@hesxenon/prelude/Dom.js"; import * as Either from "@hesxenon/prelude/Either.js"; import { iife } from "@hesxenon/prelude/Function.js"; import { attribute, customElement, CustomEventTarget, } from "simple-custom-elements"; import { TableEvents } from "./events.js"; type GridColumn = [start: number, end: number]; namespace GridColumn { export const toString = ([start, end]: GridColumn) => `${start} / ${end}`; /** * unsafe */ export const fromString = (string: string) => string.split(" / ").map(Number) as GridColumn; export const eq = (a: GridColumn, b: GridColumn) => toString(a) === toString(b); /** * checks whether a contains b */ export const contains = ( [aStart, aEnd]: GridColumn, [bStart, bEnd]: GridColumn, ) => bStart >= aStart && bEnd <= aEnd; } type Breakpoint = { width: number | undefined; isHidden: boolean; pinned: "left" | "right" | undefined; }; type ColumnIndex = number; type Meta = { visibility: IntersectionObserver; /** * associates a DOM based start index to a Grid based start index */ startIndices: ColumnIndex[]; pinningInfo: { top: UwcTrElement[]; bottom: UwcTrElement[]; /** * the order of DOM based start indices that should be rendered on the left edge * * @internal not implemented yet */ left: ColumnIndex[]; /** * the order of DOM based start indices that should be rendered on the right edge * * @internal not implemented yet */ right: ColumnIndex[]; }; }; namespace Meta { export const byTable = new Map(); } @customElement({ tagname: "uwc-table" }) export class UwcTableElement extends HTMLElement { #_breakpoints = [] as Breakpoint[]; get breakpoints() { return this.#_breakpoints; } set breakpoints(next) { this.#_breakpoints = next; this.#defaultWidth = iife(() => { return this.#initialScroll / this.breakpoints.length; }); this.style.gridTemplateColumns = next .map((breakpoint) => breakpoint.isHidden ? `0px` : `${breakpoint.width ?? this.defaultWidth}px`, ) .join(" "); } get #meta() { const meta = Meta.byTable.get(this); if (meta == null) { throw new Error(" is not initialized"); } return meta; } #defaultWidth = NaN; get defaultWidth() { return this.#defaultWidth; } #initialScroll = NaN; /** * @internal */ connectedCallback() { this.#initialScroll = this.scrollWidth; Meta.byTable.set(this, { visibility: iife(() => { let animationFrameHandle: | ReturnType | undefined; const handleVisibilityChange = ( entries: IntersectionObserverEntry[], ): void => { if (animationFrameHandle) { cancelAnimationFrame(animationFrameHandle); } animationFrameHandle = requestAnimationFrame(() => { for (const entry of entries) { const row = entry.target as UwcTrElement; row.isVisible = entry.isIntersecting; } }); }; const rowVisibilityObserver = new IntersectionObserver( handleVisibilityChange, { root: this, rootMargin: "200px" }, ); return rowVisibilityObserver; }), startIndices: [], pinningInfo: { top: [], bottom: [], left: [], right: [], }, }); } move = (gridColumn: GridColumn, nextStart: number) => { const beforeMoveEvent = createEvent({ type: TableEvents.beforemove, cancelable: true, detail: { gridColumn, nextStart, }, }); dispatch(this, beforeMoveEvent); if (beforeMoveEvent.defaultPrevented) { return; } const [start, end] = gridColumn; const colspan = end - start; const distance = nextStart - start; const nextEnd = nextStart + colspan; if (distance === 0) { return; } const movingBreakpoints = this.breakpoints.splice(start - 1, colspan); this.breakpoints = this.breakpoints.toSpliced( nextStart - movingBreakpoints.length, 0, ...movingBreakpoints, ); const affectedStart = Math.min(start, nextStart); const affectedEnd = Math.max(end, nextEnd); this.#meta.startIndices = this.#meta.startIndices .concat( Array.from( { length: Math.max(0, affectedEnd - this.#meta.startIndices.length) }, (_, i) => i + 1, ), ) .map((gridIndex) => { if (start <= gridIndex && gridIndex < end) { return gridIndex + distance; } else if (affectedStart <= gridIndex && gridIndex < affectedEnd) { return gridIndex + (distance < 0 ? colspan : -colspan); } return gridIndex; }); this.querySelectorAll("uwc-tr.visible").forEach((row) => { row.updateGridColumns(); }); }; getWidth = ([start, end]: GridColumn): number => { return this.breakpoints .slice(start - 1, end - 1) .reduce( (width, breakpoint) => width + (breakpoint.width ?? this.defaultWidth), 0, ); }; setWidth = ([start, end]: GridColumn, width: number) => { const currentWidth = this.getWidth([start, end]) || width; const scaleFactor = width / currentWidth; const range = end - start; const explicitWidths = this.breakpoints .slice(start - 1, end - 1) .reduce((widths, breakpoint) => { if (breakpoint.width != null) { widths.push(breakpoint.width); } return widths; }, [] as number[]); /** * this is the proportional width of all breakpoints that are not explicitly scaled yet */ const defaultWidth = (width - explicitWidths.reduce((sum, width) => sum + width, 0)) / (range - explicitWidths.length); this.breakpoints = this.breakpoints .concat( Array.from( { length: end - this.breakpoints.length }, (): Breakpoint => ({ width: undefined, pinned: undefined, isHidden: false, }), ), ) .map((breakpoint, index) => { const breakpointIndex = index + 1; if (breakpointIndex < start || breakpointIndex >= end) { return breakpoint; } return { ...breakpoint, width: breakpoint.width == null ? defaultWidth : breakpoint.width * scaleFactor, }; }); }; setHidden = ([start, end]: GridColumn, isHidden: boolean) => { this.breakpoints = this.breakpoints .concat( Array.from( { length: end - this.breakpoints.length }, (): Breakpoint => ({ width: undefined, pinned: undefined, isHidden: false, }), ), ) .map((breakpoint, index) => { const gridIndex = index + 1; if (gridIndex < start || end <= gridIndex) { return breakpoint; } return { ...breakpoint, isHidden, }; }); }; } @customElement({ tagname: "uwc-tr" }) export class UwcTrElement extends HTMLElement { @attribute({ codec: { encode: Either.right, decode: (value) => { switch (value) { case "top": case "bottom": return Either.right(value); default: console.warn( `ignoring unknown value for content attribute [pinned]: ${value}`, ); return Either.right(undefined); } }, }, set(value, prev) { if (value === prev) { return; } const { pinningInfo } = this.#meta; pinningInfo.top = pinningInfo.top.filter((row) => row !== this); pinningInfo.bottom = pinningInfo.bottom.filter((row) => row !== this); const getOffset = (rows: UwcTrElement[]) => rows.reduce((offset, tr) => offset + tr.offsetHeight, 0); switch (value) { case "top": { this.style.position = "sticky"; this.style.top = `${getOffset(pinningInfo.top)}px`; this.style.bottom = ""; pinningInfo.top.push(this); break; } case "bottom": { this.style.position = "sticky"; this.style.top = ""; this.style.bottom = `${getOffset(pinningInfo.bottom)}px`; this.style.gridRowStart = String( this.#table.querySelectorAll(":scope > uwc-tr") .length - pinningInfo.bottom.length, ); pinningInfo.bottom.push(this); break; } default: { this.style.position = ""; this.style.top = ""; this.style.bottom = ""; this.style.gridRowStart = ""; } } }, }) pinned?: "top" | "bottom"; /** * @internal */ get cells() { return Array.from(this.children).filter( (child) => child instanceof UwcCellElement, ); } /** * @internal */ get isVisible() { return this.classList.contains("visible"); } /** * @internal */ set isVisible(next) { if (next) { this.updateGridColumns(); } this.classList.toggle("visible", next); } get #table() { const table = this.closest("uwc-table"); if (table == null) { throw new Error( " elements must be children of elements", ); } return table; } get #meta() { const meta = Meta.byTable.get(this.#table); if (meta == null) { throw new Error(" is child of a non-initialized table"); } return meta; } /** * @internal */ connectedCallback() { this.#meta.visibility.observe(this); } /** * @internal */ disconnectedCallback() { this.#meta.visibility.unobserve(this); } /** * @internal */ updateGridColumns = () => { const cells = this.cells; const additionalBreakpoints = [] as Breakpoint[]; { // first pass: adjust the starts of each cell according to the startIndices let domIndex = 0; for (const cell of cells) { const sortedGridIndices = this.#meta.startIndices .slice(domIndex, domIndex + cell.colspan) .sort((a, b) => a - b); cell.start = sortedGridIndices[0] ?? (this.#meta.startIndices[domIndex] ??= domIndex + 1); if (domIndex >= this.#table.breakpoints.length) { additionalBreakpoints.push({ width: cell.width, isHidden: cell.hidden, pinned: undefined, }); } domIndex += cell.colspan; } } if (additionalBreakpoints.length > 0) { this.#table.breakpoints = this.#table.breakpoints.concat( additionalBreakpoints, ); } { // second pass: shift starts of each cell as necessary to avoid conflicts let gridIndex = 1; for (const cell of cells.sort((a, b) => a.start - b.start)) { cell.start = Math.max(gridIndex, cell.start); cell.updateGridColumn(); gridIndex += cell.colspan; } } }; } abstract class UwcCellElement extends HTMLElement { @attribute({ codec: contentAttribute__number as Codec, eventConfig: false, reflect: false, }) start = 1; @attribute({ codec: contentAttribute__number as Codec, eventConfig: false, reflect: false, }) colspan = 1; @attribute({ codec: contentAttribute__maybeNumber, eventConfig: false, reflect: false, }) width?: number; @attribute({ codec: contentAttribute__number as Codec, eventConfig: false, reflect: false, }) minwidth = 20; get gridColumn() { return GridColumn.fromString(this.style.gridColumn); } protected get table() { const table = this.closest("uwc-table"); if (table == null) { throw new Error("cells must be children of "); } return table; } protected get meta() { const meta = Meta.byTable.get(this.table); if (meta == null) { throw new Error("cell is child of a non-initialized table"); } return meta; } /** * @internal */ updateGridColumn = () => { this.style.gridColumn = `${this.start} / ${this.start + this.colspan}`; }; } @customElement({ tagname: "uwc-th" }) export class UwcThElement extends UwcCellElement { static #draggedHeader: UwcThElement | undefined; @attribute({ codec: ariaAttribute__boolean as Codec, }) draggable = true; @attribute({ codec: contentAttribute__boolean as Codec, }) resizable = true; connectedCallback() { { // setup draggable this.addEventListener("dragstart", () => { if (!this.draggable) { return; } this.classList.add("dragging"); UwcThElement.#draggedHeader = this; }); this.addEventListener("dragend", () => { this.classList.remove("dragging"); UwcThElement.#draggedHeader = undefined; }); this.addEventListener("dragover", (e) => { const draggedHeader = UwcThElement.#draggedHeader; if (draggedHeader == null || draggedHeader === this) { return; } e.preventDefault(); const nextStart = isEventInLeftHalf(e, this) ? this.start : this.start + this.colspan; const start = draggedHeader.start; const end = start + draggedHeader.colspan; if (end === nextStart) { return; } this.table.move([start, end], nextStart); }); } { // setup resize handle this.append( create("div", { className: "resize-handle", onmousedown: (e) => { // prevent e.g. accidental dragging during resizing e.preventDefault(); e.stopPropagation(); let lastX = e.clientX; let width = this.table.getWidth(this.gridColumn); let animationFrameHandle: | undefined | ReturnType; const mouseUp = new AbortController(); document.addEventListener( "mouseup", () => { mouseUp.abort(); }, { capture: true, signal: mouseUp.signal }, ); document.addEventListener( "mousemove", (e) => { width = Math.max(this.minwidth, width + (e.clientX - lastX)); lastX = e.clientX; if (animationFrameHandle != null) { cancelAnimationFrame(animationFrameHandle); } animationFrameHandle = requestAnimationFrame(() => { this.table.setWidth(this.gridColumn, width); }); }, mouseUp, ); }, }), ); } } } @customElement({ tagname: "uwc-td" }) export class UwcTdElement extends UwcCellElement {} declare global { const UwcTableElement: typeof import("./uwc-table.ts").UwcTableElement; type UwcTableElement = import("./uwc-table.ts").UwcTableElement & CustomEventTarget; const UwcTrElement: typeof import("./uwc-table.ts").UwcTrElement; type UwcTrElement = import("./uwc-table.ts").UwcTrElement; const UwcThElement: typeof import("./uwc-table.ts").UwcThElement; type UwcThElement = import("./uwc-table.ts").UwcThElement; const UwcTdElement: typeof import("./uwc-table.ts").UwcTdElement; type UwcTdElement = import("./uwc-table.ts").UwcTdElement; interface HTMLElementTagNameMap { "uwc-table": UwcTableElement; "uwc-tr": UwcTrElement; "uwc-th": UwcThElement; "uwc-td": UwcTdElement; } // not if you want to extend an interface by merging other declarations // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface HTMLElementEventMap extends TableEvents {} } Object.assign(globalThis, { UwcTableElement, UwcTrElement, UwcThElement, UwcTdElement, });