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.
This commit is contained in:
Karl Seamon
2019-04-03 15:52:26 -04:00
committed by mmalerba
parent fe569529bf
commit 457ff28d56
33 changed files with 1915 additions and 6 deletions
+2
View File
@@ -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
+1
View File
@@ -24,6 +24,7 @@ CDK_TARGETS = ["//src/cdk"] + ["//src/cdk/%s" % p for p in CDK_PACKAGES]
CDK_EXPERIMENTAL_PACKAGES = [
"dialog",
"popover-edit",
"scrolling",
]
@@ -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"]
)
@@ -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;
@@ -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<Element|null>();
/** A subject that indicates which table row is currently hovered. */
readonly hovering = new Subject<Element|null>();
/** A subject that emits mouse move events for table rows. */
readonly mouseMove = new Subject<Element|null>();
/** 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<boolean> {
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<boolean> {
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(),
);
}
}
@@ -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<FormValue> implements OnDestroy {
/** Emits the final value of this edit instance before closing. */
private readonly _finalValueSubject = new Subject<FormValue>();
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);
}
}
@@ -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';
@@ -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<FormValue> implements OnDestroy, OnInit {
protected readonly destroyed = new ReplaySubject<void>();
/**
* 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<FormValue>();
/**
* 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<FormValue>) {}
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<FormValue> {
constructor(
protected readonly editRef: EditRef<FormValue>) {}
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<FormValue> {
constructor(
protected readonly editRef: EditRef<FormValue>) {}
closeEdit(): void {
this.editRef.closeAfterEnterKeypress();
}
}
@@ -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;
}
@@ -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 { }
@@ -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',
}]);
}
}
@@ -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 = `
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmit(element, f)"
[cdkEditControlPreservedFormValue]="preservedValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedValues.set(element, $event)"
[cdkEditControlIgnoreSubmitUnlessValid]="ignoreSubmitUnlessValid"
[cdkEditControlClickOutBehavior]="clickOutBehavior">
<input [ngModel]="element.name" name="name" required>
<br>
<button class="submit" type="submit">Confirm</button>
<button class="revert" cdkEditRevert>Revert</button>
<button class="close" cdkEditClose>Close</button>
</form>
</div>
`;
const CELL_TEMPLATE = `
{{element.name}}
<span *cdkRowHoverContent>
<button class="open" cdkEditOpen>Edit</button>
</span>
`;
interface PeriodicElement {
name: string;
weight: number;
}
abstract class BaseTestComponent {
@ViewChild('table') table: ElementRef;
preservedValues = new Map<number, PeriodicElement>();
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: `
<table #table editable>
<ng-template #nameEdit let-element>
${EDIT_TEMPLATE}
</ng-template>
<tr *ngFor="let element of elements">
<td [cdkPopoverEdit]="nameEdit" [cdkPopoverEditContext]="element">
${CELL_TEMPLATE}
</td>
<td> {{element.weight}} </td>
</tr>
</table>
`
})
class VanillaTableOutOfCell extends BaseTestComponent {
elements = createElementData();
}
@Component({
template: `
<table #table editable>
<tr *ngFor="let element of elements">
<td [cdkPopoverEdit]="nameEdit">
${CELL_TEMPLATE}
<ng-template #nameEdit>
${EDIT_TEMPLATE}
</ng-template>
</td>
<td> {{element.weight}} </td>
</tr>
</table>
`
})
class VanillaTableInCell extends BaseTestComponent {
elements = createElementData();
}
class ElementDataSource extends DataSource<PeriodicElement> {
/** 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: `
<div #table>
<cdk-table cdk-table editable [dataSource]="dataSource">
<ng-container cdkColumnDef="name">
<cdk-cell *cdkCellDef="let element"
[cdkPopoverEdit]="nameEdit">
${CELL_TEMPLATE}
<ng-template #nameEdit>
${EDIT_TEMPLATE}
</ng-template>
<span *cdkIfRowHovered>
<button cdkEditOpen>Edit</button>
</span>
</cdk-cell>
</ng-container>
<ng-container cdkColumnDef="weight">
<cdk-cell *cdkCellDef="let element">
{{element.weight}}
</cdk-cell>
</ng-container>
<cdk-row *cdkRowDef="let row; columns: displayedColumns;"></cdk-row>
</cdk-table>
</div>
`
})
class CdkFlexTableInCell extends BaseTestComponent {
displayedColumns = ['name', 'weight'];
dataSource = new ElementDataSource();
}
@Component({
template: `
<div #table>
<table cdk-table editable [dataSource]="dataSource">
<ng-container cdkColumnDef="name">
<td cdk-cell *cdkCellDef="let element"
[cdkPopoverEdit]="nameEdit">
${CELL_TEMPLATE}
<ng-template #nameEdit>
${EDIT_TEMPLATE}
</ng-template>
<span *cdkIfRowHovered>
<button cdkEditOpen>Edit</button>
</span>
</td>
</ng-container>
<ng-container cdkColumnDef="weight">
<td cdk-cell *cdkCellDef="let element">
{{element.weight}}
</td>
</ng-container>
<tr cdk-row *cdkRowDef="let row; columns: displayedColumns;"></tr>
</table>
<div>
`
})
class CdkTableInCell extends BaseTestComponent {
displayedColumns = ['name', 'weight'];
dataSource = new ElementDataSource();
}
const testCases: ReadonlyArray<[Type<BaseTestComponent>, 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<BaseTestComponent>;
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');
}
@@ -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';
@@ -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<void>();
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<MouseEvent>(element, 'mouseover').pipe(
takeUntil(this.destroyed),
toClosest(ROW_SELECTOR),
).subscribe(this.editEventDispatcher.hovering);
fromEvent<MouseEvent>(element, 'mouseleave').pipe(
takeUntil(this.destroyed),
mapTo(null),
).subscribe(this.editEventDispatcher.hovering);
fromEvent<MouseEvent>(element, 'mousemove').pipe(
takeUntil(this.destroyed),
debounceTime(DEFAULT_MOUSE_MOVE_DELAY_MS),
toClosest(ROW_SELECTOR),
).subscribe(this.editEventDispatcher.mouseMove);
fromEvent<KeyboardEvent>(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<C> implements AfterViewInit, OnDestroy {
/** The edit lens template shown over the cell on edit. */
@Input('cdkPopoverEdit') template: TemplateRef<any>|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<void>();
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<void>();
protected viewRef: EmbeddedViewRef<any>|null = null;
constructor(
protected readonly elementRef: ElementRef,
protected readonly editEventDispatcher: EditEventDispatcher,
protected readonly ngZone: NgZone,
protected readonly templateRef: TemplateRef<any>,
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 {}
@@ -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
}
}
+2 -3
View File
@@ -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(
+1
View File
@@ -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'},
+4
View File
@@ -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',
@@ -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 {
}
@@ -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: '<material-example-list [ids]="examples"></material-example-list>',
})
export class PopoverEditDemo {
examples = Object.keys(EXAMPLE_COMPONENTS)
.filter(example => example.startsWith('popover-edit-') ||
example.startsWith('cdk-popover-edit-'));
}
+2
View File
@@ -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',
+2 -2
View File
@@ -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",
@@ -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;
}
@@ -0,0 +1,81 @@
<cdk-table class="example-table" editable [dataSource]="dataSource">
<!--
This edit lens is specified outside of the cell and must explicitly declare
its context. It could be reused in multiple cells.
-->
<ng-template #weightEdit let-element>
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmitWeight(element, f)"
[cdkEditControlPreservedFormValue]="preservedWeightValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedWeightValues.set(element, $event)">
Edit b:
<input type="number" [ngModel]="element.weight" name="weight" required>
<br>
<button type="submit">Confirm</button>
<button cdkEditRevert>Revert</button>
<button cdkEditClose>Close</button>
</form>
</div>
</ng-template>
<!-- Position Column -->
<ng-container cdkColumnDef="position">
<cdk-header-cell *cdkHeaderCellDef> No. </cdk-header-cell>
<cdk-cell *cdkCellDef="let element"> {{element.position}} </cdk-cell>
</ng-container>
<!-- Name Column -->
<ng-container cdkColumnDef="name">
<cdk-header-cell *cdkHeaderCellDef> Name </cdk-header-cell>
<cdk-cell *cdkCellDef="let element"
[cdkPopoverEdit]="nameEdit">
{{element.name}}
<!-- This edit is defined in the cell and can implicitly access element -->
<ng-template #nameEdit>
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmitName(element, f)"
[cdkEditControlPreservedFormValue]="preservedNameValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedNameValues.set(element, $event)">
Edit a:
<input [ngModel]="element.name" name="name" required>
<br>
<button type="submit">Confirm</button>
<button cdkEditRevert>Revert</button>
<button cdkEditClose>Close</button>
</form>
</div>
</ng-template>
<span *cdkRowHoverContent>
<button cdkEditOpen>Edit</button>
</span>
</cdk-cell>
</ng-container>
<!-- Weight Column -->
<ng-container cdkColumnDef="weight">
<cdk-header-cell *cdkHeaderCellDef> Weight </cdk-header-cell>
<cdk-cell *cdkCellDef="let element"
[cdkPopoverEdit]="weightEdit" [cdkPopoverEditContext]="element">
{{element.weight}}
<span *cdkRowHoverContent>
<button cdkEditOpen>Edit</button>
</span>
</cdk-cell>
</ng-container>
<!-- Symbol Column -->
<ng-container cdkColumnDef="symbol">
<cdk-header-cell *cdkHeaderCellDef> Symbol </cdk-header-cell>
<cdk-cell *cdkCellDef="let element"> {{element.symbol}} </cdk-cell>
</ng-container>
<cdk-header-row *cdkHeaderRowDef="displayedColumns"></cdk-header-row>
<cdk-row *cdkRowDef="let row; columns: displayedColumns;"></cdk-row>
</cdk-table>
@@ -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<PeriodicElement, any>();
readonly preservedWeightValues = new WeakMap<PeriodicElement, any>();
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<PeriodicElement> {
/** Stream of data that is provided to the table. */
data = new BehaviorSubject<PeriodicElement[]>(ELEMENT_DATA);
/** Connect function called by the table to retrieve one stream containing the data to render. */
connect(): Observable<PeriodicElement[]> {
return this.data;
}
disconnect() {}
}
@@ -0,0 +1,12 @@
.example-table {
width: 100%;
}
.example-table th {
text-align: left;
}
.example-table td,
.example-table th {
width: 25%;
}
@@ -0,0 +1,80 @@
<table class="example-table" cdk-table editable [dataSource]="dataSource">
<!--
This edit lens is specified outside of the cell and must explicitly declare
its context. It could be reused in multiple cells.
-->
<ng-template #weightEdit let-element>
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmitWeight(element, f)"
[cdkEditControlPreservedFormValue]="preservedWeightValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedWeightValues.set(element, $event)">
Edit b:
<input type="number" [ngModel]="element.weight" name="weight" required>
<br>
<button type="submit">Confirm</button>
<button cdkEditRevert cdkEditClose>Revert and close</button>
</form>
</div>
</ng-template>
<!-- Position Column -->
<ng-container cdkColumnDef="position">
<th cdk-header-cell *cdkHeaderCellDef> No. </th>
<td cdk-cell *cdkCellDef="let element"> {{element.position}} </td>
</ng-container>
<!-- Name Column -->
<ng-container cdkColumnDef="name">
<th cdk-header-cell *cdkHeaderCellDef> Name </th>
<td cdk-cell *cdkCellDef="let element"
[cdkPopoverEdit]="nameEdit">
{{element.name}}
<!-- This edit is defined in the cell and can implicitly access element -->
<ng-template #nameEdit>
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmitName(element, f)"
[cdkEditControlPreservedFormValue]="preservedNameValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedNameValues.set(element, $event)">
Edit a:
<input [ngModel]="element.name" name="name" required>
<br>
<button type="submit">Confirm</button>
<button cdkEditRevert>Revert</button>
<button cdkEditClose>Close</button>
</form>
</div>
</ng-template>
<span *cdkRowHoverContent>
<button cdkEditOpen>Edit</button>
</span>
</td>
</ng-container>
<!-- Weight Column -->
<ng-container cdkColumnDef="weight">
<th cdk-header-cell *cdkHeaderCellDef> Weight </th>
<td cdk-cell *cdkCellDef="let element"
[cdkPopoverEdit]="weightEdit" [cdkPopoverEditContext]="element">
{{element.weight}}
<span *cdkRowHoverContent>
<button cdkEditOpen>Edit</button>
</span>
</td>
</ng-container>
<!-- Symbol Column -->
<ng-container cdkColumnDef="symbol">
<th cdk-header-cell *cdkHeaderCellDef> Symbol </th>
<td cdk-cell *cdkCellDef="let element"> {{element.symbol}} </td>
</ng-container>
<tr cdk-header-row *cdkHeaderRowDef="displayedColumns"></tr>
<tr cdk-row *cdkRowDef="let row; columns: displayedColumns;"></tr>
</table>
@@ -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<PeriodicElement, any>();
readonly preservedWeightValues = new WeakMap<PeriodicElement, any>();
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<PeriodicElement> {
/** Stream of data that is provided to the table. */
data = new BehaviorSubject<PeriodicElement[]>(ELEMENT_DATA);
/** Connect function called by the table to retrieve one stream containing the data to render. */
connect(): Observable<PeriodicElement[]> {
return this.data;
}
disconnect() {}
}
@@ -0,0 +1,12 @@
.example-table {
width: 100%;
}
.example-table th {
text-align: left;
}
.example-table td,
.example-table th {
width: 25%;
}
@@ -0,0 +1,69 @@
<table editable class="example-table">
<!--
This edit lens is specified outside of the cell and must explicitly declare
its context. It could be reused in multiple cells.
-->
<ng-template #weightEdit let-element>
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmitWeight(element, f)"
[cdkEditControlPreservedFormValue]="preservedWeightValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedWeightValues.set(element, $event)">
Edit b:
<input type="number" [ngModel]="element.weight" name="weight" required>
<br>
<button type="submit">Confirm</button>
<button cdkEditRevert>Revert</button>
<button cdkEditClose>Close</button>
</form>
</div>
</ng-template>
<tr>
<th> No. </th>
<th> Name </th>
<th> Weight </th>
<th> Symbol </th>
</tr>
<tr *ngFor="let element of elements">
<td> {{element.position}} </td>
<td [cdkPopoverEdit]="nameEdit">
{{element.name}}
<!-- This edit is defined in the cell and can implicitly access element -->
<ng-template #nameEdit>
<div style="background-color: white;">
<form #f="ngForm"
cdkEditControl
(ngSubmit)="onSubmitName(element, f)"
[cdkEditControlPreservedFormValue]="preservedNameValues.get(element)"
(cdkEditControlPreservedFormValueChange)="preservedNameValues.set(element, $event)">
Edit a:
<input [ngModel]="element.name" name="name" required>
<br>
<button type="submit">Confirm</button>
<button cdkEditRevert>Revert</button>
<button cdkEditClose>Close</button>
</form>
</div>
</ng-template>
<span *cdkRowHoverContent>
<button cdkEditOpen>Edit</button>
</span>
</td>
<td [cdkPopoverEdit]="weightEdit" [cdkPopoverEditContext]="element">
{{element.weight}}
<span *cdkRowHoverContent>
<button cdkEditOpen>Edit</button>
</span>
</td>
<td> {{element.symbol}} </td>
</tr>
</table>
@@ -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<PeriodicElement, any>();
readonly preservedWeightValues = new WeakMap<PeriodicElement, any>();
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;
}
}
+3
View File
@@ -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,
+2 -1
View File
@@ -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": [