From 457ff28d569cfd471014c0a6d930bc4ba15c3ca1 Mon Sep 17 00:00:00 2001 From: Karl Seamon Date: Wed, 3 Apr 2019 15:52:26 -0400 Subject: [PATCH] feat(popover-edit): experimental popover edit for tables (mvp) (#15496) Adds a new set of directives to cdk-experimental that allow editing of content in tables (including cdk-table). Will be expanded with additional functionality and Material-styled versions in coming PRs. --- .github/CODEOWNERS | 2 + packages.bzl | 1 + src/cdk-experimental/popover-edit/BUILD.bazel | 36 ++ .../popover-edit/constants.ts | 19 + .../popover-edit/edit-event-dispatcher.ts | 86 +++ src/cdk-experimental/popover-edit/edit-ref.ts | 114 ++++ src/cdk-experimental/popover-edit/index.ts | 9 + .../popover-edit/lens-directives.ts | 143 +++++ src/cdk-experimental/popover-edit/polyfill.ts | 27 + .../popover-edit/popover-edit-module.ts | 49 ++ .../popover-edit-position-strategy-factory.ts | 42 ++ .../popover-edit/popover-edit.spec.ts | 521 ++++++++++++++++++ .../popover-edit/public-api.ts | 14 + .../popover-edit/table-directives.ts | 291 ++++++++++ .../popover-edit/tsconfig-build.json | 15 + src/dev-app/BUILD.bazel | 5 +- src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/dev-app/routes.ts | 4 + .../popover-edit/popover-edit-demo-module.ts | 24 + src/dev-app/popover-edit/popover-edit-demo.ts | 21 + src/dev-app/system-config.ts | 2 + src/material-examples/BUILD.bazel | 4 +- ...dk-popover-edit-cdk-table-flex-example.css | 10 + ...k-popover-edit-cdk-table-flex-example.html | 81 +++ ...cdk-popover-edit-cdk-table-flex-example.ts | 81 +++ .../cdk-popover-edit-cdk-table-example.css | 12 + .../cdk-popover-edit-cdk-table-example.html | 80 +++ .../cdk-popover-edit-cdk-table-example.ts | 81 +++ ...cdk-popover-edit-vanilla-table-example.css | 12 + ...dk-popover-edit-vanilla-table-example.html | 69 +++ .../cdk-popover-edit-vanilla-table-example.ts | 59 ++ src/material-examples/material-module.ts | 3 + src/material-examples/tsconfig-build.json | 3 +- 33 files changed, 1915 insertions(+), 6 deletions(-) create mode 100644 src/cdk-experimental/popover-edit/BUILD.bazel create mode 100644 src/cdk-experimental/popover-edit/constants.ts create mode 100644 src/cdk-experimental/popover-edit/edit-event-dispatcher.ts create mode 100644 src/cdk-experimental/popover-edit/edit-ref.ts create mode 100644 src/cdk-experimental/popover-edit/index.ts create mode 100644 src/cdk-experimental/popover-edit/lens-directives.ts create mode 100644 src/cdk-experimental/popover-edit/polyfill.ts create mode 100644 src/cdk-experimental/popover-edit/popover-edit-module.ts create mode 100644 src/cdk-experimental/popover-edit/popover-edit-position-strategy-factory.ts create mode 100644 src/cdk-experimental/popover-edit/popover-edit.spec.ts create mode 100644 src/cdk-experimental/popover-edit/public-api.ts create mode 100644 src/cdk-experimental/popover-edit/table-directives.ts create mode 100644 src/cdk-experimental/popover-edit/tsconfig-build.json create mode 100644 src/dev-app/popover-edit/popover-edit-demo-module.ts create mode 100644 src/dev-app/popover-edit/popover-edit-demo.ts create mode 100644 src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.css create mode 100644 src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.html create mode 100644 src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.ts create mode 100644 src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.css create mode 100644 src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.html create mode 100644 src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.ts create mode 100644 src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.css create mode 100644 src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.html create mode 100644 src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1d965df66c..e8674c26601 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -89,6 +89,7 @@ /src/cdk-experimental/** @jelbourn /src/cdk-experimental/dialog/** @jelbourn @josephperrott @crisbeto /src/cdk-experimental/scrolling/** @mmalerba +/src/cdk-experimental/popover-edit/** @kseamon @andrewseguin # Docs examples & guides /guides/** @jelbourn @@ -130,6 +131,7 @@ /src/dev-app/paginator/** @andrewseguin /src/dev-app/platform/** @jelbourn @devversion /src/dev-app/portal/** @jelbourn +/src/dev-app/popover-edit/** @kseamon @andrewseguin /src/dev-app/progress-bar/** @jelbourn @crisbeto @josephperrott /src/dev-app/progress-spinner/** @jelbourn @crisbeto @josephperrott /src/dev-app/radio/** @jelbourn @devversion diff --git a/packages.bzl b/packages.bzl index 3907b0acfdb..59829ef3907 100644 --- a/packages.bzl +++ b/packages.bzl @@ -24,6 +24,7 @@ CDK_TARGETS = ["//src/cdk"] + ["//src/cdk/%s" % p for p in CDK_PACKAGES] CDK_EXPERIMENTAL_PACKAGES = [ "dialog", + "popover-edit", "scrolling", ] diff --git a/src/cdk-experimental/popover-edit/BUILD.bazel b/src/cdk-experimental/popover-edit/BUILD.bazel new file mode 100644 index 00000000000..56c9f760257 --- /dev/null +++ b/src/cdk-experimental/popover-edit/BUILD.bazel @@ -0,0 +1,36 @@ +package(default_visibility=["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") + +ng_module( + name = "popover-edit", + srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), + module_name = "@angular/cdk-experimental/popover-edit", + deps = [ + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@angular/forms", + "@npm//rxjs", + "//src/cdk/a11y", + "//src/cdk/overlay", + "//src/cdk/portal", + ], +) + +ng_test_library( + name = "popover_edit_test_sources", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":popover-edit", + "@npm//@angular/common", + "@npm//@angular/forms", + "@npm//rxjs", + "//src/cdk/collections", + "//src/cdk/table", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":popover_edit_test_sources"] +) diff --git a/src/cdk-experimental/popover-edit/constants.ts b/src/cdk-experimental/popover-edit/constants.ts new file mode 100644 index 00000000000..74fc0316c51 --- /dev/null +++ b/src/cdk-experimental/popover-edit/constants.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 + */ + +/** Selector for finding table cells. */ +export const CELL_SELECTOR = '.cdk-cell, .mat-cell, td'; + +/** Selector for finding table rows. */ +export const ROW_SELECTOR = '.cdk-row, .mat-row, tr'; + +/** CSS class added to the edit lens pane. */ +export const EDIT_PANE_CLASS = 'cdk-edit-pane'; + +/** Selector for finding the edit lens pane. */ +export const EDIT_PANE_SELECTOR = '.' + EDIT_PANE_CLASS; diff --git a/src/cdk-experimental/popover-edit/edit-event-dispatcher.ts b/src/cdk-experimental/popover-edit/edit-event-dispatcher.ts new file mode 100644 index 00000000000..37de5d7ecc3 --- /dev/null +++ b/src/cdk-experimental/popover-edit/edit-event-dispatcher.ts @@ -0,0 +1,86 @@ +/** + * @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, timer} from 'rxjs'; +import {audit, distinctUntilChanged, filter, map, share} from 'rxjs/operators'; + +import {CELL_SELECTOR, ROW_SELECTOR} from './constants'; +import {closest} from './polyfill'; + +/** The delay between mouse out events and hiding hover content. */ +const DEFAULT_MOUSE_OUT_DELAY_MS = 30; + +/** + * Service for sharing delegated events and state for triggering table edits. + */ +@Injectable() +export class EditEventDispatcher { + /** A subject that indicates which table cell is currently editing. */ + readonly editing = new Subject(); + + /** A subject that indicates which table row is currently hovered. */ + readonly hovering = new Subject(); + + /** A subject that emits mouse move events for table rows. */ + readonly mouseMove = new Subject(); + + /** The table cell that has an active edit lens (or null). */ + private _currentlyEditing: Element|null = null; + + private readonly _hoveringDistinct = this.hovering.pipe(distinctUntilChanged(), share()); + private readonly _editingDistinct = this.editing.pipe(distinctUntilChanged(), share()); + + constructor() { + this._editingDistinct.subscribe(cell => { + this._currentlyEditing = cell; + }); + } + + /** + * Gets an Observable that emits true when the specified element's cell + * is editing and false when not. + */ + editingCell(element: Element|EventTarget): Observable { + let cell: Element|null = null; + + return this._editingDistinct.pipe( + map(editCell => editCell === (cell || (cell = closest(element, CELL_SELECTOR)))), + distinctUntilChanged(), + ); + } + + /** + * Stops editing for the specified cell. If the specified cell is not the current + * edit cell, does nothing. + */ + doneEditingCell(element: Element|EventTarget): void { + const cell = closest(element, CELL_SELECTOR); + + if (this._currentlyEditing === cell) { + this.editing.next(null); + } + } + + /** + * Gets an Observable that emits true when the specified element's row + * is being hovered over and false when not. Hovering is defined as when + * the mouse has momentarily stopped moving over the cell. + */ + hoveringOnRow(element: Element|EventTarget): Observable { + let row: Element|null = null; + + return this._hoveringDistinct.pipe( + map(hoveredRow => hoveredRow === (row || (row = closest(element, ROW_SELECTOR)))), + audit( + (hovering) => hovering ? this.mouseMove.pipe(filter(hoveredRow => hoveredRow === row)) : + timer(DEFAULT_MOUSE_OUT_DELAY_MS)), + distinctUntilChanged(), + ); + } +} diff --git a/src/cdk-experimental/popover-edit/edit-ref.ts b/src/cdk-experimental/popover-edit/edit-ref.ts new file mode 100644 index 00000000000..684fb380d2d --- /dev/null +++ b/src/cdk-experimental/popover-edit/edit-ref.ts @@ -0,0 +1,114 @@ +/** + * @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, OnDestroy, Self} from '@angular/core'; +import {ControlContainer} from '@angular/forms'; +import {Subject} from 'rxjs'; +import {take} from 'rxjs/operators'; + +import {EditEventDispatcher} from './edit-event-dispatcher'; + +/** + * Used for communication between the form within the edit lens and the + * table that launched it. Provided by CdkEditControl within the lens. + */ +@Injectable() +export class EditRef implements OnDestroy { + /** Emits the final value of this edit instance before closing. */ + private readonly _finalValueSubject = new Subject(); + readonly finalValue = this._finalValueSubject.asObservable(); + + /** The value to set the form back to on revert. */ + private _revertFormValue: FormValue; + + /** + * The flags are used to track whether a keyboard enter press is in progress at the same time + * as other events that would cause the edit lens to close. We must track this so that the + * Enter keyup event does not fire after we close as it would cause the edit to immediately + * reopen. + */ + private _enterPressed = false; + private _closePending = false; + + constructor( + @Self() private readonly _form: ControlContainer, + private readonly _editEventDispatcher: EditEventDispatcher) {} + + /** + * Called by the host directive's OnInit hook. Reads the initial state of the + * form and overrides it with persisted state from previous openings, if + * applicable. + */ + init(previousFormValue: FormValue|undefined): void { + // Wait for either the first value to be set, then override it with + // the previously entered value, if any. + this._form.valueChanges!.pipe(take(1)).subscribe(() => { + this.updateRevertValue(); + + if (previousFormValue) { + this.reset(previousFormValue); + } + }); + } + + ngOnDestroy(): void { + this._finalValueSubject.next(this._form.value); + this._finalValueSubject.complete(); + } + + /** Whether the attached form is in a valid state. */ + isValid(): boolean|null { + return this._form.valid; + } + + /** Set the form's current value as what it will be set to on revert/reset. */ + updateRevertValue(): void { + this._revertFormValue = this._form.value; + } + + /** Tells the table to close the edit popup. */ + close(): void { + this._editEventDispatcher.editing.next(null); + } + + /** + * Closes the edit if the enter key is not down. + * Otherwise, sets _closePending to true so that the edit will close on the + * next enter keyup. + */ + closeAfterEnterKeypress(): void { + // If the enter key is currently pressed, delay closing the popup so that + // the keyUp event does not cause it to immediately reopen. + if (this._enterPressed) { + this._closePending = true; + } else { + this.close(); + } + } + + /** + * Called on Enter keyup/keydown. + * Closes the edit if pending. Otherwise just updates _enterPressed. + */ + trackEnterPressForClose(pressed: boolean): void { + if (this._closePending) { + this.close(); + return; + } + + this._enterPressed = pressed; + } + + /** + * Resets the form value to the specified value or the previously set + * revert value. + */ + reset(value?: FormValue): void { + this._form.reset(value || this._revertFormValue); + } +} diff --git a/src/cdk-experimental/popover-edit/index.ts b/src/cdk-experimental/popover-edit/index.ts new file mode 100644 index 00000000000..676ca90f1ff --- /dev/null +++ b/src/cdk-experimental/popover-edit/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/popover-edit/lens-directives.ts b/src/cdk-experimental/popover-edit/lens-directives.ts new file mode 100644 index 00000000000..c7691c48d8b --- /dev/null +++ b/src/cdk-experimental/popover-edit/lens-directives.ts @@ -0,0 +1,143 @@ +/** + * @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 {ReplaySubject} from 'rxjs'; +import {Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import {EDIT_PANE_SELECTOR} from './constants'; +import {closest} from './polyfill'; +import {EditRef} from './edit-ref'; + +/** Options for what do to when the user clicks outside of an edit lens. */ +export type PopoverEditClickOutBehavior = 'close' | 'submit' | 'noop'; + +/** + * A directive that attaches to a form within the edit lens. + * It coordinates the form state with the table-wide edit system and handles + * closing the edit lens when the form is submitted or the user clicks + * out. + */ +@Directive({ + selector: 'form[cdkEditControl]', + host: { + '(ngSubmit)': 'handleFormSubmit()', + '(keydown.enter)': 'editRef.trackEnterPressForClose(true)', + '(keyup.enter)': 'editRef.trackEnterPressForClose(false)', + '(keyup.escape)': 'close()', + '(document:click)': 'handlePossibleClickOut($event)', + }, + providers: [EditRef], +}) +export class CdkEditControl implements OnDestroy, OnInit { + protected readonly destroyed = new ReplaySubject(); + + /** + * Specifies what should happen when the user clicks outside of the edit lens. + * The default behavior is to close the lens without submitting the form. + */ + @Input('cdkEditControlClickOutBehavior') clickOutBehavior: PopoverEditClickOutBehavior = 'close'; + + /** + * A two-way binding for storing unsubmitted form state. If not provided + * then form state will be discarded on close. The PeristBy directive is offered + * as a convenient shortcut for these bindings. + */ + @Input('cdkEditControlPreservedFormValue') preservedFormValue?: FormValue; + @Output('cdkEditControlPreservedFormValueChange') readonly preservedFormValueChange = + new EventEmitter(); + + /** + * Determines whether the lens will close on form submit if the form is not in a valid + * state. By default the lens will remain open. + */ + @Input('cdkEditControlIgnoreSubmitUnlessValid') ignoreSubmitUnlessValid = true; + + constructor(protected readonly elementRef: ElementRef, readonly editRef: EditRef) {} + + ngOnInit(): void { + this.editRef.init(this.preservedFormValue); + this.editRef.finalValue.subscribe(this.preservedFormValueChange); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } + + /** + * Called when the form submits. If ignoreSubmitUnlessValid is true, checks + * the form for validity before proceeding. + * Updates the revert state with the latest submitted value then closes the edit. + */ + handleFormSubmit(): void { + if (this.ignoreSubmitUnlessValid && !this.editRef.isValid()) { return; } + + this.editRef.updateRevertValue(); + this.editRef.closeAfterEnterKeypress(); + } + + /** Called on Escape keyup. Closes the edit. */ + close(): void { + // todo - allow this behavior to be customized as well, such as calling + // reset before close + this.editRef.close(); + } + + /** + * Called on click anywhere in the document. + * If the click was outside of the lens, trigger the specified click out behavior. + */ + handlePossibleClickOut(evt: Event): void { + if (closest(evt.target, EDIT_PANE_SELECTOR)) { return; } + + switch (this.clickOutBehavior) { + case 'submit': + // Manually cause the form to submit before closing. + this.elementRef.nativeElement!.dispatchEvent(new Event('submit')); + // Fall through + case 'close': + this.editRef.close(); + // Fall through + default: + break; + } + } +} + +/** Reverts the form to its initial or previously submitted state on click. */ +@Directive({ + selector: 'button[cdkEditRevert]', + host: { + '(click)': 'revertEdit()', + 'type': 'button', // Prevents accidental form submits. + } +}) +export class CdkEditRevert { + constructor( + protected readonly editRef: EditRef) {} + + revertEdit(): void { + this.editRef.reset(); + } +} + +/** Closes the lens on click. */ +@Directive({ + selector: 'button[cdkEditClose]', + host: { + '(click)': 'closeEdit()', + 'type': 'button', // Prevents accidental form submits. + } +}) +export class CdkEditClose { + constructor( + protected readonly editRef: EditRef) {} + + closeEdit(): void { + this.editRef.closeAfterEnterKeypress(); + } +} diff --git a/src/cdk-experimental/popover-edit/polyfill.ts b/src/cdk-experimental/popover-edit/polyfill.ts new file mode 100644 index 00000000000..4a6ea1f21f0 --- /dev/null +++ b/src/cdk-experimental/popover-edit/polyfill.ts @@ -0,0 +1,27 @@ +/** + * @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 + */ + +/** IE 11 compatible matches implementation. */ +export function matches(element: Element, selector: string): boolean { + return element.matches ? + element.matches(selector) : + (element as any)['msMatchesSelector'](selector); +} + +/** IE 11 compatible closest implementation. */ +export function closest(element: EventTarget|Element|null|undefined, selector: string): Element| + null { + if (!(element instanceof Node)) { return null; } + + let curr: Node|null = element; + while (curr != null && !(curr instanceof Element && matches(curr, selector))) { + curr = curr.parentNode; + } + + return (curr || null) as Element|null; +} diff --git a/src/cdk-experimental/popover-edit/popover-edit-module.ts b/src/cdk-experimental/popover-edit/popover-edit-module.ts new file mode 100644 index 00000000000..cbcf013408a --- /dev/null +++ b/src/cdk-experimental/popover-edit/popover-edit-module.ts @@ -0,0 +1,49 @@ +/** + * @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 { + CdkPopoverEdit, + CdkRowHoverContent, + CdkEditable, + CdkEditOpen, + CdkEditOpenButton +} from './table-directives'; +import {CdkEditControl, + CdkEditRevert, + CdkEditClose +} from './lens-directives'; +import { + DefaultPopoverEditPositionStrategyFactory, + PopoverEditPositionStrategyFactory +} from './popover-edit-position-strategy-factory'; + +const EXPORTED_DECLARATIONS = [ + CdkPopoverEdit, + CdkRowHoverContent, + CdkEditControl, + CdkEditRevert, + CdkEditClose, + CdkEditable, + CdkEditOpen, + CdkEditOpenButton, +]; + +@NgModule({ + imports: [ + OverlayModule, + ], + exports: EXPORTED_DECLARATIONS, + declarations: EXPORTED_DECLARATIONS, + providers: [{ + provide: PopoverEditPositionStrategyFactory, + useClass: DefaultPopoverEditPositionStrategyFactory + }], +}) +export class CdkPopoverEditModule { } diff --git a/src/cdk-experimental/popover-edit/popover-edit-position-strategy-factory.ts b/src/cdk-experimental/popover-edit/popover-edit-position-strategy-factory.ts new file mode 100644 index 00000000000..b2ecf2baa88 --- /dev/null +++ b/src/cdk-experimental/popover-edit/popover-edit-position-strategy-factory.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 {ElementRef, Injectable} from '@angular/core'; +import {Overlay, PositionStrategy} from '@angular/cdk/overlay'; + +/** + * Overridable factory responsible for configuring how cdkPopoverEdit popovers are positioned. + */ +@Injectable() +export abstract class PopoverEditPositionStrategyFactory { + abstract forElementRef(elementRef: ElementRef): PositionStrategy; +} + +/** + * Default implementation of PopoverEditPositionStrategyFactory. + * Uses a FlexibleConnectedPositionStrategy anchored to the start + top of the cell. + * Note: This will change to CoverPositionStrategy once it implemented. + */ +@Injectable() +export class DefaultPopoverEditPositionStrategyFactory extends PopoverEditPositionStrategyFactory { + constructor(protected readonly overlay: Overlay) { + super(); + } + + forElementRef(elementRef: ElementRef): PositionStrategy { + return this.overlay.position().flexibleConnectedTo(elementRef) + .withGrowAfterOpen() + .withPush() + .withPositions([{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + }]); + } +} diff --git a/src/cdk-experimental/popover-edit/popover-edit.spec.ts b/src/cdk-experimental/popover-edit/popover-edit.spec.ts new file mode 100644 index 00000000000..5f3aeb7341d --- /dev/null +++ b/src/cdk-experimental/popover-edit/popover-edit.spec.ts @@ -0,0 +1,521 @@ +import {BehaviorSubject} from 'rxjs'; +import {Component, ElementRef, Type, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule, NgForm} from '@angular/forms'; +import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing'; +import {DataSource} from '@angular/cdk/collections'; +import {CdkTableModule} from '@angular/cdk/table'; +import {CdkPopoverEditModule, PopoverEditClickOutBehavior} from './index'; + +const EDIT_TEMPLATE = ` +
+
+ +
+ + + +
+
+ `; + +const CELL_TEMPLATE = ` + {{element.name}} + + + + + `; + +interface PeriodicElement { + name: string; + weight: number; +} + +abstract class BaseTestComponent { + @ViewChild('table') table: ElementRef; + + preservedValues = new Map(); + + ignoreSubmitUnlessValid = true; + clickOutBehavior: PopoverEditClickOutBehavior = 'close'; + + onSubmit(element: PeriodicElement, form: NgForm) { + if (!form.valid) { return; } + + element.name = form.value['name']; + } + + triggerHoverState(rowIndex = 0) { + const row = getRows(this.table.nativeElement)[rowIndex]; + row.dispatchEvent(new Event('mouseover', {bubbles: true})); + row.dispatchEvent(new Event('mousemove', {bubbles: true})); + + tick(31); + } + + getEditCell(rowIndex = 0) { + const row = getRows(this.table.nativeElement)[rowIndex]; + return getCells(row)[0]; + } + + focusEditCell(rowIndex = 0) { + this.getEditCell(rowIndex).focus(); + } + + getOpenButton(rowIndex = 0) { + return this.getEditCell(rowIndex).querySelector('.open') as HTMLElement|null; + } + + clickOpenButton(rowIndex = 0) { + this.getOpenButton(rowIndex)!.click(); + } + + openLens(rowIndex = 0) { + this.focusEditCell(rowIndex); + this.getEditCell(rowIndex).dispatchEvent( + new KeyboardEvent('keyup', {bubbles: true, key: 'Enter'})); + flush(); + } + + getInput() { + return document.querySelector('input') as HTMLInputElement|null; + } + + lensIsOpen() { + return !!this.getInput(); + } + + getSubmitButton() { + return document.querySelector('.submit') as HTMLElement|null; + } + + clickSubmitButton() { + this.getSubmitButton()!.click(); + } + + getRevertButton() { + return document.querySelector('.revert') as HTMLElement|null; + } + + clickRevertButton() { + this.getRevertButton()!.click(); + } + + getCloseButton() { + return document.querySelector('.close') as HTMLElement|null; + } + + clickCloseButton() { + this.getCloseButton()!.click(); + } +} + +@Component({ + template: ` + + + ${EDIT_TEMPLATE} + + + + + + + +
+ ${CELL_TEMPLATE} + {{element.weight}}
+ ` +}) +class VanillaTableOutOfCell extends BaseTestComponent { + elements = createElementData(); +} + +@Component({ + template: ` + + + + + + +
+ ${CELL_TEMPLATE} + + + ${EDIT_TEMPLATE} + + {{element.weight}}
+ ` +}) +class VanillaTableInCell extends BaseTestComponent { + elements = createElementData(); +} + +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() {} +} + +@Component({ + template: ` +
+ + + + ${CELL_TEMPLATE} + + + ${EDIT_TEMPLATE} + + + + + + + + + + + {{element.weight}} + + + + + +
+ ` +}) +class CdkFlexTableInCell extends BaseTestComponent { + displayedColumns = ['name', 'weight']; + dataSource = new ElementDataSource(); +} + +@Component({ + template: ` +
+ + + + + + + + + + +
+ ${CELL_TEMPLATE} + + + ${EDIT_TEMPLATE} + + + + + + + {{element.weight}} +
+
+ ` +}) +class CdkTableInCell extends BaseTestComponent { + displayedColumns = ['name', 'weight']; + dataSource = new ElementDataSource(); +} + +const testCases: ReadonlyArray<[Type, string]> = [ + [VanillaTableOutOfCell, 'Vanilla HTML table; edit defined outside of cell'], + [VanillaTableInCell, 'Vanilla HTML table; edit defined within cell'], + [CdkFlexTableInCell, 'Flex cdk-table; edit defined within cell'], + [CdkTableInCell, 'Table cdk-table; edit defined within cell'], +]; + +describe('CDK Popover Edit', () => { + for (const [componentClass, label] of testCases) { + describe(label, () => { + let component: BaseTestComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CdkTableModule, CdkPopoverEditModule, CommonModule, FormsModule], + declarations: [componentClass], + }).compileComponents(); + fixture = TestBed.createComponent(componentClass); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('triggering edit', () => { + it('shows and hides on-hover content only after a delay', fakeAsync(() => { + const [row0, row1] = getRows(component.table.nativeElement); + row0.dispatchEvent(new Event('mouseover', {bubbles: true})); + row0.dispatchEvent(new Event('mousemove', {bubbles: true})); + + expect(component.getOpenButton(0)).toBe(null); + + tick(20); + row0.dispatchEvent(new Event('mousemove', {bubbles: true})); + tick(20); + + expect(component.getOpenButton(0)).toBe(null); + + tick(11); + + expect(component.getOpenButton(0)).toEqual(jasmine.any(HTMLElement)); + + row1.dispatchEvent(new Event('mouseover', {bubbles: true})); + row1.dispatchEvent(new Event('mousemove', {bubbles: true})); + + expect(component.getOpenButton(0)).toEqual(jasmine.any(HTMLElement)); + expect(component.getOpenButton(1)).toBe(null); + + tick(31); + + expect(component.getOpenButton(0)).toBe(null); + expect(component.getOpenButton(1)).toEqual(jasmine.any(HTMLElement)); + })); + + it('opens edit from on-hover button', fakeAsync(() => { + component.triggerHoverState(); + component.clickOpenButton(); + + expect(component.lensIsOpen()).toBe(true); + })); + + it('opens edit from Enter on focued cell', fakeAsync(() => { + // Uses Enter to open the lens. + component.openLens(); + + expect(component.lensIsOpen()).toBe(true); + })); + }); + + describe('edit lens', () => { + it('shows a lens with the value from the table', fakeAsync(() => { + component.openLens(); + + expect(component.getInput()!.value).toBe('Hydrogen'); + })); + + it('updates the form and submits, closing the lens', fakeAsync(() => { + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + fixture.detectChanges(); + + expect(component.getEditCell().textContent!.trim()).toBe('Hydragon'); + expect(component.lensIsOpen()).toBe(false); + })); + + it('does not close the lens on submit when form is invalid', fakeAsync(() => { + component.openLens(); + + component.getInput()!.value = ''; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + + expect(component.lensIsOpen()).toBe(true); + })); + + it('closes lens on submit when form is invalid with ' + + 'cdkEditControlIgnoreSubmitUnlessValid = false', fakeAsync(() => { + component.ignoreSubmitUnlessValid = false; + component.openLens(); + + component.getInput()!.value = ''; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + + expect(component.lensIsOpen()).toBe(false); + })); + + it('closes the lens on close', fakeAsync(() => { + component.openLens(); + + component.clickCloseButton(); + + expect(component.lensIsOpen()).toBe(false); + })); + + it('closes and reopens a lens with modified value persisted', fakeAsync(() => { + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickCloseButton(); + fixture.detectChanges(); + + expect(component.getEditCell().textContent!.trim()).toBe('Hydrogen'); + expect(component.lensIsOpen()).toBe(false); + + component.openLens(); + + expect(component.getInput()!.value).toBe('Hydragon'); + })); + + it('resets the lens to original value', fakeAsync(() => { + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickRevertButton(); + + expect(component.getInput()!.value).toBe('Hydrogen'); + })); + + it('resets the lens to previously submitted value', fakeAsync(() => { + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + fixture.detectChanges(); + + component.openLens(); + + component.getInput()!.value = 'Hydragon X'; + component.getInput()!.dispatchEvent(new Event('input')); + + component.clickRevertButton(); + + expect(component.getInput()!.value).toBe('Hydragon'); + })); + + it('closes the lens on escape', fakeAsync(() => { + component.openLens(); + + component.getInput()!.dispatchEvent( + new KeyboardEvent('keyup', {bubbles: true, key: 'Escape'})); + + expect(component.lensIsOpen()).toBe(false); + })); + + it('does not close the lens on click within lens', fakeAsync(() => { + component.openLens(); + + component.getInput()!.dispatchEvent(new Event('click', {bubbles: true})); + + expect(component.lensIsOpen()).toBe(true); + })); + + it('closes the lens on outside click', fakeAsync(() => { + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + document.body.dispatchEvent(new Event('click', {bubbles: true})); + fixture.detectChanges(); + + expect(component.lensIsOpen()).toBe(false); + expect(component.getEditCell().textContent!.trim()).toBe('Hydrogen'); + + })); + + it('submits the lens on outside click with ' + + 'cdkEditControlClickOutBehavior = "submit"', fakeAsync(() => { + component.clickOutBehavior = 'submit'; + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + document.body.dispatchEvent(new Event('click', {bubbles: true})); + fixture.detectChanges(); + + expect(component.lensIsOpen()).toBe(false); + expect(component.getEditCell().textContent!.trim()).toBe('Hydragon'); + })); + + it('does nothing on outside click with ' + + 'cdkEditControlClickOutBehavior = "noop"', fakeAsync(() => { + component.clickOutBehavior = 'noop'; + component.openLens(); + + component.getInput()!.value = 'Hydragon'; + component.getInput()!.dispatchEvent(new Event('input')); + document.body.dispatchEvent(new Event('click', {bubbles: true})); + fixture.detectChanges(); + + expect(component.lensIsOpen()).toBe(true); + expect(component.getEditCell().textContent!.trim()).toBe('Hydrogen'); + })); + + it('sets focus on the first input in the lens', fakeAsync(() => { + component.openLens(); + + expect(document.activeElement).toBe(component.getInput()); + })); + + it('returns focus to the edited cell after closing', fakeAsync(() => { + component.openLens(); + + component.clickCloseButton(); + + expect(document.activeElement).toBe(component.getEditCell()); + })); + + it('does not focus to the edited cell after closing if another element ' + + 'outside the lens is already focused', fakeAsync(() => { + component.openLens(0); + + component.getEditCell(1).focus(); + component.getEditCell(1).dispatchEvent(new Event('click', {bubbles: true})); + + expect(document.activeElement).toBe(component.getEditCell(1)); + })); + }); + }); + } +}); + +function createElementData() { + return [ + {name: 'Hydrogen', weight: 1.007}, + {name: 'Helium', weight: 4.0026}, + {name: 'Lithium', weight: 6.941}, + {name: 'Beryllium', weight: 9.0122}, + {name: 'Boron', weight: 10.81}, + ]; +} + +function getElements(element: Element, query: string): HTMLElement[] { + return [].slice.call(element.querySelectorAll(query)); +} + +function getRows(tableElement: Element): HTMLElement[] { + return getElements(tableElement, '.cdk-row, tr'); +} + +function getCells(row: Element): HTMLElement[] { + if (!row) { + return []; + } + + return getElements(row, '.cdk-cell, td'); +} diff --git a/src/cdk-experimental/popover-edit/public-api.ts b/src/cdk-experimental/popover-edit/public-api.ts new file mode 100644 index 00000000000..c449b2bc349 --- /dev/null +++ b/src/cdk-experimental/popover-edit/public-api.ts @@ -0,0 +1,14 @@ +/** + * @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 './edit-event-dispatcher'; +export * from './edit-ref'; +export * from './lens-directives'; +export * from './popover-edit-module'; +export * from './popover-edit-position-strategy-factory'; +export * from './table-directives'; diff --git a/src/cdk-experimental/popover-edit/table-directives.ts b/src/cdk-experimental/popover-edit/table-directives.ts new file mode 100644 index 00000000000..f29395a22ee --- /dev/null +++ b/src/cdk-experimental/popover-edit/table-directives.ts @@ -0,0 +1,291 @@ +/** + * @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 {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; +import {Overlay, OverlayRef} from '@angular/cdk/overlay'; +import {TemplatePortal} from '@angular/cdk/portal'; +import { + AfterViewInit, + Directive, + ElementRef, + EmbeddedViewRef, + Input, + NgZone, + OnDestroy, + TemplateRef, + ViewContainerRef +} from '@angular/core'; +import {fromEvent, ReplaySubject} from 'rxjs'; +import {debounceTime, filter, map, mapTo, takeUntil} from 'rxjs/operators'; + +import {CELL_SELECTOR, EDIT_PANE_CLASS, EDIT_PANE_SELECTOR, ROW_SELECTOR} from './constants'; +import {EditEventDispatcher} from './edit-event-dispatcher'; +import {closest} from './polyfill'; +import {PopoverEditPositionStrategyFactory} from './popover-edit-position-strategy-factory'; + +/** + * The delay between the mouse entering a row and the mouse stopping its movement before + * showing on-hover content. + */ +const DEFAULT_MOUSE_MOVE_DELAY_MS = 30; + +/** + * A directive that must be attached to enable editability on a table. + * It is responsible for setting up delegated event handlers and providing the + * EditEventDispatcher service for use by the other edit directives. + */ +@Directive({ + selector: 'table[editable], cdk-table[editable], mat-table[editable]', + providers: [EditEventDispatcher], +}) +export class CdkEditable implements AfterViewInit, OnDestroy { + protected readonly destroyed = new ReplaySubject(); + + constructor( + protected readonly elementRef: ElementRef, + protected readonly editEventDispatcher: EditEventDispatcher, + protected readonly ngZone: NgZone) {} + + ngAfterViewInit(): void { + this._listenForTableEvents(); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } + + private _listenForTableEvents(): void { + const element = this.elementRef.nativeElement!; + + const toClosest = (selector: string) => + map((event: UIEvent) => closest(event.target, selector)); + + this.ngZone.runOutsideAngular(() => { + fromEvent(element, 'mouseover').pipe( + takeUntil(this.destroyed), + toClosest(ROW_SELECTOR), + ).subscribe(this.editEventDispatcher.hovering); + fromEvent(element, 'mouseleave').pipe( + takeUntil(this.destroyed), + mapTo(null), + ).subscribe(this.editEventDispatcher.hovering); + fromEvent(element, 'mousemove').pipe( + takeUntil(this.destroyed), + debounceTime(DEFAULT_MOUSE_MOVE_DELAY_MS), + toClosest(ROW_SELECTOR), + ).subscribe(this.editEventDispatcher.mouseMove); + + fromEvent(element, 'keyup').pipe( + takeUntil(this.destroyed), + filter(event => event.key === 'Enter'), + toClosest(CELL_SELECTOR), + ).subscribe(this.editEventDispatcher.editing); + }); + } +} + +/** + * Attaches an ng-template to a cell and shows it when instructed to by the + * EditEventDispatcher service. + * Makes the cell focusable. + */ +@Directive({ + selector: '[cdkPopoverEdit]', + host: { + 'tabIndex': '0', + '[attr.aria-haspopup]': 'true', + } +}) +export class CdkPopoverEdit implements AfterViewInit, OnDestroy { + /** The edit lens template shown over the cell on edit. */ + @Input('cdkPopoverEdit') template: TemplateRef|null = null; + + /** + * Implicit context to pass along to the template. Can be omitted if the template + * is defined within the cell. + */ + @Input('cdkPopoverEditContext') context?: C; + + protected focusTrap?: FocusTrap; + protected overlayRef?: OverlayRef; + protected readonly destroyed = new ReplaySubject(); + + constructor( + protected readonly editEventDispatcher: EditEventDispatcher, + protected readonly elementRef: ElementRef, + protected readonly focusTrapFactory: FocusTrapFactory, + protected readonly ngZone: NgZone, + protected readonly overlay: Overlay, + protected readonly positionFactory: PopoverEditPositionStrategyFactory, + protected readonly viewContainerRef: ViewContainerRef) {} + + ngAfterViewInit(): void { + this._startListeningToEditEvents(); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + + if (this.overlayRef) { + this.overlayRef.dispose(); + } + } + + private _startListeningToEditEvents(): void { + this.editEventDispatcher.editingCell(this.elementRef.nativeElement!) + .pipe(takeUntil(this.destroyed)) + .subscribe((open) => { + this.ngZone.run(() => { + if (open && this.template) { + if (!this.overlayRef) { + this._createEditOverlay(); + } + + this._showEditOverlay(); + } else if (this.overlayRef) { + this._maybeReturnFocusToCell(); + + this.overlayRef.detach(); + } + }); + }); + } + + private _createEditOverlay(): void { + this.overlayRef = this.overlay.create({ + disposeOnNavigation: true, + panelClass: EDIT_PANE_CLASS, + positionStrategy: this.positionFactory.forElementRef(this.elementRef), + scrollStrategy: this.overlay.scrollStrategies.reposition({autoClose: true}), + }); + + this.focusTrap = this.focusTrapFactory.create(this.overlayRef.overlayElement); + this.overlayRef.overlayElement.setAttribute('aria-role', 'dialog'); + + this.overlayRef.detachments().subscribe(() => { + this.editEventDispatcher.doneEditingCell(this.elementRef.nativeElement!); + }); + } + + private _showEditOverlay(): void { + this.overlayRef!.attach(new TemplatePortal( + this.template!, + this.viewContainerRef, + {$implicit: this.context})); + this.focusTrap!.focusInitialElementWhenReady(); + } + + private _maybeReturnFocusToCell(): void { + if (closest(document.activeElement, EDIT_PANE_SELECTOR) === + this.overlayRef!.overlayElement) { + this.elementRef.nativeElement!.focus(); + } + } +} + +/** + * A structural directive that shows its contents when the table row containing + * it is hovered. + * + * Note that the contents of this directive are invisible to screen readers. + * Typically this is used to show a button that launches the edit popup, which + * is ok because screen reader users can trigger edit by pressing Enter on a focused + * table cell. + * + * If this directive contains buttons for functionality other than opening edit then + * care should be taken to make sure that this functionality is also exposed in + * an accessible way. + */ +@Directive({ + selector: '[cdkRowHoverContent]', + host: { + '[attr.aria-hidden]': 'true', + } +}) +export class CdkRowHoverContent implements AfterViewInit, OnDestroy { + protected readonly destroyed = new ReplaySubject(); + protected viewRef: EmbeddedViewRef|null = null; + + constructor( + protected readonly elementRef: ElementRef, + protected readonly editEventDispatcher: EditEventDispatcher, + protected readonly ngZone: NgZone, + protected readonly templateRef: TemplateRef, + protected readonly viewContainerRef: ViewContainerRef + ) {} + + ngAfterViewInit(): void { + this._listenForHoverEvents(); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + + if (this.viewRef) { + this.viewRef.destroy(); + } + } + + private _listenForHoverEvents(): void { + this.editEventDispatcher.hoveringOnRow(this.elementRef.nativeElement!) + .pipe(takeUntil(this.destroyed)) + .subscribe(isHovering => { + this.ngZone.run(() => { + if (isHovering) { + if (!this.viewRef) { + // Not doing any positioning in CDK version. Material version + // will absolutely position on right edge of cell. + this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef, {}); + } else { + this.viewContainerRef.insert(this.viewRef); + } + } else if (this.viewRef) { + this.viewContainerRef.detach(this.viewContainerRef.indexOf(this.viewRef)); + } + }); + }); + } +} + +/** + * Opens the closest edit popover to this element, whether it's associated with this exact + * element or an ancestor element. + */ +@Directive({ + // Specify :not(button) as we only need to add type: button on actual buttons. + selector: '[cdkEditOpen]:not(button)', + host: { + '(click)': 'openEdit($event)', + } +}) +export class CdkEditOpen { + constructor( + protected readonly elementRef: ElementRef, + protected readonly editEventDispatcher: EditEventDispatcher) {} + + openEdit(evt: Event): void { + this.editEventDispatcher.editing.next(closest(this.elementRef.nativeElement!, CELL_SELECTOR)); + evt.stopPropagation(); + } +} + +/** + * Opens the closest edit popover to this element, whether it's associated with this exact + * element or an ancestor element. + */ +@Directive({ + // Specify button as we only need to add type: button on actual buttons. + selector: 'button[cdkEditOpen]', + host: { + '(click)': 'openEdit($event)', + 'type': 'button', // Prevents accidental form submits. + } +}) +export class CdkEditOpenButton extends CdkEditOpen {} diff --git a/src/cdk-experimental/popover-edit/tsconfig-build.json b/src/cdk-experimental/popover-edit/tsconfig-build.json new file mode 100644 index 00000000000..3ca167f03a3 --- /dev/null +++ b/src/cdk-experimental/popover-edit/tsconfig-build.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk-experimental/popover-edit", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index da99817af63..6cf7ad52e41 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -1,7 +1,7 @@ package(default_visibility=["//visibility:public"]) load("@io_bazel_rules_sass//:defs.bzl", "sass_binary") -load("//:packages.bzl", "MATERIAL_TARGETS", "CDK_TARGETS") +load("//:packages.bzl", "MATERIAL_TARGETS", "CDK_TARGETS", "CDK_EXPERIMENTAL_TARGETS") load("//tools:defaults.bzl", "ng_module") load("//tools:sass_generate_binaries.bzl", "sass_generate_binaries") @@ -25,9 +25,8 @@ ng_module( "@npm//@angular/platform-browser-dynamic", "@npm//@angular/router", "@npm//rxjs", - "//src/cdk-experimental", "//src/material-examples:examples", - ] + CDK_TARGETS + MATERIAL_TARGETS + ] + CDK_TARGETS + CDK_EXPERIMENTAL_TARGETS + MATERIAL_TARGETS ) sass_binary( diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index efe01680292..5644707d40c 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -49,6 +49,7 @@ export class DevAppLayout { {name: 'Menu', route: '/menu'}, {name: 'Paginator', route: '/paginator'}, {name: 'Platform', route: '/platform'}, + {name: 'Popover Edit', route: '/popover-edit'}, {name: 'Portal', route: '/portal'}, {name: 'Progress Bar', route: '/progress-bar'}, {name: 'Progress Spinner', route: '/progress-spinner'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index 159aef8d036..93ae4823aa8 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -51,6 +51,10 @@ export const DEV_APP_ROUTES: Routes = [ {path: 'menu', loadChildren: 'menu/menu-demo-module#MenuDemoModule'}, {path: 'paginator', loadChildren: 'paginator/paginator-demo-module#PaginatorDemoModule'}, {path: 'platform', loadChildren: 'platform/platform-demo-module#PlatformDemoModule'}, + { + path: 'popover-edit', + loadChildren: 'popover-edit/popover-edit-demo-module#PopoverEditDemoModule' + }, {path: 'portal', loadChildren: 'portal/portal-demo-module#PortalDemoModule'}, { path: 'progress-bar', diff --git a/src/dev-app/popover-edit/popover-edit-demo-module.ts b/src/dev-app/popover-edit/popover-edit-demo-module.ts new file mode 100644 index 00000000000..278298443da --- /dev/null +++ b/src/dev-app/popover-edit/popover-edit-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 {FormsModule} from '@angular/forms'; +import {RouterModule} from '@angular/router'; +import {ExampleModule} from '../example/example-module'; +import {PopoverEditDemo} from './popover-edit-demo'; + +@NgModule({ + imports: [ + ExampleModule, + FormsModule, + RouterModule.forChild([{path: '', component: PopoverEditDemo}]), + ], + declarations: [PopoverEditDemo], +}) +export class PopoverEditDemoModule { +} diff --git a/src/dev-app/popover-edit/popover-edit-demo.ts b/src/dev-app/popover-edit/popover-edit-demo.ts new file mode 100644 index 00000000000..793d6e8c881 --- /dev/null +++ b/src/dev-app/popover-edit/popover-edit-demo.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 {Component} from '@angular/core'; +import {EXAMPLE_COMPONENTS} from '@angular/material-examples'; + + +@Component({ + moduleId: module.id, + template: '', +}) +export class PopoverEditDemo { + examples = Object.keys(EXAMPLE_COMPONENTS) + .filter(example => example.startsWith('popover-edit-') || + example.startsWith('cdk-popover-edit-')); +} diff --git a/src/dev-app/system-config.ts b/src/dev-app/system-config.ts index b9329312b18..f1637d28e5d 100644 --- a/src/dev-app/system-config.ts +++ b/src/dev-app/system-config.ts @@ -66,6 +66,8 @@ System.config({ '@angular/cdk-experimental/scrolling': 'dist/packages/cdk-experimental/scrolling/index.js', '@angular/cdk-experimental/dialog': 'dist/packages/cdk-experimental/dialog/index.js', + '@angular/cdk-experimental/popover-edit': + 'dist/packages/cdk-experimental/popover-edit/index.js', '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js', diff --git a/src/material-examples/BUILD.bazel b/src/material-examples/BUILD.bazel index 4ec4bb73614..1a8d851f798 100644 --- a/src/material-examples/BUILD.bazel +++ b/src/material-examples/BUILD.bazel @@ -1,6 +1,6 @@ package(default_visibility=["//visibility:public"]) -load("//:packages.bzl", "CDK_TARGETS", "MATERIAL_TARGETS", "ROLLUP_GLOBALS") +load("//:packages.bzl", "CDK_TARGETS", "CDK_EXPERIMENTAL_TARGETS", "MATERIAL_TARGETS", "ROLLUP_GLOBALS") load("//tools:defaults.bzl", "ng_module", "ng_package") load("//tools/highlight-files:index.bzl", "highlight_files") load("//tools/package-docs-content:index.bzl", "package_docs_content") @@ -17,7 +17,7 @@ ng_module( "@npm//@angular/forms", "@npm//moment", "//src/material-moment-adapter", - ] + CDK_TARGETS + MATERIAL_TARGETS, + ] + CDK_TARGETS + CDK_EXPERIMENTAL_TARGETS + MATERIAL_TARGETS, # Specify the tsconfig that is also used by Gulp. We need to explicitly use this tsconfig # because in order to import Moment with TypeScript, some specific options need to be set. tsconfig = ":tsconfig-build.json", diff --git a/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.css b/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.css new file mode 100644 index 00000000000..05106dca317 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.css @@ -0,0 +1,10 @@ +/** + * Add basic flex styling so that the cells evenly space themselves in the row. + */ +.example-table cdk-row, cdk-header-row, cdk-footer-row { + display: flex; +} + +.example-table cdk-cell, cdk-header-cell, cdk-footer-cell { + flex: 1; +} diff --git a/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.html b/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.html new file mode 100644 index 00000000000..51f3419a39f --- /dev/null +++ b/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.html @@ -0,0 +1,81 @@ + + + +
+
+ Edit b: + +
+ + + +
+
+
+ + + + No. + {{element.position}} + + + + + Name + + {{element.name}} + + + +
+
+ Edit a: + +
+ + + +
+
+
+ + + + +
+
+ + + + Weight + + {{element.weight}} + + + + + + + + + + Symbol + {{element.symbol}} + + + + +
diff --git a/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.ts b/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.ts new file mode 100644 index 00000000000..ee4189d2475 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-cdk-table-flex/cdk-popover-edit-cdk-table-flex-example.ts @@ -0,0 +1,81 @@ +import {DataSource} from '@angular/cdk/collections'; +import {Component} from '@angular/core'; +import {NgForm} from '@angular/forms'; +import {BehaviorSubject, Observable} from 'rxjs'; + +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 CDK Popover Edit on a flex cdk-table. + */ +@Component({ + selector: 'cdk-popover-edit-cdk-table-flex-example', + styleUrls: ['cdk-popover-edit-cdk-table-flex-example.css'], + templateUrl: 'cdk-popover-edit-cdk-table-flex-example.html', +}) +export class CdkPopoverEditCdkTableFlexExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = new ExampleDataSource(); + + readonly preservedNameValues = new WeakMap(); + readonly preservedWeightValues = new WeakMap(); + + onSubmitName(element: PeriodicElement, f: NgForm) { + if (!f.valid) { return; } + + element.name = f.value.name; + } + + onSubmitWeight(element: PeriodicElement, f: NgForm) { + if (!f.valid) { return; } + + element.weight = f.value.weight; + } +} + +/** + * Data source to provide what data should be rendered in the table. Note that the data source + * can retrieve its data in any way. In this case, the data source is provided a reference + * to a common data base, ExampleDatabase. It is not the data source's responsibility to manage + * the underlying data. Instead, it only needs to take the data and send the table exactly what + * should be rendered. + */ +export class ExampleDataSource extends DataSource { + /** Stream of data that is provided to the table. */ + data = new BehaviorSubject(ELEMENT_DATA); + + /** Connect function called by the table to retrieve one stream containing the data to render. */ + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.css b/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.css new file mode 100644 index 00000000000..eba60b11b33 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.css @@ -0,0 +1,12 @@ +.example-table { + width: 100%; +} + +.example-table th { + text-align: left; +} + +.example-table td, +.example-table th { + width: 25%; +} diff --git a/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.html b/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.html new file mode 100644 index 00000000000..d08fd15d5a8 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.html @@ -0,0 +1,80 @@ + + + +
+
+ Edit b: + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name + {{element.name}} + + + +
+
+ Edit a: + +
+ + + +
+
+
+ + + + +
Weight + {{element.weight}} + + + + + Symbol {{element.symbol}}
diff --git a/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.ts b/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.ts new file mode 100644 index 00000000000..b9dd2cfb5d6 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-cdk-table/cdk-popover-edit-cdk-table-example.ts @@ -0,0 +1,81 @@ +import {DataSource} from '@angular/cdk/collections'; +import {Component} from '@angular/core'; +import {NgForm} from '@angular/forms'; +import {BehaviorSubject, Observable} from 'rxjs'; + +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 CDK Popover Edit on a CDK data-table + */ +@Component({ + selector: 'cdk-popover-edit-cdk-table-example', + styleUrls: ['cdk-popover-edit-cdk-table-example.css'], + templateUrl: 'cdk-popover-edit-cdk-table-example.html', +}) +export class CdkPopoverEditCdkTableExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = new ExampleDataSource(); + + readonly preservedNameValues = new WeakMap(); + readonly preservedWeightValues = new WeakMap(); + + onSubmitName(element: PeriodicElement, f: NgForm) { + if (!f.valid) { return; } + + element.name = f.value.name; + } + + onSubmitWeight(element: PeriodicElement, f: NgForm) { + if (!f.valid) { return; } + + element.weight = f.value.weight; + } +} + +/** + * Data source to provide what data should be rendered in the table. Note that the data source + * can retrieve its data in any way. In this case, the data source is provided a reference + * to a common data base, ExampleDatabase. It is not the data source's responsibility to manage + * the underlying data. Instead, it only needs to take the data and send the table exactly what + * should be rendered. + */ +export class ExampleDataSource extends DataSource { + /** Stream of data that is provided to the table. */ + data = new BehaviorSubject(ELEMENT_DATA); + + /** Connect function called by the table to retrieve one stream containing the data to render. */ + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.css b/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.css new file mode 100644 index 00000000000..eba60b11b33 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.css @@ -0,0 +1,12 @@ +.example-table { + width: 100%; +} + +.example-table th { + text-align: left; +} + +.example-table td, +.example-table th { + width: 25%; +} diff --git a/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.html b/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.html new file mode 100644 index 00000000000..fa31d52a25c --- /dev/null +++ b/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.html @@ -0,0 +1,69 @@ + + + +
+
+ Edit b: + +
+ + + + +
+
+ + + + + + + + + + + + + + + + + +
No. Name Weight Symbol
{{element.position}} + {{element.name}} + + + +
+
+ Edit a: + +
+ + + +
+
+
+ + + + +
+ {{element.weight}} + + + + + {{element.symbol}}
diff --git a/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.ts b/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.ts new file mode 100644 index 00000000000..3d2116ed3e5 --- /dev/null +++ b/src/material-examples/cdk-popover-edit-vanilla-table/cdk-popover-edit-vanilla-table-example.ts @@ -0,0 +1,59 @@ +import {Component} from '@angular/core'; +import {NgForm} from '@angular/forms'; + +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 CDK Popover Edit on an HTML data-table + */ +@Component({ + selector: 'cdk-popover-edit-vanilla-table-example', + styleUrls: ['cdk-popover-edit-vanilla-table-example.css'], + templateUrl: 'cdk-popover-edit-vanilla-table-example.html', +}) +export class CdkPopoverEditVanillaTableExample { + readonly preservedNameValues = new WeakMap(); + readonly preservedWeightValues = new WeakMap(); + + readonly elements = ELEMENT_DATA; + + onSubmitName(element: PeriodicElement, f: NgForm) { + if (!f.valid) { return; } + + element.name = f.value.name; + } + + onSubmitWeight(element: PeriodicElement, f: NgForm) { + if (!f.valid) { return; } + + element.weight = f.value.weight; + } +} diff --git a/src/material-examples/material-module.ts b/src/material-examples/material-module.ts index bd02be545bf..f38135e4f10 100644 --- a/src/material-examples/material-module.ts +++ b/src/material-examples/material-module.ts @@ -2,6 +2,7 @@ import {NgModule} from '@angular/core'; import {ScrollingModule} from '@angular/cdk/scrolling'; import {A11yModule} from '@angular/cdk/a11y'; +import {CdkPopoverEditModule} from '@angular/cdk-experimental/popover-edit'; import {CdkTableModule} from '@angular/cdk/table'; import {CdkTreeModule} from '@angular/cdk/tree'; import {DragDropModule} from '@angular/cdk/drag-drop'; @@ -21,6 +22,7 @@ import { @NgModule({ imports: [ A11yModule, + CdkPopoverEditModule, CdkTableModule, CdkTreeModule, CdkStepperModule, @@ -66,6 +68,7 @@ import { ], exports: [ A11yModule, + CdkPopoverEditModule, CdkTableModule, CdkTreeModule, CdkStepperModule, diff --git a/src/material-examples/tsconfig-build.json b/src/material-examples/tsconfig-build.json index 76d15b8e4e0..609e5cffe33 100644 --- a/src/material-examples/tsconfig-build.json +++ b/src/material-examples/tsconfig-build.json @@ -29,7 +29,8 @@ "@angular/material/*": ["../../dist/packages/material/*"], "@angular/material": ["../../dist/packages/material"], "@angular/material-moment-adapter": ["../../dist/packages/material-moment-adapter"], - "@angular/cdk/*": ["../../dist/packages/cdk/*"] + "@angular/cdk/*": ["../../dist/packages/cdk/*"], + "@angular/cdk-experimental/*": ["../../dist/packages/cdk-experimental/*"], } }, "files": [