diff --git a/demo/app/components/table/table.component.html b/demo/app/components/table/table.component.html index cddb4c6cb..387257ba1 100644 --- a/demo/app/components/table/table.component.html +++ b/demo/app/components/table/table.component.html @@ -1,4 +1,18 @@ -
+
+
+

+ To get around GitHub rate limiting, we cache response data by default. +
+ Clear cached data and/or disable below. +

+ + +
+ + - - + + Title @@ -32,7 +47,7 @@ - + Updated @@ -60,7 +75,7 @@ - + Number @@ -127,10 +142,7 @@ - - - - +
@@ -143,3 +155,6 @@ >
+
+ +
diff --git a/demo/app/components/table/table.component.scss b/demo/app/components/table/table.component.scss index ec464b980..1dcc3c750 100644 --- a/demo/app/components/table/table.component.scss +++ b/demo/app/components/table/table.component.scss @@ -1,10 +1,5 @@ -@import '~@terminus/ui/helpers'; - - .example-container { height: 400px; - overflow: auto; - @include visible-scrollbars; } .truncate { @@ -12,4 +7,6 @@ max-height: 100px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; + word-break: break-word; } diff --git a/demo/app/components/table/table.component.ts b/demo/app/components/table/table.component.ts index f8c95f21b..6780d63b9 100644 --- a/demo/app/components/table/table.component.ts +++ b/demo/app/components/table/table.component.ts @@ -4,7 +4,10 @@ import { Component, ViewChild, } from '@angular/core'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { + DomSanitizer, + SafeHtml, +} from '@angular/platform-browser'; import { TsPaginatorComponent, TsPaginatorMenuItem, @@ -12,6 +15,8 @@ import { import { TsSortDirective } from '@terminus/ui/sort'; import { TsColumn, + TsTableColumnsChangeEvent, + TsTableComponent, TsTableDataSource, } from '@terminus/ui/table'; import { @@ -101,6 +106,7 @@ export class ExampleHttpDao { constructor(private http: HttpClient) {} public getRepoIssues(sort: string, order: string, page: number, perPage: number): Observable { + console.log('HITTING GITHUB'); const href = `https://api.github.com/search/issues`; const requestUrl = `${href}?q=repo:GetTerminus/terminus-ui`; const requestParams = `&sort=${sort}&order=${order}&page=${page + 1}&per_page=${perPage}`; @@ -116,6 +122,8 @@ export class ExampleHttpDao { }) export class TableComponent implements AfterViewInit { public allColumns = COLUMNS_SOURCE_GITHUB.slice(0); + public savedResponse: GithubApi | null = null; + public useCachedData = true; public displayedColumns = [ 'title', 'updated', @@ -136,22 +144,25 @@ export class TableComponent implements AfterViewInit { name: 'title', width: '400px', }, - { name: 'updated' }, - { name: 'comments' }, + { name: 'number' }, { - name: 'assignee', - width: '160px', + name: 'updated', + // width: '300px', }, - { name: 'number' }, + // { name: 'comments' }, + // { + // name: 'assignee', + // width: '160px', + // }, { name: 'labels', - width: '260px', + // width: '260px', }, - { name: 'created' }, - { name: 'id' }, + // { name: 'created' }, + // { name: 'id' }, { name: 'body', - width: '500px', + // width: '500px', }, { name: 'html_url' }, ]; @@ -162,6 +173,8 @@ export class TableComponent implements AfterViewInit { @ViewChild(TsPaginatorComponent, { static: true }) public readonly paginator!: TsPaginatorComponent; + @ViewChild('myTable', { static: false }) + public readonly myTable!: TsTableComponent; constructor( private domSanitizer: DomSanitizer, @@ -182,14 +195,21 @@ export class TableComponent implements AfterViewInit { merge(this.sort.sortChange, this.paginator.pageSelect, this.paginator.recordsPerPageChange) .pipe( startWith({}), - switchMap(() => this.exampleDatabase.getRepoIssues( - this.sort.active, - this.sort.direction, - this.paginator.currentPageIndex, - this.paginator.recordsPerPage, - )), + switchMap(() => { + if (this.useCachedData && this.savedResponse && this.savedResponse.items) { + return of(this.savedResponse); + } + + return this.exampleDatabase.getRepoIssues( + this.sort.active, + this.sort.direction, + this.paginator.currentPageIndex, + this.paginator.recordsPerPage, + ); + }), map(data => { console.log('Demo: fetched data: ', data); + this.savedResponse = data as GithubApi; this.resultsLength = data.total_count; return data.items; @@ -203,17 +223,28 @@ export class TableComponent implements AfterViewInit { }); } + public clearCachedData(): void { + this.savedResponse = null; + } public perPageChange(e: number): void { - console.log('DEMO records per page changed: ', e); + console.log('DEMO: Records per page changed: ', e); } public onPageSelect(e: TsPaginatorMenuItem): void { - console.log('DEMO page selected: ', e); + console.log('DEMO: Page selected: ', e); + } + + public columnsChange(e: TsTableColumnsChangeEvent): void { + console.log('DEMO: Columns change: ', e); } public sanitize(content): SafeHtml { return this.domSanitizer.bypassSecurityTrustHtml(content); } + public log() { + console.log('Demo: columns: ', this.myTable.columns); + } + } diff --git a/terminus-ui/table/package.json b/terminus-ui/table/package.json index 6266a819b..93fe46f98 100644 --- a/terminus-ui/table/package.json +++ b/terminus-ui/table/package.json @@ -3,8 +3,11 @@ "lib": { "entryFile": "src/public-api.ts", "umdModuleIds": { - "@terminus/ngx-tools/type-guards": "terminus.ngxTools.type-guards" + "@terminus/ngx-tools/browser": "terminus.ngxTools.browser", + "@terminus/ngx-tools/type-guards": "terminus.ngxTools.type-guards", + "@terminus/ngx-tools/utilities": "terminus.ngxTools.utilities" } } } } + diff --git a/terminus-ui/table/src/cell.ts b/terminus-ui/table/src/cell.ts index 528601ea3..dcf968ef1 100644 --- a/terminus-ui/table/src/cell.ts +++ b/terminus-ui/table/src/cell.ts @@ -9,37 +9,25 @@ import { CdkHeaderCellDef, } from '@angular/cdk/table'; import { + AfterViewInit, Directive, ElementRef, - Host, - Input, + EventEmitter, isDevMode, - Optional, + OnDestroy, + Output, Renderer2, - SkipSelf, } from '@angular/core'; +import { + TsDocumentService, + TsWindowService, +} from '@terminus/ngx-tools/browser'; import { TsUILibraryError } from '@terminus/ui/utilities'; -import { TsTableComponent } from './table.component'; - - -/** - * Allowed column alignments - */ -export type TsTableColumnAlignment - = 'left' - | 'center' - | 'right' -; - -/** - * An array of the allowed {@link TsTableColumnAlignment} for checking values - */ -export const tsTableColumnAlignmentTypesArray: TsTableColumnAlignment[] = [ - 'left', - 'center', - 'right', -]; +import { + TsColumnDefDirective, + tsTableColumnAlignmentTypesArray, +} from './column'; /** @@ -72,6 +60,30 @@ export class TsCellDefDirective extends CdkCellDef {} export class TsHeaderCellDefDirective extends CdkHeaderCellDef {} +/** + * Define the event object for header cell resizes + */ +export class TsHeaderCellResizeEvent { + constructor( + // The header cell instance that originated the event + public instance: TsHeaderCellDirective, + // The new width + public width: string, + ) {} +} + +/** + * Define the event object for a header cell resizer hover + */ +export class TsHeaderCellResizeHoverEvent { + constructor( + // The header cell instance that originated the event + public instance: TsHeaderCellDirective, + // If the cell is currently hovered + public isHovered: boolean, + ) {} +} + /** * Header cell template container that adds the right classes and role. @@ -82,29 +94,174 @@ export class TsHeaderCellDefDirective extends CdkHeaderCellDef {} class: 'ts-header-cell', role: 'columnheader', }, + exportAs: 'tsHeaderCell', }) -export class TsHeaderCellDirective extends CdkHeaderCell { +export class TsHeaderCellDirective extends CdkHeaderCell implements AfterViewInit, OnDestroy { + /** + * Store reference to the document + */ + private document: Document; + + /** + * Store reference to the window + */ + private window: Window; + + /** + * Store references to event listener removal functions + */ + private resizeListenerRemover: Function; + private clickListenerRemover: Function; + private mouseEnterListenerRemover: Function; + private mouseLeaveListenerRemover: Function; + + /** + * Store the starting offset when a resize event begins + */ + public startOffsetX = 0; + + /** + * Store the starting width of a cell before resizing + */ + public startWidth = 0; + + /** + * Create bound methods to preserve 'this' context + */ + public readonly boundDragging = e => this.triggerDragging(e); + public readonly boundDragEnd = e => this.triggerDragEnd(e); + + /** + * Event emitted when the cell is hovered + */ + @Output() + public readonly hovered = new EventEmitter(); + + /** + * Event emitted when the cell is resized + */ + @Output() + public readonly resized = new EventEmitter(); + + constructor( public columnDef: CdkColumnDef, public renderer: Renderer2, - private elementRef: ElementRef, - @Optional() @Host() @SkipSelf() parent: TsTableComponent, + public elementRef: ElementRef, + private documentService: TsDocumentService, + private windowService: TsWindowService, ) { super(columnDef, elementRef); + this.document = documentService.document; + this.window = windowService.nativeWindow; elementRef.nativeElement.classList.add(`ts-column-${columnDef.cssClassFriendlyName}`); - // tslint:disable-next-line no-any - const column: any = columnDef; + // eslint-disable-next-line no-underscore-dangle + if (columnDef._stickyEnd) { + elementRef.nativeElement.classList.add(`ts-table--sticky-end`); + } + } - if (parent.columns) { - const width = parent.columns.filter(c => c.name === columnDef.name).map(v => v.width)[0]; - // istanbul ignore else - if (width) { - renderer.setStyle(elementRef.nativeElement, 'flex-basis', width); - } + + /** + * Inject the cell resizer + */ + public ngAfterViewInit(): void { + this.injectResizeElement(); + } + + + /** + * Remove all event listeners when destroyed + */ + public ngOnDestroy(): void { + this.removeAllEventListeners(); + } + + + /** + * Remove any existing event listeners + */ + private removeAllEventListeners(): void { + if (this.resizeListenerRemover) { + this.resizeListenerRemover(); + } + if (this.clickListenerRemover) { + this.clickListenerRemover(); } + if (this.mouseEnterListenerRemover) { + this.mouseEnterListenerRemover(); + } + if (this.mouseLeaveListenerRemover) { + this.mouseLeaveListenerRemover(); + } + } + + + /** + * Inject the resize 'grabber' element + */ + private injectResizeElement(): void { + const resizeElement = this.renderer.createElement('span'); + resizeElement.classList.add('ts-header-cell__resizer'); + resizeElement.classList.add(`ts-header-cell__resizer--${this.columnDef.cssClassFriendlyName}`); + resizeElement.title = 'Drag to resize column.'; + this.renderer.appendChild(this.elementRef.nativeElement, resizeElement); + + // Remove any existing event listeners + this.removeAllEventListeners(); + this.resizeListenerRemover = this.renderer.listen(resizeElement, 'mousedown', e => this.onResizeColumn(e)); + + // Stop click events so that sorting is not triggered + this.clickListenerRemover = this.renderer.listen(resizeElement, 'click', e => e.stopPropagation()); + + // Listen to mouse enter/leave and emit events + this.mouseEnterListenerRemover = + this.renderer.listen(resizeElement, 'mouseenter', () => this.hovered.emit(new TsHeaderCellResizeHoverEvent(this, true))); + this.mouseLeaveListenerRemover = + this.renderer.listen(resizeElement, 'mouseleave', () => this.hovered.emit(new TsHeaderCellResizeHoverEvent(this, false))); + } + + + /** + * Save initial width and offset, bind to more events + * + * @param event - The mouse event + */ + private onResizeColumn(event: MouseEvent): void { + this.startOffsetX = event.clientX; + this.document.addEventListener('mousemove', this.boundDragging); + this.document.addEventListener('mouseup', this.boundDragEnd); + const style = this.window.getComputedStyle(this.elementRef.nativeElement); + // NOTE: Else branch should never be reachable in the browser + this.startWidth = style.width ? parseInt(style.width, 10) /* istanbul ignore next */ : 0; + } + + /** + * Determine the new width as the cell is resized and emit the width + * + * @param event - The mouse event + */ + private triggerDragging(event: MouseEvent): void { + const diff = event.clientX - this.startOffsetX; + const newWidth = `${this.startWidth + diff}px`; + this.resized.emit(new TsHeaderCellResizeEvent(this, newWidth)); } + + + /** + * Reset resize items + * + * @param event - The mouse event + */ + private triggerDragEnd(event: MouseEvent): void { + this.startOffsetX = 0; + this.document.removeEventListener('mousemove', this.boundDragging); + this.document.removeEventListener('mouseup', this.boundDragEnd); + this.startWidth = 0; + } + } @@ -119,13 +276,16 @@ export class TsHeaderCellDirective extends CdkHeaderCell { }, }) export class TsCellDirective extends CdkCell { + /** + * Store a reference to the column + */ public column: TsColumnDefDirective; + constructor( + public elementRef: ElementRef, private columnDef: CdkColumnDef, - private elementRef: ElementRef, private renderer: Renderer2, - @Optional() @Host() @SkipSelf() parent: TsTableComponent, ) { super(columnDef, elementRef); // NOTE: Changing the type in the constructor from `CdkColumnDef` to `TsColumnDefDirective` doesn't seem to play well with the CDK. @@ -142,18 +302,11 @@ export class TsCellDirective extends CdkCell { TsCellDirective.setColumnAlignment(this.column, renderer, elementRef); - if (parent.columns) { - const width = parent.columns.filter(c => c.name === columnDef.name).map(v => v.width)[0]; - // istanbul ignore else - if (width) { - renderer.setStyle(elementRef.nativeElement, 'flex-basis', width); - } - } - // eslint-disable-next-line no-underscore-dangle if (columnDef._stickyEnd) { - elementRef.nativeElement.classList.add(`ts-cell--sticky-end`); + elementRef.nativeElement.classList.add(`ts-table--sticky-end`); } + } @@ -178,58 +331,3 @@ export class TsCellDirective extends CdkCell { } } - - -/** - * Column definition for the {@link TsTableComponent}. - * - * Defines a set of cells available for a table column. - */ -@Directive({ - selector: '[tsColumnDef]', - providers: [ - { - provide: CdkColumnDef, - useExisting: TsColumnDefDirective, - }, - { - provide: 'MAT_SORT_HEADER_COLUMN_DEF', - useExisting: TsColumnDefDirective, - }, - ], -}) -export class TsColumnDefDirective extends CdkColumnDef { - // NOTE: We must rename here so that the property matches the extended CdkColumnDef class - // tslint:disable: no-input-rename - /** - * Define a unique name for this column - */ - @Input('tsColumnDef') - public name!: string; - // tslint:enable: no-input-rename - - /** - * Define if a column's contents should wrap when long - */ - @Input() - public noWrap = false; - - /** - * Define an alignment type for the cell. - */ - @Input() - public alignment: TsTableColumnAlignment | undefined; - - /** - * Define stickiness for the column - */ - @Input() - public sticky = false; - - /** - * Define if a column should stick to the end - */ - @Input() - public stickyEnd = false; - -} diff --git a/terminus-ui/table/src/column.ts b/terminus-ui/table/src/column.ts new file mode 100644 index 000000000..7d9289046 --- /dev/null +++ b/terminus-ui/table/src/column.ts @@ -0,0 +1,87 @@ +/** + * Column definition for the {@link TsTableComponent}. + * + * Defines a set of cells available for a table column. + */ +import { CdkColumnDef } from '@angular/cdk/table'; +import { + Directive, + ElementRef, + Input, +} from '@angular/core'; + + +/** + * Allowed column alignments + */ +export type TsTableColumnAlignment + = 'left' + | 'center' + | 'right' +; + +/** + * An array of the allowed {@link TsTableColumnAlignment} for checking values + */ +export const tsTableColumnAlignmentTypesArray: TsTableColumnAlignment[] = [ + 'left', + 'center', + 'right', +]; + + +@Directive({ + selector: '[tsColumnDef]', + providers: [ + { + provide: CdkColumnDef, + useExisting: TsColumnDefDirective, + }, + { + provide: 'MAT_SORT_HEADER_COLUMN_DEF', + useExisting: TsColumnDefDirective, + }, + ], +}) +export class TsColumnDefDirective extends CdkColumnDef { + /** + * Define a unique name for this column + */ + // NOTE: We must rename here so that the property matches the extended CdkColumnDef class + // tslint:disable: no-input-rename + @Input('tsColumnDef') + public name!: string; + // tslint:enable: no-input-rename + + /** + * Define if a column's contents should wrap when long + */ + @Input() + public noWrap = false; + + /** + * Define an alignment type for the cell. + */ + @Input() + public alignment: TsTableColumnAlignment | undefined; + + /** + * Define if the column should stick to the start + */ + @Input() + public sticky = false; + + /** + * Define if a column should stick to the end + */ + @Input() + public stickyEnd = false; + + + constructor( + public elementRef: ElementRef, + ) { + super(); + } + +} diff --git a/terminus-ui/table/src/row.ts b/terminus-ui/table/src/row.ts index 2bce71c3f..a69a5c6aa 100644 --- a/terminus-ui/table/src/row.ts +++ b/terminus-ui/table/src/row.ts @@ -9,8 +9,10 @@ import { ChangeDetectionStrategy, Component, Directive, + ElementRef, ViewEncapsulation, } from '@angular/core'; + import { TsTableComponent } from './table.component'; @@ -29,7 +31,13 @@ import { TsTableComponent } from './table.component'; exportAs: 'tsHeaderRow', preserveWhitespaces: false, }) -export class TsHeaderRowComponent extends CdkHeaderRow {} +export class TsHeaderRowComponent extends CdkHeaderRow { + constructor( + public elementRef: ElementRef, + ) { + super(); + } +} /** @@ -47,7 +55,13 @@ export class TsHeaderRowComponent extends CdkHeaderRow {} exportAs: 'tsRow', preserveWhitespaces: false, }) -export class TsRowComponent extends CdkRow {} +export class TsRowComponent extends CdkRow { + constructor( + public elementRef: ElementRef, + ) { + super(); + } +} /** * Header row definition for the {@link TsTableComponent}. diff --git a/terminus-ui/table/src/table.component.html b/terminus-ui/table/src/table.component.html index 19cd8f08d..c36faea04 100644 --- a/terminus-ui/table/src/table.component.html +++ b/terminus-ui/table/src/table.component.html @@ -1,3 +1,5 @@ - - - \ No newline at end of file +
+ + + +
diff --git a/terminus-ui/table/src/table.component.md b/terminus-ui/table/src/table.component.md index 2bf62db36..de88c3423 100644 --- a/terminus-ui/table/src/table.component.md +++ b/terminus-ui/table/src/table.component.md @@ -17,7 +17,11 @@ - [Sticky header](#sticky-header) - [Sticky column](#sticky-column) - [Sticky column at end](#sticky-column-at-end) +- [Events](#events) + - [Table](#table) + - [Cell](#cell) - [Full example with pagination, sorting, and dynamic columns](#full-example-with-pagination-sorting-and-dynamic-columns) +- [Test Helpers](#test-helpers) @@ -58,6 +62,9 @@ const columns: TsColumn = [ ]; ``` +> NOTE: Any valid CSS string can be passed in for the width: `1rem|12px|13vw|etc`. But when the user resizes the column, the width will be +> converted into pixels. + ### 3. Define the table's rows After defining your columns, provide the header and data row templates that will be rendered out by the table. Each row needs to be given a @@ -66,12 +73,10 @@ list of the columns that it should contain. The order of the names will define t NOTE: It is not required to provide a list of all the defined column names, but only the ones that you want to have rendered. ```html - - + - - + ``` The table component provides an array of column names built from the array of `TsColumn` definitions passed to the table. You can use this @@ -342,7 +347,62 @@ than one column. ``` ---- +## Events + +### Table + +| Event | Description | Payload | +|:----------------|:---------------------------------|:----------------------------| +| `columnsChange` | Fired when any column is changed | `TsTableColumnsChangeEvent` | + +> NOTE: This event is not throttled or debounced and may be called repeatedly. + +```html + + ... + +``` + +The `TsTableColumnsChangeEvent` structure: + +```typescript +class TsTableColumnsChangeEvent { + constructor( + // The table instance that originated the event + public table: TsTableComponent, + // The updated array of columns + public columns: TsColumn[], + ) {} +} +``` + +### Cell + +| Event | Description | Payload | +|:----------|:--------------------------------------|:--------------------------| +| `resized` | Fired when the header cell is resized | `TsHeaderCellResizeEvent` | + +```html + + + Title + + + {{ item.title }} + + +``` + +```typescript +class TsHeaderCellResizeEvent { + constructor( + // The header cell instance that originated the event + public instance: TsHeaderCellDirective, + // The new width + public width: string, + ) {} +} +``` ## Full example with pagination, sorting, and dynamic columns @@ -547,3 +607,21 @@ export class ExampleHttpDao { } } ``` + +## Test Helpers + +Some helpers are exposed to assist with testing. These are imported from `@terminus/ui/table/testing`; + +[[source]][test-helpers-src] + +| Function | +|-----------------------------| +| `getElements` | +| `getHeaderRow` | +| `getRows` | +| `getCells` | +| `getHeaderCells` | +| `expectTableToMatchContent` | + + +[test-helpers-src]: https://github.com/GetTerminus/terminus-ui/blob/release/terminus-ui/table/testing/src/test-helpers.ts diff --git a/terminus-ui/table/src/table.component.scss b/terminus-ui/table/src/table.component.scss index 9920fe6e3..80ea7c694 100644 --- a/terminus-ui/table/src/table.component.scss +++ b/terminus-ui/table/src/table.component.scss @@ -1,48 +1,129 @@ @import './../../scss/helpers/color'; +@import './../../scss/helpers/cursors'; @import './../../scss/helpers/layout'; @import './../../scss/helpers/reset'; @import './../../scss/helpers/spacing'; +@import './../../scss/helpers/scrollbars'; @import './../../scss/helpers/typography'; -$ts-header-row-height: 56px; -$ts-row-height: 48px; -$ts-row-horizontal-padding: 24px; -$ts-row-hover: rgba(color(utility, xlight), .5); - - // // @component // Table // @description // A table component -// .ts-table { + --table-bg: #{color(pure)}; + --header-bg: #{color(utility, xlight)}; + --header-text-color: #{color(utility, dark)}; + --border-color: #{color(utility, light)}; + $primary: color(primary); + --row-bg--hover: #{desaturate(lighten($primary, 66%), 70%)}; + // NOTE: This must be above 40 as that is the number of header cell z-indexes set + --header-cell-sticky-z-index: 50; + --row-cell-sticky-z-index: 3; + --z-index-negative: -1; + --z-index-base-context: 1; @include reset; @include typography; - display: inline-block; + @include visible-scrollbars; + border: 1px solid var(--border-color); + display: block; + max-height: 100%; + max-width: 100%; + overflow: scroll; + + //
Inner wrapper for the table + &__inner { + display: table; + } + + // Class added to all sticky cells and rows + .ts-table--sticky { + &:not(.ts-table--sticky-end) { + // NOTE: Important needed to overwrite inline style + // stylelint-disable-next-line declaration-no-important + z-index: var(--row-cell-sticky-z-index) !important; + } + + // Since the sticky cell has a right border at all times, we can remove the left border from the cell that immediately follows it. + + .ts-cell, + + .ts-header-cell { + border-left: none; + } + } + + // Class added to all sticky-end cells + .ts-table--sticky-end { + z-index: var(--z-index-negative); + + // For the last sticky cell of a row, hide the overflow so that the resize grabber doesn't create extra space past the final cell. + &:last-of-type { + &.ts-header-cell { + overflow: hidden; + + // Move the resizer back into view since we aren't overlapping a following cell + .ts-header-cell__resizer { + transform: translateX(40%); + } + } + } + } + + // Any row + .ts-row, + .ts-header-row { + align-items: stretch; + box-sizing: border-box; + display: table-row; + white-space: nowrap; + + .ts-cell, + .ts-header-cell { + &:not(last-of-type) { + &:not(.ts-table--sticky) { + border-right: none; + } + } + } - // - // Rows - // + // No border needed for the bottom of the last row since the table already has one + &:not(last-of-type) { + .ts-cell { + border-bottom: none; + } + } + } // Header row .ts-header-row { @include typography(caption); - color: color(utility); - min-height: $ts-header-row-height; + color: var(--header-text-color); transition: background-color 200ms ease-out; - will-change: background-color; + // Create base z-index context + z-index: var(--z-index-base-context); + + .ts-table--sticky { + // NOTE: Important needed to overwrite inline style from the CDK + // stylelint-disable-next-line declaration-no-important + z-index: var(--header-cell-sticky-z-index) !important; + } } // Body row .ts-row { - min-height: $ts-row-height; - transition: background-color 200ms ease-out; - will-change: background-color; + z-index: var(--z-index-negative); &:hover { - background-color: $ts-row-hover; + .ts-cell { + background-color: var(--row-bg--hover); + } + } + + &:first-of-type { + .ts-cell { + border-top: none; + } } // Workaround for https://goo.gl/pFmjJD in IE 11. Adds a pseudo @@ -55,67 +136,115 @@ $ts-row-hover: rgba(color(utility, xlight), .5); } } - // Any row - .ts-row, - .ts-header-row { + // Any cell + .ts-cell, + .ts-header-cell { align-items: stretch; - border-bottom: 1px solid color(utility, xlight); - box-sizing: border-box; - display: flex; - } + border: 1px solid var(--border-color); + display: table-cell; + min-height: inherit; + position: relative; + word-wrap: break-word; + + // Class added if a column should not wrap + &.ts-column-no-wrap { + white-space: nowrap; + } - // - // Cells - // + // The table already has a border + &:first-of-type { + border-left: none; + } + } // Body cell .ts-cell { - $sticky-border: 1px solid color(utility, xlight); - background-color: color(pure); + background-color: var(--table-bg); + overflow: hidden; padding: spacing(default); + transition: background-color 200ms ease-out; vertical-align: middle; - &--sticky { - border-right: $sticky-border; - } - - &--sticky-end { - border-left: $sticky-border; + &.ts-table--sticky { + background-color: var(--table-bg); } } // Header cell .ts-header-cell { - background-color: color(pure); + background-color: var(--header-bg); + border-top: none; padding: spacing(default); - - &.ts-sortable { - border-bottom: 3px solid color(utility, light); - transition: border-bottom 200ms ease-out; - will-change: border-bottom-color; + position: sticky; + top: 0; + + // Reverse the natural z-index order so that all borders on the right created with box-shadow are visible above the following cell. + // Supports up to 40 columns + $possible-columns: 40; + @for $i from 1 through $possible-columns { + &:nth-child(#{$i}) { + $z: #{$possible-columns - $i}; + z-index: $z; + } } // Class added when the column is sorted &.ts-sort-header-sorted { - border-bottom-color: color(accent); color: color(accent); } - } - // Any cell - .ts-cell, - .ts-header-cell { - align-items: stretch; - background-color: color(pure); - flex: 1; - flex-basis: 6em; - min-height: inherit; - overflow: hidden; - word-wrap: break-word; + // Class added when the user hovers the resize column hit area + &.ts-cell--resizing { + .ts-header-cell__resizer { + $resizer-width: #{spacing(small, 2)}; + opacity: 1; - // Class added if a column should not wrap - &.ts-column-no-wrap { - white-space: nowrap; + &::after { + width: calc(#{$resizer-width} + 1px); + } + } + } + + // 'Grabber' hit area to resize a column + &__resizer { + bottom: 0; + cursor: cursor(col-resize); + display: block; + opacity: 0; + position: absolute; + right: 0; + top: 0; + transform: translateX(50%); + transition: opacity 200ms ease-out; + width: spacing(large); + + // Visible marker for grabber + &::after { + background-color: color(primary); + bottom: 0; + content: ''; + display: block; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%); + transition: width 100ms ease-out; + width: 1px; + z-index: var(--z-index-base-context); + } + + // Dots inside grabber + &::before { + --z-index-above-bg: 2; + color: color(utility, xlight); + content: '\2026'; + display: block; + left: 50%; + position: absolute; + top: 30%; + transform: rotate(90deg) translateY(4%); + z-index: var(--z-index-above-bg); + } } } } diff --git a/terminus-ui/table/src/table.component.spec.ts b/terminus-ui/table/src/table.component.spec.ts index 03cbaaa25..cb5ce1b6d 100644 --- a/terminus-ui/table/src/table.component.spec.ts +++ b/terminus-ui/table/src/table.component.spec.ts @@ -1,26 +1,54 @@ /* eslint-disable no-underscore-dangle */ +import { Injectable } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; +import { TsWindowService } from '@terminus/ngx-tools/browser'; import { createComponent, + createMouseEvent, ElementRefMock, + Renderer2Mock, } from '@terminus/ngx-tools/testing'; +import { noop } from '@terminus/ngx-tools/utilities'; import * as testComponents from '@terminus/ui/table/testing'; // eslint-disable-next-line no-duplicate-imports import { expectTableToMatchContent, - getCells, + getCells, getColumnElements, getHeaderCells, getHeaderRow, } from '@terminus/ui/table/testing'; -import { - TsCellDirective, - TsColumnDefDirective, -} from './cell'; +import { TsCellDirective } from './cell'; +import { TsColumnDefDirective } from './column'; import { TsTableDataSource } from './table-data-source'; import { TsTableModule } from './table.module'; +@Injectable({ providedIn: 'root' }) +export class TsWindowServiceMock { + public styleObject: CSSStyleDeclaration = { width: '90px' } as any; + + public get nativeWindow(): Window { + return { + getComputedStyle: e => this.styleObject as CSSStyleDeclaration, + open: noop, + location: { + href: 'foo/bar', + protocol: 'https:', + }, + alert: noop, + getSelection: () => ({ + removeAllRanges: noop, + addRange: noop, + }), + scrollTo: (x: number, y: number) => { + }, + prompt: noop, + } as any; + } + +} + interface TestData { a: string|number|undefined; b: string|number|undefined; @@ -118,18 +146,19 @@ describe(`TsTableComponent`, function() { }); test(`should add the min-width style`, function() { + fixture.detectChanges(); const column = fixture.nativeElement.querySelector('.ts-cell.ts-column-column_b'); const headerColumn = fixture.nativeElement.querySelector('.ts-header-cell.ts-column-column_b'); let style; let headerStyle; if (column.style && column.style._values) { - style = column.style._values['flex-basis']; - headerStyle = headerColumn.style._values['flex-basis']; + style = column.style._values['max-width']; + headerStyle = headerColumn.style._values['max-width']; } - expect(style).toEqual('7rem'); - expect(headerStyle).toEqual('7rem'); + expect(style).toEqual('1000px'); + expect(headerStyle).toEqual('1000px'); }); }); @@ -175,17 +204,17 @@ describe(`TsTableComponent`, function() { expect(style).toBeUndefined(); }); - }); + test(`should throw error for invalid alignment arguments`, () => { + const col = new TsColumnDefDirective(new ElementRefMock()); + Object.defineProperties(col, { alignment: { get: () => 'foo' } }); - test(`should throw error for invalid alignment arguments`, () => { - const col = new TsColumnDefDirective(); - Object.defineProperties(col, { alignment: { get: () => 'foo' } }); + const actual = () => { + const test = new TsCellDirective(new ElementRefMock(), col, new Renderer2Mock()); + }; - const actual = () => { - const test = new TsCellDirective(col, new ElementRefMock(), {} as any, {} as any); - }; + expect(actual).toThrowError('TsCellDirective: '); + }); - expect(actual).toThrowError('TsCellDirective: '); }); describe(`pinned header and column`, () => { @@ -201,19 +230,98 @@ describe(`TsTableComponent`, function() { test(`should set a header to be sticky`, () => { const header = getHeaderRow(tableElement); - expect(header.classList).toContain('ts-cell--sticky'); + expect(header.classList).toContain('ts-table--sticky'); }); test(`should set a column to be sticky`, () => { const headerCells = getHeaderCells(tableElement); const cells = getCells(tableElement); - expect(headerCells[0].classList).toContain('ts-cell--sticky'); - expect(headerCells[1].classList).not.toContain('ts-cell--sticky'); - expect(cells[0].classList).not.toContain('ts-cell--sticky-end'); - expect(cells[2].classList).toContain('ts-cell--sticky-end'); + expect(headerCells[0].classList).toContain('ts-table--sticky'); + expect(headerCells[1].classList).not.toContain('ts-table--sticky'); + expect(cells[0].classList).not.toContain('ts-table--sticky-end'); + expect(cells[2].classList).toContain('ts-table--sticky-end'); }); }); -}); + describe(`resizable columns`, () => { + + test(`should reveal the 'grabber' when the header cell resize is hovered`, () => { + const fixture = createComponent(testComponents.TableApp, undefined, [TsTableModule]); + fixture.detectChanges(); + const tableElement = fixture.nativeElement.querySelector('.ts-table'); + const headerCells = getHeaderCells(tableElement); + fixture.detectChanges(); + + expect(headerCells[0].classList).not.toContain('ts-cell--resizing'); + + const firstResizer = headerCells[0].querySelector('.ts-header-cell__resizer')!; + const hoverEvent = createMouseEvent('mouseenter'); + firstResizer.dispatchEvent(hoverEvent); + fixture.detectChanges(); + const columnElements = getColumnElements('column_a', tableElement); + + for (const col of columnElements) { + expect(col.classList).toContain('ts-cell--resizing'); + } + + const hoverEndEvent = createMouseEvent('mouseleave'); + firstResizer.dispatchEvent(hoverEndEvent); + fixture.detectChanges(); + + for (const col of columnElements) { + expect(col.classList).not.toContain('ts-cell--resizing'); + } + + expect.assertions(9); + }); + + test(`should allow column resizing and emit the updated columns`, () => { + const fixture = createComponent(testComponents.TableApp, [{ + provide: TsWindowService, + useExisting: TsWindowServiceMock, + }], [TsTableModule]); + fixture.detectChanges(); + const tableElement = fixture.nativeElement.querySelector('.ts-table'); + const headerCells = getHeaderCells(tableElement); + fixture.detectChanges(); + const firstResizer = headerCells[0].querySelector('.ts-header-cell__resizer')!; + + fixture.componentInstance.columnsChanged = jest.fn(); + + const mousedownEvent = createMouseEvent('mousedown'); + Object.defineProperties(mousedownEvent, { clientX: { get: () => '300' } }); + firstResizer.dispatchEvent(mousedownEvent); + fixture.detectChanges(); + const mousemoveEvent = createMouseEvent('mousemove'); + Object.defineProperties(mousemoveEvent, { clientX: { get: () => '280' } }); + document.dispatchEvent(mousemoveEvent); + fixture.detectChanges(); + + const mouseupEvent = createMouseEvent('mouseup'); + document.dispatchEvent(mouseupEvent); + fixture.detectChanges(); + fixture.detectChanges(); + + expect(fixture.componentInstance.columnsChanged).toHaveBeenCalled(); + }); + + test(`should stop click propagation so sorting isn't triggered`, () => { + const fixture = createComponent(testComponents.TableApp, undefined, [TsTableModule]); + fixture.detectChanges(); + const tableElement = fixture.nativeElement.querySelector('.ts-table'); + const headerCells = getHeaderCells(tableElement); + fixture.detectChanges(); + const firstResizer = headerCells[0].querySelector('.ts-header-cell__resizer')!; + const clickEvent = createMouseEvent('click'); + Object.defineProperties(clickEvent, { stopPropagation: { value: jest.fn() } }); + firstResizer.dispatchEvent(clickEvent); + fixture.detectChanges(); + + expect(clickEvent.stopPropagation).toHaveBeenCalled(); + }); + + }); + +}); diff --git a/terminus-ui/table/src/table.component.ts b/terminus-ui/table/src/table.component.ts index 94ea13a30..38f362c26 100644 --- a/terminus-ui/table/src/table.component.ts +++ b/terminus-ui/table/src/table.component.ts @@ -1,11 +1,44 @@ +import { Directionality } from '@angular/cdk/bidi'; +import { Platform } from '@angular/cdk/platform'; import { CdkTable } from '@angular/cdk/table'; +import { DOCUMENT } from '@angular/common'; import { + AfterViewChecked, + Attribute, ChangeDetectionStrategy, + ChangeDetectorRef, Component, + ContentChildren, + ElementRef, + EventEmitter, + Inject, Input, + IterableDiffers, + NgZone, + OnInit, + Optional, + Output, + QueryList, + Renderer2, ViewEncapsulation, } from '@angular/core'; import { isUndefined } from '@terminus/ngx-tools/type-guards'; +import { + defer, + merge, + Observable, +} from 'rxjs'; +import { + switchMap, + take, +} from 'rxjs/operators'; + +import { + TsHeaderCellDirective, + TsHeaderCellResizeEvent, + TsHeaderCellResizeHoverEvent, +} from './cell'; +import { TsRowComponent } from './row'; /** @@ -21,15 +54,38 @@ export interface TsColumn { [key: string]: any; } -// Default column width = ~112px -const DEFAULT_COLUMN_WIDTH = '7rem'; +/** + * Default column width. + * + * NOTE: Columns will only expand until all cell content is visible for the entire column. So we are defaulting to a very large number and + * rely on the consumer to constrain width where needed. + */ +const DEFAULT_COLUMN_WIDTH = '1000px'; /** - * Wrapper for the CdkTable with Material design styles. + * The payload for a columns change event + */ +export class TsTableColumnsChangeEvent { + constructor( + // The table instance that originated the event + public table: TsTableComponent, + // The updated array of columns + public columns: TsColumn[], + ) {} +} + + +/** + * The primary data table implementation * * @example - * + * * * * Title @@ -48,11 +104,8 @@ const DEFAULT_COLUMN_WIDTH = '7rem'; * * * - * - * - * - * - * + * + * * * * https://getterminus.github.io/ui-demos-release/components/table @@ -70,11 +123,42 @@ const DEFAULT_COLUMN_WIDTH = '7rem'; changeDetection: ChangeDetectionStrategy.OnPush, exportAs: 'tsTable', }) -export class TsTableComponent extends CdkTable { +// tslint:disable-next-line no-any +export class TsTableComponent extends CdkTable implements OnInit, AfterViewChecked { /** * Override the sticky CSS class set by the `CdkTable` */ - protected stickyCssClass = 'ts-cell--sticky'; + protected stickyCssClass = 'ts-table--sticky'; + + /** + * An observable of all header cell resize events + */ + public readonly headerCellResizes: Observable | Observable<{}> = defer(() => { + if (this.headerCells && this.headerCells.length) { + return merge(...this.headerCells.map(cell => cell.resized)); + } + + // If there are any subscribers before `ngAfterViewInit`, `headerCells` may be undefined. + // In that case, return a stream that we'll replace with the real one once everything is in place. + return this.ngZone.onStable + .asObservable() + .pipe(take(1), switchMap(() => this.headerCellResizes)); + }); + + /** + * An observable of all header cell hover events + */ + public readonly headerCellHovers: Observable | Observable<{}> = defer(() => { + if (this.headerCells && this.headerCells.length) { + return merge(...this.headerCells.map(cell => cell.hovered)); + } + + // If there are any subscribers before `ngAfterViewInit`, `headerCells` may be undefined. + // In that case, return a stream that we'll replace with the real one once everything is in place. + return this.ngZone.onStable + .asObservable() + .pipe(take(1), switchMap(() => this.headerCellHovers)); + }); /** * Return a simple array of column names @@ -85,6 +169,18 @@ export class TsTableComponent extends CdkTable { return this.columns.map(c => c.name); } + /** + * Access child rows + */ + @ContentChildren(TsRowComponent) + public rows: QueryList; + + /** + * Access header cells + */ + @ContentChildren(TsHeaderCellDirective, { descendants: true }) + public headerCells: QueryList; + /** * Define the array of columns */ @@ -100,6 +196,122 @@ export class TsTableComponent extends CdkTable { public get columns(): ReadonlyArray { return this._columns; } - private _columns: ReadonlyArray; + private _columns: TsColumn[]; + + /** + * Emit when a column is resized + * + * NOTE: This output is not debounce or throttled and may be called repeatedly + */ + @Output() + public readonly columnsChange = new EventEmitter(); + + + constructor( + private platform: Platform, + protected readonly differs: IterableDiffers, + protected readonly changeDetectorRef: ChangeDetectorRef, + @Optional() protected readonly dir: Directionality, + public readonly elementRef: ElementRef, + // tslint:disable-next-line no-attribute-decorator + @Attribute('role') role: string, + // tslint:disable-next-line no-any + @Inject(DOCUMENT) public document: any, + public renderer: Renderer2, + public ngZone: NgZone, + ) { + super(differs, changeDetectorRef, elementRef, role, dir, document, platform); + } + + + /** + * Subscribe to header cell resize & hover events + */ + public ngOnInit(): void { + super.ngOnInit(); + + this.headerCellResizes.subscribe(v => { + const { instance, width } = v as TsHeaderCellResizeEvent; + this.updateColumnWidth(instance.columnDef.name, width); + }); + + this.headerCellHovers.subscribe(v => { + const { instance, isHovered } = v as TsHeaderCellResizeHoverEvent; + this.updateColumnHoverClass(instance.columnDef.name, isHovered); + }); + } + + + /** + * Update the column width definitions when the rows change + */ + public ngAfterViewChecked(): void { + this.rows.changes.subscribe(() => { + for (const column of this.columns) { + this.setColumnWidthStyle(column.name, column.width); + } + }); + } + + + /** + * Update the stored column width + * + * @param columnName - The name of the column + * @param width - The width to set + */ + public updateColumnWidth(columnName: string, width: string): void { + this.setColumnWidthStyle(columnName, width); + const columns = this.columns.slice(); + const foundIndex = columns.findIndex(c => c.name === columnName); + + // istanbul ignore else + if (foundIndex >= 0) { + columns[foundIndex].width = width; + this.columns = columns; + this.columnsChange.emit(new TsTableColumnsChangeEvent(this, this.columns.slice())); + } + } + + + /** + * Set the max-width style for a column of cells + */ + public setColumnWidthStyle(columnName: string, width: string): void { + const columnCells = this.getColumnElements(columnName); + for (const cell of columnCells) { + this.renderer.setStyle(cell, 'max-width', width); + } + } + + + /** + * Collect all column elements from a column name + * + * @param columnName - The name of the column to collect + * @return An array of HTMLElements + */ + public getColumnElements(columnName: string): HTMLElement[] { + const className = `.ts-column-${columnName}`; + return Array.from(this.elementRef.nativeElement.querySelectorAll(className)); + } + + + /** + * Set the appropriate column class on mouseenter/mouseleave + * + * @param columnName - The name of the column to update + * @param isHovered - Whether the column is currently hovered + */ + public updateColumnHoverClass(columnName: string, isHovered: boolean): void { + const columnCells = this.getColumnElements(columnName); + for (const cell of columnCells) { + if (isHovered) { + this.renderer.addClass(cell, 'ts-cell--resizing'); + } else { + this.renderer.removeClass(cell, 'ts-cell--resizing'); + } + } + } } diff --git a/terminus-ui/table/src/table.module.ts b/terminus-ui/table/src/table.module.ts index df898f462..d8974ec9a 100644 --- a/terminus-ui/table/src/table.module.ts +++ b/terminus-ui/table/src/table.module.ts @@ -7,10 +7,10 @@ import { TsSortModule } from '@terminus/ui/sort'; import { TsCellDefDirective, TsCellDirective, - TsColumnDefDirective, TsHeaderCellDefDirective, TsHeaderCellDirective, } from './cell'; +import { TsColumnDefDirective } from './column'; import { TsHeaderRowComponent, TsHeaderRowDefDirective, @@ -21,6 +21,7 @@ import { TsTableComponent } from './table.component'; export * from './table-data-source'; export * from './cell'; +export * from './column'; export * from './row'; export * from './table.component'; diff --git a/terminus-ui/table/testing/src/test-components.ts b/terminus-ui/table/testing/src/test-components.ts index 4318223c8..37d2bd1c4 100644 --- a/terminus-ui/table/testing/src/test-components.ts +++ b/terminus-ui/table/testing/src/test-components.ts @@ -8,6 +8,7 @@ import { TsSortHeaderComponent, } from '@terminus/ui/sort'; import { + TsTableColumnsChangeEvent, TsTableComponent, TsTableDataSource, TsTableModule, @@ -21,7 +22,12 @@ import { @Component({ template: ` - + Column A {{ row.a }} @@ -41,8 +47,8 @@ import { fourth_row - - + + `, @@ -55,9 +61,10 @@ export class TableApp { public columnsToRender = ['column_a', 'column_b', 'column_c']; public columns = this.columnsToRender.map(c => ({ name: c, - width: '112px', + width: '100px', })); public isFourthRow = (i: number, _rowData: TestData) => i === 3; + public columnsChanged(e: TsTableColumnsChangeEvent) {} } diff --git a/terminus-ui/table/testing/src/test-helpers.ts b/terminus-ui/table/testing/src/test-helpers.ts index 019656bea..159007761 100644 --- a/terminus-ui/table/testing/src/test-helpers.ts +++ b/terminus-ui/table/testing/src/test-helpers.ts @@ -11,7 +11,6 @@ export interface TestData { c: string; } -// TODO: change to my datasource - says properties connect aren't the same??? export class FakeDataSource extends DataSource { public _dataChange = new BehaviorSubject([]); public set data(data: TestData[]) { @@ -50,28 +49,76 @@ export class FakeDataSource extends DataSource { -// Utilities copied from CDKTable's spec +/** + * Query elements within another element + * + * @param element - The element to search within + * @param query - The selector to query + * @return An array of Elements + */ export function getElements(element: Element, query: string): Element[] { return [].slice.call(element.querySelectorAll(query)); } +/** + * Get the header row + * + * @param tableElement - The table element to search within + * @return The table element + */ export function getHeaderRow(tableElement: Element): Element { return tableElement.querySelector('.ts-header-row')!; } +/** + * Get all row elements + * + * @param tableElement - The table to search within + * @return An array of row elements + */ export function getRows(tableElement: Element): Element[] { return getElements(tableElement, '.ts-row'); } +/** + * Get all cells within a row + * + * @param row - The row to search within + * @return An array of cells + */ export function getCells(row: Element): Element[] { return row ? getElements(row, '.ts-cell') : []; } +/** + * Get all header cells + * + * @param tableElement - The table to search within + * @return An array of header cells + */ export function getHeaderCells(tableElement: Element): Element[] { return getElements(getHeaderRow(tableElement), '.ts-header-cell'); } -export function expectTableToMatchContent(tableElement: Element, expectedTableContent: any[]) { +/** + * Get all cells within a column + * + * @param columnName - The name of the column to search for + * @param tableElement - The table to search within + * @return An array of column cells + */ +export function getColumnElements(columnName: string, tableElement: Element): HTMLElement[] { + const className = `.ts-column-${columnName}`; + return Array.from(tableElement.querySelectorAll(className)); +} + +/** + * Custom matcher to determine if the table's contents are correct + * + * @param tableElement - The table element to check + * @param expectedTableContent - The content that should be found in the table + */ +export function expectTableToMatchContent(tableElement: Element, expectedTableContent: any[]): void { const missedExpectations: string[] = []; function checkCellContent(cell: Element, expectedTextContent: string) { const actualTextContent = cell.textContent!.trim();