,
) {
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();