UNPKG

carbon-components-angular

Version:
1,224 lines (1,206 loc) 179 kB
import * as i0 from '@angular/core'; import { EventEmitter, Component, Input, Output, HostBinding, Directive, HostListener, ContentChild, NgModule } from '@angular/core'; import * as i1 from 'carbon-components-angular/i18n'; import { I18nModule } from 'carbon-components-angular/i18n'; import * as i2 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i3 from 'carbon-components-angular/button'; import { ButtonModule } from 'carbon-components-angular/button'; import { Search, SearchModule } from 'carbon-components-angular/search'; import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; import * as i3$1 from 'carbon-components-angular/icon'; import { IconModule } from 'carbon-components-angular/icon'; import { Subject, BehaviorSubject, combineLatest, Subscription, fromEvent } from 'rxjs'; import { getFocusElementList, tabbableSelectorIgnoreTabIndex } from 'carbon-components-angular/common'; import { getScrollbarWidth, merge } from 'carbon-components-angular/utils'; import { map } from 'rxjs/operators'; import * as i3$2 from 'carbon-components-angular/checkbox'; import * as i3$3 from 'carbon-components-angular/radio'; import { NFormsModule } from 'carbon-components-angular/forms'; import { DialogModule } from 'carbon-components-angular/dialog'; /** * The table toolbar is reserved for global table actions such as table settings, complex filter, export, or editing table data. * * ## Basic usage * * ```html * <cds-table-toolbar [model]="model"> * <cds-table-toolbar-actions> * <button cdsButton="primary"> * Delete * <svg cdsIcon="trash-can" size="16" class="cds--btn__icon"></svg> * </button> * <button cdsButton="primary"> * Save * <svg cdsIcon="save" size="16" class="cds--btn__icon"></svg> * </button> * <button cdsButton="primary"> * Download * <svg cdsIcon="download" size="16" class="cds--btn__icon"></svg> * </button> * </cds-table-toolbar-actions> * <cds-table-toolbar-content> * <cds-table-toolbar-search [expandable]="true"></cds-table-toolbar-search> * <button cdsButton="toolbar-action"> * <svg cdsIcon="settings" size="16" class="cds--toolbar-action__icon"></svg> * </button> * <button cdsButton="primary" size="sm"> * Primary Button * <svg cdsIcon="add" size="20" class="cds--btn__icon"></svg> * </button> * </cds-table-toolbar-content> * </cds-table-toolbar> * ``` * */ class TableToolbar { constructor(i18n) { this.i18n = i18n; this.size = "md"; this.cancel = new EventEmitter(); this.actionBarLabel = this.i18n.getOverridable("TABLE_TOOLBAR.ACTION_BAR"); this._cancelText = this.i18n.getOverridable("TABLE_TOOLBAR.CANCEL"); this._batchTextLegacy = this.i18n.getOverridable("TABLE_TOOLBAR.BATCH_TEXT"); this._batchTextSingle = this.i18n.getOverridable("TABLE_TOOLBAR.BATCH_TEXT_SINGLE"); this._batchTextMultiple = this.i18n.getOverridable("TABLE_TOOLBAR.BATCH_TEXT_MULTIPLE"); } set batchText(value) { if (typeof value === "object") { this._batchTextSingle.override(value.SINGLE); this._batchTextMultiple.override(value.MULTIPLE); } else { // For compatibility with old code this._batchTextLegacy.override(value); } } set ariaLabel(value) { this.actionBarLabel.override(value.ACTION_BAR); } set cancelText(value) { this._cancelText.override(value.CANCEL); } get cancelText() { return { CANCEL: this._cancelText.value }; } get count() { return this.model.totalDataLength > 0 ? this.model.rowsSelected.reduce((previous, current) => previous + (current ? 1 : 0), 0) : 0; } get selected() { return this.model.totalDataLength > 0 ? this.model.rowsSelected.some(item => item) : false; } onCancel() { this.model.selectAll(false); this.cancel.emit(); } } TableToolbar.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbar, deps: [{ token: i1.I18n }], target: i0.ɵɵFactoryTarget.Component }); TableToolbar.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: TableToolbar, selector: "cds-table-toolbar, ibm-table-toolbar", inputs: { model: "model", batchText: "batchText", ariaLabel: "ariaLabel", cancelText: "cancelText", size: "size" }, outputs: { cancel: "cancel" }, ngImport: i0, template: ` <section class="cds--table-toolbar" [ngClass]="{'cds--table-toolbar--sm' : size === 'sm'}" [attr.aria-label]="actionBarLabel.subject | async"> <div *ngIf="model" class="cds--batch-actions" [ngClass]="{ 'cds--batch-actions--active': selected }"> <div class="cds--batch-summary"> <p class="cds--batch-summary__para" *ngIf="count as n"> <ng-container *ngIf="_batchTextLegacy.subject | async as legacyText; else batchTextBlock"> <span>{{n}}</span> {{legacyText}} </ng-container> <ng-template #batchTextBlock> <span *ngIf="n === 1">{{_batchTextSingle.subject | async}}</span> <span *ngIf="n !== 1">{{_batchTextMultiple.subject | i18nReplace: {count: n} | async}}</span> </ng-template> </p> </div> <div class="cds--action-list"> <ng-content select="cds-table-toolbar-actions,ibm-table-toolbar-actions"></ng-content> <button cdsButton="primary" class="cds--batch-summary__cancel" [tabindex]="selected ? 0 : -1" (click)="onCancel()"> {{_cancelText.subject | async}} </button> </div> </div> <ng-content></ng-content> </section> `, isInline: true, dependencies: [{ kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i3.Button, selector: "[cdsButton], [ibmButton]", inputs: ["ibmButton", "cdsButton", "size", "skeleton", "iconOnly", "isExpressive"] }, { kind: "pipe", type: i2.AsyncPipe, name: "async" }, { kind: "pipe", type: i1.ReplacePipe, name: "i18nReplace" }] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbar, decorators: [{ type: Component, args: [{ selector: "cds-table-toolbar, ibm-table-toolbar", template: ` <section class="cds--table-toolbar" [ngClass]="{'cds--table-toolbar--sm' : size === 'sm'}" [attr.aria-label]="actionBarLabel.subject | async"> <div *ngIf="model" class="cds--batch-actions" [ngClass]="{ 'cds--batch-actions--active': selected }"> <div class="cds--batch-summary"> <p class="cds--batch-summary__para" *ngIf="count as n"> <ng-container *ngIf="_batchTextLegacy.subject | async as legacyText; else batchTextBlock"> <span>{{n}}</span> {{legacyText}} </ng-container> <ng-template #batchTextBlock> <span *ngIf="n === 1">{{_batchTextSingle.subject | async}}</span> <span *ngIf="n !== 1">{{_batchTextMultiple.subject | i18nReplace: {count: n} | async}}</span> </ng-template> </p> </div> <div class="cds--action-list"> <ng-content select="cds-table-toolbar-actions,ibm-table-toolbar-actions"></ng-content> <button cdsButton="primary" class="cds--batch-summary__cancel" [tabindex]="selected ? 0 : -1" (click)="onCancel()"> {{_cancelText.subject | async}} </button> </div> </div> <ng-content></ng-content> </section> ` }] }], ctorParameters: function () { return [{ type: i1.I18n }]; }, propDecorators: { model: [{ type: Input }], batchText: [{ type: Input }], ariaLabel: [{ type: Input }], cancelText: [{ type: Input }], size: [{ type: Input }], cancel: [{ type: Output }] } }); class TableToolbarActions { } TableToolbarActions.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbarActions, deps: [], target: i0.ɵɵFactoryTarget.Component }); TableToolbarActions.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: TableToolbarActions, selector: "cds-table-toolbar-actions, ibm-table-toolbar-actions", ngImport: i0, template: `<ng-content></ng-content>`, isInline: true }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbarActions, decorators: [{ type: Component, args: [{ selector: "cds-table-toolbar-actions, ibm-table-toolbar-actions", template: `<ng-content></ng-content>` }] }] }); class TableToolbarSearch extends Search { constructor() { super(...arguments); this.tableSearch = true; this.size = "lg"; this.hostClass = true; } ngAfterViewInit() { setTimeout(() => { if (this.value) { this.openSearch(); } }); } } TableToolbarSearch.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbarSearch, deps: null, target: i0.ɵɵFactoryTarget.Component }); TableToolbarSearch.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: TableToolbarSearch, selector: "cds-table-toolbar-search, ibm-table-toolbar-search", host: { properties: { "class.cds--toolbar-content": "this.hostClass" } }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: TableToolbarSearch, multi: true } ], usesInheritance: true, ngImport: i0, template: "<div\n\tclass=\"cds--search\"\n\t[ngClass]=\"{\n\t\t'cds--search--sm': size === 'sm',\n\t\t'cds--search--md': size === 'md',\n\t\t'cds--search--lg': size === 'lg',\n\t\t'cds--search--light': theme === 'light',\n\t\t'cds--skeleton': skeleton && !fluid,\n\t\t'cds--search--expandable': expandable && !tableSearch,\n\t\t'cds--search--expanded': expandable && !tableSearch && active,\n\t\t'cds--toolbar-search': toolbar && !expandable,\n\t\t'cds--toolbar-search--active': toolbar && !expandable && active,\n\t\t'cds--toolbar-search-container-persistent': tableSearch && !expandable,\n\t\t'cds--toolbar-search-container-expandable': tableSearch && expandable,\n\t\t'cds--toolbar-search-container-active': tableSearch && expandable && active,\n\t\t'cds--search--fluid': fluid,\n\t\t'cds--search--disabled': disabled\n\t}\"\n\trole=\"search\"\n\t[attr.aria-label]=\"ariaLabel\"\n\t(click)=\"openSearch()\">\n\t<label\n\t\tclass=\"cds--label\"\n\t\t[for]=\"id\"\n\t\t[ngClass]=\"{ 'cds--skeleton': skeleton && fluid }\">\n\t\t{{ !skeleton ? label : ''}}\n\t</label>\n\n\t<div *ngIf=\"skeleton; else enableInput\" class=\"cds--text-input cds--skeleton\"></div>\n\t<ng-template #enableInput>\n\t\t<input\n\t\t\t#input\n\t\t\tclass=\"cds--search-input\"\n\t\t\t[type]=\"tableSearch || !toolbar ? 'text' : 'search'\"\n\t\t\t[id]=\"id\"\n\t\t\t[value]=\"value\"\n\t\t\t[autocomplete]=\"autocomplete\"\n\t\t\t[placeholder]=\"placeholder\"\n\t\t\t[disabled]=\"disabled\"\n\t\t\t[required]=\"required\"\n\t\t\t(input)=\"onSearch($event.target.value)\"\n\t\t\t(keyup.enter)=\"onEnter()\"/>\n\t\t<button\n\t\t\t*ngIf=\"!tableSearch && toolbar\"\n\t\t\tclass=\"cds--toolbar-search__btn\"\n\t\t\t(click)=\"openSearch()\"\n\t\t\taria-label=\"Open search\">\n\t\t\t<svg cdsIcon=\"search\" size=\"16\" class=\"cds--search-magnifier-icon\"></svg>\n\t\t</button>\n\t\t<svg\n\t\t\tcdsIcon=\"search\"\n\t\t\t*ngIf=\"tableSearch || !toolbar\"\n\t\t\tclass=\"cds--search-magnifier-icon\"\n\t\t\tsize=\"16\">\n\t\t</svg>\n\t</ng-template>\n\n\t<button\n\t\t*ngIf=\"tableSearch || !toolbar\"\n\t\tclass=\"cds--search-close\"\n\t\t[ngClass]=\"{\n\t\t\t'cds--search-close--hidden': !value || value.length === 0\n\t\t}\"\n\t\t[title]=\"clearButtonTitle\"\n\t\t(click)=\"clearSearch()\">\n\t\t<span class=\"cds--visually-hidden\">{{ clearButtonTitle }}</span>\n\t\t<svg cdsIcon=\"close\" size=\"16\"></svg>\n\t</button>\n</div>\n", dependencies: [{ kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i3$1.IconDirective, selector: "[cdsIcon], [ibmIcon]", inputs: ["ibmIcon", "cdsIcon", "size", "title", "ariaLabel", "ariaLabelledBy", "ariaHidden", "isFocusable"] }] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbarSearch, decorators: [{ type: Component, args: [{ selector: "cds-table-toolbar-search, ibm-table-toolbar-search", providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: TableToolbarSearch, multi: true } ], template: "<div\n\tclass=\"cds--search\"\n\t[ngClass]=\"{\n\t\t'cds--search--sm': size === 'sm',\n\t\t'cds--search--md': size === 'md',\n\t\t'cds--search--lg': size === 'lg',\n\t\t'cds--search--light': theme === 'light',\n\t\t'cds--skeleton': skeleton && !fluid,\n\t\t'cds--search--expandable': expandable && !tableSearch,\n\t\t'cds--search--expanded': expandable && !tableSearch && active,\n\t\t'cds--toolbar-search': toolbar && !expandable,\n\t\t'cds--toolbar-search--active': toolbar && !expandable && active,\n\t\t'cds--toolbar-search-container-persistent': tableSearch && !expandable,\n\t\t'cds--toolbar-search-container-expandable': tableSearch && expandable,\n\t\t'cds--toolbar-search-container-active': tableSearch && expandable && active,\n\t\t'cds--search--fluid': fluid,\n\t\t'cds--search--disabled': disabled\n\t}\"\n\trole=\"search\"\n\t[attr.aria-label]=\"ariaLabel\"\n\t(click)=\"openSearch()\">\n\t<label\n\t\tclass=\"cds--label\"\n\t\t[for]=\"id\"\n\t\t[ngClass]=\"{ 'cds--skeleton': skeleton && fluid }\">\n\t\t{{ !skeleton ? label : ''}}\n\t</label>\n\n\t<div *ngIf=\"skeleton; else enableInput\" class=\"cds--text-input cds--skeleton\"></div>\n\t<ng-template #enableInput>\n\t\t<input\n\t\t\t#input\n\t\t\tclass=\"cds--search-input\"\n\t\t\t[type]=\"tableSearch || !toolbar ? 'text' : 'search'\"\n\t\t\t[id]=\"id\"\n\t\t\t[value]=\"value\"\n\t\t\t[autocomplete]=\"autocomplete\"\n\t\t\t[placeholder]=\"placeholder\"\n\t\t\t[disabled]=\"disabled\"\n\t\t\t[required]=\"required\"\n\t\t\t(input)=\"onSearch($event.target.value)\"\n\t\t\t(keyup.enter)=\"onEnter()\"/>\n\t\t<button\n\t\t\t*ngIf=\"!tableSearch && toolbar\"\n\t\t\tclass=\"cds--toolbar-search__btn\"\n\t\t\t(click)=\"openSearch()\"\n\t\t\taria-label=\"Open search\">\n\t\t\t<svg cdsIcon=\"search\" size=\"16\" class=\"cds--search-magnifier-icon\"></svg>\n\t\t</button>\n\t\t<svg\n\t\t\tcdsIcon=\"search\"\n\t\t\t*ngIf=\"tableSearch || !toolbar\"\n\t\t\tclass=\"cds--search-magnifier-icon\"\n\t\t\tsize=\"16\">\n\t\t</svg>\n\t</ng-template>\n\n\t<button\n\t\t*ngIf=\"tableSearch || !toolbar\"\n\t\tclass=\"cds--search-close\"\n\t\t[ngClass]=\"{\n\t\t\t'cds--search-close--hidden': !value || value.length === 0\n\t\t}\"\n\t\t[title]=\"clearButtonTitle\"\n\t\t(click)=\"clearSearch()\">\n\t\t<span class=\"cds--visually-hidden\">{{ clearButtonTitle }}</span>\n\t\t<svg cdsIcon=\"close\" size=\"16\"></svg>\n\t</button>\n</div>\n" }] }], propDecorators: { hostClass: [{ type: HostBinding, args: ["class.cds--toolbar-content"] }] } }); class TableToolbarContent { constructor() { this.class = true; } } TableToolbarContent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbarContent, deps: [], target: i0.ɵɵFactoryTarget.Component }); TableToolbarContent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: TableToolbarContent, selector: "cds-table-toolbar-content, ibm-table-toolbar-content", host: { properties: { "class.cds--toolbar-content": "this.class" } }, ngImport: i0, template: `<ng-content></ng-content>`, isInline: true }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableToolbarContent, decorators: [{ type: Component, args: [{ selector: "cds-table-toolbar-content, ibm-table-toolbar-content", template: `<ng-content></ng-content>` }] }], propDecorators: { class: [{ type: HostBinding, args: ["class.cds--toolbar-content"] }] } }); class TableHeaderDescription { constructor() { this.id = `table-description-${TableHeaderDescription.counter++}`; this.descriptionClass = true; } } TableHeaderDescription.counter = 0; TableHeaderDescription.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableHeaderDescription, deps: [], target: i0.ɵɵFactoryTarget.Directive }); TableHeaderDescription.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.3.0", type: TableHeaderDescription, selector: "[cdsTableHeaderDescription], [ibmTableHeaderDescription]", inputs: { id: "id" }, host: { properties: { "attr.id": "this.id", "class.cds--data-table-header__description": "this.descriptionClass" } }, ngImport: i0 }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableHeaderDescription, decorators: [{ type: Directive, args: [{ selector: "[cdsTableHeaderDescription], [ibmTableHeaderDescription]" }] }], propDecorators: { id: [{ type: HostBinding, args: ["attr.id"] }, { type: Input }], descriptionClass: [{ type: HostBinding, args: ["class.cds--data-table-header__description"] }] } }); class TableHeaderTitle { constructor() { this.id = `table-title-${TableHeaderTitle.counter++}`; this.titleClass = true; } } TableHeaderTitle.counter = 0; TableHeaderTitle.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableHeaderTitle, deps: [], target: i0.ɵɵFactoryTarget.Directive }); TableHeaderTitle.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.3.0", type: TableHeaderTitle, selector: "[cdsTableHeaderTitle], [ibmTableHeaderTitle]", inputs: { id: "id" }, host: { properties: { "attr.id": "this.id", "class.cds--data-table-header__title": "this.titleClass" } }, ngImport: i0 }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: TableHeaderTitle, decorators: [{ type: Directive, args: [{ selector: "[cdsTableHeaderTitle], [ibmTableHeaderTitle]" }] }], propDecorators: { id: [{ type: HostBinding, args: ["attr.id"] }, { type: Input }], titleClass: [{ type: HostBinding, args: ["class.cds--data-table-header__title"] }] } }); class TableHeaderItem { /** * Creates an instance of TableHeaderItem. */ constructor(rawData) { /** * Defines if column under this TableHeaderItem should be displayed. * */ this.visible = true; /** * Disables sorting by default. * */ this.sorted = false; /** * Enables sorting on click by default. * If false then this column won't show a sorting arrow at all. * */ this.sortable = true; /** * Number of applied filters. * * `filter()` should set it to appropriate number. * */ this.filterCount = 0; /** * The number of rows to span * **NOTE:** not supported by the default carbon table */ this.rowSpan = 1; /** * The number of columns to span */ this.colSpan = 1; /** * Style for the column, applied to every element in the column. * * ngStyle-like format * */ this.style = {}; this.sortDirection = "NONE"; // defaults so we dont leave things empty const defaults = { data: "", visible: this.visible, style: this.style, filterCount: this.filterCount, filterData: { data: "" } }; // fill our object with provided props, and fallback to defaults const data = Object.assign({}, defaults, rawData); for (let property of Object.getOwnPropertyNames(data)) { if (data.hasOwnProperty(property)) { this[property] = data[property]; } } } /** * If true, sort is set to ascending, if false descending will be true. * */ set ascending(asc) { this.sortDirection = asc ? "ASCENDING" : "DESCENDING"; } get ascending() { return this.sortDirection === "ASCENDING"; } /** * If true, sort is set to descending, if false ascending will be true. * */ set descending(desc) { this.sortDirection = desc ? "DESCENDING" : "ASCENDING"; } get descending() { return this.sortDirection === "DESCENDING"; } get title() { if (this._title) { return this._title; } if (!this.data) { return ""; } if (typeof this.data === "string") { return this.data; } if (this.data.toString && this.data.constructor !== ({}).constructor) { return this.data.toString(); } // data can’t be reasonably converted to an end user readable string return ""; } set title(title) { this._title = title; } /** * Used for sorting rows of the table. * * Override to enable different sorting. * * < 0 if `one` should go before `two` * > 0 if `one` should go after `two` * 0 if it doesn't matter (they are the same) */ compare(one, two) { if (!one || !two) { return 0; } if (typeof one.data === "string") { return one.data.localeCompare(two.data); } if (one.data < two.data) { return -1; } else if (one.data > two.data) { return 1; } else { return 0; } } /** * Used to filter rows in the table. * * Override to make a custom filter. * * Even though there is just one filter function, there can be multiple filters. * When implementing filter, set `filterCount` before returning. * * `true` to hide the row * `false` to show the row */ filter(item) { this.filterCount = 0; return false; } } class TableItem { /** * Creates an instance of TableItem. */ constructor(rawData) { /** * The number of rows to span */ this.rowSpan = 1; /** * The number of columns to span */ this.colSpan = 1; // defaults so we dont leave things empty const defaults = { data: "" }; // fill our object with provided props, and fallback to defaults const data = Object.assign({}, defaults, rawData); for (const property of Object.getOwnPropertyNames(data)) { if (data.hasOwnProperty(property)) { this[property] = data[property]; } } } get title() { if (typeof this._title === "string") { return this._title; } if (!this.data) { return ""; } if (typeof this.data === "string") { return this.data; } if (this.data.toString && this.data.constructor !== ({}).constructor) { return this.data.toString(); } // data can’t be reasonably converted to an end user readable string return ""; } set title(title) { this._title = title; } } /** * TableModel represents a data model for two-dimensional data. It's used for all things table * (table component, table toolbar, pagination, etc) * * TableModel manages its internal data integrity very well if you use the provided helper * functions for modifying rows and columns and assigning header and data in that order. * * It also provides direct access to the data so you can read and modify it. * If you change the structure of the data (by directly pushing into the arrays or otherwise), * keep in mind to keep the data structure intact. * * Header length and length of every line in the data should be equal. * * If they are not consistent, unexpected things will happen. * * Use the provided functions when in doubt. */ class TableModel { constructor() { this.dataChange = new EventEmitter(); this.rowsSelectedChange = new EventEmitter(); this.rowsExpandedChange = new EventEmitter(); this.rowsExpandedAllChange = new EventEmitter(); this.rowsCollapsedAllChange = new EventEmitter(); /** * Gets emitted when `selectAll` is called. Emits false if all rows are deselected and true if * all rows are selected. */ this.selectAllChange = new Subject(); /** * Contains information about selection state of rows in the table. */ this.rowsSelected = []; /** * Contains information about expanded state of rows in the table. */ this.rowsExpanded = []; /** * Contains information about initial index of rows in the table */ this.rowsIndices = []; /** * Contains information about the context of the row. * * It affects styling of the row to reflect the context. * * string can be one of `"success" | "warning" | "info" | "error" | ""` and it's * empty or undefined by default */ this.rowsContext = []; /** * Contains class name(s) of the row. * * It affects styling of the row to reflect the appended class name(s). * * It's empty or undefined by default */ this.rowsClass = []; /** * Contains information about the header cells of the table. */ this.header = []; /** * Tracks the current page. */ this.currentPage = 1; /** * Length of page. */ this.pageLength = 10; /** * Set to true when there is no more data to load in the table */ this.isEnd = false; /** * Set to true when lazy loading to show loading indicator */ this.isLoading = false; /** * Used in `data` */ this._data = [[]]; /** * The number of models instantiated, this is to make sure each table has a different * model count for unique id generation. */ this.tableModelCount = 0; this.tableModelCount = TableModel.COUNT++; } /** * Sets data of the table. * * Make sure all rows are the same length to keep the column count accurate. */ set data(newData) { if (!newData || (Array.isArray(newData) && newData.length === 0)) { newData = [[]]; } this._data = newData; // init rowsSelected this.rowsSelected = new Array(this._data.length).fill(false); this.rowsExpanded = new Array(this._data.length).fill(false); // init rows indices this.rowsIndices = [...Array(this._data.length).keys()]; // init rowsContext this.rowsContext = new Array(this._data.length); // init rowsClass this.rowsClass = new Array(this._data.length); // only create a fresh header if necessary (header doesn't exist or differs in length) if (this.header == null || (this.header.length !== this._data[0].length && this._data[0].length > 0)) { let header = new Array(); for (let i = 0; i < this._data[0].length; i++) { header.push(new TableHeaderItem()); } this.header = header; } this.dataChange.emit(); } /** * Gets the full data. * * You can use it to alter individual `TableItem`s but if you need to change * table structure, use `addRow()` and/or `addColumn()` */ get data() { return this._data; } /** * Manually set data length in case the data in the table doesn't * correctly reflect all the data that table is to display. * * Example: if you have multiple pages of data that table will display * but you're loading one at a time. * * Set to `null` to reset to default behavior. */ set totalDataLength(length) { // if this function is called without a parameter we need to set to null to avoid having undefined != null this._totalDataLength = isNaN(length) ? null : length; } /** * Total length of data that table has access to, or the amount manually set */ get totalDataLength() { // if manually set data length if (this._totalDataLength !== null && this._totalDataLength >= 0) { return this._totalDataLength; } // if empty dataset if (this.data && this.data.length === 1 && this.data[0].length === 0) { return 0; } return this.data.length; } /** * Returns an id for the given column * * @param column the column to generate an id for * @param row the row of the header to generate an id for */ getId(column, row = 0) { return `table-header-${row}-${column}-${this.tableModelCount}`; } /** * Returns the id of the header. Used to link the cells with headers (or headers with headers) * * @param column the column to start getting headers for * @param colSpan the number of columns to get headers for (defaults to 1) */ getHeaderId(column, colSpan = 1) { if (column === "select" || column === "expand") { return this.getId(column); } let ids = []; for (let i = column; i >= 0; i--) { if (this.header[i]) { for (let j = 0; j < colSpan; j++) { ids.push(this.getId(i + j)); } break; } } return ids.join(" "); } /** * Finds closest header by trying the `column` and then working its way to the left * * @param column the target column */ getHeader(column) { if (!this.header) { return null; } for (let i = column; i >= 0; i--) { const headerCell = this.header[i]; if (headerCell) { return headerCell; } } return null; } /** * Returns how many rows is currently selected */ selectedRowsCount() { let count = 0; if (this.rowsSelected) { this.rowsSelected.forEach(rowSelected => { if (rowSelected) { count++; } }); } return count; } /** * Returns how many rows is currently expanded */ expandedRowsCount() { let count = 0; if (this.rowsExpanded) { this.rowsExpanded.forEach(rowExpanded => { if (rowExpanded) { count++; } }); } return count; } /** * Returns `index`th row of the table. * * Negative index starts from the end. -1 being the last element. * * @param index */ row(index) { return this.data[this.realRowIndex(index)]; } /** * Adds a row to the `index`th row or appends to table if index not provided. * * If row is shorter than other rows or not provided, it will be padded with * empty `TableItem` elements. * * If row is longer than other rows, others will be extended to match so no data is lost. * * If called on an empty table with no parameters, it creates a 1x1 table. * * Negative index starts from the end. -1 being the last element. * * @param [row] * @param [index] */ addRow(row, index) { // if table empty create table with row if (!this.data || this.data.length === 0 || this.data[0].length === 0) { let newData = new Array(); newData.push(row ? row : [new TableItem()]); // row or one empty one column row this.data = newData; return; } let realRow = row; const columnCount = this.data[0].length; if (row == null) { realRow = new Array(); for (let i = 0; i < columnCount; i++) { realRow.push(new TableItem()); } } if (realRow.length < columnCount) { // extend the length of realRow const difference = columnCount - realRow.length; for (let i = 0; i < difference; i++) { realRow.push(new TableItem()); } } else if (realRow.length > columnCount) { // extend the length of header let difference = realRow.length - this.header.length; for (let j = 0; j < difference; j++) { this.header.push(new TableHeaderItem()); } // extend the length of every other row for (let i = 0; i < this.data.length; i++) { let currentRow = this.data[i]; difference = realRow.length - currentRow.length; for (let j = 0; j < difference; j++) { currentRow.push(new TableItem()); } } } if (index == null) { this.data.push(realRow); // update rowsSelected property for length this.rowsSelected.push(false); // update rowsExpanded property for length this.rowsExpanded.push(false); // update rowsContext property for length this.rowsContext.push(undefined); // update rowsClass property for length this.rowsClass.push(undefined); // update rowsIndices property for length this.rowsIndices.push(this.data.length - 1); } else { const ri = this.realRowIndex(index); this.data.splice(ri, 0, realRow); // update rowsSelected property for length this.rowsSelected.splice(ri, 0, false); // update rowsExpanded property for length this.rowsExpanded.splice(ri, 0, false); // update rowsContext property for length this.rowsContext.splice(ri, 0, undefined); // update rowsClass property for length this.rowsClass.splice(ri, 0, undefined); // update rowsIndices property for length this.rowsIndices.splice(ri, 0, this.data.length - 1); } this.dataChange.emit(); } /** * Deletes `index`th row. * * Negative index starts from the end. -1 being the last element. * * @param index */ deleteRow(index) { const rri = this.realRowIndex(index); this.data.splice(rri, 1); this.rowsSelected.splice(rri, 1); this.rowsExpanded.splice(rri, 1); this.rowsContext.splice(rri, 1); this.rowsClass.splice(rri, 1); const rowIndex = this.rowsIndices[rri]; this.rowsIndices.splice(rri, 1); this.rowsIndices = this.rowsIndices.map((value) => (value > rowIndex) ? --value : value); this.dataChange.emit(); } /** * Deletes all rows. */ deleteAllRows() { this.data = []; } hasExpandableRows() { return this.data.some(data => data.some(d => d && d.expandedData)); // checking for some in 2D array } /** * Number of rows that can be expanded. * * @returns number */ expandableRowsCount() { return this.data.reduce((counter, _, index) => { counter = (this.isRowExpandable(index)) ? counter + 1 : counter; return counter; }, 0); } isRowExpandable(index) { return this.data[index].some(d => d && d.expandedData); } isRowExpanded(index) { return this.rowsExpanded[index]; } getRowContext(index) { return this.rowsContext[index]; } /** * Returns `index`th column of the table. * * Negative index starts from the end. -1 being the last element. * * @param index */ column(index) { let column = new Array(); const ri = this.realColumnIndex(index); const rc = this.data.length; for (let i = 0; i < rc; i++) { const row = this.data[i]; column.push(row[ri]); } return column; } /** * Adds a column to the `index`th column or appends to table if index not provided. * * If column is shorter than other columns or not provided, it will be padded with * empty `TableItem` elements. * * If column is longer than other columns, others will be extended to match so no data is lost. * * If called on an empty table with no parameters, it creates a 1x1 table. * * Negative index starts from the end. -1 being the last element. * * @param [column] * @param [index] */ addColumn(column, index) { // if table empty create table with row if (!this.data || this.data.length === 0 || this.data[0].length === 0) { let newData = new Array(); if (column == null) { newData.push([new TableItem()]); } else { for (let i = 0; i < column.length; i++) { let item = column[i]; newData.push([item]); } } this.data = newData; return; } let rc = this.data.length; // row count let ci = this.realColumnIndex(index); // append missing rows for (let i = 0; column != null && i < column.length - rc; i++) { this.addRow(); } rc = this.data.length; if (index == null) { // append to end for (let i = 0; i < rc; i++) { let row = this.data[i]; row.push(column == null || column[i] == null ? new TableItem() : column[i]); } // update header if not already set by user if (this.header.length < this.data[0].length) { this.header.push(new TableHeaderItem()); } } else { if (index >= this.data[0].length) { // if trying to append ci++; } // insert for (let i = 0; i < rc; i++) { let row = this.data[i]; row.splice(ci, 0, column == null || column[i] == null ? new TableItem() : column[i]); } // update header if not already set by user if (this.header.length < this.data[0].length) { this.header.splice(ci, 0, new TableHeaderItem()); } } this.dataChange.emit(); } /** * Deletes `index`th column. * * Negative index starts from the end. -1 being the last element. * * @param index */ deleteColumn(index) { const rci = this.realColumnIndex(index); const rowCount = this.data.length; for (let i = 0; i < rowCount; i++) { this.data[i].splice(rci, 1); } // update header if not already set by user if (this.header.length > this.data[0].length) { this.header.splice(rci, 1); } this.dataChange.emit(); } moveColumn(indexFrom, indexTo) { const headerFrom = this.header[indexFrom]; this.addColumn(this.column(indexFrom), indexTo); this.deleteColumn(indexFrom + (indexTo < indexFrom ? 1 : 0)); this.header[indexTo + (indexTo > indexFrom ? -1 : 0)] = headerFrom; } /** * cycle through the three sort states * @param index */ cycleSortState(index) { // no sort provided so do the simple sort switch (this.header[index].sortDirection) { case "ASCENDING": this.header[index].sortDirection = "DESCENDING"; break; case "DESCENDING": this.header[index].sortDirection = "NONE"; break; default: this.header[index].sortDirection = "ASCENDING"; break; } } /** * Sorts the data currently present in the model based on `compare()` * * Direction is set by `ascending` and `descending` properties of `TableHeaderItem` * in `index`th column. * * @param index The column based on which it's sorting */ sort(index) { this.pushRowStateToModelData(); const headerSorted = this.header[index].sorted; // We only allow sorting by a single column, so reset sort state for all columns before specifying new sort state this.header.forEach(column => column.sorted = false); if (this.header[index].sortDirection === "NONE" && headerSorted) { // Restore initial order of rows const oldData = this._data; this._data = []; for (let i = 0; i < this.rowsIndices.length; i++) { const ri = this.rowsIndices[i]; this._data[ri] = oldData[i]; } } else { const descending = this.header[index].sortDirection === "DESCENDING" ? -1 : 1; this.data.sort((a, b) => { return descending * this.header[index].compare(a[index], b[index]); }); this.header[index].sorted = true; } this.popRowStateFromModelData(); } /** * Appends `rowsSelected` and `rowsExpanded` info to model data. * * When sorting rows, do this first so information about row selection * gets sorted with the other row info. * * Call `popRowSelectionFromModelData()` after sorting to make everything * right with the world again. */ pushRowStateToModelData() { for (let i = 0; i < this.data.length; i++) { const rowSelectedMark = new TableItem(); rowSelectedMark.data = this.rowsSelected[i]; this.data[i].push(rowSelectedMark); const rowExpandedMark = new TableItem(); rowExpandedMark.data = this.rowsExpanded[i]; this.data[i].push(rowExpandedMark); const rowContext = new TableItem(); rowContext.data = this.rowsContext[i]; this.data[i].push(rowContext); const rowClass = new TableItem(); rowClass.data = this.rowsClass[i]; this.data[i].push(rowClass); const rowIndex = new TableItem(); rowIndex.data = this.rowsIndices[i]; this.data[i].push(rowIndex); } } /** * Restores `rowsSelected` from data pushed by `pushRowSelectionToModelData()` * * Call after sorting data (if you previously pushed to maintain selection order) * to make everything right with the world again. */ popRowStateFromModelData() { for (let i = 0; i < this.data.length; i++) { this.rowsIndices[i] = this.data[i].pop().data; this.rowsClass[i] = this.data[i].pop().data; this.rowsContext[i] = this.data[i].pop().data; this.rowsExpanded[i] = !!this.data[i].pop().data; this.rowsSelected[i] = !!this.data[i].pop().data; } } /** * Checks if row is filtered out. * * @param index * @returns true if any of the filters in header filters out the `index`th row */ isRowFiltered(index) { const realIndex = this.realRowIndex(index); return this.header.some((item, i) => item && item.filter(this.row(realIndex)[i])); } /** * Select/deselect `index`th row based on value * * @param index index of the row to select * @param value state to set the row to. Defaults to `true` */ selectRow(index, value = true) { if (this.isRowDisabled(index)) { return; } this.rowsSelected[index] = value; this.rowsSelectedChange.emit(index); } /** * Selects or deselects all rows in the model * * @param value state to set all rows to. Defaults to `true` */ selectAll(value = true) { if (this.data.length >= 1 && this.data[0].length >= 1) { for (let i = 0; i < this.rowsSelected.length; i++) { this.selectRow(i, value); } } this.selectAllChange.next(value); } isRowSelected(index) { return this.rowsSelected[index]; } /** * Checks if row is disabled or not. */ isRowDisabled(index) { const row = this.data[index]; return !!row.disabled; } /** * Expands/Collapses `index`th row based on value * * @param index index of the row to expand or collapse * @param value expanded state of the row. `true` is expanded and `false` is collapsed */ expandRow(index, value = true) { this.rowsExpanded[index] = value; this.rowsExpandedChange.emit(index); } /** * Expands / collapses all rows * * @param value expanded state of the rows. `true` is expanded and `false` is collapsed */ expandAllRows(value = true) { if (this.data.length > 0) { for (let i = 0; i < this.data.length; i++) { if (this.isRowExpandable(i)) { this.rowsExpanded[i] = value; } } if (value) { this.rowsExpandedAllChange.emit(); } else { this.rowsCollapsedAllChange.emit(); } } } /** * Gets the true index of a row based on it's relative position. * Like in Python, positive numbers start from the top and * negative numbers start from the bottom. * * @param index */ realRowIndex(index) { return this.realIndex(index, this.data.length); } /** * Gets the true index of a column based on it's relative position. * Like in Python, positive numbers start from the top and * negative numbers start from the bottom. * * @param index */ realColumnIndex(index) { return this.realIndex(index, this.data[0].length); } /** * Generic function to calculate the real index of something. * Used by `realRowIndex()` and `realColumnIndex()` * * @param index * @param length */ realIndex(index, length) { if (index == null) { return length - 1; } else if (index >= 0) { return index >= length ? length - 1 : index; } else { return -index >= length ? 0 : length + index; } } } /** * The number of models instantiated, used for (among other things) unique id generation */ TableModel.COUNT = 0; /** * `DataGridInteractionModel` provides centralized control over arbitrary 2d grids, following the w3 specs. * * Refs: * - https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html * - https://www.w3.org/TR/wai-aria-practices/#grid * * Example usage (taken from `table.component`): ```typescript // a standard HTML table const table = this.elementRef.nativeElement.querySelector("table") as HTMLTableElement; // `TableDomAdapter` implements `TableAdapter` and provides a consistent interface to query rows and columns in a table const tableAdapter = new TableDomAdapter(table); // the keydown events that we'll use for keyboard navigation of the table const keydownEventStream = fromEvent<KeyboardEvent>(table, "keydown"); // the click events we'll use to ensure focus is updated correctly on click const clickEventStream = fromEvent<MouseEvent>(table, "click"); // the `DataGridInteractionModel` instance! this.interactionModel = new DataGridInteractionModel(keydownEventStream, clickEventStream, tableAdapter); // subscribe to the combined position updates this.interactionModel.position.subscribe(event => { const [currentRow, currentColumn] = event.current; const [previousRow, previousColumn] = event.previous; // query the TableAdapter for the cell at the current row and column ... const currentElement = tableAdapter.getCell(currentRow, currentColumn); // ... and make it focusable it Table.setTabIndex(currentElement, 0); // if the model has just initialized don't focus or reset anything if (previousRow === -1 || previousColumn === -1) { return; } // query the TableAdapter for the cell at the previous row and column ... const previousElement = tableAdapter.getCell(previousRow, previousColumn); // ... and make it unfocusable (now there is only a si