@sparser/au2-data-grid
Version:
A data grid for Aurelia 2
300 lines (272 loc) • 10.1 kB
text/typescript
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).
*/
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.
*/
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.
*/
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.
*/
({ 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,
);
}