UNPKG

@sparser/au2-data-grid

Version:
300 lines (272 loc) 10.1 kB
import { Constructable, ILogger, resolve, } from '@aurelia/kernel'; import { bindable, CustomElement, CustomElementDefinition, CustomElementType, ICustomElementController, ICustomElementViewModel, IHydrationContext, INode, IPlatform, BindingMode, ISignaler, } from '@aurelia/runtime-html'; import { ContentModel, ItemSelectionMode, } from './content-model.js'; import template from './data-grid.html'; import { DefaultGridHeader, GridHeader, } from './grid-header.js'; import { ChangeType, Column, ExportableGridState, GridStateChangeSubscriber, GridStateModel, IGridStateModel, OrderChangeData, } from './grid-state.js'; import { SortDirection, SortOption, } from './sorting-options.js'; import { GridContent, GridHeaders, } from './template-controllers.js'; const ascPattern = /^asc$|^ascending$/i; const descPattern = /^desc$|^descending$/i; const stateLookup: Map<number, GridStateModel> = new Map<number, GridStateModel>(); /** * Default implementation of the data-grid. */ export class DataGrid implements ICustomElementViewModel, GridStateChangeSubscriber { private static id: number = 0; /** * The content model (data). */ @bindable public model!: ContentModel<Record<string, unknown>>; /** * Any bound state is read only once in the binding stage. * Any 'incoming' changes from the consumer side thereafter is disregarded. * In the post-binding phase this property is treated as a write-only property to provide the consumer side with any changes in the exportable grid state. */ @bindable public state?: ExportableGridState = void 0; /** * Callback when a item is * - clicked with the 'None' selection mode, or * - double-clicked with 'Single' or 'Multiple' selection mode. */ @bindable public itemClicked?: (data: { item: unknown; index: number }) => void; /** * This is a one-time bindable array of string columnIds that needs to be hidden from the current instance of the grid. */ @bindable({ mode: BindingMode.oneTime }) public hiddenColumns: string[] = []; private stateModel!: IGridStateModel; public readonly $controller?: ICustomElementController<this>; // This is set by the controller after this instance is constructed private readonly containerEl!: HTMLElement; private lastClickedRow: number | null = null; private selectionUpdateSignal: string = ''; private readonly hydrationContext: IHydrationContext = resolve(IHydrationContext); private readonly node: HTMLElement = resolve(INode) as HTMLElement; private readonly signaler: ISignaler = resolve(ISignaler); private readonly logger: ILogger = resolve(ILogger).scopeTo('DataGrid'); public created(controller: ICustomElementController<this>): void { const instanceIdStr = this.node.dataset.instanceId!; const instanceId = Number(instanceIdStr); if (!Number.isInteger(instanceId)) throw new Error(`Invalid data grid instanceId: ${instanceIdStr}; expected integer.`); this.selectionUpdateSignal = `update-selection-${instanceIdStr}`; const state = stateLookup.get(Number(instanceId)); if (state === undefined) throw new Error(`Cannot find the model for the instance #${instanceIdStr}`); this.stateModel = state; const container = this.hydrationContext .controller .container .createChild({ inheritParentResources: true }) .register(controller.definition.dependencies); state.createViewFactories(container); this.node.style.setProperty('--num-columns', state.columns.length.toString()); } public binding(): void { const stateModel = this.stateModel; const state = this.state; if (state != null) { stateModel.applyState(state); } stateModel.hideColumns(this.hiddenColumns); const sortingOptions = stateModel.initializeActiveSortOptions(); if (sortingOptions !== null) { this.model.applySorting(sortingOptions); } stateModel.addSubscriber(this); } public attaching(): void { this.adjustColumnWidth(); } public unbinding(): void { this.stateModel.removeSubscriber(this); } public exportState(): ExportableGridState | undefined { try { return this.state = this.stateModel.export(); } catch (e) { this.logger.warn((e as Error).message); } } public handleGridStateChange(type: ChangeType.Width): void; public handleGridStateChange(type: ChangeType.Order, value: OrderChangeData): void; public handleGridStateChange(type: ChangeType.Sort, newValue: SortOption<Record<string, unknown>>, oldValue: SortOption<Record<string, unknown>> | null): void; public handleGridStateChange(type: ChangeType, newValue?: SortOption<Record<string, unknown>> | OrderChangeData, _oldValue?: SortOption<Record<string, unknown>> | null): void { switch (type) { case ChangeType.Sort: this.model.applySorting(newValue as SortOption<Record<string, unknown>>); break; case ChangeType.Width: case ChangeType.Order: this.adjustColumnWidth(); break; } this.exportState(); } protected adjustColumnWidth(): void { const columns = this.stateModel.columns; const fallback = columns.some(c => c.widthPx != null) ? 'auto' : '1fr'; this.containerEl.style.gridTemplateColumns = columns.map(c => `minmax(0px, ${c.widthPx ?? fallback})`).join(' '); } protected handleDblClick(item: Record<string, unknown>, index: number): void { getSelection()?.empty(); this.itemClicked?.({ item, index }); this.lastClickedRow = index; } protected handleClick(event: MouseEvent, item: Record<string, unknown>, index: number): void { getSelection()?.empty(); const model = this.model; switch (model.selectionMode) { case ItemSelectionMode.None: this.itemClicked?.({ item, index }); break; case ItemSelectionMode.Single: model.selectItem(item); break; case ItemSelectionMode.Multiple: if (event.shiftKey) { const lastClickedRow = this.lastClickedRow; if (lastClickedRow !== null) { model.selectRange(lastClickedRow, index); } else { model.selectItem(item); } } else if (event.ctrlKey) { model.toggleSelection(item); } else { model.clearSelections(); model.selectItem(item); } break; } this.lastClickedRow = index; this.signaler.dispatchSignal(this.selectionUpdateSignal); } public static processContent(content: HTMLElement, platform: IPlatform): void { const columns = content.querySelectorAll('grid-column'); const numColumns = columns.length; const state = new GridStateModel(); const doc = platform.document; for (let i = 0; i < numColumns; i++) { const col = columns[i]; // extract metadata let isExportable = true; const property = col.getAttribute('property'); const id = col.getAttribute('id') ?? property ?? (isExportable = false, Column.generateId()); const directionRaw = col.getAttribute('sort-direction'); let direction: SortDirection | null = null; if (directionRaw !== null) { if (ascPattern.test(directionRaw)) { direction = SortDirection.Ascending; } else if (descPattern.test(directionRaw)) { direction = SortDirection.Descending; } } const isResizable = !col.hasAttribute('non-resizable'); let width: string | null = null; if (isResizable) { width = col.getAttribute('width'); width = width === null || Number.isNaN(width) ? null : `${width}px`; } // extract header let container = doc.createElement('grid-header'); container.setAttribute('state.bind', ''); const header = col.querySelector('header'); const headerContent = header?.childNodes; const projection = doc.createElement('template'); projection.setAttribute('au-slot', 'default'); projection.content.append(...(headerContent !== undefined ? Array.from(headerContent) : [doc.createTextNode(`Column ${i + 1}`)] )); container.append(projection); const headerDfn = CustomElementDefinition.create({ name: CustomElement.generateName(), template: container }); header?.remove(); // extract content container = doc.createElement('div'); container.setAttribute('role', 'cell'); container.append(...Array.from(col.childNodes)); const contentDfn = CustomElementDefinition.create({ name: CustomElement.generateName(), template: container }); void new Column( state, id, property, isExportable, direction, isResizable, width, headerDfn, contentDfn, ); col.remove(); } const id = ++this.id; stateLookup.set(id, state); content.setAttribute('data-instance-id', id.toString()); } } // eslint-disable-next-line @typescript-eslint/naming-convention export const DefaultDataGrid = defineDataGridCustomElement(DefaultGridHeader); /** * Creates data-grid custom element registration. * @param {CustomElementType<THeader>} header The grid-header custom element registration. * @returns {CustomElementType<Constructable<DataGrid>>} Data grid custom element registration. * @template THeader */ export function defineDataGridCustomElement< THeader extends Constructable<GridHeader>, >( header: CustomElementType<THeader>, ): CustomElementType<Constructable<DataGrid>> { return CustomElement.define( { name: 'data-grid', template, dependencies: [ // TCs GridHeaders, GridContent, //CEs header, ] }, DataGrid, ); }