├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.e2e.json ├── package-lock.json ├── package.json ├── projects └── material-dynamic-table │ ├── css │ └── column-resize.css │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── column-config.model.ts │ │ ├── column-filter.model.ts │ │ ├── column-resize │ │ │ ├── cdk-overlay-handle.ts │ │ │ ├── closest.ts │ │ │ ├── column-resize-directives │ │ │ │ ├── column-resize.ts │ │ │ │ └── common.ts │ │ │ ├── column-resize-module.ts │ │ │ ├── column-resize-notifier.ts │ │ │ ├── column-resize.ts │ │ │ ├── event-dispatcher.ts │ │ │ ├── overlay-handle.ts │ │ │ ├── resizable-directives │ │ │ │ └── resizable.ts │ │ │ ├── resizable.ts │ │ │ ├── resize-ref.ts │ │ │ ├── resize-strategy.ts │ │ │ └── selectors.ts │ │ ├── dynamic-table.component.css │ │ ├── dynamic-table.component.html │ │ ├── dynamic-table.component.spec.ts │ │ ├── dynamic-table.component.ts │ │ ├── dynamic-table.module.ts │ │ ├── filter-description.ts │ │ ├── multi-sort │ │ │ ├── multi-sort-data-source.ts │ │ │ ├── multi-sort-header.html │ │ │ ├── multi-sort-header.scss │ │ │ ├── multi-sort-header.ts │ │ │ ├── multi-sort.directive.ts │ │ │ └── table-filter.ts │ │ └── table-cell │ │ │ ├── cell-types │ │ │ ├── cell.component.ts │ │ │ ├── cell.service.spec.ts │ │ │ ├── cell.service.ts │ │ │ ├── column-filter.service.ts │ │ │ ├── date-cell.component.ts │ │ │ └── text-cell.component.ts │ │ │ ├── cell.directive.ts │ │ │ └── table-cell.component.ts │ ├── public_api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── src ├── .browserslistrc ├── app │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── cells │ │ └── options-cell │ │ │ ├── options-cell.component.html │ │ │ └── options-cell.component.ts │ ├── data-source │ │ └── filtered-data-source.ts │ ├── filters │ │ ├── date-filter │ │ │ ├── date-filter.component.html │ │ │ ├── date-filter.component.ts │ │ │ └── date-filter.model.ts │ │ └── text-filter │ │ │ ├── text-filter.component.html │ │ │ ├── text-filter.component.ts │ │ │ └── text-filter.model.ts │ └── product.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── karma.conf.js ├── main.ts ├── styles.css ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── tslint.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /.angular 5 | /dist 6 | /dist-server 7 | /tmp 8 | /out-tsc 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # IDEs and editors 14 | /.idea 15 | .project 16 | /.vs 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # IDE - VSCode 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | 30 | # misc 31 | /.sass-cache 32 | /connect.lock 33 | /coverage 34 | /libpeerconnection.log 35 | npm-debug.log 36 | yarn-error.log 37 | testem.log 38 | /typings 39 | 40 | # System Files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Crown Copyright 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/material-dynamic-table.svg)](//npmjs.com/package/material-dynamic-table) 2 | 3 | # material-dynamic-table 4 | 5 | Dynamic table component for angular built on top of angular material table. It offers sorting, pagination, filtering per column and the ability to specify content types and components used for displaying them. 6 | The initial purpose of this library was to display data coming from OData API, although it can work with MatTableDataSource (however it needs to be extended to enable filtering - see example). 7 | 8 | ## Demo 9 | 10 | Online demo: https://stackblitz.com/edit/dynamic-table 11 | 12 | Run `ng serve` for the main project to launch demo for this library. 13 | 14 | ## Getting started 15 | 16 | #### 1. Prerequisites: 17 | 18 | Angular material: 19 | Please follow https://material.angular.io/guide/getting-started 20 | The supported version of Angular Material will be indicated by the major version number of this library. Version 8.3.0 21 | is for Angular Material ^8.0.0, version 9.3.0 is for ^9.0.0, while versions <1.3.0 should work for versions <8.0.0. 22 | 23 | For Angular Material 15 and 16 the library is using legacy material components. 24 | 25 | Version >=17 of this library is using new versions of material components for table and paginator. 26 | 27 | Filter is using material icon, so adding material icons may be needed as well: 28 | https://material.angular.io/guide/getting-started#step-6-optional-add-material-icons 29 | 30 | #### 2. Install material-dynamic-table: 31 | 32 | ```bash 33 | npm install material-dynamic-table --save 34 | ``` 35 | 36 | #### 3. Import the installed libraries: 37 | 38 | ```ts 39 | import { NgModule } from '@angular/core'; 40 | import { BrowserModule } from '@angular/platform-browser'; 41 | 42 | import { DynamicTableModule } from 'material-dynamic-table'; 43 | 44 | import { AppComponent } from './app'; 45 | 46 | @NgModule({ 47 | ... 48 | imports: [ 49 | ... 50 | 51 | DynamicTableModule 52 | ], 53 | }) 54 | export class AppModule {} 55 | 56 | ``` 57 | 58 | #### 4. Include `mdt-dynamic-table` in your component: 59 | 60 | ```ts 61 | 62 | ``` 63 | 64 | #### 5. Specify column definitions and data source: 65 | ```ts 66 | import { Component } from '@angular/core'; 67 | 68 | import { ColumnConfig } from 'material-dynamic-table'; 69 | 70 | @Component({ 71 | selector: 'app', 72 | templateUrl: './app.component.html', 73 | }) 74 | export class AppComponent { 75 | columns: ColumnConfig[] = [ 76 | { 77 | name: 'product', 78 | displayName: 'Product', 79 | type: 'string', 80 | hint: 'Product name' 81 | }, 82 | { 83 | name: 'description', 84 | displayName: 'Description', 85 | type: 'string', 86 | resizable: { minWidth: 130, maxWidth: 200 } 87 | }, 88 | { 89 | name: 'recievedOn', 90 | displayName: 'Received On', 91 | type: 'date' 92 | }, 93 | { 94 | name: 'created', 95 | displayName: 'Created Date', 96 | type: 'date', 97 | options: { 98 | dateFormat: 'shortDate' 99 | } 100 | } 101 | ]; 102 | 103 | data: object[] = [ 104 | { 105 | product: 'Mouse', 106 | description: 'Fast and wireless', 107 | recievedOn: new Date('2018-01-02T11:05:53.212Z'), 108 | created: new Date('2015-04-22T18:12:21.111Z') 109 | }, 110 | { 111 | product: 'Keyboard', 112 | description: 'Loud and Mechanical', 113 | recievedOn: new Date('2018-06-09T12:08:23.511Z'), 114 | created: new Date('2015-03-11T11:44:11.431Z') 115 | }, 116 | { 117 | product: 'Laser', 118 | description: 'It\'s bright', 119 | recievedOn: new Date('2017-05-22T18:25:43.511Z'), 120 | created: new Date('2015-04-21T17:15:23.111Z') 121 | }, 122 | { 123 | product: 'Baby food', 124 | description: 'It\'s good for you', 125 | recievedOn: new Date('2017-08-26T18:25:43.511Z'), 126 | created: new Date('2016-01-01T01:25:13.055Z') 127 | }, 128 | { 129 | product: 'Coffee', 130 | description: 'Prepared from roasted coffee beans', 131 | recievedOn: new Date('2015-04-16T23:52:23.565Z'), 132 | created: new Date('2016-12-21T21:05:03.253Z') 133 | }, 134 | { 135 | product: 'Cheese', 136 | description: 'A dairy product', 137 | recievedOn: new Date('2017-11-06T21:22:53.542Z'), 138 | created: new Date('2014-02-11T11:34:12.442Z') 139 | } 140 | ]; 141 | 142 | dataSource = new FilteredDataSource(this.data); 143 | } 144 | ``` 145 | 146 | ## Further info 147 | 148 | #### API reference for material-dynamic-table 149 | 150 | ##### Properties 151 | | Name | Description | 152 | |--------------|-----------------------------------------------------------------------------------------------------| 153 | | @Input() columns: ColumnConfig[] | Column definition for dynamic table, order will determine column order | 154 | | @Input() dataSource: DataSource | Data source that provides data for dynamic table | 155 | | @Input() pageSize: number | Initial page size for pagination - default 20 | 156 | | @Input() pageSizeOptions : number[] | The set of provided page size options to display to the user. | 157 | | @Input() showFilters: boolean | If the filters are defined adds the ability to turn them off - default true | 158 | | @Input() stickyHeader : boolean | Whether the table should have sticky header | 159 | | @Input() multiSort : boolean | Enable multi sort - requires data source that can handle MdtMultiSort | 160 | | @Input() hintDelay : number | Delay before column hint is shown in miliseconds - default 500 | 161 | | @Input() paginator : MatPaginator | Paginator to be used instead of internal paginator or null to hide internal | 162 | | @Output() rowClick: EventEmitter | Event emmited when row is clicked, parameter is the object used for displaying the row | 163 | 164 | ##### Methods 165 | | Name | Description | 166 | |--------------|-----------------------------------------------------------------------------------------------------| 167 | | getFilter(columnName: string): any | Returns currently set filter for the column with provided name | 168 | | setFilter(columnName: string, filter: any) | Sets the filter for the column with provided name | 169 | | getFilters() | Returns all set column filters | 170 | | clearFilters() | Removes all applied filters | 171 | | getSort() | Returns all set column sorts | 172 | | setSort(sortedBy: { id: string; direction: 'asc' | 'desc'; }[]) | Sets sorts for all columns | 173 | 174 | 175 | #### ColumnConfig definition 176 | ColumnConfig is used to provide specification for the columns to be displayed 177 | 178 | | Property | Description | 179 | |------------------|--------------------------------------------------------------------------------------------| 180 | | name | Name of the property to display - it should match propery name from data source | 181 | | displayName | Name to be displayed in column header | 182 | | type | Type of the data displayed by this column - it should match one of your defined cell types | 183 | | options | Optional field that can be used to pass extra data for cells | 184 | | sticky | Optional field that can make column sticky to start or end of table. Values: 'start', 'end'| 185 | | sort | Optional field that can disable sort on the column if the value is false | 186 | | hint | Optional field that specifies column hint to be displayed for column header | 187 | | resizable | Optional field that enables column to be resizable and allows setting min and max width | 188 | 189 | #### Cell types 190 | By default there are two types provided: 191 | ###### string 192 | This displays plain string value for property defined under `name` in ColumnConfig. 193 | This is the default type used if there is no type specified for the data type. 194 | ###### date 195 | This type will format property from `ColumnConfig.name` as date. It can take additional parameter in `ColumnConfig.options` - `dateFormat`, which specifies what date format should be used (default is 'short') 196 | 197 | #### Adding additional cell types 198 | New cell types can be defined by adding a component, inheriting from CellComponent 199 | 200 | Here is an example of options cell that can be used for showing possible actions 201 | 202 | ```ts 203 | import { Component, Input } from '@angular/core'; 204 | import { CellComponent, ColumnConfig } from 'material-dynamic-table'; 205 | import { Product } from '../../product'; 206 | 207 | @Component({ 208 | selector: 'ld-options-cell', 209 | template: ` 210 | 211 | 212 | 213 | ` 216 | }) 217 | export class OptionsCellComponent implements CellComponent { 218 | @Input() 219 | column: ColumnConfig; 220 | 221 | @Input() 222 | row: Product; 223 | 224 | constructor() {} 225 | 226 | showDetails() { 227 | const productName = this.row.product; 228 | 229 | alert(`Product name is ${productName}.`); 230 | } 231 | } 232 | ``` 233 | 234 | Cell types then need to be registered: 235 | ```ts 236 | import { OptionsCellComponent } from './cells/optionsCell/options-cell.component'; 237 | 238 | @NgModule({ 239 | ... 240 | declarations: [ 241 | ... 242 | 243 | OptionsCellComponent 244 | ], 245 | entryComponents: [ 246 | ... 247 | 248 | OptionsCellComponent 249 | ] 250 | }) 251 | export class AppModule { 252 | constructor(private readonly cellService: CellService) { 253 | cellService.registerCell('options', OptionsCellComponent); 254 | } 255 | } 256 | ``` 257 | 258 | #### Adding filters 259 | Filters icon an the column will be displayed whenever there is a registered filter for that columns type. To add a filter first create a component for modal dialog with a model that implements your filter interface. Then register it in the following way: 260 | 261 | ```ts 262 | import { TextFilterComponent } from './filters/text-filter/text-filter.component'; 263 | 264 | @NgModule({ 265 | ... 266 | declarations: [ 267 | ... 268 | 269 | TextFilterComponent 270 | ], 271 | entryComponents: [ 272 | ... 273 | 274 | TextFilterComponent 275 | ] 276 | }) 277 | export class AppModule { 278 | constructor(private readonly columnFilterService: ColumnFilterService) { 279 | columnFilterService.registerFilter('string', TextFilterComponent); 280 | } 281 | } 282 | ``` 283 | 284 | In this case it is a filter for `string` cell type named `TextFilterComponent`. See the example project for full design. 285 | To make use of filters you need to have data source that can handle them. See `FilteredDataSource` from the example to see how `MatTableDataSource` can be extended to handle it. 286 | 287 | Filters can have a description that is displayed when the filter is applied. To set the description for the filter the filter model should have a method getDescription that returns a string. 288 | Implement interface 'FilterDescription' for your filter model to have the description displayed. 289 | 290 | #### Resizable column 291 | To enable resizable columns add the css file from node_modules\material-dynamic-table\css\column-resize.css in addition to setting specific columns 292 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "lib-demo": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "ld", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/lib-demo", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "zone.js", 20 | "tsConfig": "src/tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", 27 | "dist/material-dynamic-table/css/column-resize.css", 28 | "src/styles.css" 29 | ], 30 | "scripts": [] 31 | }, 32 | "configurations": { 33 | "production": { 34 | "fileReplacements": [ 35 | { 36 | "replace": "src/environments/environment.ts", 37 | "with": "src/environments/environment.prod.ts" 38 | } 39 | ], 40 | "optimization": true, 41 | "outputHashing": "all", 42 | "sourceMap": false, 43 | "namedChunks": false, 44 | "aot": true, 45 | "extractLicenses": true, 46 | "vendorChunk": false, 47 | "buildOptimizer": true 48 | } 49 | } 50 | }, 51 | "serve": { 52 | "builder": "@angular-devkit/build-angular:dev-server", 53 | "options": { 54 | "browserTarget": "lib-demo:build" 55 | }, 56 | "configurations": { 57 | "production": { 58 | "browserTarget": "lib-demo:build:production" 59 | } 60 | } 61 | }, 62 | "extract-i18n": { 63 | "builder": "@angular-devkit/build-angular:extract-i18n", 64 | "options": { 65 | "browserTarget": "lib-demo:build" 66 | } 67 | }, 68 | "test": { 69 | "builder": "@angular-devkit/build-angular:karma", 70 | "options": { 71 | "main": "src/test.ts", 72 | "polyfills": "zone.js", 73 | "tsConfig": "src/tsconfig.spec.json", 74 | "karmaConfig": "src/karma.conf.js", 75 | "styles": [ 76 | "src/styles.css" 77 | ], 78 | "scripts": [], 79 | "assets": [ 80 | "src/favicon.ico", 81 | "src/assets" 82 | ] 83 | } 84 | } 85 | } 86 | }, 87 | "lib-demo-e2e": { 88 | "root": "e2e/", 89 | "projectType": "application", 90 | "architect": { 91 | "e2e": { 92 | "builder": "@angular-devkit/build-angular:protractor", 93 | "options": { 94 | "protractorConfig": "e2e/protractor.conf.js", 95 | "devServerTarget": "lib-demo:serve" 96 | }, 97 | "configurations": { 98 | "production": { 99 | "devServerTarget": "lib-demo:serve:production" 100 | } 101 | } 102 | } 103 | } 104 | }, 105 | "material-dynamic-table": { 106 | "root": "projects/material-dynamic-table", 107 | "sourceRoot": "projects/material-dynamic-table/src", 108 | "projectType": "library", 109 | "prefix": "dt", 110 | "architect": { 111 | "build": { 112 | "builder": "@angular-devkit/build-angular:ng-packagr", 113 | "options": { 114 | "tsConfig": "projects/material-dynamic-table/tsconfig.lib.json", 115 | "project": "projects/material-dynamic-table/ng-package.json" 116 | }, 117 | "configurations": { 118 | "production": { 119 | "tsConfig": "projects/material-dynamic-table/tsconfig.lib.prod.json" 120 | } 121 | } 122 | }, 123 | "test": { 124 | "builder": "@angular-devkit/build-angular:karma", 125 | "options": { 126 | "main": "projects/material-dynamic-table/src/test.ts", 127 | "tsConfig": "projects/material-dynamic-table/tsconfig.spec.json", 128 | "karmaConfig": "projects/material-dynamic-table/karma.conf.js" 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './src/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: require('path').join(__dirname, './tsconfig.e2e.json') 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to lib-demo!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('ld-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lib-demo", 3 | "version": "18.8.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "e2e": "ng e2e", 10 | "copy": "cp README.md dist/material-dynamic-table", 11 | "build-lib": "ng build --configuration production material-dynamic-table && npm run copy" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "^18.0.3", 16 | "@angular/cdk": "^18.0.3", 17 | "@angular/common": "^18.0.3", 18 | "@angular/compiler": "^18.0.3", 19 | "@angular/core": "^18.0.3", 20 | "@angular/forms": "^18.0.3", 21 | "@angular/material": "^18.0.3", 22 | "@angular/platform-browser": "^18.0.3", 23 | "@angular/platform-browser-dynamic": "^18.0.3", 24 | "@angular/router": "^18.0.3", 25 | "rxjs": "^6.6.7", 26 | "zone.js": "^0.14.7" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^18.0.4", 30 | "@angular/cli": "^18.0.4", 31 | "@angular/compiler-cli": "^18.0.3", 32 | "@angular/language-service": "^18.0.3", 33 | "@types/jasmine": "~4.3.2", 34 | "@types/jasminewd2": "^2.0.10", 35 | "@types/node": "~20.2.5", 36 | "jasmine-core": "~5.0.0", 37 | "jasmine-spec-reporter": "~7.0.0", 38 | "karma": "~6.4.2", 39 | "karma-chrome-launcher": "~3.2.0", 40 | "karma-coverage-istanbul-reporter": "~3.0.2", 41 | "karma-jasmine": "~5.1.0", 42 | "karma-jasmine-html-reporter": "^2.1.0", 43 | "ng-packagr": "^18.0.0", 44 | "ts-node": "~10.9.1", 45 | "tslib": "^2.5.3", 46 | "typescript": "5.4.5", 47 | "eslint-plugin-import": "latest", 48 | "eslint-plugin-jsdoc": "latest", 49 | "eslint-plugin-prefer-arrow": "latest" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/css/column-resize.css: -------------------------------------------------------------------------------- 1 | 2 | .mat-column-resize-table.cdk-column-resize-with-resized-column { 3 | table-layout: fixed; 4 | } 5 | 6 | .mat-column-resize-flex .mat-header-cell, .mat-mdc-header-cell, .mat-cell, .mat-mdc-cell { 7 | box-sizing: border-box; 8 | min-width: 32px; 9 | } 10 | 11 | .mat-header-cell, 12 | .mat-mdc-header-cell { 13 | position: relative; 14 | } 15 | 16 | .mat-resizable { 17 | box-sizing: border-box; 18 | } 19 | 20 | .mat-header-cell:not(.mat-resizable)::after, 21 | .mat-mdc-header-cell:not(.mat-resizable)::after, 22 | .mat-resizable-handle { 23 | background: transparent; 24 | bottom: 0; 25 | position: absolute; 26 | top: 0; 27 | transition: background 300ms cubic-bezier(0.55, 0, 0.55, 0.2); 28 | width: 1px; 29 | } 30 | 31 | .mat-header-cell:not(.mat-resizable)::after, 32 | .mat-mdc-header-cell:not(.mat-resizable)::after { 33 | content: ''; 34 | } 35 | 36 | .mat-header-cell:not(.mat-resizable)::after, 37 | .mat-mdc-header-cell:not(.mat-resizable)::after, 38 | .mat-resizable-handle { 39 | right: 0; 40 | } 41 | 42 | .mat-header-row.cdk-column-resize-hover-or-active, 43 | .mat-mdc-header-row.cdk-column-resize-hover-or-active .mat-header-cell, 44 | .mat-mdc-header-cell { 45 | border-right: none; 46 | } 47 | 48 | .mat-header-row.cdk-column-resize-hover-or-active, 49 | .mat-mdc-header-row.cdk-column-resize-hover-or-active .mat-header-cell:not(.mat-resizable)::after, 50 | .mat-mdc-header-cell:not(.mat-resizable)::after { 51 | background: none; 52 | } 53 | 54 | .mat-header-row.cdk-column-resize-hover-or-active, 55 | .mat-mdc-header-row.cdk-column-resize-hover-or-active .mat-resizable-handle { 56 | background: rgba(0, 0, 0, .12); 57 | } 58 | 59 | .mat-resizable.cdk-resizable-overlay-thumb-active > .mat-resizable-handle { 60 | opacity: 0; 61 | transition: none; 62 | } 63 | 64 | .mat-resizable-handle:focus, 65 | .mat-header-row.cdk-column-resize-hover-or-active .mat-resizable-handle:focus, 66 | .mat-mdc-header-row.cdk-column-resize-hover-or-active .mat-resizable-handle:focus { 67 | background: rgba(0, 0, 0, .12); 68 | outline: none; 69 | } 70 | 71 | .mat-column-resize-overlay-thumb { 72 | background: transparent; 73 | cursor: col-resize; 74 | height: 100%; 75 | transition: background 300ms cubic-bezier(0.55, 0, 0.55, 0.2); 76 | user-select: none; 77 | -webkit-user-select: none; 78 | width: 100%; 79 | } 80 | 81 | .mat-column-resize-overlay-thumb:active { 82 | background: linear-gradient(90deg, transparent, transparent 7px, rgba(0, 0, 0, .12) 7px, rgba(0, 0, 0, .12) 9px, transparent 9px, transparent); 83 | will-change: transform; 84 | } 85 | 86 | .mat-column-resize-overlay-thumb:active .mat-column-resize-overlay-thumb-top { 87 | background: linear-gradient(90deg, transparent, transparent 4px, rgba(0, 0, 0, .12) 4px, rgba(0, 0, 0, .12) 12px, transparent 12px, transparent); 88 | } 89 | 90 | .mat-column-resize-overlay-thumb-top { 91 | width: 100%; 92 | } 93 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/material-dynamic-table", 4 | "lib": { 5 | "entryFile": "src/public_api.ts" 6 | }, 7 | "assets": [ 8 | "./css" 9 | ] 10 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "material-dynamic-table", 3 | "version": "18.8.0", 4 | "license": "MIT", 5 | "author": "Marcin Suty", 6 | "description": "Dynamic table component for angular built on top of angular material table. It offers sorting, pagination, filtering per column and the ability to specify content types and components used for displaying them.", 7 | "keywords": [ 8 | "angular", 9 | "angular2", 10 | "angular18", 11 | "ng", 12 | "ng2", 13 | "dynamic", 14 | "material", 15 | "table", 16 | "data" 17 | ], 18 | "repository": { 19 | "url": "https://github.com/relair/material-dynamic-table.git", 20 | "type": "git" 21 | }, 22 | "peerDependencies": { 23 | "@angular/common": ">=18.0.0-0", 24 | "@angular/core": ">=18.0.0-0", 25 | "@angular/material": ">=18.0.0-0", 26 | "rxjs": ">=6.6.7" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-config.model.ts: -------------------------------------------------------------------------------- 1 | export class ColumnConfig { 2 | name: string; 3 | displayName?: string; 4 | type: string; 5 | options?: any; 6 | sticky?: string; 7 | sort?: boolean; 8 | hint?: string; 9 | resizable?: boolean | { minWidth?: number, maxWidth?: number }; 10 | } 11 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-filter.model.ts: -------------------------------------------------------------------------------- 1 | import { ColumnConfig } from './column-config.model'; 2 | 3 | export class ColumnFilter { 4 | column: ColumnConfig; 5 | filter: any; 6 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/cdk-overlay-handle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { AfterViewInit, Directive, ElementRef, OnDestroy, NgZone } from '@angular/core'; 10 | import { coerceCssPixelValue } from '@angular/cdk/coercion'; 11 | import { Directionality } from '@angular/cdk/bidi'; 12 | import { ESCAPE } from '@angular/cdk/keycodes'; 13 | import { CdkColumnDef, _CoalescedStyleScheduler } from '@angular/cdk/table'; 14 | import { fromEvent, Subject, merge } from 'rxjs'; 15 | import { 16 | distinctUntilChanged, 17 | filter, 18 | map, 19 | mapTo, 20 | pairwise, 21 | startWith, 22 | takeUntil, 23 | } from 'rxjs/operators'; 24 | 25 | import { closest } from './closest'; 26 | 27 | import { HEADER_CELL_SELECTOR } from './selectors'; 28 | import { ColumnResizeNotifierSource } from './column-resize-notifier'; 29 | import { HeaderRowEventDispatcher } from './event-dispatcher'; 30 | import { ResizeRef } from './resize-ref'; 31 | 32 | // TODO: Take another look at using cdk drag drop. IIRC I ran into a couple 33 | // good reasons for not using it but I don't remember what they were at this point. 34 | /** 35 | * Base class for a component shown over the edge of a resizable column that is responsible 36 | * for handling column resize mouse events and displaying any visible UI on the column edge. 37 | */ 38 | @Directive() 39 | export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { 40 | protected readonly destroyed = new Subject(); 41 | 42 | protected abstract readonly columnDef: CdkColumnDef; 43 | protected abstract readonly document: Document; 44 | protected abstract readonly directionality: Directionality; 45 | protected abstract readonly elementRef: ElementRef; 46 | protected abstract readonly eventDispatcher: HeaderRowEventDispatcher; 47 | protected abstract readonly ngZone: NgZone; 48 | protected abstract readonly resizeNotifier: ColumnResizeNotifierSource; 49 | protected abstract readonly resizeRef: ResizeRef; 50 | protected abstract readonly styleScheduler: _CoalescedStyleScheduler; 51 | 52 | ngAfterViewInit() { 53 | this._listenForMouseEvents(); 54 | } 55 | 56 | ngOnDestroy() { 57 | this.destroyed.next(); 58 | this.destroyed.complete(); 59 | } 60 | 61 | private _listenForMouseEvents() { 62 | this.ngZone.runOutsideAngular(() => { 63 | fromEvent(this.elementRef.nativeElement!, 'mouseenter') 64 | .pipe(mapTo(this.resizeRef.origin.nativeElement!), takeUntil(this.destroyed)) 65 | .subscribe(cell => this.eventDispatcher.headerCellHovered.next(cell)); 66 | 67 | fromEvent(this.elementRef.nativeElement!, 'mouseleave') 68 | .pipe( 69 | map( 70 | event => 71 | event.relatedTarget && closest(event.relatedTarget as Element, HEADER_CELL_SELECTOR), 72 | ), 73 | takeUntil(this.destroyed), 74 | ) 75 | .subscribe(cell => this.eventDispatcher.headerCellHovered.next(cell)); 76 | 77 | fromEvent(this.elementRef.nativeElement!, 'mousedown') 78 | .pipe(takeUntil(this.destroyed)) 79 | .subscribe(mousedownEvent => { 80 | this._dragStarted(mousedownEvent); 81 | }); 82 | }); 83 | } 84 | 85 | private _dragStarted(mousedownEvent: MouseEvent) { 86 | // Only allow dragging using the left mouse button. 87 | if (mousedownEvent.button !== 0) { 88 | return; 89 | } 90 | 91 | const mouseup = fromEvent(this.document, 'mouseup'); 92 | const mousemove = fromEvent(this.document, 'mousemove'); 93 | const escape = fromEvent(this.document, 'keyup').pipe( 94 | filter(event => event.keyCode === ESCAPE), 95 | ); 96 | 97 | const startX = mousedownEvent.screenX; 98 | 99 | const initialSize = this._getOriginWidth(); 100 | let overlayOffset = 0; 101 | let originOffset = this._getOriginOffset(); 102 | let size = initialSize; 103 | let overshot = 0; 104 | 105 | this.updateResizeActive(true); 106 | 107 | mouseup.pipe(takeUntil(merge(escape, this.destroyed))).subscribe(({ screenX }) => { 108 | this.styleScheduler.scheduleEnd(() => { 109 | this._notifyResizeEnded(size, screenX !== startX); 110 | }); 111 | }); 112 | 113 | escape.pipe(takeUntil(merge(mouseup, this.destroyed))).subscribe(() => { 114 | this._notifyResizeEnded(initialSize); 115 | }); 116 | 117 | mousemove 118 | .pipe( 119 | map(({ screenX }) => screenX), 120 | startWith(startX), 121 | distinctUntilChanged(), 122 | pairwise(), 123 | takeUntil(merge(mouseup, escape, this.destroyed)), 124 | ) 125 | .subscribe(([prevX, currX]) => { 126 | let deltaX = currX - prevX; 127 | 128 | // If the mouse moved further than the resize was able to match, limit the 129 | // movement of the overlay to match the actual size and position of the origin. 130 | if (overshot !== 0) { 131 | if ((overshot < 0 && deltaX < 0) || (overshot > 0 && deltaX > 0)) { 132 | overshot += deltaX; 133 | return; 134 | } else { 135 | const remainingOvershot = overshot + deltaX; 136 | overshot = 137 | overshot > 0 ? Math.max(remainingOvershot, 0) : Math.min(remainingOvershot, 0); 138 | deltaX = remainingOvershot - overshot; 139 | 140 | if (deltaX === 0) { 141 | return; 142 | } 143 | } 144 | } 145 | 146 | let computedNewSize: number = size + (this._isLtr() ? deltaX : -deltaX); 147 | computedNewSize = Math.min( 148 | Math.max(computedNewSize, this.resizeRef.minWidthPx, 0), 149 | this.resizeRef.maxWidthPx, 150 | ); 151 | 152 | this.resizeNotifier.triggerResize.next({ 153 | columnId: this.columnDef.name, 154 | size: computedNewSize, 155 | previousSize: size, 156 | isStickyColumn: this.columnDef.sticky || this.columnDef.stickyEnd, 157 | }); 158 | 159 | this.styleScheduler.scheduleEnd(() => { 160 | const originNewSize = this._getOriginWidth(); 161 | const originNewOffset = this._getOriginOffset(); 162 | const originOffsetDeltaX = originNewOffset - originOffset; 163 | const originSizeDeltaX = originNewSize - size; 164 | size = originNewSize; 165 | originOffset = originNewOffset; 166 | 167 | overshot += deltaX + (this._isLtr() ? -originSizeDeltaX : originSizeDeltaX); 168 | overlayOffset += originOffsetDeltaX + (this._isLtr() ? originSizeDeltaX : 0); 169 | 170 | this._updateOverlayOffset(overlayOffset); 171 | }); 172 | }); 173 | } 174 | 175 | protected updateResizeActive(active: boolean): void { 176 | this.eventDispatcher.overlayHandleActiveForCell.next( 177 | active ? this.resizeRef.origin.nativeElement! : null, 178 | ); 179 | } 180 | 181 | private _getOriginWidth(): number { 182 | return this.resizeRef.origin.nativeElement!.offsetWidth; 183 | } 184 | 185 | private _getOriginOffset(): number { 186 | return this.resizeRef.origin.nativeElement!.offsetLeft; 187 | } 188 | 189 | private _updateOverlayOffset(offset: number): void { 190 | this.resizeRef.overlayRef.overlayElement.style.transform = `translateX(${coerceCssPixelValue( 191 | offset, 192 | )})`; 193 | } 194 | 195 | private _isLtr(): boolean { 196 | return this.directionality.value === 'ltr'; 197 | } 198 | 199 | private _notifyResizeEnded(size: number, completedSuccessfully = false): void { 200 | this.updateResizeActive(false); 201 | 202 | this.ngZone.run(() => { 203 | const sizeMessage = { columnId: this.columnDef.name, size }; 204 | if (completedSuccessfully) { 205 | this.resizeNotifier.resizeCompleted.next(sizeMessage); 206 | } else { 207 | this.resizeNotifier.resizeCanceled.next(sizeMessage); 208 | } 209 | }); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/closest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | /** closest implementation that is able to start from non-Element Nodes. */ 10 | export function closest( 11 | element: EventTarget | Element | null | undefined, 12 | selector: string, 13 | ): Element | null { 14 | if (!(element instanceof Node)) { 15 | return null; 16 | } 17 | 18 | let curr: Node | null = element; 19 | while (curr != null && !(curr instanceof Element)) { 20 | curr = curr.parentNode; 21 | } 22 | 23 | return curr?.closest(selector) ?? null; 24 | } 25 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/column-resize-directives/column-resize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { Directive, ElementRef, NgZone } from '@angular/core'; 10 | import { ColumnResize } from '../column-resize'; 11 | import { ColumnResizeNotifier, ColumnResizeNotifierSource } from '../column-resize-notifier'; 12 | import { HeaderRowEventDispatcher } from '../event-dispatcher'; 13 | 14 | import { TABLE_HOST_BINDINGS, TABLE_PROVIDERS } from './common'; 15 | 16 | /** 17 | * Explicitly enables column resizing for a table-based mat-table. 18 | * Individual columns must be annotated specifically. 19 | */ 20 | @Directive({ 21 | selector: 'table[mat-table][columnResize]', 22 | host: TABLE_HOST_BINDINGS, 23 | providers: [...TABLE_PROVIDERS, { provide: ColumnResize, useExisting: MatColumnResize }], 24 | standalone: true 25 | }) 26 | export class MatColumnResize extends ColumnResize { 27 | constructor( 28 | readonly columnResizeNotifier: ColumnResizeNotifier, 29 | readonly elementRef: ElementRef, 30 | protected readonly eventDispatcher: HeaderRowEventDispatcher, 31 | protected readonly ngZone: NgZone, 32 | protected readonly notifier: ColumnResizeNotifierSource, 33 | ) { 34 | super(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/column-resize-directives/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { Provider } from '@angular/core'; 10 | 11 | import { ColumnResize } from '../column-resize'; 12 | import { ColumnResizeNotifier, ColumnResizeNotifierSource } from '../column-resize-notifier'; 13 | import { HeaderRowEventDispatcher } from '../event-dispatcher'; 14 | 15 | import { TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER } from '../resize-strategy'; 16 | 17 | export const TABLE_PROVIDERS: Provider[] = [ 18 | ColumnResizeNotifier, 19 | HeaderRowEventDispatcher, 20 | ColumnResizeNotifierSource, 21 | TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER, 22 | ]; 23 | 24 | export const TABLE_HOST_BINDINGS = { 25 | 'class': 'mat-column-resize-table', 26 | }; 27 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/column-resize-module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { NgModule } from '@angular/core'; 10 | import { MatCommonModule } from '@angular/material/core'; 11 | import { OverlayModule } from '@angular/cdk/overlay'; 12 | 13 | import { MatColumnResize } from './column-resize-directives/column-resize'; 14 | import { MatResizable } from './resizable-directives/resizable'; 15 | import { MatColumnResizeOverlayHandle } from './overlay-handle'; 16 | 17 | const ENTRY_COMMON_COMPONENTS = [MatColumnResizeOverlayHandle]; 18 | 19 | @NgModule({ 20 | imports: [...ENTRY_COMMON_COMPONENTS], 21 | exports: ENTRY_COMMON_COMPONENTS, 22 | }) 23 | export class MatColumnResizeCommonModule { } 24 | 25 | @NgModule({ 26 | imports: [MatCommonModule, OverlayModule, MatColumnResizeCommonModule, MatColumnResize, MatResizable], 27 | exports: [MatColumnResize, MatResizable], 28 | }) 29 | export class MatColumnResizeModule { } 30 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/column-resize-notifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { Injectable } from '@angular/core'; 10 | import { Observable, Subject } from 'rxjs'; 11 | 12 | /** Indicates the width of a column. */ 13 | export interface ColumnSize { 14 | /** The ID/name of the column, as defined in CdkColumnDef. */ 15 | readonly columnId: string; 16 | 17 | /** The width in pixels of the column. */ 18 | readonly size: number; 19 | 20 | /** The width in pixels of the column prior to this update, if known. */ 21 | readonly previousSize?: number; 22 | } 23 | 24 | /** Interface describing column size changes. */ 25 | export interface ColumnSizeAction extends ColumnSize { 26 | /** 27 | * Whether the resize action should be applied instantaneously. False for events triggered during 28 | * a UI-triggered resize (such as with the mouse) until the mouse button is released. True 29 | * for all programmatically triggered resizes. 30 | */ 31 | readonly completeImmediately?: boolean; 32 | 33 | /** 34 | * Whether the resize action is being applied to a sticky/stickyEnd column. 35 | */ 36 | readonly isStickyColumn?: boolean; 37 | } 38 | 39 | /** 40 | * Originating source of column resize events within a table. 41 | * @docs-private 42 | */ 43 | @Injectable() 44 | export class ColumnResizeNotifierSource { 45 | /** Emits when an in-progress resize is canceled. */ 46 | readonly resizeCanceled = new Subject(); 47 | 48 | /** Emits when a resize is applied. */ 49 | readonly resizeCompleted = new Subject(); 50 | 51 | /** Triggers a resize action. */ 52 | readonly triggerResize = new Subject(); 53 | } 54 | 55 | /** Service for triggering column resizes imperatively or being notified of them. */ 56 | @Injectable() 57 | export class ColumnResizeNotifier { 58 | /** Emits whenever a column is resized. */ 59 | readonly resizeCompleted: Observable = this._source.resizeCompleted; 60 | 61 | constructor(private readonly _source: ColumnResizeNotifierSource) { } 62 | 63 | /** Instantly resizes the specified column. */ 64 | resize(columnId: string, size: number): void { 65 | this._source.triggerResize.next({ 66 | columnId, 67 | size, 68 | completeImmediately: true, 69 | isStickyColumn: true, 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/column-resize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { AfterViewInit, Directive, ElementRef, NgZone, OnDestroy } from '@angular/core'; 10 | import { fromEvent, merge, Subject } from 'rxjs'; 11 | import { filter, map, mapTo, pairwise, startWith, take, takeUntil } from 'rxjs/operators'; 12 | 13 | import { closest } from './closest'; 14 | 15 | import { ColumnResizeNotifier, ColumnResizeNotifierSource } from './column-resize-notifier'; 16 | import { HEADER_CELL_SELECTOR, RESIZE_OVERLAY_SELECTOR } from './selectors'; 17 | import { HeaderRowEventDispatcher } from './event-dispatcher'; 18 | 19 | const HOVER_OR_ACTIVE_CLASS = 'cdk-column-resize-hover-or-active'; 20 | const WITH_RESIZED_COLUMN_CLASS = 'cdk-column-resize-with-resized-column'; 21 | 22 | let nextId = 0; 23 | 24 | /** 25 | * Base class for ColumnResize directives which attach to mat-table elements to 26 | * provide common events and services for column resizing. 27 | */ 28 | @Directive() 29 | export abstract class ColumnResize implements AfterViewInit, OnDestroy { 30 | protected readonly destroyed = new Subject(); 31 | 32 | /* Publicly accessible interface for triggering and being notified of resizes. */ 33 | abstract readonly columnResizeNotifier: ColumnResizeNotifier; 34 | 35 | /* ElementRef that this directive is attached to. Exposed For use by column-level directives */ 36 | abstract readonly elementRef: ElementRef; 37 | 38 | protected abstract readonly eventDispatcher: HeaderRowEventDispatcher; 39 | protected abstract readonly ngZone: NgZone; 40 | protected abstract readonly notifier: ColumnResizeNotifierSource; 41 | 42 | /** Unique ID for this table instance. */ 43 | protected readonly selectorId = `${++nextId}`; 44 | 45 | /** The id attribute of the table, if specified. */ 46 | id?: string; 47 | 48 | ngAfterViewInit() { 49 | this.elementRef.nativeElement!.classList.add(this.getUniqueCssClass()); 50 | 51 | this._listenForRowHoverEvents(); 52 | this._listenForResizeActivity(); 53 | this._listenForHoverActivity(); 54 | } 55 | 56 | ngOnDestroy() { 57 | this.destroyed.next(); 58 | this.destroyed.complete(); 59 | } 60 | 61 | /** Gets the unique CSS class name for this table instance. */ 62 | getUniqueCssClass() { 63 | return `cdk-column-resize-${this.selectorId}`; 64 | } 65 | 66 | /** Called when a column in the table is resized. Applies a css class to the table element. */ 67 | setResized() { 68 | this.elementRef.nativeElement!.classList.add(WITH_RESIZED_COLUMN_CLASS); 69 | } 70 | 71 | getTableHeight() { 72 | return this.elementRef.nativeElement!.offsetHeight; 73 | } 74 | 75 | private _listenForRowHoverEvents() { 76 | this.ngZone.runOutsideAngular(() => { 77 | const element = this.elementRef.nativeElement!; 78 | 79 | fromEvent(element, 'mouseover') 80 | .pipe( 81 | map(event => closest(event.target, HEADER_CELL_SELECTOR)), 82 | takeUntil(this.destroyed), 83 | ) 84 | .subscribe(this.eventDispatcher.headerCellHovered); 85 | fromEvent(element, 'mouseleave') 86 | .pipe( 87 | filter( 88 | event => 89 | !!event.relatedTarget && 90 | !(event.relatedTarget as Element).matches(RESIZE_OVERLAY_SELECTOR), 91 | ), 92 | mapTo(null), 93 | takeUntil(this.destroyed), 94 | ) 95 | .subscribe(this.eventDispatcher.headerCellHovered); 96 | }); 97 | } 98 | 99 | private _listenForResizeActivity() { 100 | merge( 101 | this.eventDispatcher.overlayHandleActiveForCell.pipe(mapTo(undefined)), 102 | this.notifier.triggerResize.pipe(mapTo(undefined)), 103 | this.notifier.resizeCompleted.pipe(mapTo(undefined)), 104 | ) 105 | .pipe(take(1), takeUntil(this.destroyed)) 106 | .subscribe(() => { 107 | this.setResized(); 108 | }); 109 | } 110 | 111 | private _listenForHoverActivity() { 112 | this.eventDispatcher.headerRowHoveredOrActiveDistinct 113 | .pipe(startWith(null), pairwise(), takeUntil(this.destroyed)) 114 | .subscribe(([previousRow, hoveredRow]) => { 115 | if (hoveredRow) { 116 | hoveredRow.classList.add(HOVER_OR_ACTIVE_CLASS); 117 | } 118 | if (previousRow) { 119 | previousRow.classList.remove(HOVER_OR_ACTIVE_CLASS); 120 | } 121 | }); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/event-dispatcher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { Injectable, NgZone } from '@angular/core'; 10 | import { combineLatest, MonoTypeOperatorFunction, Observable, Subject } from 'rxjs'; 11 | import { distinctUntilChanged, map, share, skip, startWith } from 'rxjs/operators'; 12 | 13 | import { closest } from './closest'; 14 | 15 | import { HEADER_ROW_SELECTOR } from './selectors'; 16 | 17 | /** Coordinates events between the column resize directives. */ 18 | @Injectable() 19 | export class HeaderRowEventDispatcher { 20 | /** 21 | * Emits the currently hovered header cell or null when no header cells are hovered. 22 | * Exposed publicly for events to feed in, but subscribers should use headerCellHoveredDistinct, 23 | * defined below. 24 | */ 25 | readonly headerCellHovered = new Subject(); 26 | 27 | /** 28 | * Emits the header cell for which a user-triggered resize is active or null 29 | * when no resize is in progress. 30 | */ 31 | readonly overlayHandleActiveForCell = new Subject(); 32 | 33 | constructor(private readonly _ngZone: NgZone) { } 34 | 35 | /** Distinct and shared version of headerCellHovered. */ 36 | readonly headerCellHoveredDistinct = this.headerCellHovered.pipe(distinctUntilChanged(), share()); 37 | 38 | /** 39 | * Emits the header that is currently hovered or hosting an active resize event (with active 40 | * taking precedence). 41 | */ 42 | readonly headerRowHoveredOrActiveDistinct = combineLatest([ 43 | this.headerCellHoveredDistinct.pipe( 44 | map(cell => closest(cell, HEADER_ROW_SELECTOR)), 45 | startWith(null), 46 | distinctUntilChanged(), 47 | ), 48 | this.overlayHandleActiveForCell.pipe( 49 | map(cell => closest(cell, HEADER_ROW_SELECTOR)), 50 | startWith(null), 51 | distinctUntilChanged(), 52 | ), 53 | ]).pipe( 54 | skip(1), // Ignore initial [null, null] emission. 55 | map(([hovered, active]) => active || hovered), 56 | distinctUntilChanged(), 57 | share(), 58 | ); 59 | 60 | private readonly _headerRowHoveredOrActiveDistinctReenterZone = 61 | this.headerRowHoveredOrActiveDistinct.pipe(this._enterZone(), share()); 62 | 63 | // Optimization: Share row events observable with subsequent callers. 64 | // At startup, calls will be sequential by row (and typically there's only one). 65 | private _lastSeenRow: Element | null = null; 66 | private _lastSeenRowHover: Observable | null = null; 67 | 68 | /** 69 | * Emits whether the specified row should show its overlay controls. 70 | * Emission occurs within the NgZone. 71 | */ 72 | resizeOverlayVisibleForHeaderRow(row: Element): Observable { 73 | if (row !== this._lastSeenRow) { 74 | this._lastSeenRow = row; 75 | this._lastSeenRowHover = this._headerRowHoveredOrActiveDistinctReenterZone.pipe( 76 | map(hoveredRow => hoveredRow === row), 77 | distinctUntilChanged(), 78 | share(), 79 | ); 80 | } 81 | 82 | return this._lastSeenRowHover!; 83 | } 84 | 85 | private _enterZone(): MonoTypeOperatorFunction { 86 | return (source: Observable) => 87 | new Observable(observer => 88 | source.subscribe({ 89 | next: value => this._ngZone.run(() => observer.next(value)), 90 | error: err => observer.error(err), 91 | complete: () => observer.complete(), 92 | }), 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/overlay-handle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { 10 | ChangeDetectionStrategy, 11 | Component, 12 | ElementRef, 13 | Inject, 14 | NgZone, 15 | ViewChild, 16 | ViewEncapsulation, 17 | } from '@angular/core'; 18 | import { DOCUMENT } from '@angular/common'; 19 | import { 20 | CdkColumnDef, 21 | _CoalescedStyleScheduler, 22 | _COALESCED_STYLE_SCHEDULER, 23 | } from '@angular/cdk/table'; 24 | import { Directionality } from '@angular/cdk/bidi'; 25 | import { ResizeOverlayHandle } from './cdk-overlay-handle'; 26 | import { ColumnResize } from './column-resize'; 27 | import { ColumnResizeNotifierSource } from './column-resize-notifier'; 28 | import { HeaderRowEventDispatcher } from './event-dispatcher'; 29 | import { ResizeRef } from './resize-ref'; 30 | 31 | /** 32 | * Component shown over the edge of a resizable column that is responsible 33 | * for handling column resize mouse events and displaying a vertical line along the column edge. 34 | */ 35 | @Component({ 36 | changeDetection: ChangeDetectionStrategy.OnPush, 37 | encapsulation: ViewEncapsulation.None, 38 | host: { 'class': 'mat-column-resize-overlay-thumb' }, 39 | template: '
', 40 | standalone: true, 41 | }) 42 | export class MatColumnResizeOverlayHandle extends ResizeOverlayHandle { 43 | protected readonly document: Document; 44 | 45 | @ViewChild('top', { static: true }) topElement: ElementRef; 46 | 47 | constructor( 48 | protected readonly columnDef: CdkColumnDef, 49 | protected readonly columnResize: ColumnResize, 50 | protected readonly directionality: Directionality, 51 | protected readonly elementRef: ElementRef, 52 | protected readonly eventDispatcher: HeaderRowEventDispatcher, 53 | protected readonly ngZone: NgZone, 54 | protected readonly resizeNotifier: ColumnResizeNotifierSource, 55 | protected readonly resizeRef: ResizeRef, 56 | @Inject(_COALESCED_STYLE_SCHEDULER) 57 | protected readonly styleScheduler: _CoalescedStyleScheduler, 58 | @Inject(DOCUMENT) document: any, 59 | ) { 60 | super(); 61 | this.document = document; 62 | } 63 | 64 | protected override updateResizeActive(active: boolean): void { 65 | super.updateResizeActive(active); 66 | 67 | const originHeight = this.resizeRef.origin.nativeElement.offsetHeight; 68 | this.topElement.nativeElement.style.height = `${originHeight}px`; 69 | this.resizeRef.overlayRef.updateSize({ 70 | height: active 71 | ? this.columnResize.getTableHeight() 72 | : originHeight, 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/resizable-directives/resizable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { 10 | Directive, 11 | ElementRef, 12 | Inject, 13 | Injector, 14 | NgZone, 15 | ViewContainerRef, 16 | ChangeDetectorRef, 17 | Type 18 | } from '@angular/core'; 19 | import { DOCUMENT } from '@angular/common'; 20 | import { Directionality } from '@angular/cdk/bidi'; 21 | import { Overlay } from '@angular/cdk/overlay'; 22 | import { CdkColumnDef, _CoalescedStyleScheduler, _COALESCED_STYLE_SCHEDULER } from '@angular/cdk/table'; 23 | import { ColumnResize } from '../column-resize'; 24 | import { ColumnResizeNotifierSource } from '../column-resize-notifier'; 25 | import { ResizeStrategy } from '../resize-strategy'; 26 | import { HeaderRowEventDispatcher } from '../event-dispatcher'; 27 | 28 | import { Resizable } from '../resizable'; 29 | import { MatColumnResizeOverlayHandle } from '../overlay-handle'; 30 | 31 | /** 32 | * Explicitly enables column resizing for a mat-header-cell. 33 | */ 34 | @Directive({ 35 | selector: 'mat-header-cell[resizable], th[mat-header-cell][resizable]', 36 | host: { 'class': 'mat-resizable' }, 37 | inputs: [ 38 | { name: 'settings', alias: 'resizable' }, 39 | ], 40 | standalone: true, 41 | }) 42 | export class MatResizable extends Resizable { 43 | protected readonly document: Document; 44 | 45 | override minWidthPxInternal = 32; 46 | 47 | constructor( 48 | protected readonly columnDef: CdkColumnDef, 49 | protected readonly columnResize: ColumnResize, 50 | protected readonly directionality: Directionality, 51 | @Inject(DOCUMENT) document: any, 52 | protected readonly elementRef: ElementRef, 53 | protected readonly eventDispatcher: HeaderRowEventDispatcher, 54 | protected readonly injector: Injector, 55 | protected readonly ngZone: NgZone, 56 | protected readonly overlay: Overlay, 57 | protected readonly resizeNotifier: ColumnResizeNotifierSource, 58 | protected readonly resizeStrategy: ResizeStrategy, 59 | @Inject(_COALESCED_STYLE_SCHEDULER) 60 | protected readonly styleScheduler: _CoalescedStyleScheduler, 61 | protected readonly viewContainerRef: ViewContainerRef, 62 | protected readonly changeDetectorRef: ChangeDetectorRef, 63 | ) { 64 | super(); 65 | this.document = document; 66 | } 67 | 68 | protected override getInlineHandleCssClassName(): string { 69 | return 'mat-resizable-handle'; 70 | } 71 | 72 | protected override getOverlayHandleComponentType(): Type { 73 | return MatColumnResizeOverlayHandle; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/resizable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { 10 | AfterViewInit, 11 | Directive, 12 | ElementRef, 13 | Injector, 14 | NgZone, 15 | OnDestroy, 16 | Type, 17 | ViewContainerRef, 18 | ChangeDetectorRef, 19 | } from '@angular/core'; 20 | import { Directionality } from '@angular/cdk/bidi'; 21 | import { ComponentPortal } from '@angular/cdk/portal'; 22 | import { Overlay, OverlayRef } from '@angular/cdk/overlay'; 23 | import { CdkColumnDef, _CoalescedStyleScheduler } from '@angular/cdk/table'; 24 | import { merge, Subject } from 'rxjs'; 25 | import { filter, takeUntil } from 'rxjs/operators'; 26 | 27 | import { closest } from './closest'; 28 | 29 | import { HEADER_ROW_SELECTOR } from './selectors'; 30 | import { ResizeOverlayHandle } from './cdk-overlay-handle'; 31 | import { ColumnResize } from './column-resize'; 32 | import { ColumnSizeAction, ColumnResizeNotifierSource } from './column-resize-notifier'; 33 | import { HeaderRowEventDispatcher } from './event-dispatcher'; 34 | import { ResizeRef } from './resize-ref'; 35 | import { ResizeStrategy } from './resize-strategy'; 36 | 37 | const OVERLAY_ACTIVE_CLASS = 'cdk-resizable-overlay-thumb-active'; 38 | 39 | /** 40 | * Base class for Resizable directives which are applied to column headers to make those columns 41 | * resizable. 42 | */ 43 | @Directive() 44 | export abstract class Resizable 45 | implements AfterViewInit, OnDestroy { 46 | protected minWidthPxInternal: number = 0; 47 | protected maxWidthPxInternal: number = Number.MAX_SAFE_INTEGER; 48 | protected enabled: boolean = false; 49 | 50 | protected inlineHandle?: HTMLElement; 51 | protected overlayRef?: OverlayRef; 52 | protected readonly destroyed = new Subject(); 53 | 54 | protected abstract readonly columnDef: CdkColumnDef; 55 | protected abstract readonly columnResize: ColumnResize; 56 | protected abstract readonly directionality: Directionality; 57 | protected abstract readonly document: Document; 58 | protected abstract readonly elementRef: ElementRef; 59 | protected abstract readonly eventDispatcher: HeaderRowEventDispatcher; 60 | protected abstract readonly injector: Injector; 61 | protected abstract readonly ngZone: NgZone; 62 | protected abstract readonly overlay: Overlay; 63 | protected abstract readonly resizeNotifier: ColumnResizeNotifierSource; 64 | protected abstract readonly resizeStrategy: ResizeStrategy; 65 | protected abstract readonly styleScheduler: _CoalescedStyleScheduler; 66 | protected abstract readonly viewContainerRef: ViewContainerRef; 67 | protected abstract readonly changeDetectorRef: ChangeDetectorRef; 68 | 69 | private _viewInitialized = false; 70 | private _isDestroyed = false; 71 | 72 | /** The minimum width to allow the column to be sized to. */ 73 | get minWidthPx(): number { 74 | return this.minWidthPxInternal; 75 | } 76 | set minWidthPx(value: number) { 77 | this.minWidthPxInternal = value; 78 | 79 | this.columnResize.setResized(); 80 | if (this.elementRef.nativeElement && this._viewInitialized) { 81 | this._applyMinWidthPx(); 82 | } 83 | } 84 | 85 | get settings(): boolean | { minWidth: number, maxWidth: number } { 86 | return this.enabled; 87 | } 88 | set settings(value: boolean | { minWidth: number, maxWidth: number }) { 89 | this.enabled = !!value; 90 | 91 | if (typeof value === 'object') { 92 | if (value.minWidth) { 93 | this.minWidthPx = value.minWidth; 94 | } 95 | if (value.maxWidth) { 96 | this.maxWidthPx = value.maxWidth; 97 | } 98 | } 99 | } 100 | 101 | /** The maximum width to allow the column to be sized to. */ 102 | get maxWidthPx(): number { 103 | return this.maxWidthPxInternal; 104 | } 105 | set maxWidthPx(value: number) { 106 | this.maxWidthPxInternal = value; 107 | 108 | this.columnResize.setResized(); 109 | if (this.elementRef.nativeElement && this._viewInitialized) { 110 | this._applyMaxWidthPx(); 111 | } 112 | } 113 | 114 | ngAfterViewInit() { 115 | this._listenForRowHoverEvents(); 116 | this._listenForResizeEvents(); 117 | this._appendInlineHandle(); 118 | 119 | this.styleScheduler.scheduleEnd(() => { 120 | if (this._isDestroyed) return; 121 | this._viewInitialized = true; 122 | this._applyMinWidthPx(); 123 | this._applyMaxWidthPx(); 124 | }); 125 | } 126 | 127 | ngOnDestroy(): void { 128 | this._isDestroyed = true; 129 | this.destroyed.next(); 130 | this.destroyed.complete(); 131 | this.inlineHandle?.remove(); 132 | this.overlayRef?.dispose(); 133 | } 134 | 135 | protected abstract getInlineHandleCssClassName(): string; 136 | 137 | protected abstract getOverlayHandleComponentType(): Type; 138 | 139 | private _createOverlayForHandle(): OverlayRef { 140 | // Use of overlays allows us to properly capture click events spanning parts 141 | // of two table cells and is also useful for displaying a resize thumb 142 | // over both cells and extending it down the table as needed. 143 | 144 | const isRtl = this.directionality.value === 'rtl'; 145 | const positionStrategy = this.overlay 146 | .position() 147 | .flexibleConnectedTo(this.elementRef.nativeElement!) 148 | .withFlexibleDimensions(false) 149 | .withGrowAfterOpen(false) 150 | .withPush(false) 151 | .withDefaultOffsetX(isRtl ? 1 : 0) 152 | .withPositions([ 153 | { 154 | originX: isRtl ? 'start' : 'end', 155 | originY: 'top', 156 | overlayX: 'center', 157 | overlayY: 'top', 158 | }, 159 | ]); 160 | 161 | return this.overlay.create({ 162 | // Always position the overlay based on left-indexed coordinates. 163 | direction: 'ltr', 164 | disposeOnNavigation: true, 165 | positionStrategy, 166 | scrollStrategy: this.overlay.scrollStrategies.reposition(), 167 | width: '16px', 168 | }); 169 | } 170 | 171 | private _listenForRowHoverEvents(): void { 172 | const element = this.elementRef.nativeElement!; 173 | const takeUntilDestroyed = takeUntil(this.destroyed); 174 | 175 | this.eventDispatcher 176 | .resizeOverlayVisibleForHeaderRow(closest(element, HEADER_ROW_SELECTOR)!) 177 | .pipe(takeUntilDestroyed) 178 | .subscribe(hoveringRow => { 179 | if (hoveringRow && this.enabled) { 180 | if (!this.overlayRef) { 181 | this.overlayRef = this._createOverlayForHandle(); 182 | } 183 | 184 | this._showHandleOverlay(); 185 | } else if (this.overlayRef) { 186 | // todo - can't detach during an active resize - need to work that out 187 | this.overlayRef.detach(); 188 | } 189 | }); 190 | } 191 | 192 | private _listenForResizeEvents() { 193 | const takeUntilDestroyed = takeUntil(this.destroyed); 194 | 195 | merge(this.resizeNotifier.resizeCanceled, this.resizeNotifier.triggerResize) 196 | .pipe( 197 | takeUntilDestroyed, 198 | filter(columnSize => columnSize.columnId === this.columnDef.name), 199 | ) 200 | .subscribe(({ size, previousSize, completeImmediately }) => { 201 | this.elementRef.nativeElement!.classList.add(OVERLAY_ACTIVE_CLASS); 202 | this._applySize(size, previousSize); 203 | 204 | if (completeImmediately) { 205 | this._completeResizeOperation(); 206 | } 207 | }); 208 | 209 | merge(this.resizeNotifier.resizeCanceled, this.resizeNotifier.resizeCompleted) 210 | .pipe(takeUntilDestroyed) 211 | .subscribe(columnSize => { 212 | this._cleanUpAfterResize(columnSize); 213 | }); 214 | } 215 | 216 | private _completeResizeOperation(): void { 217 | this.ngZone.run(() => { 218 | this.resizeNotifier.resizeCompleted.next({ 219 | columnId: this.columnDef.name, 220 | size: this.elementRef.nativeElement!.offsetWidth, 221 | }); 222 | }); 223 | } 224 | 225 | private _cleanUpAfterResize(columnSize: ColumnSizeAction): void { 226 | this.elementRef.nativeElement!.classList.remove(OVERLAY_ACTIVE_CLASS); 227 | 228 | if (this.overlayRef && this.overlayRef.hasAttached()) { 229 | this._updateOverlayHandleHeight(); 230 | this.overlayRef.updatePosition(); 231 | 232 | if (columnSize.columnId === this.columnDef.name) { 233 | this.inlineHandle!.focus(); 234 | } 235 | } 236 | } 237 | 238 | private _createHandlePortal(): ComponentPortal { 239 | const injector = Injector.create({ 240 | parent: this.injector, 241 | providers: [ 242 | { 243 | provide: ResizeRef, 244 | useValue: new ResizeRef( 245 | this.elementRef, 246 | this.overlayRef!, 247 | this.minWidthPx, 248 | this.maxWidthPx, 249 | ), 250 | }, 251 | ], 252 | }); 253 | 254 | return new ComponentPortal( 255 | this.getOverlayHandleComponentType(), 256 | this.viewContainerRef, 257 | injector, 258 | ); 259 | } 260 | 261 | private _showHandleOverlay(): void { 262 | this._updateOverlayHandleHeight(); 263 | this.overlayRef!.attach(this._createHandlePortal()); 264 | 265 | // Needed to ensure that all of the lifecycle hooks inside the overlay run immediately. 266 | this.changeDetectorRef.markForCheck(); 267 | } 268 | 269 | private _updateOverlayHandleHeight() { 270 | this.overlayRef!.updateSize({ height: this.elementRef.nativeElement!.offsetHeight }); 271 | } 272 | 273 | private _applySize(sizeInPixels: number, previousSize?: number): void { 274 | const sizeToApply = Math.min(Math.max(sizeInPixels, this.minWidthPx, 0), this.maxWidthPx); 275 | 276 | this.resizeStrategy.applyColumnSize( 277 | this.columnDef.cssClassFriendlyName, 278 | this.elementRef.nativeElement!, 279 | sizeToApply, 280 | previousSize, 281 | ); 282 | } 283 | 284 | private _applyMinWidthPx(): void { 285 | this.resizeStrategy.applyMinColumnSize( 286 | this.columnDef.cssClassFriendlyName, 287 | this.elementRef.nativeElement, 288 | this.minWidthPx, 289 | ); 290 | } 291 | 292 | private _applyMaxWidthPx(): void { 293 | this.resizeStrategy.applyMaxColumnSize( 294 | this.columnDef.cssClassFriendlyName, 295 | this.elementRef.nativeElement, 296 | this.maxWidthPx, 297 | ); 298 | } 299 | 300 | private _appendInlineHandle(): void { 301 | this.styleScheduler.schedule(() => { 302 | this.inlineHandle = this.document.createElement('div'); 303 | this.inlineHandle.tabIndex = 0; 304 | this.inlineHandle.className = this.getInlineHandleCssClassName(); 305 | 306 | // TODO: Apply correct aria role (probably slider) after a11y spec questions resolved. 307 | 308 | this.elementRef.nativeElement!.appendChild(this.inlineHandle); 309 | }); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/resize-ref.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { ElementRef } from '@angular/core'; 10 | import { OverlayRef } from '@angular/cdk/overlay'; 11 | 12 | /** Tracks state of resize events in progress. */ 13 | export class ResizeRef { 14 | constructor( 15 | readonly origin: ElementRef, 16 | readonly overlayRef: OverlayRef, 17 | readonly minWidthPx: number, 18 | readonly maxWidthPx: number, 19 | ) { } 20 | } 21 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/resize-strategy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { Inject, Injectable, OnDestroy, Provider, CSP_NONCE, Optional } from '@angular/core'; 10 | import { DOCUMENT } from '@angular/common'; 11 | import { coerceCssPixelValue } from '@angular/cdk/coercion'; 12 | import { CdkTable, _CoalescedStyleScheduler, _COALESCED_STYLE_SCHEDULER } from '@angular/cdk/table'; 13 | 14 | import { ColumnResize } from './column-resize'; 15 | 16 | /** 17 | * Provides an implementation for resizing a column. 18 | * The details of how resizing works for tables for flex mat-tables are quite different. 19 | */ 20 | @Injectable() 21 | export abstract class ResizeStrategy { 22 | protected abstract readonly columnResize: ColumnResize; 23 | protected abstract readonly styleScheduler: _CoalescedStyleScheduler; 24 | protected abstract readonly table: CdkTable; 25 | 26 | private _pendingResizeDelta: number | null = null; 27 | 28 | /** Updates the width of the specified column. */ 29 | abstract applyColumnSize( 30 | cssFriendlyColumnName: string, 31 | columnHeader: HTMLElement, 32 | sizeInPx: number, 33 | previousSizeInPx?: number, 34 | ): void; 35 | 36 | /** Applies a minimum width to the specified column, updating its current width as needed. */ 37 | abstract applyMinColumnSize( 38 | cssFriendlyColumnName: string, 39 | columnHeader: HTMLElement, 40 | minSizeInPx: number, 41 | ): void; 42 | 43 | /** Applies a maximum width to the specified column, updating its current width as needed. */ 44 | abstract applyMaxColumnSize( 45 | cssFriendlyColumnName: string, 46 | columnHeader: HTMLElement, 47 | minSizeInPx: number, 48 | ): void; 49 | 50 | /** Adjusts the width of the table element by the specified delta. */ 51 | protected updateTableWidthAndStickyColumns(delta: number): void { 52 | if (this._pendingResizeDelta === null) { 53 | const tableElement = this.columnResize.elementRef.nativeElement; 54 | const tableWidth = getElementWidth(tableElement); 55 | 56 | this.styleScheduler.schedule(() => { 57 | tableElement.style.width = coerceCssPixelValue(tableWidth + this._pendingResizeDelta!); 58 | 59 | this._pendingResizeDelta = null; 60 | }); 61 | 62 | this.styleScheduler.scheduleEnd(() => { 63 | this.table.updateStickyColumnStyles(); 64 | }); 65 | } 66 | 67 | this._pendingResizeDelta = (this._pendingResizeDelta ?? 0) + delta; 68 | } 69 | } 70 | 71 | /** 72 | * The optimally performing resize strategy for <table> elements with table-layout: fixed. 73 | * Tested against and outperformed: 74 | * CSS selector 75 | * CSS selector w/ CSS variable 76 | * Updating all cell nodes 77 | */ 78 | @Injectable() 79 | export class TableLayoutFixedResizeStrategy extends ResizeStrategy { 80 | constructor( 81 | protected readonly columnResize: ColumnResize, 82 | @Inject(_COALESCED_STYLE_SCHEDULER) 83 | protected readonly styleScheduler: _CoalescedStyleScheduler, 84 | protected readonly table: CdkTable, 85 | ) { 86 | super(); 87 | } 88 | 89 | applyColumnSize( 90 | _: string, 91 | columnHeader: HTMLElement, 92 | sizeInPx: number, 93 | previousSizeInPx?: number, 94 | ): void { 95 | const delta = sizeInPx - (previousSizeInPx ?? getElementWidth(columnHeader)); 96 | 97 | if (delta === 0) { 98 | return; 99 | } 100 | 101 | this.styleScheduler.schedule(() => { 102 | columnHeader.style.width = coerceCssPixelValue(sizeInPx); 103 | }); 104 | 105 | this.updateTableWidthAndStickyColumns(delta); 106 | } 107 | 108 | applyMinColumnSize(_: string, columnHeader: HTMLElement, sizeInPx: number): void { 109 | const currentWidth = getElementWidth(columnHeader); 110 | const newWidth = Math.max(currentWidth, sizeInPx); 111 | 112 | this.applyColumnSize(_, columnHeader, newWidth, currentWidth); 113 | } 114 | 115 | applyMaxColumnSize(_: string, columnHeader: HTMLElement, sizeInPx: number): void { 116 | const currentWidth = getElementWidth(columnHeader); 117 | const newWidth = Math.min(currentWidth, sizeInPx); 118 | 119 | this.applyColumnSize(_, columnHeader, newWidth, currentWidth); 120 | } 121 | } 122 | 123 | /** 124 | * The optimally performing resize strategy for flex mat-tables. 125 | * Tested against and outperformed: 126 | * CSS selector w/ CSS variable 127 | * Updating all mat-cell nodes 128 | */ 129 | @Injectable() 130 | export class CdkFlexTableResizeStrategy extends ResizeStrategy implements OnDestroy { 131 | private readonly _document: Document; 132 | private readonly _columnIndexes = new Map(); 133 | private readonly _columnProperties = new Map>(); 134 | 135 | private _styleElement?: HTMLStyleElement; 136 | private _indexSequence = 0; 137 | 138 | protected readonly defaultMinSize = 0; 139 | protected readonly defaultMaxSize = Number.MAX_SAFE_INTEGER; 140 | 141 | constructor( 142 | protected readonly columnResize: ColumnResize, 143 | @Inject(_COALESCED_STYLE_SCHEDULER) 144 | protected readonly styleScheduler: _CoalescedStyleScheduler, 145 | protected readonly table: CdkTable, 146 | @Inject(DOCUMENT) document: any, 147 | @Inject(CSP_NONCE) @Optional() private readonly _nonce?: string | null, 148 | ) { 149 | super(); 150 | this._document = document; 151 | } 152 | 153 | applyColumnSize( 154 | cssFriendlyColumnName: string, 155 | columnHeader: HTMLElement, 156 | sizeInPx: number, 157 | previousSizeInPx?: number, 158 | ): void { 159 | // Optimization: Check applied width first as we probably set it already before reading 160 | // offsetWidth which triggers layout. 161 | const delta = 162 | sizeInPx - 163 | (previousSizeInPx ?? 164 | (this._getAppliedWidth(cssFriendlyColumnName) || columnHeader.offsetWidth)); 165 | 166 | if (delta === 0) { 167 | return; 168 | } 169 | 170 | const cssSize = coerceCssPixelValue(sizeInPx); 171 | 172 | this._applyProperty(cssFriendlyColumnName, 'flex', `0 0.01 ${cssSize}`); 173 | this.updateTableWidthAndStickyColumns(delta); 174 | } 175 | 176 | applyMinColumnSize(cssFriendlyColumnName: string, _: HTMLElement, sizeInPx: number): void { 177 | const cssSize = coerceCssPixelValue(sizeInPx); 178 | 179 | this._applyProperty( 180 | cssFriendlyColumnName, 181 | 'min-width', 182 | cssSize, 183 | sizeInPx !== this.defaultMinSize, 184 | ); 185 | this.updateTableWidthAndStickyColumns(0); 186 | } 187 | 188 | applyMaxColumnSize(cssFriendlyColumnName: string, _: HTMLElement, sizeInPx: number): void { 189 | const cssSize = coerceCssPixelValue(sizeInPx); 190 | 191 | this._applyProperty( 192 | cssFriendlyColumnName, 193 | 'max-width', 194 | cssSize, 195 | sizeInPx !== this.defaultMaxSize, 196 | ); 197 | this.updateTableWidthAndStickyColumns(0); 198 | } 199 | 200 | protected getColumnCssClass(cssFriendlyColumnName: string): string { 201 | return `cdk-column-${cssFriendlyColumnName}`; 202 | } 203 | 204 | ngOnDestroy(): void { 205 | this._styleElement?.remove(); 206 | this._styleElement = undefined; 207 | } 208 | 209 | private _getPropertyValue(cssFriendlyColumnName: string, key: string): string | undefined { 210 | const properties = this._getColumnPropertiesMap(cssFriendlyColumnName); 211 | return properties.get(key); 212 | } 213 | 214 | private _getAppliedWidth(cssFriendslyColumnName: string): number { 215 | return coercePixelsFromFlexValue(this._getPropertyValue(cssFriendslyColumnName, 'flex')); 216 | } 217 | 218 | private _applyProperty( 219 | cssFriendlyColumnName: string, 220 | key: string, 221 | value: string, 222 | enable = true, 223 | ): void { 224 | const properties = this._getColumnPropertiesMap(cssFriendlyColumnName); 225 | 226 | this.styleScheduler.schedule(() => { 227 | if (enable) { 228 | properties.set(key, value); 229 | } else { 230 | properties.delete(key); 231 | } 232 | this._applySizeCss(cssFriendlyColumnName); 233 | }); 234 | } 235 | 236 | private _getStyleSheet(): CSSStyleSheet { 237 | if (!this._styleElement) { 238 | this._styleElement = this._document.createElement('style'); 239 | 240 | if (this._nonce) { 241 | this._styleElement.setAttribute('nonce', this._nonce); 242 | } 243 | 244 | this._styleElement.appendChild(this._document.createTextNode('')); 245 | this._document.head.appendChild(this._styleElement); 246 | } 247 | 248 | return this._styleElement.sheet as CSSStyleSheet; 249 | } 250 | 251 | private _getColumnPropertiesMap(cssFriendlyColumnName: string): Map { 252 | let properties = this._columnProperties.get(cssFriendlyColumnName); 253 | if (properties === undefined) { 254 | properties = new Map(); 255 | this._columnProperties.set(cssFriendlyColumnName, properties); 256 | } 257 | return properties; 258 | } 259 | 260 | private _applySizeCss(cssFriendlyColumnName: string) { 261 | const properties = this._getColumnPropertiesMap(cssFriendlyColumnName); 262 | const propertyKeys = Array.from(properties.keys()); 263 | 264 | let index = this._columnIndexes.get(cssFriendlyColumnName); 265 | if (index === undefined) { 266 | if (!propertyKeys.length) { 267 | // Nothing to set or unset. 268 | return; 269 | } 270 | 271 | index = this._indexSequence++; 272 | this._columnIndexes.set(cssFriendlyColumnName, index); 273 | } else { 274 | this._getStyleSheet().deleteRule(index); 275 | } 276 | 277 | const columnClassName = this.getColumnCssClass(cssFriendlyColumnName); 278 | const tableClassName = this.columnResize.getUniqueCssClass(); 279 | 280 | const selector = `.${tableClassName} .${columnClassName}`; 281 | const body = propertyKeys.map(key => `${key}:${properties.get(key)}`).join(';'); 282 | 283 | this._getStyleSheet().insertRule(`${selector} {${body}}`, index!); 284 | } 285 | } 286 | 287 | /** Converts CSS pixel values to numbers, eg "123px" to 123. Returns NaN for non pixel values. */ 288 | function coercePixelsFromCssValue(cssValue: string): number { 289 | return Number(cssValue.match(/(\d+)px/)?.[1]); 290 | } 291 | 292 | /** Gets the style.width pixels on the specified element if present, otherwise its offsetWidth. */ 293 | function getElementWidth(element: HTMLElement) { 294 | // Optimization: Check style.width first as we probably set it already before reading 295 | // offsetWidth which triggers layout. 296 | return coercePixelsFromCssValue(element.style.width) || element.offsetWidth; 297 | } 298 | 299 | /** 300 | * Converts CSS flex values as set in CdkFlexTableResizeStrategy to numbers, 301 | * eg "0 0.01 123px" to 123. 302 | */ 303 | function coercePixelsFromFlexValue(flexValue: string | undefined): number { 304 | return Number(flexValue?.match(/0 0\.01 (\d+)px/)?.[1]); 305 | } 306 | 307 | export const TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER: Provider = { 308 | provide: ResizeStrategy, 309 | useClass: TableLayoutFixedResizeStrategy, 310 | }; 311 | export const FLEX_RESIZE_STRATEGY_PROVIDER: Provider = { 312 | provide: ResizeStrategy, 313 | useClass: CdkFlexTableResizeStrategy, 314 | }; 315 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/column-resize/selectors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | // TODO: Figure out how to remove `mat-` classes from the CDK part of the 10 | // column resize implementation. 11 | 12 | export const HEADER_CELL_SELECTOR = '.cdk-header-cell, .mat-header-cell'; 13 | 14 | export const HEADER_ROW_SELECTOR = '.cdk-header-row, .mat-header-row'; 15 | 16 | export const RESIZE_OVERLAY_SELECTOR = '.mat-column-resize-overlay-thumb'; 17 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/dynamic-table.component.css: -------------------------------------------------------------------------------- 1 | table { 2 | width: 100%; 3 | } 4 | 5 | th .mat-icon.mat-default { 6 | opacity: 0.54; 7 | } 8 | 9 | th button:focus .mat-icon.mat-default { 10 | opacity: 0.8; 11 | } 12 | 13 | th .mat-icon.mat-default:hover { 14 | opacity: 1; 15 | } 16 | 17 | [hidden] { 18 | display: none; 19 | } 20 | 21 | button.mat-sort-header-button { 22 | border: none; 23 | background: 0 0; 24 | display: flex; 25 | align-items: center; 26 | padding: 0; 27 | cursor: inherit; 28 | outline: 0; 29 | font: inherit; 30 | color: currentColor; 31 | position: relative; 32 | } 33 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/dynamic-table.component.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 14 | 15 | 16 | 17 | 18 | 19 |
7 | {{ column.displayName }} 8 | 13 |
20 | 22 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/dynamic-table.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 3 | 4 | import { MatTableDataSource } from '@angular/material/table'; 5 | import { MatTableModule } from '@angular/material/table'; 6 | import { MatSortModule } from '@angular/material/sort'; 7 | import { MatPaginatorModule } from '@angular/material/paginator'; 8 | import { MatIconModule } from '@angular/material/icon'; 9 | import { MatTooltipModule } from '@angular/material/tooltip'; 10 | import { MatDialogModule } from '@angular/material/dialog'; 11 | import { MdtMultiSort } from './multi-sort/multi-sort.directive'; 12 | import { MdtMultiSortHeader } from './multi-sort/multi-sort-header'; 13 | import { MatColumnResizeModule } from './column-resize/column-resize-module'; 14 | 15 | import { DynamicTableComponent } from './dynamic-table.component'; 16 | import { TableCellComponent } from './table-cell/table-cell.component'; 17 | import { ColumnFilterService } from './table-cell/cell-types/column-filter.service'; 18 | 19 | describe('DynamicTableComponent', () => { 20 | let component: DynamicTableComponent; 21 | let fixture: ComponentFixture; 22 | 23 | beforeEach(waitForAsync(() => { 24 | TestBed.configureTestingModule({ 25 | imports: [ 26 | MatTableModule, 27 | MatSortModule, 28 | MatPaginatorModule, 29 | MatIconModule, 30 | MatTooltipModule, 31 | MatDialogModule, 32 | NoopAnimationsModule, 33 | MatColumnResizeModule 34 | ], 35 | declarations: [ 36 | DynamicTableComponent, 37 | TableCellComponent, 38 | MdtMultiSort, 39 | MdtMultiSortHeader 40 | ], 41 | providers: [ 42 | ColumnFilterService 43 | ] 44 | }) 45 | .compileComponents(); 46 | })); 47 | 48 | beforeEach(() => { 49 | fixture = TestBed.createComponent(DynamicTableComponent); 50 | component = fixture.componentInstance; 51 | 52 | component.columns = [ 53 | { 54 | name: 'product', 55 | displayName: 'Product', 56 | type: 'string' 57 | }, 58 | { 59 | name: 'created', 60 | displayName: 'Created Date', 61 | type: 'date', 62 | options: { 63 | dateFormat: 'shortDate' 64 | } 65 | } 66 | ]; 67 | component.dataSource = new MatTableDataSource([]); 68 | 69 | fixture.detectChanges(); 70 | }); 71 | 72 | it('should create', () => { 73 | expect(component).toBeTruthy(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/dynamic-table.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'; 2 | import { MatPaginator } from '@angular/material/paginator'; 3 | import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; 4 | import { DataSource } from '@angular/cdk/table'; 5 | 6 | import { MdtMultiSort } from './multi-sort/multi-sort.directive'; 7 | import { ColumnConfig } from './column-config.model'; 8 | import { ColumnFilter } from './column-filter.model'; 9 | import { ColumnFilterService } from './table-cell/cell-types/column-filter.service'; 10 | 11 | @Component({ 12 | selector: 'mdt-dynamic-table', 13 | templateUrl: './dynamic-table.component.html', 14 | styleUrls: ['./dynamic-table.component.css'] 15 | }) 16 | export class DynamicTableComponent implements OnInit { 17 | 18 | @Input() columns: ColumnConfig[]; 19 | @Input() dataSource: DataSource; 20 | @Input() pageSize = 20; 21 | @Input() pageSizeOptions = [20, 50, 100]; 22 | @Input() showFilters = true; 23 | @Input() stickyHeader = false; 24 | @Input() multiSort = false; 25 | @Input() hintDelay = 500; 26 | @Input() paginator: MatPaginator; 27 | 28 | @Output() rowClick = new EventEmitter(); 29 | 30 | displayedColumns: string[]; 31 | 32 | @ViewChild(MdtMultiSort, { static: true }) sort: MdtMultiSort; 33 | @ViewChild(MatPaginator, { static: true }) private internalPaginator: MatPaginator; 34 | 35 | private appliedFilters: { [key: string]: any; } = {}; 36 | 37 | isDialogOpen = false; 38 | 39 | constructor(private readonly columnFilterService: ColumnFilterService, private readonly dialog: MatDialog) { } 40 | 41 | ngOnInit() { 42 | if (this.dataSource == null) { 43 | throw Error('DynamicTable must be provided with data source.'); 44 | } 45 | if (this.columns == null) { 46 | throw Error('DynamicTable must be provided with column definitions.'); 47 | } 48 | 49 | if (this.paginator === undefined) { 50 | this.paginator = this.internalPaginator; 51 | } 52 | 53 | this.columns.forEach((column, index) => column.name = this.prepareColumnName(column.name, index)); 54 | this.displayedColumns = this.columns.map((column, index) => column.name); 55 | 56 | const dataSource = this.dataSource as any; 57 | dataSource.sort = this.sort; 58 | dataSource.paginator = this.paginator; 59 | } 60 | 61 | isUsingInternalPaginator() { 62 | return this.paginator === this.internalPaginator; 63 | } 64 | 65 | canFilter(column: ColumnConfig) { 66 | const filter = this.columnFilterService.getFilter(column.type); 67 | 68 | return filter != null; 69 | } 70 | 71 | isFiltered(column: ColumnConfig) { 72 | return this.appliedFilters[column.name]; 73 | } 74 | 75 | getFilterDescription(column: ColumnConfig) { 76 | const filter = this.appliedFilters[column.name]; 77 | if (!filter || !filter.getDescription) { 78 | return null; 79 | } 80 | 81 | return filter.getDescription(); 82 | } 83 | 84 | prepareColumnName(name: string | undefined, columnNumber: number) { 85 | return name || 'col' + columnNumber; 86 | } 87 | 88 | onFilterClick(event: Event, column: ColumnConfig): void { 89 | this.filter(column); 90 | event.stopPropagation(); 91 | } 92 | 93 | filter(column: ColumnConfig) { 94 | if (this.isDialogOpen) { 95 | return; 96 | } 97 | 98 | const filter = this.columnFilterService.getFilter(column.type); 99 | 100 | if (filter) { 101 | const dialogConfig = new MatDialogConfig(); 102 | const columnFilter = new ColumnFilter(); 103 | columnFilter.column = column; 104 | 105 | if (this.appliedFilters[column.name]) { 106 | columnFilter.filter = Object.create(this.appliedFilters[column.name]); 107 | } 108 | 109 | dialogConfig.data = columnFilter; 110 | 111 | const dialogRef = this.dialog.open(filter, dialogConfig); 112 | this.isDialogOpen = true; 113 | 114 | dialogRef.afterClosed().subscribe(result => { 115 | if (result) { 116 | this.appliedFilters[column.name] = result; 117 | } else if (result === '') { 118 | delete this.appliedFilters[column.name]; 119 | } 120 | 121 | if (result || result === '') { 122 | this.updateDataSource(); 123 | } 124 | 125 | this.isDialogOpen = false; 126 | }); 127 | } 128 | } 129 | 130 | clearFilters() { 131 | this.appliedFilters = {}; 132 | this.updateDataSource(); 133 | } 134 | 135 | protected updateDataSource() { 136 | const dataSource = this.dataSource as any; 137 | dataSource.filters = this.getFilters(); 138 | } 139 | 140 | getFilters() { 141 | const filters = this.appliedFilters; 142 | const filterArray = Object.keys(filters).map((key) => filters[key]); 143 | return filterArray; 144 | } 145 | 146 | getFilter(columnName: string): any { 147 | const filterColumn = this.getColumnByName(columnName); 148 | 149 | if (!filterColumn) { 150 | throw Error(`Column with name '${columnName}' does not exist.`); 151 | } 152 | 153 | return this.appliedFilters[filterColumn.name]; 154 | } 155 | 156 | setFilter(columnName: string, filter: any) { 157 | const filterColumn = this.getColumnByName(columnName); 158 | 159 | if (!filterColumn) { 160 | throw Error(`Cannot set filter for a column. Column with name '${columnName}' does not exist.`); 161 | } 162 | 163 | this.appliedFilters[filterColumn.name] = filter; 164 | this.updateDataSource(); 165 | } 166 | 167 | getSort() { 168 | return this.sort.sortedBy; 169 | } 170 | 171 | setSort(sortedBy: { id: string, direction: 'asc' | 'desc' }[]) { 172 | this.sort.sortedBy = sortedBy; 173 | } 174 | 175 | private getColumnByName(columnName: string): ColumnConfig | undefined { 176 | return this.columns.find(c => 177 | (c.name ? c.name.toLowerCase() : c.name) === (columnName ? columnName.toLowerCase() : columnName) 178 | ); 179 | } 180 | 181 | onRowClick(row: any) { 182 | this.rowClick.next(row); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/dynamic-table.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { MatColumnResizeModule } from './column-resize/column-resize-module'; 5 | 6 | import { MatTableModule } from '@angular/material/table'; 7 | import { MatSortModule } from '@angular/material/sort'; 8 | import { MatPaginatorModule } from '@angular/material/paginator'; 9 | import { MatIconModule } from '@angular/material/icon'; 10 | import { MatDialogModule } from '@angular/material/dialog'; 11 | import { MatTooltipModule } from '@angular/material/tooltip'; 12 | import { MdtMultiSort } from './multi-sort/multi-sort.directive'; 13 | import { MdtMultiSortHeader } from './multi-sort/multi-sort-header'; 14 | 15 | import { DynamicTableComponent } from './dynamic-table.component'; 16 | import { TableCellComponent } from './table-cell/table-cell.component'; 17 | 18 | import { CellService } from './table-cell/cell-types/cell.service'; 19 | import { CellDirective } from './table-cell/cell.directive'; 20 | import { ColumnFilterService } from './table-cell/cell-types/column-filter.service'; 21 | 22 | export { CellService, CellDirective, ColumnFilterService }; 23 | export { CellComponent } from './table-cell/cell-types/cell.component'; 24 | export { ColumnFilter } from './column-filter.model'; 25 | export { ColumnConfig } from './column-config.model'; 26 | export { FilterDescription } from './filter-description'; 27 | 28 | import { TextCellComponent } from './table-cell/cell-types/text-cell.component'; 29 | import { DateCellComponent } from './table-cell/cell-types/date-cell.component'; 30 | 31 | @NgModule({ 32 | imports: [ 33 | CommonModule, 34 | MatTableModule, 35 | MatSortModule, 36 | MatPaginatorModule, 37 | MatIconModule, 38 | MatDialogModule, 39 | MatTooltipModule, 40 | MatColumnResizeModule 41 | ], 42 | declarations: [ 43 | DynamicTableComponent, 44 | TableCellComponent, 45 | CellDirective, 46 | TextCellComponent, 47 | DateCellComponent, 48 | MdtMultiSort, 49 | MdtMultiSortHeader 50 | ], 51 | exports: [DynamicTableComponent, MdtMultiSort, MatColumnResizeModule], 52 | providers: [ 53 | CellService, 54 | ColumnFilterService 55 | ] 56 | }) 57 | export class DynamicTableModule { 58 | constructor(private readonly cellService: CellService) { 59 | cellService.registerCell('string', TextCellComponent); 60 | cellService.registerCell('date', DateCellComponent); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/filter-description.ts: -------------------------------------------------------------------------------- 1 | export interface FilterDescription { 2 | getDescription(): string | null; 3 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/multi-sort/multi-sort-data-source.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from '@angular/cdk/collections'; 2 | import { _isNumberValue } from '@angular/cdk/coercion'; 3 | import { Sort } from '@angular/material/sort'; 4 | import { MatPaginator, PageEvent } from '@angular/material/paginator'; 5 | import { Observable, BehaviorSubject, Subject, Subscription, of, merge, combineLatest } from 'rxjs'; 6 | import { map } from 'rxjs/operators'; 7 | 8 | import { MdtMultiSort } from './multi-sort.directive'; 9 | import { TableFilter } from './table-filter'; 10 | 11 | const MAX_SAFE_INTEGER = 9007199254740991; 12 | 13 | export class MdtTableDataSource extends DataSource { 14 | 15 | private readonly _data: BehaviorSubject; 16 | 17 | /** Stream emitting render data to the table (depends on ordered data changes). */ 18 | private readonly _renderData = new BehaviorSubject([]); 19 | 20 | /** Stream that emits when a new filter string is set on the data source. */ 21 | private readonly _filter = new BehaviorSubject(''); 22 | 23 | private readonly _filters = new BehaviorSubject([]); 24 | 25 | /** Used to react to internal changes of the paginator that are made by the data source itself. */ 26 | private readonly _internalPageChanges = new Subject(); 27 | 28 | _renderChangesSubscription: Subscription | null = null; 29 | 30 | filteredData: T[]; 31 | 32 | constructor(initialData: T[] = []) { 33 | super(); 34 | this._data = new BehaviorSubject(initialData); 35 | this._updateChangeSubscription(); 36 | } 37 | 38 | /** Array of data that should be rendered by the table, where each object represents one row. */ 39 | get data() { 40 | return this._data.value; 41 | } 42 | 43 | set data(data: T[]) { 44 | data = Array.isArray(data) ? data : []; 45 | this._data.next(data); 46 | if (!this._renderChangesSubscription) { 47 | this._filterData(data); 48 | } 49 | } 50 | 51 | get filter(): string { 52 | return this._filter.value; 53 | } 54 | 55 | set filter(filter: string) { 56 | this._filter.next(filter); 57 | if (!this._renderChangesSubscription) { 58 | this._filterData(this.data); 59 | } 60 | } 61 | 62 | get filters(): TableFilter[] { 63 | return this._filters.value; 64 | } 65 | set filters(filters: TableFilter[]) { 66 | this._filters.next(filters); 67 | this._updateChangeSubscription(); 68 | } 69 | 70 | get sort(): MdtMultiSort | null { return this._multiSort; } 71 | set sort(sort: MdtMultiSort | null) { 72 | this._multiSort = sort; 73 | this._updateChangeSubscription(); 74 | } 75 | private _multiSort: MdtMultiSort | null; 76 | 77 | get paginator(): MatPaginator | null { 78 | return this._paginator; 79 | } 80 | set paginator(paginator: MatPaginator | null) { 81 | this._paginator = paginator; 82 | this._updateChangeSubscription(); 83 | } 84 | private _paginator: MatPaginator | null; 85 | 86 | sortingDataAccessor: (data: T, sortHeaderId: string) => string | number = ( 87 | data: T, 88 | sortHeaderId: string, 89 | ): string | number => { 90 | const value = (data as unknown as Record)[sortHeaderId]; 91 | 92 | if (_isNumberValue(value)) { 93 | const numberValue = Number(value); 94 | 95 | // Numbers beyond `MAX_SAFE_INTEGER` can't be compared reliably so we 96 | // leave them as strings. For more info: https://goo.gl/y5vbSg 97 | return numberValue < MAX_SAFE_INTEGER ? numberValue : value; 98 | } 99 | 100 | return value; 101 | }; 102 | 103 | connect() { 104 | if (!this._renderChangesSubscription) { 105 | this._updateChangeSubscription(); 106 | } 107 | 108 | return this._renderData; 109 | } 110 | 111 | disconnect() { 112 | this._renderChangesSubscription?.unsubscribe(); 113 | this._renderChangesSubscription = null; 114 | } 115 | 116 | _updateChangeSubscription() { 117 | const sortChange: Observable = this._multiSort 118 | ? (merge(this._multiSort.multiSortChange, this._multiSort.initialized) as Observable) 119 | : of(null); 120 | const pageChange: Observable = this._paginator 121 | ? (merge( 122 | this._paginator.page, 123 | this._internalPageChanges, 124 | this._paginator.initialized, 125 | ) as Observable) 126 | : of(null); 127 | const dataStream = this._data; 128 | // Watch for base data or filter changes to provide a filtered set of data. 129 | const filteredData = combineLatest([dataStream, this._filter]).pipe( 130 | map(([data]) => this._filterData(data)), 131 | ); 132 | const filteredData2 = combineLatest([filteredData, this._filters]).pipe( 133 | map(([data]) => this._filterData(data)), 134 | ); 135 | // Watch for filtered data or sort changes to provide an ordered set of data. 136 | const orderedData = combineLatest([filteredData2, sortChange]).pipe( 137 | map(([data]) => this._orderData(data)), 138 | ); 139 | // Watch for ordered data or page changes to provide a paged set of data. 140 | const paginatedData = combineLatest([orderedData, pageChange]).pipe( 141 | map(([data]) => this._pageData(data)), 142 | ); 143 | // Watched for paged data changes and send the result to the table to render. 144 | this._renderChangesSubscription?.unsubscribe(); 145 | this._renderChangesSubscription = paginatedData.subscribe(data => this._renderData.next(data)); 146 | } 147 | 148 | _filterData(data: T[]) { 149 | // If there is a filter string, filter out data that does not contain it. 150 | // Each data object is converted to a string using the function defined by filterPredicate. 151 | // May be overridden for customization. 152 | this.filteredData = 153 | (this.filter == null || this.filter === '') && !this.filters?.length 154 | ? data 155 | : data.filter(obj => this.filterPredicate(obj, this.filter)); 156 | 157 | if (this.paginator) { 158 | this._updatePaginator(this.filteredData.length); 159 | } 160 | 161 | return this.filteredData; 162 | } 163 | 164 | filterPredicate: (data: T, filter: string) => boolean = (data: T, filter: string): boolean => { 165 | const dataStr = Object.keys(data as unknown as Record) 166 | .reduce((currentTerm: string, key: string) => { 167 | return currentTerm + (data as unknown as Record)[key] + '◬'; 168 | }, '') 169 | .toLowerCase(); 170 | 171 | const transformedFilter = filter.trim().toLowerCase(); 172 | 173 | return dataStr.indexOf(transformedFilter) != -1; 174 | }; 175 | 176 | _pageData(data: T[]): T[] { 177 | if (!this.paginator) { 178 | return data; 179 | } 180 | 181 | const startIndex = this.paginator.pageIndex * this.paginator.pageSize; 182 | return data.slice(startIndex, startIndex + this.paginator.pageSize); 183 | } 184 | 185 | _orderData(data: T[]): T[] { 186 | // If there is no active sort or direction, return the data without trying to sort. 187 | if (!this.sort) { 188 | return data; 189 | } 190 | 191 | return this.sortData(data.slice(), this.sort); 192 | } 193 | 194 | sortData: ((data: T[], sort: MdtMultiSort) => T[]) = 195 | (data: T[], sort: MdtMultiSort): T[] => { 196 | let sortedBy = sort.sortedBy; 197 | if (!Array.isArray(sortedBy) || !sortedBy.length) { 198 | return data; 199 | } 200 | 201 | return data.sort((a, b) => { 202 | // Get effective sort value after comparing all sorted properties, if values were equal for 203 | // previous propery then compare the next pair 204 | return sortedBy.reduce((previous, sort) => { 205 | if (previous !== 0) { 206 | return previous; 207 | } 208 | 209 | let valueA = this.sortingDataAccessor(a, sort.id); 210 | let valueB = this.sortingDataAccessor(b, sort.id); 211 | 212 | return this.compareValues(valueA, valueB, sort.direction); 213 | }, 0); 214 | }); 215 | } 216 | 217 | compareValues(valueA: string | number, valueB: string | number, direction: string) { 218 | let comparatorResult = 0; 219 | if (direction == '') { 220 | return comparatorResult; 221 | } 222 | 223 | if (valueA != null && valueB != null) { 224 | // Check if one value is greater than the other; if equal, comparatorResult should remain 0. 225 | if (valueA > valueB) { 226 | comparatorResult = 1; 227 | } else if (valueA < valueB) { 228 | comparatorResult = -1; 229 | } 230 | } else if (valueA != null) { 231 | comparatorResult = 1; 232 | } else if (valueB != null) { 233 | comparatorResult = -1; 234 | } 235 | 236 | return comparatorResult * (direction == 'asc' ? 1 : -1); 237 | } 238 | 239 | _updatePaginator(filteredDataLength: number) { 240 | Promise.resolve().then(() => { 241 | const paginator = this.paginator; 242 | 243 | if (!paginator) { 244 | return; 245 | } 246 | 247 | paginator.length = filteredDataLength; 248 | 249 | // If the page index is set beyond the page, reduce it to the last page. 250 | if (paginator.pageIndex > 0) { 251 | const lastPageIndex = Math.ceil(paginator.length / paginator.pageSize) - 1 || 0; 252 | const newPageIndex = Math.min(paginator.pageIndex, lastPageIndex); 253 | 254 | if (newPageIndex !== paginator.pageIndex) { 255 | paginator.pageIndex = newPageIndex; 256 | 257 | // Since the paginator only emits after user-generated changes, 258 | // we need our own stream so we know to should re-render the data. 259 | this._internalPageChanges.next(); 260 | } 261 | } 262 | }); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/multi-sort/multi-sort-header.html: -------------------------------------------------------------------------------- 1 |
6 | 7 |
8 | 9 |
10 | 11 | 12 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {{ _getSortCounter() }} 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/multi-sort/multi-sort-header.scss: -------------------------------------------------------------------------------- 1 | $header-arrow-margin: 6px; 2 | $header-arrow-container-size: 12px; 3 | $header-arrow-stem-size: 10px; 4 | $header-arrow-pointer-length: 6px; 5 | $header-arrow-thickness: 2px; 6 | $header-arrow-hint-opacity: 0.38; 7 | 8 | .mat-sort-header-container { 9 | display: flex; 10 | cursor: pointer; 11 | align-items: center; 12 | letter-spacing: normal; 13 | // Needs to be reset since we don't want an outline around the inner 14 | // div which is focusable. We have our own alternate focus styling. 15 | outline: 0; 16 | // Usually we could rely on the arrow showing up to be focus indication, but if a header is 17 | // active, the arrow will always be shown so the user has no way of telling the difference. 18 | [mat-sort-header].cdk-keyboard-focused &, 19 | [mat-sort-header].cdk-program-focused & { 20 | border-bottom: solid 1px currentColor; 21 | } 22 | 23 | .mat-sort-header-disabled & { 24 | cursor: default; 25 | } 26 | // For the sort-header element, default inset/offset values are necessary to ensure that 27 | // the focus indicator is sufficiently contrastive and renders appropriately. 28 | &::before { 29 | $border-width: var(--mat-focus-indicator-border-width, 1px); 30 | $offset: calc(#{$border-width} + 2px); 31 | margin: calc(#{$offset} * -1); 32 | } 33 | } 34 | 35 | .mat-sort-header-content { 36 | text-align: center; 37 | display: flex; 38 | align-items: center; 39 | } 40 | 41 | .mat-sort-header-position-before { 42 | flex-direction: row-reverse; 43 | } 44 | 45 | .mat-sort-header-arrow { 46 | height: $header-arrow-container-size; 47 | width: $header-arrow-container-size; 48 | min-width: $header-arrow-container-size; 49 | position: relative; 50 | display: flex; 51 | opacity: 0; 52 | 53 | &, 54 | [dir='rtl'] .mat-sort-header-position-before & { 55 | margin: 0 0 0 $header-arrow-margin; 56 | } 57 | 58 | .mat-sort-header-position-before &, 59 | [dir='rtl'] & { 60 | margin: 0 $header-arrow-margin 0 0; 61 | } 62 | } 63 | 64 | .mat-sort-header-stem { 65 | background: currentColor; 66 | height: $header-arrow-stem-size; 67 | width: $header-arrow-thickness; 68 | margin: auto; 69 | display: flex; 70 | align-items: center; 71 | } 72 | 73 | .mat-sort-header-indicator { 74 | width: 100%; 75 | height: $header-arrow-thickness; 76 | display: flex; 77 | align-items: center; 78 | position: absolute; 79 | top: 0; 80 | left: 0; 81 | } 82 | 83 | .mat-sort-header-pointer-middle { 84 | margin: auto; 85 | height: $header-arrow-thickness; 86 | width: $header-arrow-thickness; 87 | background: currentColor; 88 | transform: rotate(45deg); 89 | } 90 | 91 | .mat-sort-header-pointer-left, 92 | .mat-sort-header-pointer-right { 93 | background: currentColor; 94 | width: $header-arrow-pointer-length; 95 | height: $header-arrow-thickness; 96 | position: absolute; 97 | top: 0; 98 | } 99 | 100 | .mat-sort-header-pointer-left { 101 | transform-origin: right; 102 | left: 0; 103 | } 104 | 105 | .mat-sort-header-pointer-right { 106 | transform-origin: left; 107 | right: 0; 108 | } 109 | 110 | .mat-sort-header-counter { 111 | position: absolute; 112 | margin-top: 0px; 113 | margin-left: 13px; 114 | } 115 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/multi-sort/multi-sort-header.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | ChangeDetectorRef, 4 | Component, 5 | Input, 6 | OnDestroy, 7 | OnInit, 8 | Optional, 9 | ViewEncapsulation, 10 | Inject, 11 | ElementRef 12 | } from '@angular/core'; 13 | import { MdtMultiSort } from './multi-sort.directive'; 14 | import { SortDirection, MatSortable, matSortAnimations, MatSortHeader, MatSortHeaderIntl } from '@angular/material/sort'; 15 | import { FocusMonitor } from '@angular/cdk/a11y'; 16 | 17 | /** 18 | * Valid positions for the arrow to be in for its opacity and translation. If the state is a 19 | * sort direction, the position of the arrow will be above/below and opacity 0. If the state is 20 | * hint, the arrow will be in the center with a slight opacity. Active state means the arrow will 21 | * be fully opaque in the center. 22 | * 23 | * @docs-private 24 | */ 25 | export type ArrowViewState = SortDirection | 'hint' | 'active'; 26 | 27 | /** 28 | * States describing the arrow's animated position (animating fromState to toState). 29 | * If the fromState is not defined, there will be no animated transition to the toState. 30 | * @docs-private 31 | */ 32 | export interface ArrowViewStateTransition { 33 | fromState?: ArrowViewState; 34 | toState: ArrowViewState; 35 | } 36 | 37 | /** Column definition associated with a `MatSortHeader`. */ 38 | interface MatSortHeaderColumnDef { 39 | name: string; 40 | } 41 | 42 | /** 43 | * Applies sorting behavior (click to change sort) and styles to an element, including an 44 | * arrow to display the current sort direction. 45 | * 46 | * Must be provided with an id and contained within a parent MatSort directive. 47 | * 48 | * If used on header cells in a CdkTable, it will automatically default its id from its containing 49 | * column definition. 50 | */ 51 | @Component({ 52 | selector: '[mdt-sort-header]', 53 | exportAs: 'mdtSortHeader', 54 | templateUrl: 'multi-sort-header.html', 55 | styleUrls: ['multi-sort-header.scss'], 56 | encapsulation: ViewEncapsulation.None, 57 | changeDetection: ChangeDetectionStrategy.OnPush, 58 | inputs: ['disabled'], 59 | animations: [ 60 | matSortAnimations.indicator, 61 | matSortAnimations.leftPointer, 62 | matSortAnimations.rightPointer, 63 | matSortAnimations.arrowOpacity, 64 | matSortAnimations.arrowPosition, 65 | matSortAnimations.allowChildren, 66 | ] 67 | }) 68 | export class MdtMultiSortHeader extends MatSortHeader implements MatSortable, OnDestroy, OnInit { 69 | 70 | /** 71 | * ID of this sort header. If used within the context of a CdkColumnDef, this will default to 72 | * the column's name. 73 | */ 74 | @Input('mdt-sort-header') id: string; 75 | 76 | /** Overrides the sort start value of the containing MatSort for this MatSortable. */ 77 | @Input() start: 'asc' | 'desc' = 'asc'; 78 | 79 | private _sortHeader: MdtMultiSort; 80 | 81 | constructor(public _intl: MatSortHeaderIntl, 82 | changeDetectorRef: ChangeDetectorRef, 83 | @Optional() public _multiSort: MdtMultiSort, 84 | @Inject('MAT_SORT_HEADER_COLUMN_DEF') @Optional() public _columnDef: MatSortHeaderColumnDef, 85 | _focusMonitor: FocusMonitor, 86 | _elementRef: ElementRef) { 87 | // Note that we use a string token for the `_columnDef`, because the value is provided both by 88 | // `material/table` and `cdk/table` and we can't have the CDK depending on Material, 89 | // and we want to avoid having the sort header depending on the CDK table because 90 | // of this single reference. 91 | super(_intl, changeDetectorRef, _multiSort, _columnDef, _focusMonitor, _elementRef); 92 | this._sortHeader = _multiSort; 93 | } 94 | 95 | _handleClick() { 96 | //this._sort.direction = this.getSortDirection(); 97 | super._handleClick(); 98 | } 99 | 100 | /** Whether this MatSortHeader is currently sorted in either ascending or descending order. */ 101 | _isSorted() { 102 | if (!this._sortHeader.sortedBy) { 103 | return false; 104 | } 105 | 106 | let sort = this._sortHeader.sortedBy.find(s => s.id === this.id); 107 | return !!sort; 108 | } 109 | 110 | /** 111 | * Updates the direction the arrow should be pointing. If it is not sorted, the arrow should be 112 | * facing the start direction. Otherwise if it is sorted, the arrow should point in the currently 113 | * active sorted direction. The reason this is updated through a function is because the direction 114 | * should only be changed at specific times - when deactivated but the hint is displayed and when 115 | * the sort is active and the direction changes. Otherwise the arrow's direction should linger 116 | * in cases such as the sort becoming deactivated but we want to animate the arrow away while 117 | * preserving its direction, even though the next sort direction is actually different and should 118 | * only be changed once the arrow displays again (hint or activation). 119 | */ 120 | _updateArrowDirection() { 121 | this._arrowDirection = this.getSortDirection(); 122 | } 123 | 124 | /** 125 | * Gets the aria-sort attribute that should be applied to this sort header. If this header 126 | * is not sorted, returns null so that the attribute is removed from the host element. Aria spec 127 | * says that the aria-sort property should only be present on one header at a time, so removing 128 | * ensures this is true. 129 | */ 130 | _getAriaSortAttribute() { 131 | if (!this._isSorted()) { 132 | return 'none'; 133 | } 134 | 135 | let sort = this._sortHeader.sortedBy.find(s => s.id === this.id)!; 136 | return sort.direction == 'asc' ? 'ascending' : 'descending'; 137 | } 138 | 139 | getSortDirection(): 'asc' | 'desc' | '' { 140 | if (!this._isSorted()) { 141 | return ''; 142 | } 143 | 144 | let sort = this._sortHeader.sortedBy.find(s => s.id === this.id)!; 145 | return sort.direction; 146 | } 147 | 148 | /** 149 | * Gets the sort counter that will display whenever multisort is enabled. It shows the order 150 | * in which sort is applied, whenever there are multiple columns being used for sorting. 151 | */ 152 | _getSortCounter(): string { 153 | if (!this._sortHeader.sortedBy || this._sortHeader.mode !== 'multi') { 154 | return ''; 155 | } 156 | const index = this._sortHeader.sortedBy.findIndex(s => s.id === this.id); 157 | if (index === -1) { 158 | return ''; 159 | } 160 | 161 | return (index + 1).toString(); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/multi-sort/multi-sort.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | EventEmitter, 4 | Input, 5 | OnChanges, 6 | OnDestroy, 7 | OnInit, 8 | Output, 9 | } from '@angular/core'; 10 | import { MatSort, MatSortable, SortDirection } from '@angular/material/sort'; 11 | import { MdtMultiSortHeader } from './multi-sort-header'; 12 | 13 | /** The current sort state. */ 14 | export interface MultiSort { 15 | sortedBy: { id: string, direction: 'asc' | 'desc' }[]; 16 | } 17 | 18 | /** Container for MatSortables to manage the sort state and provide default sort parameters. */ 19 | @Directive({ 20 | selector: '[mdtMultiSort]', 21 | exportAs: 'mdtMultiSort', 22 | inputs: ['disabled: matSortDisabled'] 23 | }) 24 | export class MdtMultiSort extends MatSort implements OnChanges, OnDestroy, OnInit { 25 | 26 | /** 27 | * The array of active sort ids. Order defines sorting precedence. 28 | */ 29 | @Input('mdtSortActive') 30 | get sortedBy() { 31 | return this._sortedBy; 32 | } 33 | set sortedBy(sortedBy: { id: string, direction: 'asc' | 'desc' }[]) { 34 | this._sortedBy = sortedBy; 35 | let sort = sortedBy ? sortedBy[0] : undefined; 36 | let sortedValue = sort ? { active: sort.id, direction: sort.direction } : undefined; 37 | this.sortChange.emit(sortedValue); 38 | this.multiSortChange.emit({ sortedBy: this._sortedBy }); 39 | } 40 | 41 | private _sortedBy: { id: string, direction: 'asc' | 'desc' }[] 42 | 43 | start: 'asc' | 'desc' = 'asc'; 44 | 45 | @Input('mode') mode: 'single' | 'multi' = 'single'; 46 | 47 | isSortDirectionValid(direction: { [id: string]: SortDirection }): boolean { 48 | return Object.keys(direction).every((id) => this.isIndividualSortDirectionValid(direction[id])); 49 | } 50 | 51 | isIndividualSortDirectionValid(direction: string): boolean { 52 | return !direction || direction === 'asc' || direction === 'desc'; 53 | } 54 | 55 | /** Event emitted when the user changes either the active sort or sort direction. */ 56 | @Output('matSortChange') 57 | readonly multiSortChange: EventEmitter = new EventEmitter(); 58 | 59 | /** Sets the active sort id and determines the new sort direction. */ 60 | sort(sortable: MatSortable): void { 61 | if (!Array.isArray(this.sortedBy)) { 62 | let direction = sortable.start ? sortable.start : this.start; 63 | this._sortedBy = [{ 64 | id: sortable.id, 65 | direction: direction 66 | }]; 67 | } else { 68 | const sort = this._sortedBy.find(s => s.id === sortable.id); 69 | if (sort) { 70 | this.direction = sort.direction; 71 | let nextDirection = this.getNextSortDirection(sortable); 72 | if (nextDirection) { 73 | sort.direction = nextDirection; 74 | } else { 75 | let index = this._sortedBy.indexOf(sort); 76 | this._sortedBy.splice(index, 1); 77 | } 78 | } else { 79 | let newSort = { 80 | id: sortable.id, 81 | direction: sortable.start ? sortable.start : this.start 82 | } 83 | if (this.mode === 'multi') { 84 | this._sortedBy.push(newSort); 85 | } else { 86 | this._sortedBy = [newSort] 87 | } 88 | } 89 | } 90 | 91 | this.multiSortChange.emit({ sortedBy: this._sortedBy }); 92 | super.sort(sortable); 93 | } 94 | 95 | ngOnInit() { 96 | super.ngOnInit(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/multi-sort/table-filter.ts: -------------------------------------------------------------------------------- 1 | export interface TableFilter { 2 | getFilter(): object; 3 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell-types/cell.component.ts: -------------------------------------------------------------------------------- 1 | import { ColumnConfig } from '../../column-config.model'; 2 | 3 | export interface CellComponent { 4 | column: ColumnConfig; 5 | row: object; 6 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell-types/cell.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CellService } from './cell.service'; 4 | 5 | describe('CellService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({ 7 | providers: [ 8 | CellService 9 | ] 10 | })); 11 | 12 | it('should be created', () => { 13 | const service: CellService = TestBed.get(CellService); 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell-types/cell.service.ts: -------------------------------------------------------------------------------- 1 | import { Type, Injectable } from '@angular/core'; 2 | import { TextCellComponent } from './text-cell.component'; 3 | 4 | @Injectable() 5 | export class CellService { 6 | 7 | private registeredCells: { [key: string]: Type; } = {}; 8 | 9 | registerCell(type: string, component: Type) { 10 | this.registeredCells[type] = component; 11 | } 12 | 13 | getCell(type: string): Type { 14 | const component = this.registeredCells[type]; 15 | 16 | if (component == null) { 17 | return TextCellComponent; 18 | } 19 | 20 | return component; 21 | } 22 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell-types/column-filter.service.ts: -------------------------------------------------------------------------------- 1 | import { Type, Injectable } from '@angular/core'; 2 | 3 | @Injectable() 4 | export class ColumnFilterService { 5 | 6 | private registeredFilters: { [key: string]: Type; } = {}; 7 | 8 | registerFilter(type: string, component: Type) { 9 | this.registeredFilters[type] = component; 10 | } 11 | 12 | getFilter(type: string): Type { 13 | const component = this.registeredFilters[type]; 14 | 15 | return component; 16 | } 17 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell-types/date-cell.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { CellComponent } from './cell.component'; 3 | import { ColumnConfig } from '../../column-config.model'; 4 | 5 | @Component({ 6 | selector: 'mdt-date-cell', 7 | template: '{{ row[column.name] | date:dateFormat }}' 8 | }) 9 | export class DateCellComponent implements CellComponent, OnInit { 10 | @Input() column: ColumnConfig; 11 | @Input() row: object; 12 | 13 | dateFormat = 'short'; 14 | 15 | ngOnInit() { 16 | if (this.column.options) { 17 | if (this.column.options.dateFormat) { 18 | this.dateFormat = this.column.options.dateFormat; 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell-types/text-cell.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CellComponent } from './cell.component'; 3 | import { ColumnConfig } from '../../column-config.model'; 4 | 5 | @Component({ 6 | selector: 'mdt-text-cell', 7 | template: '{{ row[column.name] }}' 8 | }) 9 | export class TextCellComponent implements CellComponent { 10 | @Input() column: ColumnConfig; 11 | @Input() row: object; 12 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/cell.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ViewContainerRef } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[mdtCellHost]', 5 | }) 6 | export class CellDirective { 7 | constructor(public viewContainerRef: ViewContainerRef) {} 8 | } -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/lib/table-cell/table-cell.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ComponentFactoryResolver, Input, ViewChild, OnInit, ChangeDetectionStrategy } from '@angular/core'; 2 | import { CellDirective } from './cell.directive'; 3 | import { CellService } from './cell-types/cell.service'; 4 | import { CellComponent } from './cell-types/cell.component'; 5 | import { ColumnConfig } from '../column-config.model'; 6 | 7 | @Component({ 8 | selector: 'mdt-table-cell', 9 | template: '', 10 | changeDetection: ChangeDetectionStrategy.OnPush 11 | }) 12 | export class TableCellComponent implements OnInit { 13 | @ViewChild(CellDirective, { static: true }) cellHost: CellDirective; 14 | 15 | @Input() row: object; 16 | @Input() column: ColumnConfig; 17 | 18 | constructor( 19 | private readonly cellService: CellService, 20 | private readonly componentFactoryResolver: ComponentFactoryResolver) { } 21 | 22 | ngOnInit() { 23 | this.initCell(); 24 | } 25 | 26 | initCell() { 27 | const cellComponent = this.cellService.getCell(this.column.type); 28 | const componentFactory = this.componentFactoryResolver.resolveComponentFactory(cellComponent); 29 | const viewContainerRef = this.cellHost.viewContainerRef; 30 | viewContainerRef.clear(); 31 | const componentRef = viewContainerRef.createComponent(componentFactory); 32 | const cell = componentRef.instance as CellComponent; 33 | cell.row = this.row; 34 | cell.column = this.column; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/public_api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of dynamic-table 3 | */ 4 | 5 | export * from './lib/dynamic-table.component'; 6 | export * from './lib/multi-sort/multi-sort.directive'; 7 | export * from './lib/multi-sort/multi-sort-data-source'; 8 | export * from './lib/multi-sort/table-filter'; 9 | export * from './lib/column-resize/resizable-directives/resizable'; 10 | export * from './lib/column-resize/column-resize-directives/column-resize'; 11 | export * from './lib/column-resize/column-resize-module'; 12 | export * from './lib/column-resize/overlay-handle'; 13 | export * from './lib/dynamic-table.module'; 14 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | // First, initialize the Angular testing environment. 12 | getTestBed().initTestEnvironment( 13 | BrowserDynamicTestingModule, 14 | platformBrowserDynamicTesting() 15 | ); 16 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "module": "es2022", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "importHelpers": true, 14 | "types": [], 15 | "lib": [ 16 | "dom", 17 | "es2022" 18 | ] 19 | }, 20 | "angularCompilerOptions": { 21 | "annotateForClosureCompiler": true, 22 | "skipTemplateCodegen": true, 23 | "strictMetadataEmit": true, 24 | "fullTemplateTypeCheck": true, 25 | "strictInjectionParameters": true, 26 | "enableResourceInlining": true 27 | }, 28 | "exclude": [ 29 | "src/test.ts", 30 | "**/*.spec.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": true, 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/material-dynamic-table/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "mdt", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "mdt", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # 5 | # For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed 6 | 7 | > 0.5% 8 | last 2 versions 9 | Firefox ESR 10 | not dead 11 | not IE 9-11 -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .table-container { 2 | height: 300px; 3 | overflow: auto; 4 | } 5 | 6 | .buttons { 7 | margin-top: 10px; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Welcome to {{ title }}! 5 |

6 | Angular Logo 7 |
8 | 9 | 11 | 12 |
13 | 15 |
16 | 17 |
18 | 19 | 20 |
-------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | import { DynamicTableModule } from 'material-dynamic-table'; 4 | import { MatPaginatorModule } from '@angular/material/paginator'; 5 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 6 | 7 | describe('AppComponent', () => { 8 | beforeEach(waitForAsync(() => { 9 | TestBed.configureTestingModule({ 10 | declarations: [ 11 | AppComponent 12 | ], 13 | imports: [ 14 | DynamicTableModule, 15 | MatPaginatorModule, 16 | NoopAnimationsModule 17 | ], 18 | }).compileComponents(); 19 | })); 20 | 21 | it('should create the app', waitForAsync(() => { 22 | const fixture = TestBed.createComponent(AppComponent); 23 | const app = fixture.debugElement.componentInstance; 24 | expect(app).toBeTruthy(); 25 | })); 26 | 27 | it(`should have as title 'material-dynamic-table-demo'`, waitForAsync(() => { 28 | const fixture = TestBed.createComponent(AppComponent); 29 | const app = fixture.debugElement.componentInstance; 30 | expect(app.title).toEqual('material-dynamic-table-demo'); 31 | })); 32 | 33 | it('should render title in a h1 tag', waitForAsync(() => { 34 | const fixture = TestBed.createComponent(AppComponent); 35 | fixture.detectChanges(); 36 | const compiled = fixture.debugElement.nativeElement; 37 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to material-dynamic-table-demo!'); 38 | })); 39 | }); 40 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild } from '@angular/core'; 2 | import { MatPaginator } from '@angular/material/paginator'; 3 | import { FilteredDataSource } from './data-source/filtered-data-source'; 4 | import { ColumnConfig, DynamicTableComponent } from 'material-dynamic-table'; 5 | import { Product } from './product'; 6 | import { TextFilter } from './filters/text-filter/text-filter.model'; 7 | import { DateFilter } from './filters/date-filter/date-filter.model'; 8 | 9 | @Component({ 10 | selector: 'ld-root', 11 | templateUrl: './app.component.html', 12 | styleUrls: ['./app.component.css'] 13 | }) 14 | export class AppComponent { 15 | title = 'material-dynamic-table-demo'; 16 | 17 | @ViewChild(DynamicTableComponent, { static: true }) dynamicTable: DynamicTableComponent; 18 | 19 | @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; 20 | 21 | columns: ColumnConfig[] = [ 22 | { 23 | name: 'product', 24 | displayName: 'Product', 25 | type: 'string', 26 | sticky: 'start', 27 | hint: 'Product name' 28 | }, 29 | { 30 | name: 'description', 31 | displayName: 'Description', 32 | type: 'string', 33 | sort: false, 34 | hint: 'Product Description', 35 | resizable: { minWidth: 120 } 36 | }, 37 | { 38 | name: 'category', 39 | displayName: 'Category', 40 | type: 'string', 41 | resizable: { maxWidth: 200 } 42 | }, 43 | { 44 | name: 'recievedOn', 45 | displayName: 'Recieved On', 46 | type: 'date', 47 | hint: 'Date product was received on', 48 | resizable: { minWidth: 130, maxWidth: 200 } 49 | }, 50 | { 51 | name: 'created', 52 | displayName: 'Created Date', 53 | type: 'date', 54 | options: { 55 | dateFormat: 'shortDate' 56 | }, 57 | hint: 'Date entry was created', 58 | resizable: true 59 | }, 60 | { 61 | name: '', 62 | type: 'options', 63 | sticky: 'end', 64 | sort: false 65 | } 66 | ]; 67 | 68 | data: Product[] = [ 69 | { 70 | product: 'Mouse', 71 | description: 'Fast and wireless', 72 | category: 'Peripherals', 73 | recievedOn: new Date('2018-01-02T11:05:53.212Z'), 74 | created: new Date('2015-04-22T18:12:21.111Z') 75 | }, 76 | { 77 | product: 'Keyboard', 78 | description: 'Loud and Mechanical', 79 | category: 'Peripherals', 80 | recievedOn: new Date('2018-06-09T12:08:23.511Z'), 81 | created: new Date('2015-03-11T11:44:11.431Z') 82 | }, 83 | { 84 | product: 'Laser', 85 | description: 'It\'s bright', 86 | category: 'Space', 87 | recievedOn: new Date('2017-05-22T18:25:43.511Z'), 88 | created: new Date('2015-04-21T17:15:23.111Z') 89 | }, 90 | { 91 | product: 'Baby food', 92 | description: 'It\'s good for you', 93 | category: 'Food', 94 | recievedOn: new Date('2017-08-26T18:25:43.511Z'), 95 | created: new Date('2016-01-01T01:25:13.055Z') 96 | }, 97 | { 98 | product: 'Coffee', 99 | description: 'Prepared from roasted coffee beans', 100 | category: 'Food', 101 | recievedOn: new Date('2015-04-16T23:52:23.565Z'), 102 | created: new Date('2016-12-21T21:05:03.253Z') 103 | }, 104 | { 105 | product: 'Cheese', 106 | description: 'A dairy product', 107 | category: 'Food', 108 | recievedOn: new Date('2017-11-06T21:22:53.542Z'), 109 | created: new Date('2014-02-11T11:34:12.442Z') 110 | }, 111 | { 112 | product: 'Floppy disk', 113 | description: 'It belongs in a museum', 114 | category: 'Storage', 115 | recievedOn: new Date('2015-10-12T11:12:42.621Z'), 116 | created: new Date('2013-03-12T21:54:31.221Z') 117 | }, 118 | { 119 | product: 'Fan', 120 | description: 'It will blow you away', 121 | category: 'Hardware', 122 | recievedOn: new Date('2014-05-04T01:22:35.412Z'), 123 | created: new Date('2014-03-18T23:14:18.426Z') 124 | } 125 | ]; 126 | 127 | dataSource = new FilteredDataSource(this.data); 128 | 129 | clearFilters() { 130 | this.dynamicTable.clearFilters(); 131 | this.dynamicTable.setSort([]); 132 | } 133 | 134 | setFilter() { 135 | const createdColumnName = 'created'; 136 | 137 | this.dynamicTable.setSort([{ id: 'category', direction: 'asc' }, { id: 'product', direction: 'desc' }]); 138 | 139 | const appliedFilter = this.dynamicTable.getFilter(createdColumnName); 140 | if (!appliedFilter) { 141 | const filter = new DateFilter(createdColumnName); 142 | filter.fromDate = new Date(2015, 1, 1); 143 | filter.toDate = new Date(2015, 12, 31); 144 | 145 | this.dynamicTable.setFilter(createdColumnName, filter); 146 | } else { 147 | const columnName = 'description'; 148 | const filter = new TextFilter(columnName); 149 | filter.value = 'Loud'; 150 | 151 | this.dynamicTable.setFilter(columnName, filter); 152 | } 153 | } 154 | 155 | onRowClick(row: any) { 156 | console.log(row); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { FormsModule } from '@angular/forms'; 5 | 6 | import { MatButtonModule } from '@angular/material/button'; 7 | import { MatInputModule } from '@angular/material/input'; 8 | import { MatDialogModule } from '@angular/material/dialog'; 9 | import { MatDatepickerModule } from '@angular/material/datepicker'; 10 | import { MatNativeDateModule } from '@angular/material/core'; 11 | import { MatMenuModule } from '@angular/material/menu'; 12 | import { MatIconModule } from '@angular/material/icon'; 13 | import { MatPaginatorModule } from '@angular/material/paginator'; 14 | 15 | import { OptionsCellComponent } from './cells/options-cell/options-cell.component'; 16 | 17 | import { TextFilterComponent } from './filters/text-filter/text-filter.component'; 18 | import { DateFilterComponent } from './filters/date-filter/date-filter.component'; 19 | 20 | import { CellService, ColumnFilterService, DynamicTableModule } from 'material-dynamic-table'; 21 | 22 | import { AppComponent } from './app.component'; 23 | 24 | @NgModule({ 25 | declarations: [ 26 | AppComponent, 27 | OptionsCellComponent, 28 | TextFilterComponent, 29 | DateFilterComponent 30 | ], 31 | imports: [ 32 | BrowserModule, 33 | BrowserAnimationsModule, 34 | DynamicTableModule, 35 | FormsModule, 36 | MatButtonModule, 37 | MatInputModule, 38 | MatDialogModule, 39 | MatDatepickerModule, 40 | MatNativeDateModule, 41 | MatMenuModule, 42 | MatIconModule, 43 | MatPaginatorModule 44 | ], 45 | providers: [], 46 | bootstrap: [AppComponent] 47 | }) 48 | export class AppModule { 49 | constructor(private readonly cellService: CellService, private readonly columnFilterService: ColumnFilterService) { 50 | cellService.registerCell('options', OptionsCellComponent); 51 | 52 | columnFilterService.registerFilter('string', TextFilterComponent); 53 | columnFilterService.registerFilter('date', DateFilterComponent); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/cells/options-cell/options-cell.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/cells/options-cell/options-cell.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CellComponent, ColumnConfig } from 'material-dynamic-table'; 3 | import { Product } from '../../product'; 4 | 5 | @Component({ 6 | selector: 'ld-options-cell', 7 | templateUrl: './options-cell.component.html' 8 | }) 9 | export class OptionsCellComponent implements CellComponent { 10 | @Input() 11 | column: ColumnConfig; 12 | 13 | @Input() 14 | row: Product; 15 | 16 | constructor() {} 17 | 18 | showDetails() { 19 | const productName = this.row.product; 20 | 21 | alert(`Product name is ${productName}.`); 22 | } 23 | } -------------------------------------------------------------------------------- /src/app/data-source/filtered-data-source.ts: -------------------------------------------------------------------------------- 1 | import { MdtTableDataSource, TableFilter } from 'material-dynamic-table'; 2 | 3 | export class FilteredDataSource extends MdtTableDataSource { 4 | 5 | filterPredicate = (data: T): boolean => { 6 | if (!this.filters || !this.filters.length) { 7 | return true; 8 | } 9 | 10 | const result = this.filters.reduce((visible: boolean, tableFilter: TableFilter) => { 11 | if (!visible) { 12 | return visible; 13 | } 14 | 15 | const filter = tableFilter.getFilter(); 16 | 17 | return Object.keys(filter).reduce((show, columnName) => { 18 | if (!show) { 19 | return show; 20 | } 21 | return this.matchesFilter(filter[columnName], data[columnName]); 22 | }, true); 23 | }, true); 24 | 25 | return result; 26 | } 27 | 28 | private matchesFilter(filterForColumn: any, dataForColumn: any): boolean { 29 | 30 | if (filterForColumn.contains && dataForColumn.indexOf(filterForColumn.contains) !== -1) { 31 | return true; 32 | } 33 | 34 | if (filterForColumn.le && filterForColumn.ge) { 35 | if (dataForColumn.getTime() >= filterForColumn.ge.getTime() && dataForColumn.getTime() <= filterForColumn.le.getTime()) { 36 | return true; 37 | } 38 | } else if (filterForColumn.ge && dataForColumn.getTime() >= filterForColumn.ge.getTime()) { 39 | return true; 40 | } else if (filterForColumn.le && dataForColumn.getTime() <= filterForColumn.le.getTime()) { 41 | return true; 42 | } 43 | 44 | return false; 45 | } 46 | } -------------------------------------------------------------------------------- /src/app/filters/date-filter/date-filter.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Filter for {{ displayName }}

3 | 4 | 5 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
-------------------------------------------------------------------------------- /src/app/filters/date-filter/date-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, OnInit } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | import { DateFilter } from './date-filter.model'; 4 | import { ColumnFilter } from 'material-dynamic-table'; 5 | 6 | @Component({ 7 | selector: 'ld-date-filter', 8 | templateUrl: './date-filter.component.html' 9 | }) 10 | export class DateFilterComponent implements OnInit { 11 | 12 | model: DateFilter; 13 | 14 | displayName: string | undefined; 15 | 16 | public constructor( 17 | private readonly dialogRef: MatDialogRef, 18 | @Inject(MAT_DIALOG_DATA) private readonly filterData: ColumnFilter) { } 19 | 20 | ngOnInit() { 21 | this.displayName = this.filterData.column.displayName; 22 | this.model = this.filterData.filter || new DateFilter(this.filterData.column.name); 23 | } 24 | 25 | apply() { 26 | if (this.model.fromDate || this.model.toDate) { 27 | this.dialogRef.close(this.model); 28 | } else { 29 | this.dialogRef.close(''); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/app/filters/date-filter/date-filter.model.ts: -------------------------------------------------------------------------------- 1 | import { DatePipe } from '@angular/common'; 2 | import { FilterDescription, TableFilter } from 'material-dynamic-table'; 3 | 4 | export class DateFilter implements TableFilter, FilterDescription { 5 | fromDate: Date; 6 | toDate: Date; 7 | 8 | public constructor(private readonly column: string) { 9 | } 10 | 11 | getFilter(): object { 12 | const filter = {}; 13 | 14 | if (this.fromDate && this.toDate) { 15 | filter[this.column] = { ge: this.fromDate, le: this.toDate }; 16 | } else if (this.fromDate) { 17 | filter[this.column] = { ge: this.fromDate }; 18 | } else if (this.toDate) { 19 | filter[this.column] = { le: this.toDate }; 20 | } 21 | 22 | return filter; 23 | } 24 | 25 | getDescription() { 26 | if (!this.fromDate && !this.toDate) { 27 | return null; 28 | } 29 | 30 | const datePipe = new DatePipe('en-US'); 31 | const formatDate = (date: Date) => datePipe.transform(date, 'shortDate'); 32 | 33 | if (this.fromDate && this.toDate) { 34 | return `is between ${formatDate(this.fromDate)} and ${formatDate(this.toDate)}`; 35 | } else if (this.fromDate) { 36 | return `is after ${formatDate(this.fromDate)}`; 37 | } else if (this.toDate) { 38 | return `is before ${formatDate(this.toDate)}`; 39 | } else { 40 | return null; 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/app/filters/text-filter/text-filter.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Filter for {{ displayName }}

3 | 4 | 5 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
-------------------------------------------------------------------------------- /src/app/filters/text-filter/text-filter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, OnInit } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | import { TextFilter } from './text-filter.model'; 4 | import { ColumnFilter } from 'material-dynamic-table'; 5 | 6 | @Component({ 7 | selector: 'ld-text-filter', 8 | templateUrl: './text-filter.component.html' 9 | }) 10 | export class TextFilterComponent implements OnInit { 11 | 12 | model: TextFilter; 13 | 14 | displayName: string | undefined; 15 | 16 | public constructor( 17 | private readonly dialogRef: MatDialogRef, 18 | @Inject(MAT_DIALOG_DATA) private readonly filterData: ColumnFilter) { } 19 | 20 | ngOnInit() { 21 | this.displayName = this.filterData.column.displayName; 22 | this.model = this.filterData.filter || new TextFilter(this.filterData.column.name); 23 | } 24 | 25 | apply() { 26 | if (this.model.value) { 27 | this.dialogRef.close(this.model); 28 | } else { 29 | this.dialogRef.close(''); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/app/filters/text-filter/text-filter.model.ts: -------------------------------------------------------------------------------- 1 | import { FilterDescription, TableFilter } from 'material-dynamic-table'; 2 | 3 | export class TextFilter implements TableFilter, FilterDescription { 4 | value: string; 5 | 6 | public constructor(private readonly column: string) { 7 | this.value = ''; 8 | } 9 | 10 | getFilter(): object { 11 | const filter = {}; 12 | 13 | filter[this.column] = { contains: this.value }; 14 | 15 | return filter; 16 | } 17 | 18 | getDescription() { 19 | if (!this.value) { 20 | return null; 21 | } 22 | 23 | return `contains '${this.value}'`; 24 | } 25 | } -------------------------------------------------------------------------------- /src/app/product.ts: -------------------------------------------------------------------------------- 1 | export class Product { 2 | product: string; 3 | description: string; 4 | category: string; 5 | recievedOn: Date; 6 | created: Date; 7 | } -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/relair/material-dynamic-table/023276a697eab0d6d96f9eee702105f87ff71877/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/relair/material-dynamic-table/023276a697eab0d6d96f9eee702105f87ff71877/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LibDemo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | 14 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | // First, initialize the Angular testing environment. 12 | getTestBed().initTestEnvironment( 13 | BrowserDynamicTestingModule, 14 | platformBrowserDynamicTesting() 15 | ); 16 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "exclude": [ 8 | "test.ts", 9 | "**/*.spec.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "ld", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "ld", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "es2022", 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "noImplicitThis": true, 15 | "target": "ES2022", 16 | "typeRoots": [ 17 | "node_modules/@types" 18 | ], 19 | "lib": [ 20 | "es2022", 21 | "dom" 22 | ], 23 | "paths": { 24 | "material-dynamic-table": [ 25 | "dist/material-dynamic-table" 26 | ], 27 | "material-dynamic-table/*": [ 28 | "dist/material-dynamic-table/*" 29 | ] 30 | }, 31 | "useDefineForClassFields": false 32 | } 33 | } 34 | --------------------------------------------------------------------------------