From 198911f5c0dd198f42ee0476a3291ec9858a0660 Mon Sep 17 00:00:00 2001 From: Karl Seamon Date: Thu, 13 Feb 2020 16:41:59 -0500 Subject: [PATCH] feat(cdk-experimental/table): experimental column resize (#16114) --- .github/CODEOWNERS | 3 + .../column-resize/BUILD.bazel | 24 + .../column-resize-flex.ts | 39 ++ .../column-resize-directives/column-resize.ts | 39 ++ .../column-resize-directives/constants.ts | 30 ++ .../default-enabled-column-resize-flex.ts | 39 ++ .../default-enabled-column-resize.ts | 39 ++ .../column-resize/column-resize-module.ts | 38 ++ .../column-resize/column-resize-notifier.ts | 56 ++ .../column-resize/column-resize.ts | 110 ++++ .../column-resize/column-size-store.ts | 21 + .../column-resize/event-dispatcher.ts | 99 ++++ src/cdk-experimental/column-resize/index.ts | 9 + .../column-resize/overlay-handle.ts | 208 ++++++++ .../column-resize/public-api.ts | 21 + .../column-resize/resizable.ts | 250 +++++++++ .../column-resize/resize-ref.ts | 19 + .../column-resize/resize-strategy.ts | 185 +++++++ .../column-resize/selectors.ts | 16 + src/cdk-experimental/config.bzl | 1 + .../popover-edit/public-api.ts | 3 +- src/components-examples/BUILD.bazel | 1 + .../column-resize/BUILD.bazel | 26 + ...-enabled-column-resize-flex-demo-module.ts | 24 + ...fault-enabled-column-resize-flex-demo.html | 28 + ...default-enabled-column-resize-flex-demo.ts | 52 ++ ...fault-enabled-column-resize-demo-module.ts | 24 + .../default-enabled-column-resize-demo.html | 28 + .../default-enabled-column-resize-demo.ts | 52 ++ .../column-resize/index.ts | 20 + .../opt-in-column-resize-demo-module.ts | 24 + .../opt-in/opt-in-column-resize-demo.html | 28 + .../opt-in/opt-in-column-resize-demo.ts | 52 ++ src/dev-app/BUILD.bazel | 2 + src/dev-app/column-resize/BUILD.bazel | 15 + .../column-resize-demo-module.ts | 31 ++ .../column-resize/column-resize-home.html | 29 ++ .../column-resize/column-resize-home.ts | 16 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/dev-app/routes.ts | 4 + src/dev-app/theme.scss | 3 + .../column-resize/BUILD.bazel | 48 ++ .../column-resize/_column-resize.scss | 96 ++++ .../column-resize-flex.ts | 42 ++ .../column-resize-directives/column-resize.ts | 42 ++ .../column-resize-directives/common.ts | 50 ++ .../default-enabled-column-resize-flex.ts | 42 ++ .../default-enabled-column-resize.ts | 42 ++ .../column-resize/column-resize-module.ts | 68 +++ .../column-resize/column-resize.spec.ts | 477 ++++++++++++++++++ .../column-resize/index.ts | 9 + .../column-resize/overlay-handle.ts | 66 +++ .../column-resize/public-api.ts | 17 + .../resizable-directives/common.ts | 32 ++ .../default-enabled-resizable.ts | 58 +++ .../resizable-directives/resizable.ts | 57 +++ .../column-resize/resize-strategy.ts | 40 ++ src/material-experimental/config.bzl | 1 + 58 files changed, 2895 insertions(+), 1 deletion(-) create mode 100644 src/cdk-experimental/column-resize/BUILD.bazel create mode 100644 src/cdk-experimental/column-resize/column-resize-directives/column-resize-flex.ts create mode 100644 src/cdk-experimental/column-resize/column-resize-directives/column-resize.ts create mode 100644 src/cdk-experimental/column-resize/column-resize-directives/constants.ts create mode 100644 src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts create mode 100644 src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts create mode 100644 src/cdk-experimental/column-resize/column-resize-module.ts create mode 100644 src/cdk-experimental/column-resize/column-resize-notifier.ts create mode 100644 src/cdk-experimental/column-resize/column-resize.ts create mode 100644 src/cdk-experimental/column-resize/column-size-store.ts create mode 100644 src/cdk-experimental/column-resize/event-dispatcher.ts create mode 100644 src/cdk-experimental/column-resize/index.ts create mode 100644 src/cdk-experimental/column-resize/overlay-handle.ts create mode 100644 src/cdk-experimental/column-resize/public-api.ts create mode 100644 src/cdk-experimental/column-resize/resizable.ts create mode 100644 src/cdk-experimental/column-resize/resize-ref.ts create mode 100644 src/cdk-experimental/column-resize/resize-strategy.ts create mode 100644 src/cdk-experimental/column-resize/selectors.ts create mode 100644 src/components-examples/material-experimental/column-resize/BUILD.bazel create mode 100644 src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo-module.ts create mode 100644 src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.html create mode 100644 src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.ts create mode 100644 src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo-module.ts create mode 100644 src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.html create mode 100644 src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.ts create mode 100644 src/components-examples/material-experimental/column-resize/index.ts create mode 100644 src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo-module.ts create mode 100644 src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.html create mode 100644 src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.ts create mode 100644 src/dev-app/column-resize/BUILD.bazel create mode 100644 src/dev-app/column-resize/column-resize-demo-module.ts create mode 100644 src/dev-app/column-resize/column-resize-home.html create mode 100644 src/dev-app/column-resize/column-resize-home.ts create mode 100644 src/material-experimental/column-resize/BUILD.bazel create mode 100644 src/material-experimental/column-resize/_column-resize.scss create mode 100644 src/material-experimental/column-resize/column-resize-directives/column-resize-flex.ts create mode 100644 src/material-experimental/column-resize/column-resize-directives/column-resize.ts create mode 100644 src/material-experimental/column-resize/column-resize-directives/common.ts create mode 100644 src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts create mode 100644 src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts create mode 100644 src/material-experimental/column-resize/column-resize-module.ts create mode 100644 src/material-experimental/column-resize/column-resize.spec.ts create mode 100644 src/material-experimental/column-resize/index.ts create mode 100644 src/material-experimental/column-resize/overlay-handle.ts create mode 100644 src/material-experimental/column-resize/public-api.ts create mode 100644 src/material-experimental/column-resize/resizable-directives/common.ts create mode 100644 src/material-experimental/column-resize/resizable-directives/default-enabled-resizable.ts create mode 100644 src/material-experimental/column-resize/resizable-directives/resizable.ts create mode 100644 src/material-experimental/column-resize/resize-strategy.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59c7b3532199..13c3e338d63b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -91,6 +91,7 @@ # Material experimental package /src/material-experimental/* @jelbourn +/src/material-experimental/column-resize/** @kseamon @andrewseguin /src/material-experimental/mdc-autocomplete/** @crisbeto /src/material-experimental/mdc-button/** @andrewseguin /src/material-experimental/mdc-card/** @mmalerba @@ -118,6 +119,7 @@ # CDK experimental package /src/cdk-experimental/* @jelbourn +/src/cdk-experimental/column-resize/** @kseamon @andrewseguin /src/cdk-experimental/dialog/** @jelbourn @crisbeto /src/cdk-experimental/popover-edit/** @kseamon @andrewseguin /src/cdk-experimental/scrolling/** @mmalerba @@ -140,6 +142,7 @@ /src/dev-app/card/** @jelbourn /src/dev-app/checkbox/** @jelbourn @devversion /src/dev-app/chips/** @jelbourn +/src/dev-app/column-resize/** @kseamon @andrewseguin /src/dev-app/connected-overlay/** @jelbourn @crisbeto /src/dev-app/dataset/** @andrewseguin /src/dev-app/datepicker/** @mmalerba diff --git a/src/cdk-experimental/column-resize/BUILD.bazel b/src/cdk-experimental/column-resize/BUILD.bazel new file mode 100644 index 000000000000..cf332302dde7 --- /dev/null +++ b/src/cdk-experimental/column-resize/BUILD.bazel @@ -0,0 +1,24 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module") + +ng_module( + name = "column-resize", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/cdk-experimental/column-resize", + deps = [ + "//src/cdk-experimental/popover-edit", + "//src/cdk/bidi", + "//src/cdk/coercion", + "//src/cdk/keycodes", + "//src/cdk/overlay", + "//src/cdk/portal", + "//src/cdk/table", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//rxjs", + ], +) diff --git a/src/cdk-experimental/column-resize/column-resize-directives/column-resize-flex.ts b/src/cdk-experimental/column-resize/column-resize-directives/column-resize-flex.ts new file mode 100644 index 000000000000..0bcd573541e4 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-directives/column-resize-flex.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; + +import {ColumnResize} from '../column-resize'; +import {ColumnResizeNotifier, ColumnResizeNotifierSource} from '../column-resize-notifier'; +import {HeaderRowEventDispatcher} from '../event-dispatcher'; +import {HOST_BINDINGS, FLEX_PROVIDERS} from './constants'; + +/** + * Explicitly enables column resizing for a flexbox-based cdk-table. + * Individual columns must be annotated specifically. + */ +@Directive({ + selector: 'cdk-table[columnResize]', + host: HOST_BINDINGS, + providers: [ + ...FLEX_PROVIDERS, + {provide: ColumnResize, useExisting: CdkColumnResizeFlex}, + ], +}) +export class CdkColumnResizeFlex extends ColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/cdk-experimental/column-resize/column-resize-directives/column-resize.ts b/src/cdk-experimental/column-resize/column-resize-directives/column-resize.ts new file mode 100644 index 000000000000..f9097c2549d0 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-directives/column-resize.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; + +import {ColumnResize} from '../column-resize'; +import {ColumnResizeNotifier, ColumnResizeNotifierSource} from '../column-resize-notifier'; +import {HeaderRowEventDispatcher} from '../event-dispatcher'; +import {HOST_BINDINGS, TABLE_PROVIDERS} from './constants'; + +/** + * Explicitly enables column resizing for a table-based cdk-table. + * Individual columns must be annotated specifically. + */ +@Directive({ + selector: 'table[cdk-table][columnResize]', + host: HOST_BINDINGS, + providers: [ + ...TABLE_PROVIDERS, + {provide: ColumnResize, useExisting: CdkColumnResize}, + ], +}) +export class CdkColumnResize extends ColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/cdk-experimental/column-resize/column-resize-directives/constants.ts b/src/cdk-experimental/column-resize/column-resize-directives/constants.ts new file mode 100644 index 000000000000..ef884b567456 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-directives/constants.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Provider} from '@angular/core'; +import {ColumnResizeNotifier, ColumnResizeNotifierSource} from '../column-resize-notifier'; +import {HeaderRowEventDispatcher} from '../event-dispatcher'; +import { + TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER, + FLEX_RESIZE_STRATEGY_PROVIDER, +} from '../resize-strategy'; + +const PROVIDERS: Provider[] = [ + ColumnResizeNotifier, + HeaderRowEventDispatcher, + ColumnResizeNotifierSource, +]; + +export const TABLE_PROVIDERS: Provider[] = [ + ...PROVIDERS, + TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER, +]; +export const FLEX_PROVIDERS: Provider[] = [...PROVIDERS, FLEX_RESIZE_STRATEGY_PROVIDER]; +export const HOST_BINDINGS = { + '[class.cdk-column-resize-rtl]': 'directionality.value === "rtl"', +}; diff --git a/src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts b/src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts new file mode 100644 index 000000000000..b4d24f29cd71 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; + +import {ColumnResize} from '../column-resize'; +import {ColumnResizeNotifier, ColumnResizeNotifierSource} from '../column-resize-notifier'; +import {HeaderRowEventDispatcher} from '../event-dispatcher'; +import {HOST_BINDINGS, FLEX_PROVIDERS} from './constants'; + +/** + * Implicitly enables column resizing for a flex cdk-table. + * Individual columns will be resizable unless opted out. + */ +@Directive({ + selector: 'cdk-table', + host: HOST_BINDINGS, + providers: [ + ...FLEX_PROVIDERS, + {provide: ColumnResize, useExisting: CdkDefaultEnabledColumnResizeFlex}, + ], +}) +export class CdkDefaultEnabledColumnResizeFlex extends ColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts b/src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts new file mode 100644 index 000000000000..e0c71d89a520 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; + +import {ColumnResize} from '../column-resize'; +import {ColumnResizeNotifier, ColumnResizeNotifierSource} from '../column-resize-notifier'; +import {HeaderRowEventDispatcher} from '../event-dispatcher'; +import {HOST_BINDINGS, TABLE_PROVIDERS} from './constants'; + +/** + * Implicitly enables column resizing for a table-based cdk-table. + * Individual columns will be resizable unless opted out. + */ +@Directive({ + selector: 'table[cdk-table]', + host: HOST_BINDINGS, + providers: [ + ...TABLE_PROVIDERS, + {provide: ColumnResize, useExisting: CdkDefaultEnabledColumnResize}, + ], +}) +export class CdkDefaultEnabledColumnResize extends ColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/cdk-experimental/column-resize/column-resize-module.ts b/src/cdk-experimental/column-resize/column-resize-module.ts new file mode 100644 index 000000000000..501f22bee17a --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-module.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; + +import {CdkColumnResize} from './column-resize-directives/column-resize'; +import {CdkColumnResizeFlex} from './column-resize-directives/column-resize-flex'; +import { + CdkDefaultEnabledColumnResize +} from './column-resize-directives/default-enabled-column-resize'; +import { + CdkDefaultEnabledColumnResizeFlex +} from './column-resize-directives/default-enabled-column-resize-flex'; + +/** + * One of two NgModules for use with CdkColumnResize. + * When using this module, columns are resizable by default. + */ +@NgModule({ + declarations: [CdkDefaultEnabledColumnResize, CdkDefaultEnabledColumnResizeFlex], + exports: [CdkDefaultEnabledColumnResize, CdkDefaultEnabledColumnResizeFlex], +}) +export class CdkColumnResizeDefaultEnabledModule {} + +/** + * One of two NgModules for use with CdkColumnResize. + * When using this module, columns are not resizable by default. + */ +@NgModule({ + declarations: [CdkColumnResize, CdkColumnResizeFlex], + exports: [CdkColumnResize, CdkColumnResizeFlex], +}) +export class CdkColumnResizeModule {} diff --git a/src/cdk-experimental/column-resize/column-resize-notifier.ts b/src/cdk-experimental/column-resize/column-resize-notifier.ts new file mode 100644 index 000000000000..7d424c4a60c3 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize-notifier.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; + +/** Indicates the width of a column. */ +export interface ColumnSize { + /** The ID/name of the column, as defined in CdkColumnDef. */ + readonly columnId: string; + + /** The width in pixels of the column. */ + readonly size: number; +} + +/** Interface describing column size changes. */ +export interface ColumnSizeAction extends ColumnSize { + /** + * Whether the resize action should be applied instantaneously. False for events triggered during + * a UI-triggered resize (such as with the mouse) until the mouse button is released. True + * for all programatically triggered resizes. + */ + readonly completeImmediately?: boolean; +} + +/** Originating source of column resize events within a table. */ +@Injectable() +export class ColumnResizeNotifierSource { + /** Emits when an in-progress resize is canceled. */ + readonly resizeCanceled = new Subject(); + + /** Emits when a resize is applied. */ + readonly resizeCompleted = new Subject(); + + /** Triggers a resize action. */ + readonly triggerResize = new Subject(); +} + +/** Service for triggering column resizes imperatively or being notified of them. */ +@Injectable() +export class ColumnResizeNotifier { + /** Emits whenever a column is resized. */ + readonly resizeCompleted: Observable = this._source.resizeCompleted.asObservable(); + + constructor(private readonly _source: ColumnResizeNotifierSource) {} + + /** Instantly resizes the specified column. */ + resize(columnId: string, size: number): void { + this._source.triggerResize.next({columnId, size, completeImmediately: true}); + } +} diff --git a/src/cdk-experimental/column-resize/column-resize.ts b/src/cdk-experimental/column-resize/column-resize.ts new file mode 100644 index 000000000000..af9d5a405a6a --- /dev/null +++ b/src/cdk-experimental/column-resize/column-resize.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AfterViewInit, Directive, ElementRef, NgZone, OnDestroy} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import {fromEvent, merge, ReplaySubject} from 'rxjs'; +import {filter, map, mapTo, pairwise, startWith, take, takeUntil} from 'rxjs/operators'; + +import {_closest, _matches} from '@angular/cdk-experimental/popover-edit'; + +import {ColumnResizeNotifier, ColumnResizeNotifierSource} from './column-resize-notifier'; +import {HEADER_CELL_SELECTOR, RESIZE_OVERLAY_SELECTOR} from './selectors'; +import {HeaderRowEventDispatcher} from './event-dispatcher'; + +const HOVER_OR_ACTIVE_CLASS = 'cdk-column-resize-hover-or-active'; +const WITH_RESIZED_COLUMN_CLASS = 'cdk-column-resize-with-resized-column'; + +let nextId = 0; + +/** + * Base class for ColumnResize directives which attach to mat-table elements to + * provide common events and services for column resizing. + */ +@Directive() +export abstract class ColumnResize implements AfterViewInit, OnDestroy { + protected readonly destroyed = new ReplaySubject(); + + /* Publicly accessible interface for triggering and being notified of resizes. */ + abstract readonly columnResizeNotifier: ColumnResizeNotifier; + + abstract readonly directionality: Directionality; + protected abstract readonly elementRef: ElementRef; + protected abstract readonly eventDispatcher: HeaderRowEventDispatcher; + protected abstract readonly ngZone: NgZone; + protected abstract readonly notifier: ColumnResizeNotifierSource; + + /** Unique ID for this table instance. */ + protected readonly selectorId = `${++nextId}`; + + /** The id attribute of the table, if specified. */ + id?: string; + + ngAfterViewInit() { + this.elementRef.nativeElement!.classList.add(this.getUniqueCssClass()); + + this._listenForRowHoverEvents(); + this._listenForResizeActivity(); + this._listenForHoverActivity(); + } + + ngOnDestroy() { + this.destroyed.next(); + this.destroyed.complete(); + } + + /** Gets the unique CSS class name for this table instance. */ + getUniqueCssClass() { + return `cdk-column-resize-${this.selectorId}`; + } + + private _listenForRowHoverEvents() { + this.ngZone.runOutsideAngular(() => { + const element = this.elementRef.nativeElement!; + + fromEvent(element, 'mouseover').pipe( + map(event => _closest(event.target, HEADER_CELL_SELECTOR)), + takeUntil(this.destroyed), + ).subscribe(this.eventDispatcher.headerCellHovered); + fromEvent(element, 'mouseleave').pipe( + filter(event => !!event.relatedTarget && + !_matches(event.relatedTarget as Element, RESIZE_OVERLAY_SELECTOR)), + mapTo(null), + takeUntil(this.destroyed), + ).subscribe(this.eventDispatcher.headerCellHovered); + }); + } + + private _listenForResizeActivity() { + merge( + this.eventDispatcher.overlayHandleActiveForCell.pipe(mapTo(undefined)), + this.notifier.triggerResize.pipe(mapTo(undefined)), + this.notifier.resizeCompleted.pipe(mapTo(undefined)) + ).pipe( + takeUntil(this.destroyed), + take(1), + ).subscribe(() => { + this.elementRef.nativeElement!.classList.add(WITH_RESIZED_COLUMN_CLASS); + }); + } + + private _listenForHoverActivity() { + this.eventDispatcher.headerRowHoveredOrActiveDistinct.pipe( + startWith(null), + pairwise(), + takeUntil(this.destroyed), + ).subscribe(([previousRow, hoveredRow]) => { + if (hoveredRow) { + hoveredRow.classList.add(HOVER_OR_ACTIVE_CLASS); + } + if (previousRow) { + previousRow.classList.remove(HOVER_OR_ACTIVE_CLASS); + } + }); + } +} diff --git a/src/cdk-experimental/column-resize/column-size-store.ts b/src/cdk-experimental/column-resize/column-size-store.ts new file mode 100644 index 000000000000..8b923beec185 --- /dev/null +++ b/src/cdk-experimental/column-resize/column-size-store.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable} from '@angular/core'; + +/** + * Can be provided by the host application to enable persistence of column resize state. + */ +@Injectable() +export abstract class ColumnSizeStore { + /** Returns the persisted size of the specified column in the specified table. */ + abstract getSize(tableId: string, columnId: string): number; + + /** Persists the size of the specified column in the specified table. */ + abstract setSize(tableId: string, columnId: string): void; +} diff --git a/src/cdk-experimental/column-resize/event-dispatcher.ts b/src/cdk-experimental/column-resize/event-dispatcher.ts new file mode 100644 index 000000000000..b1294a66bc4f --- /dev/null +++ b/src/cdk-experimental/column-resize/event-dispatcher.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable, NgZone} from '@angular/core'; +import {combineLatest, MonoTypeOperatorFunction, Observable, Subject} from 'rxjs'; +import {distinctUntilChanged, map, share, skip, startWith} from 'rxjs/operators'; + +import {_closest} from '@angular/cdk-experimental/popover-edit'; + +import {HEADER_ROW_SELECTOR} from './selectors'; + +/** Coordinates events between the column resize directives. */ +@Injectable() +export class HeaderRowEventDispatcher { + /** + * Emits the currently hovered header cell or null when no header cells are hovered. + * Exposed publicly for events to feed in, but subscribers should use headerCellHoveredDistinct, + * defined below. + */ + readonly headerCellHovered = new Subject(); + + /** + * Emits the header cell for which a user-triggered resize is active or null + * when no resize is in progress. + */ + readonly overlayHandleActiveForCell = new Subject(); + + constructor(private readonly _ngZone: NgZone) {} + + /** Distinct and shared version of headerCellHovered. */ + readonly headerCellHoveredDistinct = this.headerCellHovered.pipe( + distinctUntilChanged(), + share(), + ); + + /** + * Emits the header that is currently hovered or hosting an active resize event (with active + * taking precedence). + */ + readonly headerRowHoveredOrActiveDistinct = combineLatest( + this.headerCellHoveredDistinct.pipe( + map(cell => _closest(cell, HEADER_ROW_SELECTOR)), + startWith(null), + distinctUntilChanged(), + ), + this.overlayHandleActiveForCell.pipe( + map(cell => _closest(cell, HEADER_ROW_SELECTOR)), + startWith(null), + distinctUntilChanged(), + ), + ).pipe( + skip(1), // Ignore initial [null, null] emission. + map(([hovered, active]) => active || hovered), + distinctUntilChanged(), + share(), + ); + + private readonly _headerRowHoveredOrActiveDistinctReenterZone = + this.headerRowHoveredOrActiveDistinct.pipe( + this._enterZone(), + share(), + ); + + // Optimization: Share row events observable with subsequent callers. + // At startup, calls will be sequential by row (and typically there's only one). + private _lastSeenRow: Element|null = null; + private _lastSeenRowHover: Observable|null = null; + + /** + * Emits whether the specified row should show its overlay controls. + * Emission occurs within the NgZone. + */ + resizeOverlayVisibleForHeaderRow(row: Element): Observable { + if (row !== this._lastSeenRow) { + this._lastSeenRow = row; + this._lastSeenRowHover = this._headerRowHoveredOrActiveDistinctReenterZone.pipe( + map(hoveredRow => hoveredRow === row), + distinctUntilChanged(), + share(), + ); + } + + return this._lastSeenRowHover!; + } + + private _enterZone(): MonoTypeOperatorFunction { + return (source: Observable) => + new Observable((observer) => source.subscribe({ + next: (value) => this._ngZone.run(() => observer.next(value)), + error: (err) => observer.error(err), + complete: () => observer.complete() + })); + } +} diff --git a/src/cdk-experimental/column-resize/index.ts b/src/cdk-experimental/column-resize/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk-experimental/column-resize/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/column-resize/overlay-handle.ts b/src/cdk-experimental/column-resize/overlay-handle.ts new file mode 100644 index 000000000000..1441edf7cf5b --- /dev/null +++ b/src/cdk-experimental/column-resize/overlay-handle.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AfterViewInit, Directive, ElementRef, OnDestroy, NgZone} from '@angular/core'; +import {coerceCssPixelValue} from '@angular/cdk/coercion'; +import {Directionality} from '@angular/cdk/bidi'; +import {ESCAPE} from '@angular/cdk/keycodes'; +import {CdkColumnDef} from '@angular/cdk/table'; +import {fromEvent, ReplaySubject} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + mapTo, + pairwise, + startWith, + takeUntil, +} from 'rxjs/operators'; + +import {_closest} from '@angular/cdk-experimental/popover-edit'; + +import {HEADER_CELL_SELECTOR} from './selectors'; +import {ColumnResizeNotifierSource} from './column-resize-notifier'; +import {HeaderRowEventDispatcher} from './event-dispatcher'; +import {ResizeRef} from './resize-ref'; + +// TODO: Take another look at using cdk drag drop. IIRC I ran into a couple +// good reasons for not using it but I don't remember what they were at this point. +/** + * Base class for a component shown over the edge of a resizable column that is responsible + * for handling column resize mouse events and displaying any visible UI on the column edge. + */ +@Directive() +export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { + protected readonly destroyed = new ReplaySubject(); + + protected abstract readonly columnDef: CdkColumnDef; + protected abstract readonly document: Document; + protected abstract readonly directionality: Directionality; + protected abstract readonly elementRef: ElementRef; + protected abstract readonly eventDispatcher: HeaderRowEventDispatcher; + protected abstract readonly ngZone: NgZone; + protected abstract readonly resizeNotifier: ColumnResizeNotifierSource; + protected abstract readonly resizeRef: ResizeRef; + + ngAfterViewInit() { + this._listenForMouseEvents(); + } + + ngOnDestroy() { + this.destroyed.next(); + this.destroyed.complete(); + } + + private _listenForMouseEvents() { + this.ngZone.runOutsideAngular(() => { + const takeUntilDestroyed = takeUntil(this.destroyed); + + fromEvent(this.elementRef.nativeElement!, 'mouseenter').pipe( + takeUntilDestroyed, + mapTo(this.resizeRef.origin.nativeElement!), + ).subscribe(cell => this.eventDispatcher.headerCellHovered.next(cell)); + + fromEvent(this.elementRef.nativeElement!, 'mouseleave').pipe( + takeUntilDestroyed, + map(event => event.relatedTarget && + _closest(event.relatedTarget as Element, HEADER_CELL_SELECTOR)), + ).subscribe(cell => this.eventDispatcher.headerCellHovered.next(cell)); + + fromEvent(this.elementRef.nativeElement!, 'mousedown') + .pipe(takeUntilDestroyed).subscribe(mousedownEvent => { + this._dragStarted(mousedownEvent); + }); + }); + } + + private _dragStarted(mousedownEvent: MouseEvent) { + const mouseup = fromEvent(this.document, 'mouseup'); + const mousemove = fromEvent(this.document, 'mousemove'); + const escape = fromEvent(this.document, 'keyup') + .pipe(filter(event => event.keyCode === ESCAPE)); + + const startX = mousedownEvent.screenX; + + const initialOverlayOffset = this._getOverlayOffset(); + const initialSize = this._getOriginWidth(); + let overlayOffset = initialOverlayOffset; + let originOffset = this._getOriginOffset(); + let size = initialSize; + let overshot = 0; + + this.updateResizeActive(true); + + mouseup.pipe(takeUntil(escape), takeUntil(this.destroyed)).subscribe(({screenX}) => { + this._notifyResizeEnded(size, screenX !== startX); + }); + + escape.pipe(takeUntil(mouseup), takeUntil(this.destroyed)).subscribe(() => { + this._notifyResizeEnded(initialSize); + }); + + mousemove.pipe( + map(({screenX}) => screenX), + startWith(startX), + distinctUntilChanged(), + pairwise(), + takeUntil(mouseup), + takeUntil(escape), + takeUntil(this.destroyed), + ).subscribe(([prevX, currX]) => { + let deltaX = currX - prevX; + + // If the mouse moved further than the resize was able to match, limit the + // movement of the overlay to match the actual size and position of the origin. + if (overshot !== 0) { + if (overshot < 0 && deltaX < 0 || overshot > 0 && deltaX > 0) { + overshot += deltaX; + return; + } else { + const remainingOvershot = overshot + deltaX; + overshot = overshot > 0 ? + Math.max(remainingOvershot, 0) : Math.min(remainingOvershot, 0); + deltaX = remainingOvershot - overshot; + + if (deltaX === 0) { + return; + } + } + } + + let computedNewSize: number = size + (this._isLtr() ? deltaX : -deltaX); + computedNewSize = Math.min( + Math.max(computedNewSize, this.resizeRef.minWidthPx, 0), this.resizeRef.maxWidthPx); + + this.resizeNotifier.triggerResize.next( + {columnId: this.columnDef.name, size: computedNewSize}); + + const originNewSize = this._getOriginWidth(); + const originNewOffset = this._getOriginOffset(); + const originOffsetDeltaX = originNewOffset - originOffset; + const originSizeDeltaX = originNewSize - size; + size = originNewSize; + originOffset = originNewOffset; + + overshot += deltaX + (this._isLtr() ? -originSizeDeltaX : originSizeDeltaX); + overlayOffset += originSizeDeltaX + originOffsetDeltaX; + + this._updateOverlayOffset(overlayOffset); + }); + } + + protected updateResizeActive(active: boolean): void { + this.eventDispatcher.overlayHandleActiveForCell.next( + active ? this.resizeRef.origin.nativeElement! : null); + } + + private _getOriginWidth(): number { + return this.resizeRef.origin.nativeElement!.offsetWidth; + } + + private _getOriginOffset(): number { + const originElement = this.resizeRef.origin.nativeElement!; + const offsetLeft = originElement.offsetLeft; + + return this._isLtr() ? + offsetLeft : + originElement.offsetParent!.offsetWidth - (offsetLeft + this._getOriginWidth()); + } + + private _getOverlayOffset(): number { + const overlayElement = this.resizeRef.overlayRef.overlayElement; + return this._isLtr() ? + parseInt(overlayElement.style.left!, 10) : parseInt(overlayElement.style.right!, 10); + } + + private _updateOverlayOffset(offset: number): void { + const overlayElement = this.resizeRef.overlayRef.overlayElement; + const overlayOffsetCssValue = coerceCssPixelValue(offset); + + if (this._isLtr()) { + overlayElement.style.left = overlayOffsetCssValue; + } else { + overlayElement.style.right = overlayOffsetCssValue; + } + } + + private _isLtr(): boolean { + return this.directionality.value === 'ltr'; + } + + private _notifyResizeEnded(size: number, completedSuccessfully = false): void { + this.updateResizeActive(false); + + this.ngZone.run(() => { + const sizeMessage = {columnId: this.columnDef.name, size}; + if (completedSuccessfully) { + this.resizeNotifier.resizeCompleted.next(sizeMessage); + } else { + this.resizeNotifier.resizeCanceled.next(sizeMessage); + } + }); + } +} diff --git a/src/cdk-experimental/column-resize/public-api.ts b/src/cdk-experimental/column-resize/public-api.ts new file mode 100644 index 000000000000..ec573c44b010 --- /dev/null +++ b/src/cdk-experimental/column-resize/public-api.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './column-resize'; +export * from './column-resize-directives/column-resize'; +export * from './column-resize-directives/column-resize-flex'; +export * from './column-resize-directives/default-enabled-column-resize'; +export * from './column-resize-directives/default-enabled-column-resize-flex'; +export * from './column-resize-module'; +export * from './column-resize-notifier'; +export * from './column-size-store'; +export * from './event-dispatcher'; +export * from './resizable'; +export * from './resize-ref'; +export * from './resize-strategy'; +export * from './overlay-handle'; diff --git a/src/cdk-experimental/column-resize/resizable.ts b/src/cdk-experimental/column-resize/resizable.ts new file mode 100644 index 000000000000..92fc85b84805 --- /dev/null +++ b/src/cdk-experimental/column-resize/resizable.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + AfterViewInit, + ElementRef, + Injector, + NgZone, + OnDestroy, + Type, + ViewContainerRef, +} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import {ComponentPortal, PortalInjector} from '@angular/cdk/portal'; +import {Overlay, OverlayRef} from '@angular/cdk/overlay'; +import {CdkColumnDef} from '@angular/cdk/table'; +import {merge, ReplaySubject} from 'rxjs'; +import {filter, takeUntil} from 'rxjs/operators'; + +import {_closest} from '@angular/cdk-experimental/popover-edit'; + +import {HEADER_ROW_SELECTOR} from './selectors'; +import {ResizeOverlayHandle} from './overlay-handle'; +import {ColumnResize} from './column-resize'; +import {ColumnSizeAction, ColumnResizeNotifierSource} from './column-resize-notifier'; +import {HeaderRowEventDispatcher} from './event-dispatcher'; +import {ResizeRef} from './resize-ref'; +import {ResizeStrategy} from './resize-strategy'; + +const OVERLAY_ACTIVE_CLASS = 'cdk-resizable-overlay-thumb-active'; + +/** + * Base class for Resizable directives which are applied to column headers to make those columns + * resizable. + */ +export abstract class Resizable + implements AfterViewInit, OnDestroy { + protected minWidthPxInternal: number = 0; + protected maxWidthPxInternal: number = Number.MAX_SAFE_INTEGER; + + protected inlineHandle?: HTMLElement; + protected overlayRef?: OverlayRef; + protected readonly destroyed = new ReplaySubject(); + + protected abstract readonly columnDef: CdkColumnDef; + protected abstract readonly columnResize: ColumnResize; + protected abstract readonly directionality: Directionality; + protected abstract readonly document: Document; + protected abstract readonly elementRef: ElementRef; + protected abstract readonly eventDispatcher: HeaderRowEventDispatcher; + protected abstract readonly injector: Injector; + protected abstract readonly ngZone: NgZone; + protected abstract readonly overlay: Overlay; + protected abstract readonly resizeNotifier: ColumnResizeNotifierSource; + protected abstract readonly resizeStrategy: ResizeStrategy; + protected abstract readonly viewContainerRef: ViewContainerRef; + + /** The minimum width to allow the column to be sized to. */ + get minWidthPx(): number { + return this.minWidthPxInternal; + } + set minWidthPx(value: number) { + this.minWidthPxInternal = value; + + if (this.elementRef.nativeElement) { + this._applyMinWidthPx(); + } + } + + /** The maximum width to allow the column to be sized to. */ + get maxWidthPx(): number { + return this.maxWidthPxInternal; + } + set maxWidthPx(value: number) { + this.maxWidthPxInternal = value; + + if (this.elementRef.nativeElement) { + this._applyMaxWidthPx(); + } + } + + ngAfterViewInit() { + this._listenForRowHoverEvents(); + this._listenForResizeEvents(); + this._appendInlineHandle(); + this._applyMinWidthPx(); + this._applyMaxWidthPx(); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + + if (this.inlineHandle) { + this.elementRef.nativeElement!.removeChild(this.inlineHandle); + } + + if (this.overlayRef) { + this.overlayRef.dispose(); + } + } + + protected abstract getInlineHandleCssClassName(): string; + + protected abstract getOverlayHandleComponentType(): Type; + + private _createOverlayForHandle(): OverlayRef { + // Use of overlays allows us to properly capture click events spanning parts + // of two table cells and is also useful for displaying a resize thumb + // over both cells and extending it down the table as needed. + + const positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.elementRef.nativeElement!) + .withFlexibleDimensions(false) + .withGrowAfterOpen(false) + .withPush(false) + .withPositions([{ + originX: 'end', + originY: 'top', + overlayX: 'center', + overlayY: 'top', + }]); + + return this.overlay.create({ + direction: this.directionality, + disposeOnNavigation: true, + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + width: '16px', + }); + } + + private _listenForRowHoverEvents(): void { + const element = this.elementRef.nativeElement!; + const takeUntilDestroyed = takeUntil(this.destroyed); + + + this.eventDispatcher.resizeOverlayVisibleForHeaderRow(_closest(element, HEADER_ROW_SELECTOR)!) + .pipe(takeUntilDestroyed).subscribe(hoveringRow => { + if (hoveringRow) { + if (!this.overlayRef) { + this.overlayRef = this._createOverlayForHandle(); + } + + this._showHandleOverlay(); + } else if (this.overlayRef) { + // todo - can't detach during an active resize - need to work that out + this.overlayRef.detach(); + } + }); + } + + private _listenForResizeEvents() { + const takeUntilDestroyed = takeUntil(this.destroyed); + + merge( + this.resizeNotifier.resizeCanceled, + this.resizeNotifier.triggerResize, + ).pipe( + takeUntilDestroyed, + filter(columnSize => columnSize.columnId === this.columnDef.name), + ).subscribe(({size, completeImmediately}) => { + this.elementRef.nativeElement!.classList.add(OVERLAY_ACTIVE_CLASS); + this._applySize(size); + + if (completeImmediately) { + this._completeResizeOperation(); + } + }); + + merge( + this.resizeNotifier.resizeCanceled, + this.resizeNotifier.resizeCompleted, + ).pipe(takeUntilDestroyed).subscribe(columnSize => { + this._cleanUpAfterResize(columnSize); + }); + } + + private _completeResizeOperation(): void { + this.ngZone.run(() => { + this.resizeNotifier.resizeCompleted.next({ + columnId: this.columnDef.name, + size: this.elementRef.nativeElement!.offsetWidth, + }); + }); + } + + private _cleanUpAfterResize(columnSize: ColumnSizeAction): void { + this.elementRef.nativeElement!.classList.remove(OVERLAY_ACTIVE_CLASS); + + if (this.overlayRef && this.overlayRef.hasAttached()) { + this._updateOverlayHandleHeight(); + this.overlayRef.updatePosition(); + + if (columnSize.columnId === this.columnDef.name) { + this.inlineHandle!.focus(); + } + } + } + + private _createHandlePortal(): ComponentPortal { + const injector = new PortalInjector(this.injector, new WeakMap([[ + ResizeRef, + new ResizeRef(this.elementRef, this.overlayRef!, this.minWidthPx, this.maxWidthPx), + ]])); + return new ComponentPortal(this.getOverlayHandleComponentType(), + this.viewContainerRef, injector); + } + + private _showHandleOverlay(): void { + this._updateOverlayHandleHeight(); + this.overlayRef!.attach(this._createHandlePortal()); + } + + private _updateOverlayHandleHeight() { + this.overlayRef!.updateSize({height: this.elementRef.nativeElement!.offsetHeight}); + } + + private _applySize(sizeInPixels: number): void { + const sizeToApply = Math.min(Math.max(sizeInPixels, this.minWidthPx, 0), this.maxWidthPx); + + this.resizeStrategy.applyColumnSize(this.columnDef.cssClassFriendlyName, + this.elementRef.nativeElement!, sizeToApply); + } + + private _applyMinWidthPx(): void { + this.resizeStrategy.applyMinColumnSize(this.columnDef.cssClassFriendlyName, + this.elementRef.nativeElement, this.minWidthPx); + } + + private _applyMaxWidthPx(): void { + this.resizeStrategy.applyMaxColumnSize(this.columnDef.cssClassFriendlyName, + this.elementRef.nativeElement, this.maxWidthPx); + } + + private _appendInlineHandle(): void { + this.inlineHandle = this.document.createElement('div'); + this.inlineHandle.tabIndex = 0; + this.inlineHandle.className = this.getInlineHandleCssClassName(); + + // TODO: Apply correct aria role (probably slider) after a11y spec questions resolved. + + this.elementRef.nativeElement!.appendChild(this.inlineHandle); + } +} diff --git a/src/cdk-experimental/column-resize/resize-ref.ts b/src/cdk-experimental/column-resize/resize-ref.ts new file mode 100644 index 000000000000..3d48604a9d75 --- /dev/null +++ b/src/cdk-experimental/column-resize/resize-ref.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ElementRef} from '@angular/core'; +import {OverlayRef} from '@angular/cdk/overlay'; + +/** Tracks state of resize events in progress. */ +export class ResizeRef { + constructor( + readonly origin: ElementRef, + readonly overlayRef: OverlayRef, + readonly minWidthPx: number, + readonly maxWidthPx: number, ) {} +} diff --git a/src/cdk-experimental/column-resize/resize-strategy.ts b/src/cdk-experimental/column-resize/resize-strategy.ts new file mode 100644 index 000000000000..76ff0e6aa15c --- /dev/null +++ b/src/cdk-experimental/column-resize/resize-strategy.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, Injectable, OnDestroy, Provider} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {coerceCssPixelValue} from '@angular/cdk/coercion'; + +import {ColumnResize} from './column-resize'; + +/** + * Provides an implementation for resizing a column. + * The details of how resizing works for tables for flex mat-tables are quite different. + */ +@Injectable() +export abstract class ResizeStrategy { + abstract applyColumnSize( + cssFriendlyColumnName: string, + columnHeader: HTMLElement, + sizeInPx: number): void; + + abstract applyMinColumnSize( + cssFriendlyColumnName: string, + columnHeader: HTMLElement, + minSizeInPx: number): void; + + abstract applyMaxColumnSize( + cssFriendlyColumnName: string, + columnHeader: HTMLElement, + minSizeInPx: number): void; +} + +/** + * The optimially performing resize strategy for <table> elements with table-layout: fixed. + * Tested against and outperformed: + * CSS selector + * CSS selector w/ CSS variable + * Updating all cell nodes + */ +@Injectable() +export class TableLayoutFixedResizeStrategy extends ResizeStrategy { + applyColumnSize(_: string, columnHeader: HTMLElement, sizeInPx: number): void { + columnHeader.style.width = coerceCssPixelValue(sizeInPx); + } + + applyMinColumnSize(_: string, columnHeader: HTMLElement, sizeInPx: number): void { + columnHeader.style.minWidth = coerceCssPixelValue(sizeInPx); + } + + applyMaxColumnSize(): void { + // Intentionally omitted as max-width causes strange rendering issues in Chrome. + // Max size will still apply when the user is resizing this column. + } +} + +/** + * The optimally performing resize strategy for flex mat-tables. + * Tested against and outperformed: + * CSS selector w/ CSS variable + * Updating all mat-cell nodes + */ +@Injectable() +export class CdkFlexTableResizeStrategy extends ResizeStrategy implements OnDestroy { + private readonly _document: Document; + private readonly _columnIndexes = new Map(); + private readonly _columnProperties = new Map>(); + + private _styleElement?: HTMLStyleElement; + private _indexSequence = 0; + + protected readonly defaultMinSize = 0; + protected readonly defaultMaxSize = Number.MAX_SAFE_INTEGER; + + constructor( + private readonly _columnResize: ColumnResize, + @Inject(DOCUMENT) document: any) { + super(); + this._document = document; + } + + applyColumnSize(cssFriendlyColumnName: string, _: HTMLElement, sizeInPx: number): void { + const cssSize = coerceCssPixelValue(sizeInPx); + + this._applyProperty(cssFriendlyColumnName, 'flex', `0 0.01 ${cssSize}`); + } + + applyMinColumnSize(cssFriendlyColumnName: string, _: HTMLElement, sizeInPx: number): void { + const cssSize = coerceCssPixelValue(sizeInPx); + + this._applyProperty(cssFriendlyColumnName, 'min-width', cssSize, + sizeInPx !== this.defaultMinSize); + } + + applyMaxColumnSize(cssFriendlyColumnName: string, _: HTMLElement, sizeInPx: number): void { + const cssSize = coerceCssPixelValue(sizeInPx); + + this._applyProperty(cssFriendlyColumnName, 'max-width', cssSize, + sizeInPx !== this.defaultMaxSize); + } + + protected getColumnCssClass(cssFriendlyColumnName: string): string { + return `cdk-column-${cssFriendlyColumnName}`; + } + + ngOnDestroy() { + // TODO: Use remove() once we're off IE11. + if (this._styleElement && this._styleElement.parentNode) { + this._styleElement.parentNode.removeChild(this._styleElement); + this._styleElement = undefined; + } + } + + private _applyProperty( + cssFriendlyColumnName: string, + key: string, + value: string, + enable = true): void { + const properties = this._getColumnPropertiesMap(cssFriendlyColumnName); + + if (enable) { + properties.set(key, value); + } else { + properties.delete(key); + } + this._applySizeCss(cssFriendlyColumnName); + } + + private _getStyleSheet(): CSSStyleSheet { + if (!this._styleElement) { + this._styleElement = this._document.createElement('style'); + this._styleElement.appendChild(this._document.createTextNode('')); + this._document.head.appendChild(this._styleElement); + } + + return this._styleElement.sheet as CSSStyleSheet; + } + + private _getColumnPropertiesMap(cssFriendlyColumnName: string): Map { + let properties = this._columnProperties.get(cssFriendlyColumnName); + if (properties === undefined) { + properties = new Map(); + this._columnProperties.set(cssFriendlyColumnName, properties); + } + return properties; + } + + private _applySizeCss(cssFriendlyColumnName: string) { + const properties = this._getColumnPropertiesMap(cssFriendlyColumnName); + const propertyKeys = Array.from(properties.keys()); + + let index = this._columnIndexes.get(cssFriendlyColumnName); + if (index === undefined) { + if (!propertyKeys.length) { + // Nothing to set or unset. + return; + } + + index = this._indexSequence++; + this._columnIndexes.set(cssFriendlyColumnName, index); + } else { + this._getStyleSheet().deleteRule(index); + } + + const columnClassName = this.getColumnCssClass(cssFriendlyColumnName); + const tableClassName = this._columnResize.getUniqueCssClass(); + + const selector = `.${tableClassName} .${columnClassName}`; + const body = propertyKeys.map(key => `${key}:${properties.get(key)}`).join(';'); + + this._getStyleSheet().insertRule(`${selector} {${body}}`, index!); + } +} + +export const TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER: Provider = { + provide: ResizeStrategy, + useClass: TableLayoutFixedResizeStrategy, +}; +export const FLEX_RESIZE_STRATEGY_PROVIDER: Provider = { + provide: ResizeStrategy, + useClass: CdkFlexTableResizeStrategy, +}; diff --git a/src/cdk-experimental/column-resize/selectors.ts b/src/cdk-experimental/column-resize/selectors.ts new file mode 100644 index 000000000000..74169783d9ce --- /dev/null +++ b/src/cdk-experimental/column-resize/selectors.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// TODO: Figure out how to remove `mat-` classes from the CDK part of the +// column resize implementation. + +export const HEADER_CELL_SELECTOR = '.cdk-header-cell, .mat-header-cell'; + +export const HEADER_ROW_SELECTOR = '.cdk-header-row, .mat-header-row'; + +export const RESIZE_OVERLAY_SELECTOR = '.mat-column-resize-overlay-thumb'; diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index e5155d7465ca..9d1de023682e 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -1,5 +1,6 @@ # List of all entry-points of the Angular cdk-experimental package. CDK_EXPERIMENTAL_ENTRYPOINTS = [ + "column-resize", "dialog", "popover-edit", "scrolling", diff --git a/src/cdk-experimental/popover-edit/public-api.ts b/src/cdk-experimental/popover-edit/public-api.ts index cdcfaf6b162b..db5e67108b82 100644 --- a/src/cdk-experimental/popover-edit/public-api.ts +++ b/src/cdk-experimental/popover-edit/public-api.ts @@ -15,5 +15,6 @@ export * from './popover-edit-module'; export * from './popover-edit-position-strategy-factory'; export * from './table-directives'; +// Private to Angular Components export {CELL_SELECTOR as _CELL_SELECTOR} from './constants'; -export {closest as _closest} from './polyfill'; +export {closest as _closest, matches as _matches} from './polyfill'; diff --git a/src/components-examples/BUILD.bazel b/src/components-examples/BUILD.bazel index b3fde2ba93d3..60011e536e1f 100644 --- a/src/components-examples/BUILD.bazel +++ b/src/components-examples/BUILD.bazel @@ -43,6 +43,7 @@ EXAMPLE_PACKAGES = [ "//src/components-examples/material/bottom-sheet", "//src/components-examples/material/badge", "//src/components-examples/material/autocomplete", + "//src/components-examples/material-experimental/column-resize", "//src/components-examples/material-experimental/popover-edit", "//src/components-examples/cdk/tree", "//src/components-examples/cdk/text-field", diff --git a/src/components-examples/material-experimental/column-resize/BUILD.bazel b/src/components-examples/material-experimental/column-resize/BUILD.bazel new file mode 100644 index 000000000000..a179b3decf9c --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/BUILD.bazel @@ -0,0 +1,26 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module") + +ng_module( + name = "column-resize", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + module_name = "@angular/components-examples/material-experimental/column-resize", + deps = [ + "//src/material-experimental/column-resize", + "//src/material/table", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo-module.ts b/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo-module.ts new file mode 100644 index 000000000000..a14480760c13 --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo-module.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {MatTableModule} from '@angular/material/table'; +import {MatDefaultEnabledColumnResizeModule} from '@angular/material-experimental/column-resize'; + +import {DefaultEnabledColumnResizeFlexDemo} from './default-enabled-column-resize-flex-demo'; + +@NgModule({ + imports: [ + MatDefaultEnabledColumnResizeModule, + MatTableModule, + ], + declarations: [DefaultEnabledColumnResizeFlexDemo], + exports: [DefaultEnabledColumnResizeFlexDemo], +}) +export class DefaultEnabledColumnResizeFlexDemoModule { +} diff --git a/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.html b/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.html new file mode 100644 index 000000000000..e90565e55c8d --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.html @@ -0,0 +1,28 @@ + + + + No. + {{element.position}} + + + + + Name + {{element.name}} + + + + + Weight (Not resizable) + {{element.weight}} + + + + + Symbol + {{element.symbol}} + + + + + diff --git a/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.ts b/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.ts new file mode 100644 index 000000000000..85ca5d9bf1f0 --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/default-enabled-flex/default-enabled-column-resize-flex-demo.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ViewEncapsulation} from '@angular/core'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, + {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na'}, + {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg'}, + {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al'}, + {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si'}, + {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P'}, + {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S'}, + {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl'}, + {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar'}, + {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K'}, + {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca'}, +]; + +/** + * @title Default-enabled column resize with a flex-based mat-table. + */ +@Component({ + selector: 'default-enabled-column-resize-flex-demo', + templateUrl: 'default-enabled-column-resize-flex-demo.html', + encapsulation: ViewEncapsulation.None, +}) +export class DefaultEnabledColumnResizeFlexDemo { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = ELEMENT_DATA; +} diff --git a/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo-module.ts b/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo-module.ts new file mode 100644 index 000000000000..fc2ee6cb2bbc --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo-module.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {MatTableModule} from '@angular/material/table'; +import {MatDefaultEnabledColumnResizeModule} from '@angular/material-experimental/column-resize'; + +import {DefaultEnabledColumnResizeDemo} from './default-enabled-column-resize-demo'; + +@NgModule({ + imports: [ + MatDefaultEnabledColumnResizeModule, + MatTableModule, + ], + declarations: [DefaultEnabledColumnResizeDemo], + exports: [DefaultEnabledColumnResizeDemo], +}) +export class DefaultEnabledColumnResizeDemoModule { +} diff --git a/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.html b/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.html new file mode 100644 index 000000000000..e1f06a760533 --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight (Not resizable) {{element.weight}} Symbol {{element.symbol}}
diff --git a/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.ts b/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.ts new file mode 100644 index 000000000000..a4fddc80e039 --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/default-enabled/default-enabled-column-resize-demo.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ViewEncapsulation} from '@angular/core'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, + {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na'}, + {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg'}, + {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al'}, + {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si'}, + {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P'}, + {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S'}, + {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl'}, + {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar'}, + {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K'}, + {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca'}, +]; + +/** + * @title Default-enabled column resize with a table-based mat-table. + */ +@Component({ + selector: 'default-enabled-column-resize-demo', + templateUrl: 'default-enabled-column-resize-demo.html', + encapsulation: ViewEncapsulation.None, +}) +export class DefaultEnabledColumnResizeDemo { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = ELEMENT_DATA; +} diff --git a/src/components-examples/material-experimental/column-resize/index.ts b/src/components-examples/material-experimental/column-resize/index.ts new file mode 100644 index 000000000000..e5058f8e5843 --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/index.ts @@ -0,0 +1,20 @@ +export { + DefaultEnabledColumnResizeDemo +} from './default-enabled/default-enabled-column-resize-demo'; +export { + DefaultEnabledColumnResizeDemoModule +} from './default-enabled/default-enabled-column-resize-demo-module'; + +export { + DefaultEnabledColumnResizeFlexDemo +} from './default-enabled-flex/default-enabled-column-resize-flex-demo'; +export { + DefaultEnabledColumnResizeFlexDemoModule +} from './default-enabled-flex/default-enabled-column-resize-flex-demo-module'; + +export { + OptInColumnResizeDemo +} from './opt-in/opt-in-column-resize-demo'; +export { + OptInColumnResizeDemoModule +} from './opt-in/opt-in-column-resize-demo-module'; diff --git a/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo-module.ts b/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo-module.ts new file mode 100644 index 000000000000..63c9ce0a7935 --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo-module.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {MatTableModule} from '@angular/material/table'; +import {MatColumnResizeModule} from '@angular/material-experimental/column-resize'; + +import {OptInColumnResizeDemo} from './opt-in-column-resize-demo'; + +@NgModule({ + imports: [ + MatColumnResizeModule, + MatTableModule, + ], + declarations: [OptInColumnResizeDemo], + exports: [OptInColumnResizeDemo], +}) +export class OptInColumnResizeDemoModule { +} diff --git a/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.html b/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.html new file mode 100644 index 000000000000..d6749709a59b --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight (Not resizable) {{element.weight}} Symbol {{element.symbol}}
diff --git a/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.ts b/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.ts new file mode 100644 index 000000000000..f9aca644f8ea --- /dev/null +++ b/src/components-examples/material-experimental/column-resize/opt-in/opt-in-column-resize-demo.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ViewEncapsulation} from '@angular/core'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, + {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na'}, + {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg'}, + {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al'}, + {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si'}, + {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P'}, + {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S'}, + {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl'}, + {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar'}, + {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K'}, + {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca'}, +]; + +/** + * @title Opt-in column resize with a table-based mat-table. + */ +@Component({ + selector: 'opt-in-column-resize-demo', + templateUrl: 'opt-in-column-resize-demo.html', + encapsulation: ViewEncapsulation.None, +}) +export class OptInColumnResizeDemo { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = ELEMENT_DATA; +} diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index c92f5b73fc33..ef3316e9edce 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -25,6 +25,7 @@ ng_module( "//src/dev-app/card", "//src/dev-app/checkbox", "//src/dev-app/chips", + "//src/dev-app/column-resize", "//src/dev-app/connected-overlay", "//src/dev-app/datepicker", "//src/dev-app/dev-app", @@ -91,6 +92,7 @@ sass_binary( "external/npm/node_modules", ], deps = [ + "//src/material-experimental/column-resize:column_resize_scss_lib", "//src/material-experimental/mdc-slider:mdc_slider_scss_lib", "//src/material-experimental/mdc-theming:all_themes", "//src/material-experimental/mdc-typography:all_typography", diff --git a/src/dev-app/column-resize/BUILD.bazel b/src/dev-app/column-resize/BUILD.bazel new file mode 100644 index 000000000000..674891b0df11 --- /dev/null +++ b/src/dev-app/column-resize/BUILD.bazel @@ -0,0 +1,15 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module") + +ng_module( + name = "column-resize", + srcs = glob(["**/*.ts"]), + assets = ["column-resize-home.html"], + deps = [ + "//src/components-examples/material-experimental/column-resize", + "//src/dev-app/example", + "//src/material/expansion", + "@npm//@angular/router", + ], +) diff --git a/src/dev-app/column-resize/column-resize-demo-module.ts b/src/dev-app/column-resize/column-resize-demo-module.ts new file mode 100644 index 000000000000..2b606330c3d5 --- /dev/null +++ b/src/dev-app/column-resize/column-resize-demo-module.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; +import {MatExpansionModule} from '@angular/material/expansion'; + +import {ColumnResizeHome} from './column-resize-home'; +import { + DefaultEnabledColumnResizeDemoModule, + DefaultEnabledColumnResizeFlexDemoModule, + OptInColumnResizeDemoModule, +} from '@angular/components-examples/material-experimental/column-resize'; + +@NgModule({ + imports: [ + MatExpansionModule, + DefaultEnabledColumnResizeDemoModule, + DefaultEnabledColumnResizeFlexDemoModule, + OptInColumnResizeDemoModule, + RouterModule.forChild([{path: '', component: ColumnResizeHome}]), + ], + declarations: [ColumnResizeHome], +}) +export class ColumnResizeDemoModule { +} diff --git a/src/dev-app/column-resize/column-resize-home.html b/src/dev-app/column-resize/column-resize-home.html new file mode 100644 index 000000000000..f112097e0c89 --- /dev/null +++ b/src/dev-app/column-resize/column-resize-home.html @@ -0,0 +1,29 @@ + + + + Enabled-by-default column resize for MatTable + + + + + + + + + + Enabled-by-default column resize for flex MatTable + + + + + + + + + + Opt-in column resize for MatTable + + + + + diff --git a/src/dev-app/column-resize/column-resize-home.ts b/src/dev-app/column-resize/column-resize-home.ts new file mode 100644 index 000000000000..2d546dee11fb --- /dev/null +++ b/src/dev-app/column-resize/column-resize-home.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Home component that shows both ways to use MatColumnResize. */ +import {Component} from '@angular/core'; + +@Component({ + templateUrl: 'column-resize-home.html', +}) +export class ColumnResizeHome { +} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index e1a09b86b1e2..6896a9bba085 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -31,6 +31,7 @@ export class DevAppLayout { {name: 'Card', route: '/card'}, {name: 'Checkbox', route: '/checkbox'}, {name: 'Chips', route: '/chips'}, + {name: 'Column Resize', route: 'column-resize'}, {name: 'Connected Overlay', route: '/connected-overlay'}, {name: 'Datepicker', route: '/datepicker'}, {name: 'Dialog', route: '/dialog'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index 09d2a91e3c29..801e5bd8c3fb 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -30,6 +30,10 @@ export const DEV_APP_ROUTES: Routes = [ {path: 'card', loadChildren: 'card/card-demo-module#CardDemoModule'}, {path: 'checkbox', loadChildren: 'checkbox/checkbox-demo-module#CheckboxDemoModule'}, {path: 'chips', loadChildren: 'chips/chips-demo-module#ChipsDemoModule'}, + { + path: 'column-resize', + loadChildren: 'column-resize/column-resize-demo-module#ColumnResizeDemoModule' + }, {path: 'datepicker', loadChildren: 'datepicker/datepicker-demo-module#DatepickerDemoModule'}, {path: 'dialog', loadChildren: 'dialog/dialog-demo-module#DialogDemoModule'}, {path: 'drawer', loadChildren: 'drawer/drawer-demo-module#DrawerDemoModule'}, diff --git a/src/dev-app/theme.scss b/src/dev-app/theme.scss index 2a01adba5396..6c03fa1f5336 100644 --- a/src/dev-app/theme.scss +++ b/src/dev-app/theme.scss @@ -1,5 +1,6 @@ @import '../material/core/theming/all-theme'; @import '../material/core/focus-indicator/focus-indicator'; +@import '../material-experimental/column-resize/column-resize'; @import '../material-experimental/mdc-helpers/mdc-helpers'; @import '../material-experimental/mdc-slider/mdc-slider'; @import '../material-experimental/mdc-theming/all-theme'; @@ -29,6 +30,7 @@ $candy-app-theme: mat-light-theme($candy-app-primary, $candy-app-accent); // Include the default theme styles. @include angular-material-theme($candy-app-theme); @include angular-material-theme-mdc($candy-app-theme); +@include mat-column-resize-theme($candy-app-theme); @include mat-slider-theme-mdc($candy-app-theme); @include mat-popover-edit-theme($candy-app-theme); @@ -51,6 +53,7 @@ $dark-theme: mat-dark-theme($dark-primary, $dark-accent, $dark-warn); .demo-unicorn-dark-theme { @include angular-material-theme($dark-theme); @include angular-material-theme-mdc($dark-theme); + @include mat-column-resize-theme($dark-theme); @include mat-slider-theme-mdc($dark-theme); @include mat-popover-edit-theme($dark-theme); } diff --git a/src/material-experimental/column-resize/BUILD.bazel b/src/material-experimental/column-resize/BUILD.bazel new file mode 100644 index 000000000000..ac80a7de6736 --- /dev/null +++ b/src/material-experimental/column-resize/BUILD.bazel @@ -0,0 +1,48 @@ +package(default_visibility = ["//visibility:public"]) + +load("@io_bazel_rules_sass//:defs.bzl", "sass_library") +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") + +ng_module( + name = "column-resize", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/material-experimental/column-resize", + deps = [ + "//src/cdk-experimental/column-resize", + "//src/cdk/overlay", + "//src/material/table", + "@npm//@angular/core", + ], +) + +sass_library( + name = "column_resize_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = [ + "//src/material/core:core_scss_lib", + ], +) + +ng_test_library( + name = "column_resize_test_sources", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":column-resize", + "//src/cdk-experimental/column-resize", + "//src/cdk/bidi", + "//src/cdk/collections", + "//src/cdk/keycodes", + "//src/cdk/overlay", + "//src/cdk/testing/private", + "//src/material/table", + "@npm//rxjs", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":column_resize_test_sources"], +) diff --git a/src/material-experimental/column-resize/_column-resize.scss b/src/material-experimental/column-resize/_column-resize.scss new file mode 100644 index 000000000000..932a736ac27f --- /dev/null +++ b/src/material-experimental/column-resize/_column-resize.scss @@ -0,0 +1,96 @@ +@import '../../material/core/style/variables'; +@import '../../material/core/style/vendor-prefixes'; +@import '../../material/core/theming/palette'; +@import '../../material/core/theming/theming'; + +@mixin mat-column-resize-theme($theme) { + $primary: map-get($theme, primary); + $foreground: map-get($theme, foreground); + + $non-resizable-hover-divider: mat-color($foreground, divider); + $resizable-hover-divider: mat-color($primary, 200); + $resizable-active-divider: mat-color($primary, 500); + + // Required for resizing to work properly. + .mat-column-resize-table.cdk-column-resize-with-resized-column { + table-layout: fixed; + } + + .mat-column-resize-flex { + .mat-header-cell, + .mat-cell { + box-sizing: border-box; + min-width: 32px; + } + } + + .mat-header-cell { + position: relative; + } + + .mat-resizable { + box-sizing: border-box; + } + + .mat-header-cell:not(.mat-resizable)::after, + .mat-resizable-handle { + background: transparent; + bottom: 0; + position: absolute; + top: 0; + transition: background $swift-ease-in-duration $swift-ease-in-timing-function; + width: 1px; + } + + .mat-header-cell:not(.mat-resizable)::after { + content: ''; + } + + .mat-header-cell:not(.mat-resizable)::after, + .mat-resizable-handle { + right: 0; + } + + .mat-column-resize-rtl .mat-header-cell:not(.mat-resizable)::after, + .mat-column-resize-rtl .mat-resizable-handle { + left: 0; + right: auto; + } + + .mat-header-row.cdk-column-resize-hover-or-active { + .mat-header-cell:not(.mat-resizable)::after { + background: $non-resizable-hover-divider; + } + + .mat-resizable-handle { + background: $resizable-hover-divider; + } + } + + .mat-resizable.cdk-resizable-overlay-thumb-active > .mat-resizable-handle { + opacity: 0; + transition: none; + } + + .mat-resizable-handle:focus, + .mat-header-row.cdk-column-resize-hover-or-active .mat-resizable-handle:focus { + background: $resizable-active-divider; + outline: none; + } + + .mat-column-resize-overlay-thumb { + background: transparent; + cursor: col-resize; + height: 100%; + transition: background $swift-ease-in-duration $swift-ease-in-timing-function; + @include user-select(none); + width: 100%; + + &:active { + background: linear-gradient(90deg, + transparent, transparent 7px, + $resizable-active-divider, $resizable-active-divider 1px, + transparent 8px, transparent); + } + } +} diff --git a/src/material-experimental/column-resize/column-resize-directives/column-resize-flex.ts b/src/material-experimental/column-resize/column-resize-directives/column-resize-flex.ts new file mode 100644 index 000000000000..f2dfa93b6fb8 --- /dev/null +++ b/src/material-experimental/column-resize/column-resize-directives/column-resize-flex.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import { + ColumnResize, + ColumnResizeNotifier, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatColumnResize, FLEX_HOST_BINDINGS, FLEX_PROVIDERS} from './common'; + +/** + * Explicitly enables column resizing for a flexbox-based mat-table. + * Individual columns must be annotated specifically. + */ +@Directive({ + selector: 'mat-table[columnResize]', + host: FLEX_HOST_BINDINGS, + providers: [ + ...FLEX_PROVIDERS, + {provide: ColumnResize, useExisting: MatColumnResizeFlex}, + ], +}) +export class MatColumnResizeFlex extends AbstractMatColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/material-experimental/column-resize/column-resize-directives/column-resize.ts b/src/material-experimental/column-resize/column-resize-directives/column-resize.ts new file mode 100644 index 000000000000..a36c14d396cf --- /dev/null +++ b/src/material-experimental/column-resize/column-resize-directives/column-resize.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import { + ColumnResize, + ColumnResizeNotifier, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatColumnResize, TABLE_HOST_BINDINGS, TABLE_PROVIDERS} from './common'; + +/** + * Explicitly enables column resizing for a table-based mat-table. + * Individual columns must be annotated specifically. + */ +@Directive({ + selector: 'table[mat-table][columnResize]', + host: TABLE_HOST_BINDINGS, + providers: [ + ...TABLE_PROVIDERS, + {provide: ColumnResize, useExisting: MatColumnResize}, + ], +}) +export class MatColumnResize extends AbstractMatColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/material-experimental/column-resize/column-resize-directives/common.ts b/src/material-experimental/column-resize/column-resize-directives/common.ts new file mode 100644 index 000000000000..961f63f8f2dc --- /dev/null +++ b/src/material-experimental/column-resize/column-resize-directives/common.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Provider} from '@angular/core'; + +import { + ColumnResize, + ColumnResizeNotifier, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, +} from '@angular/cdk-experimental/column-resize'; + +import { + TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER, + FLEX_RESIZE_STRATEGY_PROVIDER, +} from '../resize-strategy'; + +const PROVIDERS: Provider[] = [ + ColumnResizeNotifier, + HeaderRowEventDispatcher, + ColumnResizeNotifierSource, +]; +export const TABLE_PROVIDERS: Provider[] = [ + ...PROVIDERS, + TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER, +]; +export const FLEX_PROVIDERS: Provider[] = [...PROVIDERS, FLEX_RESIZE_STRATEGY_PROVIDER]; + +const HOST_BINDINGS = { + '[class.mat-column-resize-rtl]': 'directionality.value === "rtl"', +}; +export const TABLE_HOST_BINDINGS = { + ...HOST_BINDINGS, + 'class': 'mat-column-resize-table', +}; +export const FLEX_HOST_BINDINGS = { + ...HOST_BINDINGS, + 'class': 'mat-column-resize-flex', +}; + +export abstract class AbstractMatColumnResize extends ColumnResize { + getTableHeight() { + return this.elementRef.nativeElement!.offsetHeight; + } +} diff --git a/src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts b/src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts new file mode 100644 index 000000000000..fb42cec509fa --- /dev/null +++ b/src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize-flex.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import { + ColumnResize, + ColumnResizeNotifier, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatColumnResize, FLEX_HOST_BINDINGS, FLEX_PROVIDERS} from './common'; + +/** + * Implicitly enables column resizing for a flexbox-based mat-table. + * Individual columns will be resizable unless opted out. + */ +@Directive({ + selector: 'mat-table', + host: FLEX_HOST_BINDINGS, + providers: [ + ...FLEX_PROVIDERS, + {provide: ColumnResize, useExisting: MatDefaultEnabledColumnResizeFlex}, + ], +}) +export class MatDefaultEnabledColumnResizeFlex extends AbstractMatColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts b/src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts new file mode 100644 index 000000000000..750423ceca1d --- /dev/null +++ b/src/material-experimental/column-resize/column-resize-directives/default-enabled-column-resize.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, NgZone} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import { + ColumnResize, + ColumnResizeNotifier, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatColumnResize, TABLE_HOST_BINDINGS, TABLE_PROVIDERS} from './common'; + +/** + * Implicitly enables column resizing for a table-based mat-table. + * Individual columns will be resizable unless opted out. + */ +@Directive({ + selector: 'table[mat-table]', + host: TABLE_HOST_BINDINGS, + providers: [ + ...TABLE_PROVIDERS, + {provide: ColumnResize, useExisting: MatDefaultEnabledColumnResize}, + ], +}) +export class MatDefaultEnabledColumnResize extends AbstractMatColumnResize { + constructor( + readonly columnResizeNotifier: ColumnResizeNotifier, + readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly notifier: ColumnResizeNotifierSource) { + super(); + } +} diff --git a/src/material-experimental/column-resize/column-resize-module.ts b/src/material-experimental/column-resize/column-resize-module.ts new file mode 100644 index 000000000000..f82cda450cf0 --- /dev/null +++ b/src/material-experimental/column-resize/column-resize-module.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +import {MatColumnResize} from './column-resize-directives/column-resize'; +import {MatColumnResizeFlex} from './column-resize-directives/column-resize-flex'; +import { + MatDefaultEnabledColumnResize +} from './column-resize-directives/default-enabled-column-resize'; +import { + MatDefaultEnabledColumnResizeFlex +} from './column-resize-directives/default-enabled-column-resize-flex'; +import {MatDefaultResizable} from './resizable-directives/default-enabled-resizable'; +import {MatResizable} from './resizable-directives/resizable'; +import {MatColumnResizeOverlayHandle} from './overlay-handle'; + +const ENTRY_COMMON_COMPONENTS = [ + MatColumnResizeOverlayHandle, +]; + +@NgModule({ + declarations: ENTRY_COMMON_COMPONENTS, + exports: ENTRY_COMMON_COMPONENTS, + entryComponents: ENTRY_COMMON_COMPONENTS, +}) +export class MatColumnResizeCommonModule {} + +const IMPORTS = [ + OverlayModule, + MatColumnResizeCommonModule, +]; + +@NgModule({ + imports: IMPORTS, + declarations: [ + MatDefaultEnabledColumnResize, + MatDefaultEnabledColumnResizeFlex, + MatDefaultResizable, + ], + exports: [ + MatDefaultEnabledColumnResize, + MatDefaultEnabledColumnResizeFlex, + MatDefaultResizable, + ], +}) +export class MatDefaultEnabledColumnResizeModule {} + +@NgModule({ + imports: IMPORTS, + declarations: [ + MatColumnResize, + MatColumnResizeFlex, + MatResizable, + ], + exports: [ + MatColumnResize, + MatColumnResizeFlex, + MatResizable, + ], +}) +export class MatColumnResizeModule {} diff --git a/src/material-experimental/column-resize/column-resize.spec.ts b/src/material-experimental/column-resize/column-resize.spec.ts new file mode 100644 index 000000000000..633cc33582c9 --- /dev/null +++ b/src/material-experimental/column-resize/column-resize.spec.ts @@ -0,0 +1,477 @@ +import {Component, Directive, ElementRef, Type, ViewChild} from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, /*flush,*/ inject} from '@angular/core/testing'; +import {BidiModule} from '@angular/cdk/bidi'; +import {DataSource} from '@angular/cdk/collections'; +import {dispatchKeyboardEvent} from '@angular/cdk/testing/private'; +import {ESCAPE} from '@angular/cdk/keycodes'; +import {OverlayContainer} from '@angular/cdk/overlay'; +import {MatTableModule} from '@angular/material/table'; +import {BehaviorSubject} from 'rxjs'; + +import {ColumnSize} from '@angular/cdk-experimental/column-resize'; +import { + MatColumnResize, + MatColumnResizeFlex, + MatColumnResizeModule, + MatDefaultEnabledColumnResize, + MatDefaultEnabledColumnResizeFlex, + MatDefaultEnabledColumnResizeModule, +} from './index'; +import {AbstractMatColumnResize} from './column-resize-directives/common'; + +function getDefaultEnabledDirectiveStrings() { + return { + table: '', + columnEnabled: '', + columnDisabled: 'disableResize', + }; +} + +function getOptInDirectiveStrings() { + return { + table: 'columnResize', + columnEnabled: 'resizable', + columnDisabled: '', + }; +} + +function getTableTemplate(defaultEnabled: boolean) { + const directives = defaultEnabled ? + getDefaultEnabledDirectiveStrings() : getOptInDirectiveStrings(); + + return ` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} + Weight (Not resizable) + {{element.weight}} Symbol {{element.symbol}}
+
+ `; +} + +function getFlexTemplate(defaultEnabled: boolean) { + const directives = defaultEnabled ? + getDefaultEnabledDirectiveStrings() : getOptInDirectiveStrings(); + + return ` + +
+ + + + No. + {{element.position}} + + + + + Name + {{element.name}} + + + + + + Weight (Not resizable) + + {{element.weight}} + + + + + + Symbol + + {{element.symbol}} + + + + + +
+ `; +} + +const MOUSE_START_OFFSET = 1000; + +@Directive() +abstract class BaseTestComponent { + @ViewChild('table', {static: false}) table: ElementRef; + + abstract columnResize: AbstractMatColumnResize; + + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = new ElementDataSource(); + direction = 'ltr'; + + getColumnElement(index: number): HTMLElement { + return this.table.nativeElement!.querySelectorAll('.mat-resizable')[index] as HTMLElement; + } + + getColumnWidth(index: number): number { + return this.getColumnElement(index).offsetWidth; + } + + triggerHoverState(): void { + const headerCell = this.table.nativeElement.querySelector('.mat-header-cell'); + headerCell.dispatchEvent(new Event('mouseover', {bubbles: true})); + } + + endHoverState(): void { + const dataRow = this.table.nativeElement.querySelector('.mat-row'); + dataRow.dispatchEvent(new Event('mouseover', {bubbles: true})); + } + + getOverlayThumbElement(index: number): HTMLElement { + return document.querySelectorAll('.mat-column-resize-overlay-thumb')[index] as HTMLElement; + } + + getOverlayThumbPosition(index: number): number { + const thumbElement = this.getOverlayThumbElement(index); + return parseInt((thumbElement.parentNode as HTMLElement).style.left!, 10); + } + + beginColumnResizeWithMouse(index: number): void { + const thumbElement = this.getOverlayThumbElement(index); + this.table.nativeElement!.dispatchEvent(new MouseEvent('mouseleave', + {bubbles: true, relatedTarget: thumbElement})); + thumbElement.dispatchEvent(new MouseEvent('mousedown', { + bubbles: true, + screenX: MOUSE_START_OFFSET, + } as MouseEventInit)); + } + + updateResizeWithMouseInProgress(totalDelta: number): void { + document.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + screenX: MOUSE_START_OFFSET + totalDelta, + } as MouseEventInit)); + } + + completeResizeWithMouseInProgress(totalDelta: number): void { + document.dispatchEvent(new MouseEvent('mouseup', { + bubbles: true, + screenX: MOUSE_START_OFFSET + totalDelta, + } as MouseEventInit)); + } + + resizeColumnWithMouse(index: number, resizeDelta: number): void { + this.beginColumnResizeWithMouse(index); + this.updateResizeWithMouseInProgress(resizeDelta); + this.completeResizeWithMouseInProgress(resizeDelta); + } +} + +@Directive() +abstract class BaseTestComponentRtl extends BaseTestComponent { + direction = 'rtl'; + + getOverlayThumbPosition(index: number): number { + const thumbElement = this.getOverlayThumbElement(index); + return parseInt((thumbElement.parentNode as HTMLElement).style.right!, 10); + } + + updateResizeWithMouseInProgress(totalDelta: number): void { + super.updateResizeWithMouseInProgress(-totalDelta); + } + + completeResizeWithMouseInProgress(totalDelta: number): void { + super.completeResizeWithMouseInProgress(-totalDelta); + } +} + +@Component({template: getTableTemplate(false)}) +class MatResizeTest extends BaseTestComponent { + @ViewChild(MatColumnResize, {static: true}) columnResize: AbstractMatColumnResize; +} + +@Component({template: getTableTemplate(true)}) +class MatResizeDefaultTest extends BaseTestComponent { + @ViewChild(MatDefaultEnabledColumnResize, {static: true}) columnResize: AbstractMatColumnResize; +} + +@Component({template: getTableTemplate(true)}) +class MatResizeDefaultRtlTest extends BaseTestComponentRtl { + @ViewChild(MatDefaultEnabledColumnResize, {static: true}) columnResize: AbstractMatColumnResize; +} + +@Component({template: getFlexTemplate(false)}) +class MatResizeFlexTest extends BaseTestComponent { + @ViewChild(MatColumnResizeFlex, {static: true}) columnResize: AbstractMatColumnResize; +} + +@Component({template: getFlexTemplate(true)}) +class MatResizeDefaultFlexTest extends BaseTestComponent { + @ViewChild(MatDefaultEnabledColumnResizeFlex, {static: true}) + columnResize: AbstractMatColumnResize; +} + +@Component({template: getFlexTemplate(true)}) +class MatResizeDefaultFlexRtlTest extends BaseTestComponentRtl { + @ViewChild(MatDefaultEnabledColumnResizeFlex, {static: true}) + columnResize: AbstractMatColumnResize; +} + +interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +class ElementDataSource extends DataSource { + /** Stream of data that is provided to the table. */ + data = new BehaviorSubject(createElementData()); + + /** Connect function called by the table to retrieve one stream containing the data to render. */ + connect() { + return this.data.asObservable(); + } + + disconnect() {} +} + +// There's 1px of variance between different browsers in terms of positioning. +const approximateMatcher = { + isApproximately: () => ({ + compare: (actual: number, expected: number) => { + const result = { + pass: false, + message: `Expected ${actual} to be within 1 of ${expected}`, + }; + + result.pass = actual === expected || actual === expected + 1 || actual === expected - 1; + + return result; + } + }) +}; + +const testCases: ReadonlyArray<[Type, Type, string]> = [ + [MatColumnResizeModule, MatResizeTest, 'opt-in table-based mat-table'], + [MatColumnResizeModule, MatResizeFlexTest, 'opt-in flex-based mat-table'], + [ + MatDefaultEnabledColumnResizeModule, MatResizeDefaultTest, + 'default enabled table-based mat-table' + ], + [ + MatDefaultEnabledColumnResizeModule, MatResizeDefaultRtlTest, + 'default enabled rtl table-based mat-table'], + [ + MatDefaultEnabledColumnResizeModule, MatResizeDefaultFlexTest, + 'default enabled flex-based mat-table' + ], + [ + MatDefaultEnabledColumnResizeModule, MatResizeDefaultFlexRtlTest, + 'default enabled rtl flex-based mat-table' + ], +]; + +describe('Material Popover Edit', () => { + for (const [resizeModule, componentClass, label] of testCases) { + describe(label, () => { + let component: BaseTestComponent; + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + beforeEach(() => { + jasmine.addMatchers(approximateMatcher); + + TestBed.configureTestingModule({ + imports: [BidiModule, MatTableModule, resizeModule], + declarations: [componentClass], + }).compileComponents(); + inject([OverlayContainer], (oc: OverlayContainer) => { + overlayContainer = oc; + })(); + fixture = TestBed.createComponent(componentClass); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + // The overlay container's `ngOnDestroy` won't be called between test runs so we need + // to call it ourselves, in order to avoid leaking containers between tests and potentially + // throwing `querySelector` calls. + overlayContainer.ngOnDestroy(); + }); + + it('shows resize handle overlays on header row hover and while a resize handle is in use', + fakeAsync(() => { + expect(component.getOverlayThumbElement(0)).toBeUndefined(); + + component.triggerHoverState(); + fixture.detectChanges(); + + expect(component.getOverlayThumbElement(0).classList + .contains('mat-column-resize-overlay-thumb')).toBe(true); + expect(component.getOverlayThumbElement(2).classList + .contains('mat-column-resize-overlay-thumb')).toBe(true); + + component.beginColumnResizeWithMouse(0); + + expect(component.getOverlayThumbElement(0).classList + .contains('mat-column-resize-overlay-thumb')).toBe(true); + expect(component.getOverlayThumbElement(2).classList + .contains('mat-column-resize-overlay-thumb')).toBe(true); + + component.completeResizeWithMouseInProgress(0); + component.endHoverState(); + fixture.detectChanges(); + + expect(component.getOverlayThumbElement(0)).toBeUndefined(); + })); + + it('resizes the target column via mouse input', fakeAsync(() => { + const initialColumnWidth = component.getColumnWidth(1); + + component.triggerHoverState(); + fixture.detectChanges(); + component.beginColumnResizeWithMouse(1); + + const initialPosition = component.getOverlayThumbPosition(1); + + component.updateResizeWithMouseInProgress(5); + + (expect(component.getOverlayThumbPosition(1)) as any).isApproximately(initialPosition + 5); + (expect(component.getColumnWidth(1)) as any).isApproximately(initialColumnWidth + 5); + + component.updateResizeWithMouseInProgress(1); + + (expect(component.getOverlayThumbPosition(1)) as any).isApproximately(initialPosition + 1); + (expect(component.getColumnWidth(1)) as any).isApproximately(initialColumnWidth + 1); + + component.completeResizeWithMouseInProgress(1); + + (expect(component.getColumnWidth(1)) as any).isApproximately(initialColumnWidth + 1); + + component.endHoverState(); + fixture.detectChanges(); + })); + + it('cancels an active mouse resize with the escape key', fakeAsync(() => { + const initialColumnWidth = component.getColumnWidth(1); + + component.triggerHoverState(); + fixture.detectChanges(); + component.beginColumnResizeWithMouse(1); + + const initialPosition = component.getOverlayThumbPosition(1); + + component.updateResizeWithMouseInProgress(5); + + (expect(component.getOverlayThumbPosition(1)) as any).isApproximately(initialPosition + 5); + (expect(component.getColumnWidth(1)) as any).isApproximately(initialColumnWidth + 5); + + dispatchKeyboardEvent(document, 'keyup', ESCAPE); + + (expect(component.getColumnWidth(1)) as any).isApproximately(initialColumnWidth); + + component.endHoverState(); + fixture.detectChanges(); + })); + + it('notifies subscribers of a completed resize via ColumnResizeNotifier', () => { + const initialColumnWidth = component.getColumnWidth(1); + + let resize: ColumnSize|null = null; + component.columnResize.columnResizeNotifier.resizeCompleted.subscribe(size => { + resize = size; + }); + + component.triggerHoverState(); + fixture.detectChanges(); + + expect(resize).toBe(null); + + component.resizeColumnWithMouse(1, 5); + + expect(resize).toEqual({columnId: 'name', size: initialColumnWidth + 5} as any); + + component.endHoverState(); + fixture.detectChanges(); + }); + + it('does not notify subscribers of a canceled resize', () => { + let resize: ColumnSize|null = null; + component.columnResize.columnResizeNotifier.resizeCompleted.subscribe(size => { + resize = size; + }); + + component.triggerHoverState(); + fixture.detectChanges(); + component.beginColumnResizeWithMouse(0); + + component.updateResizeWithMouseInProgress(5); + + dispatchKeyboardEvent(document, 'keyup', ESCAPE); + + component.endHoverState(); + fixture.detectChanges(); + + expect(resize).toBe(null); + }); + + it('performs a column resize triggered via ColumnResizeNotifier', () => { + // Pre-verify that we are not updating the size to the initial size. + (expect(component.getColumnWidth(1)) as any).not.isApproximately(123); + + component.columnResize.columnResizeNotifier.resize('name', 123); + + (expect(component.getColumnWidth(1)) as any).isApproximately(123); + }); + }); + } +}); + +function createElementData() { + return [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + ]; +} diff --git a/src/material-experimental/column-resize/index.ts b/src/material-experimental/column-resize/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/column-resize/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/material-experimental/column-resize/overlay-handle.ts b/src/material-experimental/column-resize/overlay-handle.ts new file mode 100644 index 000000000000..27d2cb2b0b3b --- /dev/null +++ b/src/material-experimental/column-resize/overlay-handle.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Inject, + NgZone, + ViewEncapsulation, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {CdkColumnDef} from '@angular/cdk/table'; +import {Directionality} from '@angular/cdk/bidi'; +import { + ColumnResize, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, + ResizeOverlayHandle, + ResizeRef, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatColumnResize} from './column-resize-directives/common'; + +/** + * Component shown over the edge of a resizable column that is responsible + * for handling column resize mouse events and displaying a vertical line along the column edge. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: {'class': 'mat-column-resize-overlay-thumb'}, + template: '', +}) +export class MatColumnResizeOverlayHandle extends ResizeOverlayHandle { + protected readonly document: Document; + + constructor( + protected readonly columnDef: CdkColumnDef, + protected readonly columnResize: ColumnResize, + protected readonly directionality: Directionality, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly resizeNotifier: ColumnResizeNotifierSource, + protected readonly resizeRef: ResizeRef, + @Inject(DOCUMENT) document: any) { + super(); + this.document = document; + } + + protected updateResizeActive(active: boolean): void { + super.updateResizeActive(active); + + this.resizeRef.overlayRef.updateSize({ + height: active ? + (this.columnResize as AbstractMatColumnResize).getTableHeight() : + this.resizeRef.origin.nativeElement!.offsetHeight + }); + } +} diff --git a/src/material-experimental/column-resize/public-api.ts b/src/material-experimental/column-resize/public-api.ts new file mode 100644 index 000000000000..382eff848ee0 --- /dev/null +++ b/src/material-experimental/column-resize/public-api.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './column-resize-directives/column-resize'; +export * from './column-resize-directives/column-resize-flex'; +export * from './column-resize-directives/default-enabled-column-resize'; +export * from './column-resize-directives/default-enabled-column-resize-flex'; +export * from './column-resize-module'; +export * from './resizable-directives/default-enabled-resizable'; +export * from './resizable-directives/resizable'; +export * from './resize-strategy'; +export * from './overlay-handle'; diff --git a/src/material-experimental/column-resize/resizable-directives/common.ts b/src/material-experimental/column-resize/resizable-directives/common.ts new file mode 100644 index 000000000000..44a4eaabfca3 --- /dev/null +++ b/src/material-experimental/column-resize/resizable-directives/common.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Type} from '@angular/core'; +import {Resizable} from '@angular/cdk-experimental/column-resize'; +import {MatColumnResizeOverlayHandle} from '../overlay-handle'; + +export abstract class AbstractMatResizable extends Resizable { + minWidthPxInternal = 32; + + protected getInlineHandleCssClassName(): string { + return 'mat-resizable-handle'; + } + + protected getOverlayHandleComponentType(): Type { + return MatColumnResizeOverlayHandle; + } +} + +export const RESIZABLE_HOST_BINDINGS = { + 'class': 'mat-resizable', +}; + +export const RESIZABLE_INPUTS = [ + 'minWidthPx: matResizableMinWidthPx', + 'maxWidthPx: matResizableMaxWidthPx', +]; diff --git a/src/material-experimental/column-resize/resizable-directives/default-enabled-resizable.ts b/src/material-experimental/column-resize/resizable-directives/default-enabled-resizable.ts new file mode 100644 index 000000000000..ed877b3186b2 --- /dev/null +++ b/src/material-experimental/column-resize/resizable-directives/default-enabled-resizable.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + ElementRef, + Inject, + Injector, + NgZone, + ViewContainerRef, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {Directionality} from '@angular/cdk/bidi'; +import {Overlay} from '@angular/cdk/overlay'; +import {CdkColumnDef} from '@angular/cdk/table'; +import { + ColumnResize, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, + ResizeStrategy, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatResizable, RESIZABLE_HOST_BINDINGS, RESIZABLE_INPUTS} from './common'; + +/** + * Implicitly enables column resizing for a mat-header-cell unless the disableResize attribute + * is present. + */ +@Directive({ + selector: 'mat-header-cell:not([disableResize]), th[mat-header-cell]:not([disableResize])', + host: RESIZABLE_HOST_BINDINGS, + inputs: RESIZABLE_INPUTS, +}) +export class MatDefaultResizable extends AbstractMatResizable { + protected readonly document: Document; + + constructor( + protected readonly columnDef: CdkColumnDef, + protected readonly columnResize: ColumnResize, + protected readonly directionality: Directionality, + @Inject(DOCUMENT) document: any, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly injector: Injector, + protected readonly ngZone: NgZone, + protected readonly overlay: Overlay, + protected readonly resizeNotifier: ColumnResizeNotifierSource, + protected readonly resizeStrategy: ResizeStrategy, + protected readonly viewContainerRef: ViewContainerRef) { + super(); + this.document = document; + } +} diff --git a/src/material-experimental/column-resize/resizable-directives/resizable.ts b/src/material-experimental/column-resize/resizable-directives/resizable.ts new file mode 100644 index 000000000000..0d61a5c117f4 --- /dev/null +++ b/src/material-experimental/column-resize/resizable-directives/resizable.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + ElementRef, + Inject, + Injector, + NgZone, + ViewContainerRef, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {Directionality} from '@angular/cdk/bidi'; +import {Overlay} from '@angular/cdk/overlay'; +import {CdkColumnDef} from '@angular/cdk/table'; +import { + ColumnResize, + ColumnResizeNotifierSource, + HeaderRowEventDispatcher, + ResizeStrategy, +} from '@angular/cdk-experimental/column-resize'; + +import {AbstractMatResizable, RESIZABLE_HOST_BINDINGS, RESIZABLE_INPUTS} from './common'; + +/** + * Explicitly enables column resizing for a mat-header-cell. + */ +@Directive({ + selector: 'mat-header-cell[resizable], th[mat-header-cell][resizable]', + host: RESIZABLE_HOST_BINDINGS, + inputs: RESIZABLE_INPUTS, +}) +export class MatResizable extends AbstractMatResizable { + protected readonly document: Document; + + constructor( + protected readonly columnDef: CdkColumnDef, + protected readonly columnResize: ColumnResize, + protected readonly directionality: Directionality, + @Inject(DOCUMENT) document: any, + protected readonly elementRef: ElementRef, + protected readonly eventDispatcher: HeaderRowEventDispatcher, + protected readonly injector: Injector, + protected readonly ngZone: NgZone, + protected readonly overlay: Overlay, + protected readonly resizeNotifier: ColumnResizeNotifierSource, + protected readonly resizeStrategy: ResizeStrategy, + protected readonly viewContainerRef: ViewContainerRef) { + super(); + this.document = document; + } +} diff --git a/src/material-experimental/column-resize/resize-strategy.ts b/src/material-experimental/column-resize/resize-strategy.ts new file mode 100644 index 000000000000..0806d8fe7b1d --- /dev/null +++ b/src/material-experimental/column-resize/resize-strategy.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, Injectable, Provider} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; + +import { + ColumnResize, + ResizeStrategy, + CdkFlexTableResizeStrategy, + TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER, +} from '@angular/cdk-experimental/column-resize'; + +export {TABLE_LAYOUT_FIXED_RESIZE_STRATEGY_PROVIDER}; + +/** + * Overrides CdkFlexTableResizeStrategy to match mat-column elements. + */ +@Injectable() +export class MatFlexTableResizeStrategy extends CdkFlexTableResizeStrategy { + constructor( + columnResize: ColumnResize, + @Inject(DOCUMENT) document: any) { + super(columnResize, document); + } + + protected getColumnCssClass(cssFriendlyColumnName: string): string { + return `mat-column-${cssFriendlyColumnName}`; + } +} + +export const FLEX_RESIZE_STRATEGY_PROVIDER: Provider = { + provide: ResizeStrategy, + useClass: MatFlexTableResizeStrategy, +}; diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index 8467738ff3c3..df669c819a26 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -1,4 +1,5 @@ entryPoints = [ + "column-resize", "mdc-autocomplete", "mdc-button", "mdc-button/testing",