Skip to content

Instantly share code, notes, and snippets.

@jdanyow
Last active December 13, 2018 16:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jdanyow/9782317010240c90fc30e179eeb41064 to your computer and use it in GitHub Desktop.
Save jdanyow/9782317010240c90fc30e179eeb41064 to your computer and use it in GitHub Desktop.
Aurelia DataGrid Prototype
<!DOCTYPE html>
<html>
<head>
<title>SAMS</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css" />
<style>
* {
color: #333;
font-family: Helvetica, Arial, sans-serif;
}
body {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
</style>
</head>
<body aurelia-app="src/main">
<script src="https://cdn.jsdelivr.net/gh/aurelia/script@master/scripts/system.js"></script>
<script>
System.config({
transpiler: 'typescript',
typescriptOptions: {
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
map: {
typescript: 'https://cdnjs.cloudflare.com/ajax/libs/typescript/2.0.3/typescript.min.js'
},
packages: {
"src": {
defaultJSExtensions: true,
defaultExtension: "ts"
}
}
});
</script>
<script src="https://cdn.jsdelivr.net/gh/aurelia/script@master/scripts/aurelia-core.min.js"></script>
<script>
System.import('aurelia-bootstrapper');
</script>
</body>
</html>
<template>
<require from="./roster-grid/roster-grid"></require>
<style>
.slider-label {
border: 3px solid #EEE;
margin-right: 10px;
}
</style>
<label class="slider-label">
Frozen Columns:
<input type="range" value.bind="frozenColumns" min="0" max="30" step="1">
${frozenColumns}
</label>
<label class="slider-label">
Width:
<input type="range" value.bind="width" min="0" max="2000" max.bind="maxWidth" step="1">
${width}
</label>
<label class="slider-label">
Height:
<input type="range" value.bind="height" min="0" max="2000" max.bind="maxHeight" step="1">
${height}
</label>
<label>
Group By:
<select value.bind="groupBy">
<option model.bind="null"></option>
<option model.bind="'Service'">Service</option>
<option model.bind="'ClientFullName'">Consumer</option>
</select>
</label>
<label>
Sort By:
<select value.bind="sortBy">
<option model.bind="null"></option>
<option model.bind="'Service'">Service</option>
<option model.bind="'ClientFullName'">Consumer</option>
</select>
</label>
<label>
<input type="checkbox" checked.bind="sortDescending">
Descending?
</label>
<roster-grid items.bind="rosterRecordItems"
group-by.bind="groupBy"
columns.bind="columns"
frozen-columns.bind="frozenColumns"
row-height.bind="26"
left.bind="25"
top.bind="50"
width.bind="width"
height.bind="height"
sort-by.bind="sortBy"
sort-descending.bind="sortDescending">
</roster-grid>
</template>
import {RosterGridColumnDefinition} from './roster-grid/roster-grid-interfaces';
export class App {
public rosterRecordItems: any[];
public columns: RosterGridColumnDefinition[] = [];
public frozenColumns = 2;
public width = document.body.getBoundingClientRect().width - 50;
public height = document.body.getBoundingClientRect().height - 100;
public maxWidth = document.body.getBoundingClientRect().width - 50;
public maxHeight = document.body.getBoundingClientRect().height - 100;
public groupBy = 'ClientFullName';
public sortBy = 'Service';
public sortDescending = false;
constructor() {
this.columns.push({
header: 'Consumer',
property: 'ClientFullName',
width: 200,
type: 'string',
editable: false,
sortable: true
});
this.columns.push({
header: 'Service',
property: 'Service',
width: 150,
type: 'string',
editable: false,
sortable: true
});
const date = new Date();
const daysInMonth = new Date(date.getFullYear(), date.getMonth(), 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
this.columns.push({
header: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][new Date(2016, 9, day).getDay()]
+ ' ' + day.toString(10),
property: `Day${day}`,
width: 80,
type: 'number',
editable: true,
sortable: false
});
}
}
public activate() {
const items: any[] = [];
const consumers = mockConsumers;
const services = mockServices;
for (let i = 0; i < consumers.length; i++) {
for (let j = 0; j < services.length; j++) {
const item: any = {
ClientFullName: `${consumers[i].lastName}, ${consumers[i].firstName}`,
Service: services[j]
};
for (let c = 1; c <= 31; c++) {
item[`Day${c}`] = +(Math.random() * 100).toFixed(2);
}
items.push(item);
}
}
this.rosterRecordItems = items;
}
}
const mockServices = ['Congregate Meals', 'Home Delivered Meals', 'Transportation'];
const mockConsumers = [{
"firstName": "Larry",
"lastName": "Hunt"
}, {
"firstName": "Earl",
"lastName": "Hamilton"
}, {
"firstName": "Michelle",
"lastName": "Murphy"
}, {
"firstName": "Carlos",
"lastName": "Sims"
}, {
"firstName": "Sean",
"lastName": "Marshall"
}, {
"firstName": "Karen",
"lastName": "Fuller"
}, {
"firstName": "Ernest",
"lastName": "Carroll"
}, {
"firstName": "Cynthia",
"lastName": "Miller"
}, {
"firstName": "Joan",
"lastName": "Stanley"
}, {
"firstName": "Philip",
"lastName": "Ray"
}, {
"firstName": "Paula",
"lastName": "Richards"
}, {
"firstName": "Juan",
"lastName": "Russell"
}, {
"firstName": "Janet",
"lastName": "Peters"
}, {
"firstName": "Bobby",
"lastName": "Shaw"
}, {
"firstName": "Ralph",
"lastName": "Rice"
}, {
"firstName": "Patricia",
"lastName": "Burton"
}, {
"firstName": "Patricia",
"lastName": "Stanley"
}, {
"firstName": "Edward",
"lastName": "Crawford"
}, {
"firstName": "Phyllis",
"lastName": "Watson"
}, {
"firstName": "Victor",
"lastName": "Schmidt"
}, {
"firstName": "Marie",
"lastName": "Scott"
}, {
"firstName": "Justin",
"lastName": "Cruz"
}, {
"firstName": "Bobby",
"lastName": "Duncan"
}, {
"firstName": "Russell",
"lastName": "Price"
}, {
"firstName": "Heather",
"lastName": "Alvarez"
}, {
"firstName": "Jesse",
"lastName": "Howard"
}, {
"firstName": "Justin",
"lastName": "Clark"
}, {
"firstName": "Joseph",
"lastName": "Wood"
}, {
"firstName": "Annie",
"lastName": "Gilbert"
}, {
"firstName": "Steven",
"lastName": "Gonzales"
}, {
"firstName": "Carl",
"lastName": "Hayes"
}, {
"firstName": "Linda",
"lastName": "Schmidt"
}, {
"firstName": "Martha",
"lastName": "Hunt"
}, {
"firstName": "Todd",
"lastName": "Franklin"
}, {
"firstName": "Tammy",
"lastName": "Perry"
}, {
"firstName": "Carlos",
"lastName": "Hansen"
}, {
"firstName": "Nicholas",
"lastName": "Henderson"
}, {
"firstName": "Debra",
"lastName": "Hernandez"
}, {
"firstName": "Carl",
"lastName": "Warren"
}, {
"firstName": "Diana",
"lastName": "Meyer"
}, {
"firstName": "Marie",
"lastName": "Jenkins"
}, {
"firstName": "Diane",
"lastName": "Graham"
}, {
"firstName": "Jose",
"lastName": "Meyer"
}, {
"firstName": "Louise",
"lastName": "Price"
}, {
"firstName": "Steve",
"lastName": "Thompson"
}, {
"firstName": "Eugene",
"lastName": "Brooks"
}, {
"firstName": "Jacqueline",
"lastName": "Morrison"
}, {
"firstName": "Brenda",
"lastName": "Brooks"
}, {
"firstName": "Emily",
"lastName": "Reynolds"
}, {
"firstName": "Christina",
"lastName": "Evans"
}, {
"firstName": "Jesse",
"lastName": "Ellis"
}, {
"firstName": "Phyllis",
"lastName": "Allen"
}, {
"firstName": "Scott",
"lastName": "Ward"
}, {
"firstName": "Patrick",
"lastName": "Day"
}, {
"firstName": "Kathy",
"lastName": "Price"
}, {
"firstName": "Barbara",
"lastName": "Lawrence"
}, {
"firstName": "Andrew",
"lastName": "Butler"
}, {
"firstName": "Todd",
"lastName": "Torres"
}, {
"firstName": "Rose",
"lastName": "Chapman"
}, {
"firstName": "Mark",
"lastName": "Watkins"
}, {
"firstName": "Victor",
"lastName": "Fields"
}, {
"firstName": "Jean",
"lastName": "Alexander"
}, {
"firstName": "Christine",
"lastName": "Arnold"
}, {
"firstName": "Antonio",
"lastName": "Ferguson"
}, {
"firstName": "Frances",
"lastName": "Henderson"
}, {
"firstName": "Julie",
"lastName": "Howard"
}, {
"firstName": "Samuel",
"lastName": "Cole"
}, {
"firstName": "Randy",
"lastName": "Lewis"
}, {
"firstName": "Diane",
"lastName": "Smith"
}, {
"firstName": "Raymond",
"lastName": "Russell"
}, {
"firstName": "Alice",
"lastName": "Foster"
}, {
"firstName": "Janice",
"lastName": "Lawson"
}, {
"firstName": "Thomas",
"lastName": "Little"
}, {
"firstName": "Frances",
"lastName": "Nguyen"
}, {
"firstName": "Gerald",
"lastName": "Armstrong"
}, {
"firstName": "Jimmy",
"lastName": "Schmidt"
}, {
"firstName": "James",
"lastName": "Lopez"
}, {
"firstName": "Joshua",
"lastName": "Torres"
}, {
"firstName": "Juan",
"lastName": "Peters"
}, {
"firstName": "Deborah",
"lastName": "Gibson"
}, {
"firstName": "Ruby",
"lastName": "Gonzales"
}, {
"firstName": "Samuel",
"lastName": "Simmons"
}, {
"firstName": "Carlos",
"lastName": "Gomez"
}, {
"firstName": "Lori",
"lastName": "Ortiz"
}, {
"firstName": "Ryan",
"lastName": "Jones"
}, {
"firstName": "William",
"lastName": "Davis"
}, {
"firstName": "Virginia",
"lastName": "Kim"
}, {
"firstName": "Joshua",
"lastName": "Alexander"
}, {
"firstName": "Jennifer",
"lastName": "Adams"
}, {
"firstName": "Juan",
"lastName": "White"
}, {
"firstName": "Arthur",
"lastName": "Foster"
}, {
"firstName": "Mary",
"lastName": "Edwards"
}, {
"firstName": "Lisa",
"lastName": "Mccoy"
}, {
"firstName": "Lawrence",
"lastName": "Wood"
}, {
"firstName": "Carol",
"lastName": "Parker"
}, {
"firstName": "Janet",
"lastName": "Hansen"
}, {
"firstName": "William",
"lastName": "Hayes"
}, {
"firstName": "Melissa",
"lastName": "Harris"
}, {
"firstName": "Christine",
"lastName": "Coleman"
}, {
"firstName": "Theresa",
"lastName": "Ray"
}];
import {viewEngineHooks, ViewFactory} from 'aurelia-templating';
@viewEngineHooks()
export class CleanBindingCommands {
public afterCompile(viewFactory: ViewFactory) {
const targets = (<any>viewFactory).template.querySelectorAll('.au-target');
for (let i = 0; i < targets.length; i++) {
let el = targets[i];
for (let ii = 0; ii < el.attributes.length; ii++) {
let attr = el.attributes[ii];
let parts = attr.name.split('.');
if (parts.length === 2) {
el.removeAttribute(attr.name);
ii--;
}
}
el.removeAttribute('ref');
}
}
}
import {Aurelia} from 'aurelia-framework';
export function configure(aurelia: Aurelia) {
aurelia.use
.basicConfiguration()
.developmentLogging()
.globalResources([
'./src/clean-commands',
'./src/set-attribute-binding-behavior',
'./src/value-converters/coalesce-value-converter',
'./src/value-converters/slice-value-converter',
'./src/value-converters/to-fixed-value-converter',
]);
aurelia.start().then(() => aurelia.setRoot());
}
import {EventAggregator, Subscription} from 'aurelia-event-aggregator';
import {
RosterGridSelectionInfo,
RosterGridLayoutInfo,
RosterGridColumnDefinition
} from './roster-grid-interfaces';
import constants from './roster-grid-constants';
export class RosterGridCellEditor {
private input: HTMLInputElement;
private selection: RosterGridSelectionInfo;
private columns: RosterGridColumnDefinition[];
private items: any[];
private subscriptions: Subscription[];
private mouseIsDown = false;
private beginEditOnMouseUp = false;
constructor(bus: EventAggregator) {
this.createInput();
this.subscriptions = [
bus.subscribe(constants.events.layoutPropertiesChanged, this.layoutPropertiesChanged),
bus.subscribe(constants.events.dataViewChanged, this.dataViewChanged),
bus.subscribe(constants.events.beforeSelectionChanged, this.beforeSelectionChanged),
bus.subscribe(constants.events.selectionChanged, this.selectionChanged),
bus.subscribe(constants.events.dom.mousedown, () => this.mouseIsDown = true),
bus.subscribe(constants.events.dom.mouseup, this.mouseUp),
];
}
public dispose() {
this.endEdit();
this.subscriptions.forEach(subscription => subscription.dispose());
this.destroyInput();
}
private createInput() {
const input = this.input = document.createElement('input');
input.type = 'text';
input.maxLength = 10;
input.className = 'roster-grid-cell-input';
input.addEventListener('blur', this.inputBlur);
input.addEventListener('keydown', this.inputKeyDown);
}
private destroyInput() {
const input = this.input;
input.removeEventListener('blur', this.inputBlur);
input.removeEventListener('keydown', this.inputKeyDown);
}
private layoutPropertiesChanged = (layout: RosterGridLayoutInfo) => {
this.endEdit();
this.columns = layout.columns;
};
private dataViewChanged = (items: any[]) => {
this.endEdit();
this.items = items;
};
private beforeSelectionChanged = (selection: RosterGridSelectionInfo) => {
this.endEdit();
};
private selectionChanged = (selection: RosterGridSelectionInfo) => {
this.selection = selection;
if (this.mouseIsDown) {
this.beginEditOnMouseUp = true;
} else {
this.beginEditOnMouseUp = false;
this.beginEdit();
}
};
private mouseUp = () => {
this.mouseIsDown = false;
if (this.beginEditOnMouseUp) {
this.beginEditOnMouseUp = false;
this.beginEdit();
}
};
private inputBlur = (event: FocusEvent) => {
this.endEdit();
};
private inputKeyDown = (event: KeyboardEvent) => {
// todo
};
private beginEdit() {
const cell = this.selection.ranges[this.selection.ranges.length - 1].end;
const id = `roster-grid-cell-${cell.col}-${cell.row}`;
const cellDiv = <HTMLDivElement>document.getElementById(id);
if (!cellDiv) {
return;
}
cellDiv.textContent = '';
cellDiv.style.padding = '0';
cellDiv.style.border = '2px solid #99cfea';
cellDiv.appendChild(this.input);
const value = this.items[cell.row - 1][this.columns[cell.col].property];
this.input.value = value.toFixed(2);
this.input.focus();
this.input.select();
}
private endEdit() {
if (!this.items || !this.columns) {
return;
}
const cellDiv = <HTMLDivElement>this.input.parentElement;
if (cellDiv) {
cellDiv.style.padding = null;
cellDiv.style.border = null;
cellDiv.removeChild(this.input);
const cell = this.selection.ranges[this.selection.ranges.length - 1].end;
const value = this.items[cell.row][this.columns[cell.col].property];
cellDiv.textContent = value;
}
}
}
export default {
minColumnWidth: 10,
defaults: {
frozenColumns: 0,
rowHeight: 26
},
events: {
layoutPropertiesChanged: 'layout-properties-changed',
layoutApplied: 'layout-applied',
resizeColumnRequested: 'resize-column-requested',
sortRequested: 'sort-requested',
beforeSelectionChanged: 'before-selection-changed',
selectionChanged: 'selection-changed',
dataViewDefinitionChanged: 'data-view-definition-changed',
dataViewChanged: 'data-view-changed',
cellChanged: 'cell-changed',
dom: {
scroll: 'scroll',
mousemove: 'mousemove',
mouseup: 'mouseup',
mousedown: 'mousedown',
mouseover: 'mouseover',
mouseout: 'mouseout',
wheel: 'wheel'
}
}
};
import {EventAggregator, Subscription} from 'aurelia-event-aggregator';
import {RosterGridDataViewDefinition, RosterGridColumnDefinition} from './roster-grid-interfaces';
import constants from './roster-grid-constants';
interface GroupHeader {
isGroupHeader: true;
name: string;
items: any[];
[propertyName: string]: any;
}
export class RosterGridDataView {
public items: any[];
public footer: any;
private definition: RosterGridDataViewDefinition;
private groups: GroupHeader[];
private subscriptions: Subscription[];
private initial = false;
private animationFrame = 0;
constructor(private bus: EventAggregator) {
this.subscriptions = [
bus.subscribe(constants.events.dataViewDefinitionChanged, this.definitionChanged),
bus.subscribe(constants.events.sortRequested, this.sortRequested),
bus.subscribe(constants.events.cellChanged, this.updateGroups)
];
}
public dispose() {
this.subscriptions.forEach(subscription => subscription.dispose());
}
public getSortClass(property: string) {
if (property !== this.definition.sortBy) {
return '';
}
return this.definition.sortDescending ? 'roster-grid-descending' : 'roster-grid-ascending';
}
private definitionChanged = (definition: RosterGridDataViewDefinition) => {
this.definition = definition;
if (!this.initial) {
this.initial = true;
this.update();
return;
}
this.scheduleUpdate();
}
private scheduleUpdate() {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = requestAnimationFrame(() => this.update());
}
private update() {
const definition = this.definition;
const columns = definition.columns;
// sort
const sortBy = definition.sortBy === null ? columns[0].property : definition.sortBy;
definition.sortBy = sortBy;
const groupBy = definition.groupBy;
const items = definition.items
.slice(0)
.sort((a, b) => {
let groupByResult = 0;
if (groupBy !== null) {
groupByResult = stringComparisonOrdinalIgnoreCase(a[groupBy], b[groupBy], false);
}
if (groupByResult === 0) {
return stringComparisonOrdinalIgnoreCase(a[sortBy], b[sortBy], definition.sortDescending);
}
return groupByResult;
});
// make the footer item.
const footer: GroupHeader = this.footer = this.makeGroupHeader('footer', columns);
footer.items.push(...items);
this.groups = [footer];
// insert group headers
if (groupBy !== null) {
let currentGroup: GroupHeader|null = null;
for (let i = 0; i < items.length; i++) {
const item = items[i];
let name = item[groupBy];
if (currentGroup !== null && name === currentGroup.name) {
currentGroup.items.push(items[i]);
continue;
}
currentGroup = this.makeGroupHeader(name, columns);
this.groups.push(currentGroup);
items.splice(i, 0, currentGroup);
i++;
}
}
// compute totals
this.updateGroups();
// update items
this.items = items;
this.bus.publish(constants.events.dataViewChanged, this.items);
}
private makeGroupHeader(name: string, columns: RosterGridColumnDefinition[]): GroupHeader {
const header: GroupHeader = { isGroupHeader: true, name, items: [] };
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
if (column.type !== 'number') {
continue;
}
header[column.property] = null;
}
return header;
}
private updateGroups = () => {
console.info(`${this.footer.items.length} items, ${this.groups.length} groups`);
console.time('updateGroups');
const columns = this.definition.columns;
const groups = this.groups;
let c = columns.length;
while (c--) {
const column = columns[c];
if (column.type !== 'number') {
continue;
}
const property = column.property;
let g = groups.length;
while (g--) {
const group = groups[g];
const items = group.items;
let i = items.length;
let total: number|null = 0;
while (i--) {
total += (items[i][property] || 0);
}
if (total === 0) {
total = null;
}
if (total !== group[property]) {
group[property] = total;
}
}
}
console.timeEnd('updateGroups');
}
private sortRequested = (index: number) => {
const requestedColumn = this.definition.columns[index];
if (!requestedColumn.sortable) {
return;
}
const requestedProperty = requestedColumn.property;
const currentProperty = this.definition.sortBy;
if (requestedProperty === currentProperty) {
this.definition.sortDescending = !this.definition.sortDescending;
} else {
this.definition.sortBy = requestedProperty;
this.definition.sortDescending = false;
}
}
}
function stringComparisonOrdinalIgnoreCase(
a: string|null|undefined,
b: string|null|undefined,
descending: boolean
): number {
if (a === null || a === undefined) {
a = '';
} else {
a = a.toLowerCase();
}
if (b === null || b === undefined) {
b = '';
} else {
b = b.toLowerCase();
}
if (descending) {
return a.localeCompare(b) * -1;
}
return a.localeCompare(b);
}
import {EventAggregator} from 'aurelia-event-aggregator';
import constants from './roster-grid-constants';
export class RosterGridDomEvents {
constructor(private bus: EventAggregator) {
document.body.addEventListener(constants.events.dom.mousemove, this.publish);
document.body.addEventListener(constants.events.dom.mouseup, this.publish);
document.body.addEventListener(constants.events.dom.mousedown, this.publish);
document.body.addEventListener(constants.events.dom.mouseover, this.publish);
document.body.addEventListener(constants.events.dom.mouseout, this.publish);
}
public dispose() {
document.body.removeEventListener(constants.events.dom.mousemove, this.publish);
document.body.removeEventListener(constants.events.dom.mouseup, this.publish);
document.body.removeEventListener(constants.events.dom.mousedown, this.publish);
document.body.removeEventListener(constants.events.dom.mouseover, this.publish);
document.body.removeEventListener(constants.events.dom.mouseout, this.publish);
}
public publish = (event: UIEvent) => {
this.bus.publish(event.type, event);
return true;
}
}
import {EventAggregator, Subscription} from 'aurelia-event-aggregator';
import {RosterGridResizeColumnArgs, RosterGridPositionInfo} from './roster-grid-interfaces';
import constants from './roster-grid-constants';
export class RosterGridHead {
private margin = 4;
private down: { index: number; leftClientX: number; }|null;
private subscriptions: Subscription[];
constructor(
private bus: EventAggregator,
private getPositionInfo: { (clientX: number, clientY: number): RosterGridPositionInfo; }
) {
this.subscriptions = [
bus.subscribe(constants.events.dom.mousedown, this.mouseDown),
bus.subscribe(constants.events.dom.mouseup, this.mouseUp),
bus.subscribe(constants.events.dom.mousemove, this.mouseMove),
];
}
public dispose() {
this.subscriptions.forEach(subscription => subscription.dispose());
}
private mouseDown = (event: MouseEvent) => {
const { clientX, clientY } = event;
const down = this.getPositionInfo(clientX, clientY);
const cell = down.cell;
if (!cell
|| cell.clipped
|| cell.row !== 0
) {
return;
}
if (cell.x < this.margin && cell.col !== 0
|| cell.x > cell.width - this.margin
) {
let index = cell.col;
let leftClientX = clientX - cell.x;
if (cell.x < this.margin) {
index--;
leftClientX -= cell.previousColWidth;
}
this.down = { index, leftClientX };
document.body.style.cursor = 'col-resize';
event.preventDefault();
} else if (down.cell) {
this.bus.publish(constants.events.sortRequested, down.cell.col);
}
}
private mouseUp = (event: MouseEvent) => {
if (this.down) {
document.body.style.cursor = 'auto';
this.down = null;
}
}
private mouseMove = (event: MouseEvent) => {
const { clientX, clientY } = event;
if (this.down) {
const width = clientX - this.down.leftClientX;
if (width < constants.minColumnWidth) {
return;
}
const index = this.down.index;
const args: RosterGridResizeColumnArgs = { index, width };
this.bus.publish(constants.events.resizeColumnRequested, args);
return;
}
const div = <HTMLDivElement>event.target;
const { cell } = this.getPositionInfo(clientX, clientY);
if (!cell
|| cell.clipped
|| cell.row !== 0
) {
div.style.cursor = 'default';
return;
}
if (cell.x < this.margin && cell.col !== 0
|| cell.x > cell.width - this.margin
) {
div.style.cursor = 'col-resize';
} else {
div.style.cursor = 'default';
}
}
}
export interface RosterGridColumnDefinition {
header: string;
property: string;
width: number;
type: 'number'|'string';
editable: boolean;
sortable: boolean;
}
export interface RosterGridLayoutInfo {
itemsCount: number;
columns: RosterGridColumnDefinition[];
frozenColumns: number;
rowHeight: number;
left: number;
top: number;
width: number;
height: number;
}
export interface RosterGridDataViewDefinition {
columns: RosterGridColumnDefinition[];
items: any[];
groupBy: string|null;
sortBy: string|null;
sortDescending: boolean;
}
export interface RosterGridSelectionInfo {
ranges: { start: { col: number; row: number; }, end: { col: number; row: number; } }[];
}
export interface RosterGridResizeColumnArgs {
index: number;
width: number;
}
export interface RosterGridPositionInfo {
clientX: number;
clientY: number;
cell: {
clipped: boolean;
row: number;
col: number;
left: number;
top: number;
width: number;
height: number;
x: number;
y: number;
previousColWidth: number;
}|null;
grid: {
left: number;
top: number;
width: number;
height: number;
x: number;
y: number;
};
}
import {EventAggregator, Subscription} from 'aurelia-event-aggregator';
import {
RosterGridLayoutInfo,
RosterGridColumnDefinition,
RosterGridResizeColumnArgs,
RosterGridPositionInfo
} from './roster-grid-interfaces';
import constants from './roster-grid-constants';
export class RosterGridLayout implements RosterGridLayoutInfo {
public itemsCount: number;
public columns: RosterGridColumnDefinition[];
public frozenColumns: number;
public rowHeight: number;
public left: number;
public top: number;
public width: number;
public height: number;
public contentWidth: number;
public contentHeight: number;
public frozenContentWidth: number;
public scrollContentWidth: number;
public visibleItemCount: number;
public scrollLeft = 0;
public rowOffset = 0;
public scrollViewPort: HTMLDivElement;
private initialLayout = false;
private animationFrame = 0;
private scrollRequired: HTMLDivElement|null = null;
private columnWidthChanges = new Map<number, number>();
private layoutRequired = false;
private subscriptions: Subscription[];
constructor(private bus: EventAggregator) {
this.subscriptions = [
bus.subscribe(constants.events.layoutPropertiesChanged, this.layoutPropertiesChanged),
bus.subscribe(constants.events.resizeColumnRequested, this.resizeColumnRequested),
bus.subscribe(constants.events.dom.scroll, this.domScroll),
bus.subscribe(constants.events.dom.wheel, this.domWheel)
];
}
public dispose() {
this.subscriptions.forEach(subscription => subscription.dispose());
cancelAnimationFrame(this.animationFrame);
}
public getPositionInfo(clientX: number, clientY: number): RosterGridPositionInfo {
const x = clientX - this.left;
const y = clientY - this.top;
const grid = {
left: this.left,
top: this.top,
width: this.width,
height: this.height,
x,
y
};
const { scrollLeft, scrollTop, clientWidth, clientHeight } = this.scrollViewPort;
const clipped = x < 0 || x > clientWidth || y < 0 || y > clientHeight + this.rowHeight;
if (x < 0 || x > this.contentWidth - scrollLeft || y < 0 || y > this.contentHeight + this.rowHeight - scrollTop) {
return { clientX, clientY, cell: null, grid };
}
const row = y < this.rowHeight ? 0 : Math.floor((y + scrollTop) / this.rowHeight);
let col = 0;
let left = 0;
while (x > left + this.columns[col].width) {
left += this.columns[col].width;
col++;
if (col === this.frozenColumns) {
left -= scrollLeft;
}
}
const top = row === 0 ? 0 : (row + 1) * this.rowHeight - scrollTop;
return {
clientX,
clientY,
cell: {
clipped,
row,
col,
left,
top,
width: this.columns[col].width,
height: this.rowHeight,
x: x - left,
y: y - top,
previousColWidth: col === 0 ? 0 : this.columns[col - 1].width
},
grid
};
}
private layoutPropertiesChanged = (info: RosterGridLayoutInfo) => {
this.left = info.left;
this.top = info.top;
this.width = info.width;
this.height = info.height;
this.itemsCount = info.itemsCount;
this.columns = info.columns;
this.frozenColumns = info.frozenColumns;
this.rowHeight = info.rowHeight;
if (this.initialLayout) {
this.layoutRequired = true;
this.scheduleAnimation();
} else {
this.initialLayout = true;
this.layout();
this.bus.publish(constants.events.layoutApplied, this);
}
};
private resizeColumnRequested = (args: RosterGridResizeColumnArgs) => {
this.columnWidthChanges.set(args.index, args.width);
this.layoutRequired = true;
this.scheduleAnimation();
};
private domWheel = (event: WheelEvent) => {
if (event.deltaMode !== 0x00) {
// https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode
throw new Error(`Unhandled scroll deltaMode. Expected 0x00 DOM_DELTA_PIXEL, received ${event.deltaMode}`);
}
this.scrollViewPort.scrollLeft += event.deltaX;
this.scrollViewPort.scrollTop += event.deltaY;
this.scrollRequired = this.scrollViewPort;
this.scheduleAnimation();
};
private domScroll = (event: Event) => {
this.scrollRequired = <HTMLDivElement>event.target;
this.scheduleAnimation();
};
private layout() {
this.columnWidthChanges.forEach((width, index) => this.columns[index].width = width);
this.columnWidthChanges.clear();
this.contentWidth = this.columns.reduce((w, c) => w + c.width, 0);
this.contentHeight = this.itemsCount * this.rowHeight;
this.frozenContentWidth = this.columns
.slice(0, this.frozenColumns)
.reduce((w, c) => w + c.width, 0);
this.scrollContentWidth = this.columns
.slice(this.frozenColumns)
.reduce((w, c) => w + c.width, 0);
this.visibleItemCount = Math.floor(this.height / this.rowHeight);
}
private scheduleAnimation() {
if (this.animationFrame) {
return;
}
this.animationFrame = requestAnimationFrame(() => {
this.animationFrame = 0;
if (this.layoutRequired) {
this.layout();
}
if (this.scrollRequired) {
this.processScroll(this.scrollRequired);
}
this.layoutRequired = false;
this.scrollRequired = null;
this.bus.publish(constants.events.layoutApplied, this);
});
}
private processScroll(target: HTMLDivElement) {
if (target === this.scrollViewPort) {
this.scrollLeft = target.scrollLeft;
this.rowOffset = Math.floor(this.scrollViewPort.scrollTop / this.rowHeight);
} else {
this.scrollLeft = this.scrollLeft + target.scrollLeft;
this.scrollViewPort.scrollLeft = this.scrollLeft;
target.scrollLeft = 0;
}
}
}
import { EventAggregator, Subscription } from 'aurelia-event-aggregator';
import {
RosterGridPositionInfo,
RosterGridLayoutInfo,
RosterGridSelectionInfo
} from './roster-grid-interfaces';
import constants from './roster-grid-constants';
export class RosterGridSelection implements RosterGridSelectionInfo {
public ranges: { start: { col: number; row: number; }, end: { col: number; row: number; } }[] = [];
public signal = 0;
private mouseDownInCell = false;
private mouseClientX: number;
private mouseClientY: number;
private animationFrame: number;
private subscriptions: Subscription[];
constructor(
private bus: EventAggregator,
private getPositionInfo: { (clientX: number, clientY: number): RosterGridPositionInfo; }
) {
this.subscriptions = [
bus.subscribe(constants.events.dom.mousedown, this.mouseDown),
bus.subscribe(constants.events.dom.mouseup, this.mouseUp),
bus.subscribe(constants.events.dom.mousemove, this.mouseMove),
bus.subscribe(constants.events.layoutApplied, this.layoutApplied)
];
}
public dispose() {
this.subscriptions.forEach(subscription => subscription.dispose());
}
public getClass(col: number, row: number): string {
const ranges = this.ranges;
for (let i = 0; i < ranges.length; i++) {
let { start: { col: col1, row: row1 }, end: { col: col2, row: row2 } } = ranges[i];
if (col1 > col2) {
let temp = col2;
col2 = col1;
col1 = temp;
}
if (row1 > row2) {
let temp = row2;
row2 = row1;
row1 = temp;
}
if (col < col1 || col > col2 || row < row1 || row > row2) {
continue;
}
return 'roster-grid-cell-selected';
}
return '';
}
private mouseDown = (event: MouseEvent) => {
const { clientX, clientY } = event;
this.mouseClientX = clientX;
this.mouseClientY = clientY;
const { cell } = this.getPositionInfo(clientX, clientY);
// Disable selection on clipped columns and with key combinaison
if (cell === null || cell.clipped || event.shiftKey && event.ctrlKey) {
this.mouseDownInCell = false;
return;
}
this.mouseDownInCell = true;
this.bus.publish(constants.events.beforeSelectionChanged, this);
if (event.ctrlKey) {
// Does this cell already in the range array?
const clickedCell = { start: cell, end: cell };
const found = this.findCellInSelection(clickedCell);
if (found !== -1) {
this.ranges.splice(found, 1);
} else {
// Selecting only one cell, add it to range.
this.ranges.push({
start: { col: cell.col, row: cell.row },
end: { col: cell.col, row: cell.row }
});
}
} else {
this.ranges = [];
this.ranges.push({
start: { col: cell.col, row: cell.row },
end: { col: cell.col, row: cell.row }
});
}
this.notify();
}
private mouseUp = (event: MouseEvent) => {
this.mouseDownInCell = false;
}
private mouseMove = (event: MouseEvent) => {
const { clientX, clientY } = event;
this.mouseClientX = clientX;
this.mouseClientY = clientY;
this.handleMove();
}
private layoutApplied = (layout: RosterGridLayoutInfo) => {
this.handleMove();
}
private handleMove() {
if (!this.mouseDownInCell) {
return;
}
const { cell } = this.getPositionInfo(this.mouseClientX, this.mouseClientY);
if (cell === null) {
return;
}
const end = this.ranges[this.ranges.length - 1].end;
if (end.col !== cell.col || end.row !== cell.row) {
this.bus.publish(constants.events.beforeSelectionChanged, this);
end.col = cell.col;
end.row = cell.row;
this.notify();
}
}
private findCellInSelection(candidate: any): number {
return this.ranges.findIndex(c =>
c.start.col === candidate.start.col &&
c.start.row === candidate.start.row &&
c.end.col === candidate.end.col &&
c.end.row === candidate.end.row);
}
private notify() {
this.bus.publish(constants.events.selectionChanged, this);
cancelAnimationFrame(this.animationFrame);
this.animationFrame = requestAnimationFrame(() => {
this.signal++;
});
}
}
roster-grid {
position: fixed;
overflow: hidden;
border: 1px solid #EEE;
}
.roster-grid-head,
.roster-grid-head-frozen-content-container,
.roster-grid-head-viewport,
.roster-grid-head-content-container {
position: absolute;
overflow: hidden;
}
.roster-grid-body-scroll-viewport {
position: absolute;
overflow: auto;
}
.roster-grid-body,
.roster-grid-body-frozen-content-container,
.roster-grid-body-viewport,
.roster-grid-body-content-container {
position: absolute;
overflow: hidden;
}
.roster-grid-body-frozen-content-container,
.roster-grid-body-viewport {
height: 100%;
}
.roster-grid-foot,
.roster-grid-foot-frozen-content-container,
.roster-grid-foot-viewport,
.roster-grid-foot-content-container {
position: absolute;
overflow: hidden;
}
.roster-grid-cell {
display: inline-block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-style: solid;
border-width: 0 1px 1px 0;
border-color: #EEE;
box-sizing: border-box;
height: 100%;
padding-top: 6px;
padding-left: 3px;
padding-right: 3px;
/* do not allow highlighting cell text */
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-moz-user-select: none;
}
/*.roster-grid-data-cell {
cursor: cell;
}*/
.roster-grid-cell-selected {
background-color: #d9edf7;
}
.roster-grid-head-cell,
.roster-grid-foot-cell,
.roster-grid-group-header-cell {
background-color: #EEE;
border-color: #FFF;
font-weight: bold;
}
.roster-grid-cell.number {
text-align: right;
}
.roster-grid-foot > .roster-grid-foot-frozen-content-container > .roster-grid-cell.roster-grid-foot-cell {
text-align: right;
}
.roster-grid-ascending::before {
content: '\25B2';
font-size: 75%;
}
.roster-grid-descending::before {
content: '\25BC';
font-size: 75%;
}
.roster-grid-cell-input {
display: block;
width: 100%;
height: 100%;
background: #FFF;
border: none;
font-size: inherit;
padding-left: 3px;
}
<template role="grid"
aria-multiselectable="true"
aria-colcount.bind="layout.columns.length"
aria-rowcount.bind="layout.itemsCount + 1"
style.bind="'left:' + layout.left + 'px;top:' + layout.top + 'px;width:' + layout.width + 'px;height:' + layout.height + 'px'"
wheel.delegate="domEvents.publish($event)">
<require from="./roster-grid.css"></require>
<div class="roster-grid-head"
style.bind="'width:' + layout.width + 'px;height:' + layout.rowHeight + 'px'"
role="row" aria-rowindex="1">
<div class="roster-grid-head-frozen-content-container"
if.bind="frozenColumns"
style.bind="'width:' + layout.frozenContentWidth + 'px;height:' + layout.rowHeight + 'px'">
<div repeat.for="c of layout.columns | slice:0:layout.frozenColumns"
class-name.bind="'roster-grid-cell roster-grid-head-cell ' + dataView.getSortClass(c.property, dataView.definition.sortBy, dataView.definition.sortDescending) + ' ' + c.type"
role="columnheader" aria-colindex.bind="$index + 1"
aria-label.bind="c.ariaLabel"
style.bind="'width:' + c.width + 'px;height:' + layout.rowHeight + 'px'"
text-content.bind="c.header">
</div>
</div>
<div class="roster-grid-head-viewport"
scroll.trigger="domEvents.publish($event)"
style.bind="'width:' + (layout.width - layout.frozenContentWidth) + 'px;height:' + layout.rowHeight + 'px;left:' + layout.frozenContentWidth + 'px'">
<div class="roster-grid-head-content-container"
style.bind="'width:' + layout.scrollContentWidth + 'px;height:' + layout.rowHeight + 'px;left:-' + layout.scrollLeft + 'px'">
<div repeat.for="c of layout.columns | slice:layout.frozenColumns"
class-name.bind="'roster-grid-cell roster-grid-head-cell ' + dataView.getSortClass(c.property, dataView.definition.sortBy, dataView.definition.sortDescending) + ' ' + c.type"
role="columnheader" aria-colindex.bind="$index + layout.frozenColumns + 1"
aria-label.bind="c.ariaLabel"
style.bind="'width:' + c.width + 'px;height:' + layout.rowHeight + 'px'"
text-content.bind="c.header">
</div>
</div>
</div>
</div>
<div class="roster-grid-body-scroll-viewport" ref="layout.scrollViewPort"
scroll.trigger="domEvents.publish($event)"
style.bind="'width:' + layout.width + 'px;height:' + (layout.height - layout.rowHeight) + 'px;top:' + layout.rowHeight + 'px'">
<div style.bind="'width:' + layout.contentWidth + 'px;height:' + (layout.contentHeight + layout.rowHeight * 2) + 'px'"></div>
</div>
<div class="roster-grid-body"
style.bind="'width:' + layout.scrollViewPort.clientWidth + 'px;height:' + (layout.scrollViewPort.clientHeight - layout.rowHeight) + 'px;top:' + layout.rowHeight + 'px'">
<div class="roster-grid-body-frozen-content-container"
style.bind="'width:' + layout.frozenContentWidth + 'px'">
<div repeat.for="i of layout.visibleItemCount"
class="roster-grid-data-row"
role="row" aria-rowindex.bind="i + layout.rowOffset + 2"
style.bind="'width:' + layout.frozenContentWidth + 'px;height:' + layout.rowHeight + 'px'">
<div repeat.for="c of layout.columns | slice:0:layout.frozenColumns"
class-name.bind="'roster-grid-cell roster-grid-data-cell ' + c.type + ' ' + (dataView.items[i + layout.rowOffset].isGroupHeader ? 'aurelia-hide' : '')"
id.bind="'roster-grid-cell-' + $index + '-' + (i + layout.rowOffset + 1)"
role="gridcell"
aria-colindex.bind="$index + 1"
style.bind="'width:' + c.width + 'px'"
text-content.bind="dataView.items[i + layout.rowOffset][c.property] | coalesce">
</div>
<div class="roster-grid-cell roster-grid-group-header-cell"
show.bind="dataView.items[i + layout.rowOffset].isGroupHeader"
style.bind="'width:' + layout.frozenContentWidth + 'px;height:' + layout.rowHeight + 'px'"
role="gridcell"
aria-colindex="0"
aria-colspan.bind="layout.frozenColumns"
class="roster-grid"
text-content.bind="dataView.items[i + layout.rowOffset].name">
</div>
</div>
</div>
<div class="roster-grid-body-viewport"
scroll.trigger="domEvents.publish($event)"
style.bind="'width:' + (layout.scrollViewPort.clientWidth - layout.frozenContentWidth) + 'px;left:' + layout.frozenContentWidth + 'px'">
<div class="roster-grid-body-content-container"
style.bind="'width:' + layout.scrollContentWidth + 'px;left:-' + layout.scrollLeft + 'px'">
<div repeat.for="i of layout.visibleItemCount"
class="roster-grid-data-row"
role="row" aria-rowindex.bind="i + layout.rowOffset + 2"
style.bind="'width:' + layout.scrollContentWidth + 'px;height:' + layout.rowHeight + 'px'">
<div repeat.for="c of layout.columns | slice:layout.frozenColumns"
class-name.bind="'roster-grid-cell ' + (dataView.items[i + layout.rowOffset].isGroupHeader ? 'roster-grid-group-header-cell ' : 'roster-grid-data-cell ') + selection.getClass($index + layout.frozenColumns, i + layout.rowOffset + 1, selection.signal) + ' ' + c.type"
id.bind="'roster-grid-cell-' + ($index + layout.frozenColumns) + '-' + (i + layout.rowOffset + 1)"
role="gridcell"
aria-colindex.bind="$index + layout.frozenColumns + 1"
style.bind="'width:' + c.width + 'px'"
text-content.bind="dataView.items[i + layout.rowOffset][c.property] | toFixed:2 | coalesce">
</div>
</div>
</div>
</div>
</div>
<div class="roster-grid-foot"
style.bind="'top:' + (layout.scrollViewPort.clientHeight) + 'px;width:' + layout.scrollViewPort.clientWidth + 'px;height:' + layout.rowHeight + 'px'"
role="row" aria-rowindex.bind="layout.itemsCount + 1">
<div class="roster-grid-foot-frozen-content-container"
if.bind="frozenColumns"
style.bind="'width:' + layout.frozenContentWidth + 'px;height:' + layout.rowHeight + 'px'">
<div class="roster-grid-cell roster-grid-foot-cell"
style.bind="'width:' + layout.frozenContentWidth + 'px;height:' + layout.rowHeight + 'px'"
role="gridcell"
aria-colindex.bind="$index + 1"
aria-colspan.bind="layout.frozenColumns">
Totals:
</div>
</div>
<div class="roster-grid-foot-viewport"
scroll.trigger="domEvents.publish($event)"
style.bind="'width:' + (layout.scrollViewPort.clientWidth - layout.frozenContentWidth) + 'px;height:' + layout.rowHeight + 'px;left:' + layout.frozenContentWidth + 'px'">
<div class="roster-grid-foot-content-container"
style.bind="'width:' + layout.scrollContentWidth + 'px;height:' + layout.rowHeight + 'px;left:-' + layout.scrollLeft + 'px'">
<div repeat.for="c of layout.columns | slice:layout.frozenColumns"
class-name.bind="'roster-grid-cell roster-grid-foot-cell ' + c.type"
id.bind="'footer-' + ($index + layout.frozenColumns)"
role="gridcell" aria-colindex.bind="$index + layout.frozenColumns + 1"
style.bind="'width:' + c.width + 'px;height:' + layout.rowHeight + 'px'"
text-content.bind="dataView.footer[c.property] | toFixed:2 | coalesce">
</div>
</div>
</div>
</div>
</template>
import {EventAggregator} from 'aurelia-event-aggregator';
import {bindable} from 'aurelia-templating';
import {bindingMode} from 'aurelia-binding';
import {RosterGridColumnDefinition, RosterGridLayoutInfo, RosterGridDataViewDefinition} from './roster-grid-interfaces';
import {RosterGridDomEvents} from './roster-grid-dom-events';
import {RosterGridLayout} from './roster-grid-layout';
import {RosterGridHead} from './roster-grid-head';
import {RosterGridSelection} from './roster-grid-selection';
import {RosterGridDataView} from './roster-grid-data-view';
import {RosterGridCellEditor} from './roster-grid-cell-editor';
import constants from './roster-grid-constants';
const twoWay = { defaultBindingMode: bindingMode.twoWay };
export class RosterGrid implements RosterGridLayoutInfo, RosterGridDataViewDefinition {
@bindable public items: any[];
@bindable public columns: RosterGridColumnDefinition[];
@bindable(twoWay) public sortBy: string|null = null;
@bindable(twoWay) public sortDescending = false;
@bindable public groupBy: string|null = null;
@bindable public frozenColumns: number = constants.defaults.frozenColumns;
@bindable public rowHeight: number = constants.defaults.rowHeight;
@bindable public left: number;
@bindable public top: number;
@bindable public width: number;
@bindable public height: number;
public domEvents: RosterGridDomEvents;
public layout: RosterGridLayout;
public dataView: RosterGridDataView;
public head: RosterGridHead;
public selection: RosterGridSelection;
private cellEditor: RosterGridCellEditor;
private bus: EventAggregator;
constructor() {
this.bus = new EventAggregator();
this.domEvents = new RosterGridDomEvents(this.bus);
this.layout = new RosterGridLayout(this.bus);
this.dataView = new RosterGridDataView(this.bus);
this.head = new RosterGridHead(this.bus, this.layout.getPositionInfo.bind(this.layout));
this.selection = new RosterGridSelection(this.bus, this.layout.getPositionInfo.bind(this.layout));
this.cellEditor = new RosterGridCellEditor(this.bus);
}
get itemsCount() {
return this.dataView.items.length;
}
public bind() {
this.bus.publish(constants.events.dataViewDefinitionChanged, <RosterGridDataViewDefinition>this);
this.bus.publish(constants.events.layoutPropertiesChanged, <RosterGridLayoutInfo>this);
}
public unbind() {
this.domEvents.dispose();
this.layout.dispose();
this.dataView.dispose();
this.head.dispose();
this.selection.dispose();
this.cellEditor.dispose();
}
public propertyChanged(propertyName: string, newValue: any, oldValue: any) {
if (['items', 'columns', 'groupBy', 'sortBy', 'sortDescending'].indexOf(propertyName) !== -1) {
this.bus.publish(constants.events.dataViewDefinitionChanged, <RosterGridDataViewDefinition>this);
}
this.bus.publish(constants.events.layoutPropertiesChanged, <RosterGridLayoutInfo>this);
}
}
import {DataAttributeObserver} from 'aurelia-binding';
export class SetAttributeBindingBehavior {
public bind(binding: any, source: any) {
binding.targetObserver = new DataAttributeObserver(binding.target, binding.targetProperty);
}
public unbind() {}
}
export class CoalesceValueConverter {
public toView(value: any, fallback: any = '') {
return value === null || value === undefined ? fallback : value;
}
}
export class SliceValueConverter {
public toView(value: any[], start: number, end: number|undefined) {
return value.slice(start, end);
}
}
export class ToFixedValueConverter {
public toView(value: number|null|undefined, fractionDigits: number = 2) {
if (value === null || value === undefined || !value.toFixed) {
return value;
}
return value.toFixed(fractionDigits);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment