From b1ba2d6f4eb088c288f8a8c6f0b3fbb1092b1752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mis=CC=8Cko=20Hevery?= Date: Thu, 4 Apr 2019 15:03:40 -0700 Subject: [PATCH] test(ivy): add expanding_rows performance benchmark which runs in ViewEngine and Ivy (#30449) PR Close #30449 --- .../benchmarks/src/expanding_rows/BUILD.bazel | 74 +++ .../src/expanding_rows/benchmark.ts | 76 +++ .../src/expanding_rows/benchmark_module.ts | 54 ++ .../src/expanding_rows/benchmark_spec.ts | 24 + .../benchmarkable_expanding_row.ts | 73 +++ .../benchmarkable_expanding_row_module.ts | 25 + .../src/expanding_rows/expanding_row.ts | 371 +++++++++++++ .../expanding_rows/expanding_row_blacklist.ts | 19 + .../src/expanding_rows/expanding_row_css.ts | 87 +++ .../expanding_row_details_caption.ts | 58 ++ .../expanding_row_details_content.ts | 50 ++ .../src/expanding_rows/expanding_row_host.ts | 519 ++++++++++++++++++ .../expanding_rows/expanding_row_module.ts | 42 ++ .../expanding_rows/expanding_row_summary.ts | 219 ++++++++ .../expanding_row_toggle_event.ts | 22 + .../benchmarks/src/expanding_rows/index.html | 35 ++ .../benchmarks/src/expanding_rows/index.ts | 19 + 17 files changed, 1767 insertions(+) create mode 100644 modules/benchmarks/src/expanding_rows/BUILD.bazel create mode 100644 modules/benchmarks/src/expanding_rows/benchmark.ts create mode 100644 modules/benchmarks/src/expanding_rows/benchmark_module.ts create mode 100644 modules/benchmarks/src/expanding_rows/benchmark_spec.ts create mode 100644 modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row.ts create mode 100644 modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row_module.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_blacklist.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_css.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_details_caption.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_details_content.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_host.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_module.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_summary.ts create mode 100644 modules/benchmarks/src/expanding_rows/expanding_row_toggle_event.ts create mode 100644 modules/benchmarks/src/expanding_rows/index.html create mode 100644 modules/benchmarks/src/expanding_rows/index.ts diff --git a/modules/benchmarks/src/expanding_rows/BUILD.bazel b/modules/benchmarks/src/expanding_rows/BUILD.bazel new file mode 100644 index 0000000000..2d1f00d691 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/BUILD.bazel @@ -0,0 +1,74 @@ +package(default_visibility = ["//modules/benchmarks:__subpackages__"]) + +load("//tools:defaults.bzl", "ng_module", "ng_rollup_bundle") +load("@npm_bazel_typescript//:index.bzl", "ts_devserver") +load("//modules/benchmarks:benchmark_test.bzl", "benchmark_test") + +ng_module( + name = "application_lib", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*_spec.ts"], + ), + deps = [ + "//packages:types", + "//packages/common", + "//packages/core", + "//packages/platform-browser", + "@npm//rxjs", + ], +) + +ng_module( + name = "application_spec", + srcs = glob(["**/*_spec.ts"]), + deps = [ + "//packages:types", + "//packages/common", + "//packages/core", + "@npm//reflect-metadata", + ], +) + +ng_rollup_bundle( + name = "bundle", + entry_point = "modules/benchmarks/src/expanding_rows/index.js", + deps = [ + ":application_lib", + "@npm//rxjs", + ], +) + +ts_devserver( + name = "prodserver", + static_files = [ + ":bundle.min_debug.js", + ":bundle.min.js", + "@npm//node_modules/zone.js:dist/zone.js", + "index.html", + ], +) + +ts_devserver( + name = "devserver", + entry_module = "angular/modules/benchmarks/src/expanding_rows/index", + index_html = "index.html", + scripts = [ + "@npm//node_modules/tslib:tslib.js", + "//tools/rxjs:rxjs_umd_modules", + ], + serving_path = "/index.js", + static_files = [ + "@npm//node_modules/zone.js:dist/zone.js", + "index.html", + ], + deps = [":application_lib"], +) + +benchmark_test( + name = "perf", + server = ":prodserver", + deps = [ + ":application_spec", + ], +) diff --git a/modules/benchmarks/src/expanding_rows/benchmark.ts b/modules/benchmarks/src/expanding_rows/benchmark.ts new file mode 100644 index 0000000000..c7c8b90640 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/benchmark.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Google Inc. 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 {CommonModule} from '@angular/common'; +import {AfterViewInit, Component, NgModule, ViewChild, ViewEncapsulation} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; + +import {BenchmarkModule} from './benchmark_module'; +import {BenchmarkableExpandingRow} from './benchmarkable_expanding_row'; +import {BenchmarkableExpandingRowModule} from './benchmarkable_expanding_row_module'; + +@Component({ + selector: 'benchmark-root', + encapsulation: ViewEncapsulation.None, + template: ` +

cfc-expanding-row initialization benchmark

+ +
+ + + +
+ + + + `, +}) +export class InitializationRoot implements AfterViewInit { + @ViewChild(BenchmarkableExpandingRow, {static: true}) + expandingRow !: BenchmarkableExpandingRow; + + ngAfterViewInit() {} + + reset() { this.expandingRow.reset(); } + + async runAll() { + await execTimed('initialization_benchmark', async() => { await this.doInit(); }); + } + + async handleInitClick() { await this.doInit(); } + + private async doInit() { + await execTimed('initial_load', async() => { this.expandingRow.init(); }); + } +} + +@NgModule({ + declarations: [InitializationRoot], + exports: [InitializationRoot], + imports: [ + CommonModule, + BenchmarkableExpandingRowModule, + BenchmarkModule, + BrowserModule, + ], + bootstrap: [InitializationRoot], +}) +// Component benchmarks must export a BenchmarkModule. +export class ExpandingRowBenchmarkModule { +} + +export async function execTimed(description: string, func: () => Promise) { + console.time(description); + await func(); + await nextTick(200); + console.timeEnd(description); +} + +export async function nextTick(delay = 1) { + return new Promise((res, rej) => { setTimeout(() => { res(); }, delay); }); +} diff --git a/modules/benchmarks/src/expanding_rows/benchmark_module.ts b/modules/benchmarks/src/expanding_rows/benchmark_module.ts new file mode 100644 index 0000000000..783dc9fbf6 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/benchmark_module.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google Inc. 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 {ErrorHandler} from '@angular/core'; +import {Component, Injectable, NgModule} from '@angular/core'; + +@Component({ + selector: 'benchmark-area', + template: '', + styles: [` + :host { + padding: 1; + margin: 1; + background-color: white; + width: 1000px; + display: block; + }`], + host: { + 'class': 'cfc-ng2-region', + } +}) +export class BenchmarkArea { +} + +declare interface ExtendedWindow extends Window { + benchmarkErrors?: string[]; +} +const extendedWindow = window as ExtendedWindow; + +@Injectable({providedIn: 'root'}) +export class BenchmarkErrorHandler implements ErrorHandler { + handleError(error: Error) { + if (!extendedWindow.benchmarkErrors) { + extendedWindow.benchmarkErrors = []; + } + extendedWindow.benchmarkErrors.push(error.message); + console.error(error); + } +} + +@NgModule({ + declarations: [BenchmarkArea], + exports: [BenchmarkArea], + providers: [ + {provide: ErrorHandler, useClass: BenchmarkErrorHandler}, + ] +}) +export class BenchmarkModule { +} diff --git a/modules/benchmarks/src/expanding_rows/benchmark_spec.ts b/modules/benchmarks/src/expanding_rows/benchmark_spec.ts new file mode 100644 index 0000000000..ed661f4edf --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/benchmark_spec.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google Inc. 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 {$} from 'protractor'; +import {openTreeBenchmark, runTreeBenchmark} from './tree_perf_test_utils'; + +describe('benchmarks', () => { + + it('should work for createOnly', done => { + runTreeBenchmark({ + // This cannot be called "createOnly" because the actual destroy benchmark + // has the "createOnly" id already. See: https://github.com/angular/angular/pull/21503 + id: 'createOnlyForReal', + prepare: () => $('#destroyDom').click(), + work: () => $('#createDom').click() + }).then(done, done.fail); + }); + +}); diff --git a/modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row.ts b/modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row.ts new file mode 100644 index 0000000000..a677ef152e --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google Inc. 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'; + +export interface MlbTeam { + name: string; + id: number; + division: string; + stadium: string; + projection: string; +} + +@Component({ + selector: 'benchmarkable-expanding-row', + template: ` + + + + Team {{team.id}} + + + {{team.name}} + + {{team.id}} + + + +
    +
  • Division: {{team.division}}
  • +
  • + {{team.stadium}} +
  • +
  • Projected Record: {{team.projection}}
  • +
+
+
+
`, +}) +export class BenchmarkableExpandingRow { + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + showExpandingRow!: boolean; + + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + teams!: MlbTeam[]; + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + private fakeTeams!: MlbTeam[]; + + init(): void { + this.teams = this.fakeTeams; + this.showExpandingRow = true; + } + + reset(numItems = 5000): void { + this.showExpandingRow = false; + + this.fakeTeams = []; + for (let i = 0; i < numItems; i++) { + this.fakeTeams.push({ + name: `name ${i}`, + id: i, + division: `division ${i}`, + stadium: `stadium ${i}`, + projection: `projection ${i}`, + }); + } + } +} diff --git a/modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row_module.ts b/modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row_module.ts new file mode 100644 index 0000000000..9294f21ac2 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/benchmarkable_expanding_row_module.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google Inc. 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 {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {ExpandingRowModule} from './expanding_row_module'; + +import {BenchmarkableExpandingRow} from './benchmarkable_expanding_row'; + +@NgModule({ + declarations: [BenchmarkableExpandingRow], + exports: [BenchmarkableExpandingRow], + imports: [ + CommonModule, + ExpandingRowModule, + ], +}) +export class BenchmarkableExpandingRowModule { +} + \ No newline at end of file diff --git a/modules/benchmarks/src/expanding_rows/expanding_row.ts b/modules/benchmarks/src/expanding_rows/expanding_row.ts new file mode 100644 index 0000000000..ce098dfe8b --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row.ts @@ -0,0 +1,371 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, InjectionToken, Input, Output, QueryList, ViewChild} from '@angular/core'; + +import {ExpandingRowSummary} from './expanding_row_summary'; +import {ExpandingRowToggleEvent} from './expanding_row_toggle_event'; +import { expanding_row_css } from './expanding_row_css'; + +/** + * Injection token to break cylic dependency between ExpandingRow and + * ExpandingRowHost + */ +export const EXPANDING_ROW_HOST_INJECTION_TOKEN = + new InjectionToken('ExpandingRowHost'); + +/** The base class for ExpandingRowHost component to break cylic dependency. */ +export interface ExpandingRowHostBase { + /** + * A reference to all child cfc-expanding-row elements. We will need for + * keyboard accessibility and scroll adjustments. For example, we need to know + * which row is previous row when user presses "left arrow" on a focused row. + */ + contentRows: QueryList; + + /** + * Keeps track of the last row that had focus before focus left the list + * of expanding rows. + */ + lastFocusedRow?: ExpandingRow; + + /** + * Handles summary element click on a cfc-expanding-row component. Note + * that summary element is visible only when the row is collapsed. So this + * event will fired prior to expansion of a collapsed row. Scroll adjustment + * below makes sure mouse stays on the caption element when the collapsed + * row expands. + */ + handleRowSummaryClick(row: ExpandingRow): void; + + /** + * Check if element is blacklisted. Blacklisted elements will not collapse an + * open row when clicked. + */ + isBlacklisted(element: HTMLElement|null): boolean; + + /** + * Handles caption element click on a cfc-expanding-row component. Note + * that caption element is visible only when the row is expanded. So this + * means we will collapse the expanded row. The scroll adjustment below + * makes sure that the mouse stays under the summary of the expanded row + * when the row collapses. + */ + handleRowCaptionClick(row: ExpandingRow): void; + + /** + * Handles expansion of a row. When a new row expands, we need to remove + * previous expansion and collapse. We also need to save the currently + * expanded row so that we can collapse this row once another row expands. + */ + handleRowExpand(row: ExpandingRow): void; + + /** + * Handles focus on a row. When a new row gets focus (note that this is + * different from expansion), we need to remove previous focus and expansion. + * We need to save the reference to this focused row so that we can unfocus + * this row when another row is focused. + */ + handleRowFocus(row: ExpandingRow): void; + + /** + * Function that is called by expanding row summary to focus on the last + * focusable element before the list of expanding rows. + */ + focusOnPreviousFocusableElement(): void; + + /** + * Function that is called by expanding row summary to focus on the next + * focusable element after the list of expanding rows. + */ + focusOnNextFocusableElement(): void; +} + +/** + * This component is used to render a single expanding row. It should contain + * cfc-expanding-row-summary, cfc-expanding-row-details-caption and + * cfc-expanding-row-details-content components. + */ +@Component({ + selector: 'cfc-expanding-row', + styles: [expanding_row_css], + template: ` +
+ +
`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpandingRow { + /** + * The identifier for this node provided by the user code. We need this + * while we are emitting onToggle event. + */ + @Input() rowId!: string; + + /** + * An ElementRef to the main element in this component. We need a reference + * to this element to compute the height. The height of cfc-expanding-row + * is used in [cfcExpandingRowHost] directive for scroll adjustments. + */ + @ViewChild('expandingRowMainElement', {static: true}) + expandingRowMainElement!: ElementRef; + + /** + * This @Output event emitter will be triggered when the user expands or + * collapses this node. + */ + @Output() onToggle = new EventEmitter(); + + /** + * A boolean indicating if this node is expanded. This value is used to + * hide/show summary, caption, and content of the expanding row. There should + * only be one expanded row within [cfcExpandingRowHost] directive. And if + * there is an expanded row, there shouldn't be any focused rows. + */ + set isExpanded(value: boolean) { + const changed: boolean = this.isExpandedInternal !== value; + this.isExpandedInternal = value; + + if (changed) { + this.isExpandedChange.emit(); + this.changeDetectorRef.markForCheck(); + } + } + + /** TS getter for isExpanded property. */ + get isExpanded(): boolean { + return this.isExpandedInternal; + } + + /** Triggered when isExpanded property changes. */ + isExpandedChange = new EventEmitter(); + + /** Triggered when index property changes. */ + indexChange = new EventEmitter(); + + /** + * A boolean indicating if this node is focused. This value is used to add + * a CSS class that should render a blue border on the right. There should + * only be one focused row in [cfcExpandingRowHost] directive. + */ + set isFocused(value: boolean) { + this.isFocusedInternal = value; + this.changeDetectorRef.markForCheck(); + } + + /** TS getter for isFocused property. */ + get isFocused(): boolean { + return this.isFocusedInternal; + } + + /** The index of the row in the context of the entire collection. */ + set index(value: number) { + const changed: boolean = this.indexInternal !== value; + this.indexInternal = value; + + if (changed) { + this.indexChange.emit(); + this.changeDetectorRef.markForCheck(); + } + } + + /** TS getter for index property. */ + get index(): number { + return this.indexInternal; + } + + /** + * We should probably rename this to summaryContentChild. Because technically + * this is not a @ViewChild that is in a template. This will be transcluded. + * Note that we are not using @ContentChild directive here. The @ContentChild + * will cause cyclic reference if the class definition for ExpandingRowSummary + * component is not in the same file as ExpandingRow. + */ + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + summaryViewChild!: ExpandingRowSummary; + + /** + * We compute the collapsed height (which is just height of + * cfc-expanding-row-summary component) in this component. This is used in + * [cfcExpandingRowHost] for scroll adjustment calculation. + */ + collapsedHeight = -1; + + /** Internal storage for isExpanded public property. */ + private isExpandedInternal = false; + + /** Internal storage for isFocused public property. */ + private isFocusedInternal = false; + + /** Internal storage for index public property. */ + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + private indexInternal!: number; + + /** + * This holds a reference to [cfcExpandingRowHost] directive. We need + * this reference to notify the host when this row expands/collapses or is + * focused. + */ + constructor( + public elementRef: ElementRef, + @Inject(EXPANDING_ROW_HOST_INJECTION_TOKEN) public expandingRowHost: + ExpandingRowHostBase, + private readonly changeDetectorRef: ChangeDetectorRef) {} + + /** + * Handles click on cfc-expanding-row-summary component. This will expand + * this row and collapse the previously expanded row. The collapse & blur + * is handled in [cfcExpandingRowHost] directive. + */ + handleSummaryClick(): void { + this.collapsedHeight = this.elementRef.nativeElement + .querySelector('.cfc-expanding-row-summary') + .offsetHeight; + this.expandingRowHost.handleRowSummaryClick(this); + this.expand(); + } + + /** + * When user tabs into child cfc-expanding-row-summary component. This method + * will make sure we focuse on this row, and blur on previously focused row. + */ + handleSummaryFocus(): void { + this.focus(); + } + + /** + * cfc-expanding-row-details-caption component will call this function to + * notify click on its host element. Note that caption is only shown when + * the row is expanded. Hence this will collapse this row and put the focus + * on it. + * If a blacklisted element exists in the caption, clicking that element will + * not trigger the row collapse. + */ + handleCaptionClick(event: MouseEvent): void { + if (this.expandingRowHost.isBlacklisted( + event.target as {} as HTMLElement)) { + return; + } + this.expandingRowHost.handleRowCaptionClick(this); + this.collapse(); + this.focus(); + } + + /** + * Gets the height of this component. This height is used in parent + * [cfcExpandingRowHost] directive to compute scroll adjustment. + */ + getHeight(): number { + return this.expandingRowMainElement.nativeElement.offsetHeight; + } + + /** + * Expands this row. This will notify the host so that it can collapse + * previously expanded row. This function also emits onToggle @Output event + * to the user code. + */ + expand(): void { + this.isExpanded = true; + this.expandingRowHost.handleRowExpand(this); + + // setTimeout here makes sure we scroll this row into view after animation. + setTimeout(() => { + this.expandingRowMainElement.nativeElement.focus(); + }); + + this.onToggle.emit({rowId: this.rowId, isExpand: true}); + } + + /** + * Collapses this row. Setting isExpanded to false will make sure we hide + * the caption and details, and show cfc-expanding-row-summary component. + * This also emits onToggle @Output event to the user code. + */ + collapse(): void { + this.isExpanded = false; + this.onToggle.emit({rowId: this.rowId, isExpand: false}); + } + + /** + * Blurs this row. This should remove the blue border on the left if there + * is any. This function will remove DOM focus on the + * cfc-expanding-row-summary + * component. + */ + blur(): void { + this.isFocused = false; + this.summaryViewChild.blur(); + } + + /** + * Focuses this row. This should put blue border on the left. If there is + * any previous focus/selection, those should be gone. Parent + * [cfcExpandingRowHost] component takes care of that. + */ + focus(): void { + this.isFocused = true; + this.expandingRowHost.handleRowFocus(this); + + // Summary child is not present currently. We need to NG2 to update the + // template. + setTimeout(() => { + this.summaryViewChild.focus(); + }); + } + + /** + * We listen for TAB press here to make sure we trap the focus on the + * expanded + * row. If the row is not expanded, we don't care about this event since focus + * trap should work for expanded rows only. + */ + @HostListener('keydown', ['$event']) + handleKeyDown(event: KeyboardEvent) { + const charCode = event.which || event.keyCode; + + switch (charCode) { + case 9: + if (!this.isExpanded) { + return; + } + + this.trapFocus(event); + break; + default: + break; + } + } + + /** + * When this row is expanded, this function traps the focus between focusable + * elements contained in this row. + */ + private trapFocus(event: KeyboardEvent): void { + const rowElement: HTMLElement = this.expandingRowMainElement.nativeElement; + const focusableEls: HTMLElement[] = []; + let lastFocusableEl: HTMLElement = rowElement; + + if (focusableEls.length) { + lastFocusableEl = focusableEls[focusableEls.length - 1]; + } + + if (event.target === lastFocusableEl && !event.shiftKey) { + rowElement.focus(); + event.preventDefault(); + } else if (event.target === rowElement && event.shiftKey) { + lastFocusableEl.focus(); + event.preventDefault(); + } + } +} diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_blacklist.ts b/modules/benchmarks/src/expanding_rows/expanding_row_blacklist.ts new file mode 100644 index 0000000000..fb0546b28a --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_blacklist.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive} from '@angular/core'; + + +/** + * This directive is used to flag an element to NOT trigger collapsing an + * expanded row + */ +@Directive({ + selector: '[cfcExpandingRowBlacklist]', +}) +export class ExpandingRowBlacklist {} diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_css.ts b/modules/benchmarks/src/expanding_rows/expanding_row_css.ts new file mode 100644 index 0000000000..6eff25fba7 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_css.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google Inc. 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 const expanding_row_css = ` + ::ng-deep [cfcExpandingRowHost] { + display: block; + margin-bottom: 2; + } + + :host(cfc-expanding-row), + :host(cfc-expanding-row-summary), + :host(cfc-expanding-row-details-caption), + :host(cfc-expanding-row-details-content) { + display: block; + } + + .cfc-expanding-row { + background: white; + border-top: 1 solid black; + box-shadow: 0 1 1 gray; + transition: margin 1 1; + will-change: margin; + } + + .cfc-expanding-row.cfc-expanding-row-is-expanded { + margin: 1 (-1); + } + + .cfc-expanding-row:focus { + outline: none; + } + + .cfc-expanding-row-summary { + + display: flex; + border-left: 6 solid transparent; + cursor: pointer; + padding: 6 2; + + } + + .cfc-expanding-row-summary:focus { + outline: none; + border-left-color: $cfc-color-active; + } + + // Adjust icons to be positioned correctly in the row. + .cfc-expanding-row-summary::ng-deep cfc-icon { + margin-top: 3; + } + + .cfc-expanding-row-details-caption { + display: flex; + cursor: pointer; + padding: 4 2; + + } + + .cfc-expanding-row-details-caption::ng-deep a, + .cfc-expanding-row-details-caption::ng-deep a:visited, + .cfc-expanding-row-details-caption::ng-deep a .cfc-external-link-content { + border-color: $cfc-color-text-primary-inverse; + color: $cfc-color-text-primary-inverse; + } + + // Adjust icons to be positioned correctly in the row. + ::ng-deep cfc-icon { + margin-top: 3; + } + + .cfc-expanding-row-details-content { + padding: 2; + } + + .cfc-expanding-row-details-content::ng-deep .ace-kv-list.cfc-full-bleed { + width: 200px; + } + + + .cfc-expanding-row-accessibility-text { + display: none; + }`; diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_details_caption.ts b/modules/benchmarks/src/expanding_rows/expanding_row_details_caption.ts new file mode 100644 index 0000000000..ee514f6f40 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_details_caption.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + + +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Host, Input, OnDestroy} from '@angular/core'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; + +import {ExpandingRow} from './expanding_row'; +import { expanding_row_css } from './expanding_row_css'; + +/** + * This component should be within cfc-expanding-row component. The caption + * is only visible when the row is expanded. + */ +@Component({ + selector: 'cfc-expanding-row-details-caption', + styles: [expanding_row_css], + template: ` +
+ +
`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpandingRowDetailsCaption implements OnDestroy { + /** The background color of this component. */ + @Input() color: string = 'blue'; + + /** This is triggered when this component is destroyed. */ + private readonly onDestroy = new Subject(); + + /** + * We need a reference to parent cfc-expanding-row component here to hide + * this component when the row is collapsed. We also need to relay clicks + * to the parent component. + */ + constructor( + @Host() public expandingRow: ExpandingRow, + changeDetectorRef: ChangeDetectorRef) { + this.expandingRow.isExpandedChange.pipe(takeUntil(this.onDestroy)) + .subscribe(() => { + changeDetectorRef.markForCheck(); + }); + } + + /** When component is destroyed, unlisten to isExpanded. */ + ngOnDestroy(): void { + this.onDestroy.next(); + } +} diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_details_content.ts b/modules/benchmarks/src/expanding_rows/expanding_row_details_content.ts new file mode 100644 index 0000000000..b67cc1f02d --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_details_content.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Host, OnDestroy} from '@angular/core'; +import {Subscription} from 'rxjs'; + +import {ExpandingRow} from './expanding_row'; +import { expanding_row_css } from './expanding_row_css'; + +/** + * This component should be within cfc-expanding-row component. Note that the + * content is visible only when the row is expanded. + */ +@Component({ + styles: [expanding_row_css], + selector: 'cfc-expanding-row-details-content', + template: ` +
+ +
`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpandingRowDetailsContent implements OnDestroy { + /** Used for unsubscribing to changes in isExpanded parent property. */ + private isExpandedChangeSubscription: Subscription; + + /** + * We need a reference to parent cfc-expanding-row component to make sure we + * hide this component if the row is collapsed. + */ + constructor( + @Host() public expandingRow: ExpandingRow, + changeDetectorRef: ChangeDetectorRef) { + this.isExpandedChangeSubscription = + this.expandingRow.isExpandedChange.subscribe(() => { + changeDetectorRef.markForCheck(); + }); + } + + /** Unsubscribe from changes in parent isExpanded property. */ + ngOnDestroy(): void { + this.isExpandedChangeSubscription.unsubscribe(); + } +} diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_host.ts b/modules/benchmarks/src/expanding_rows/expanding_row_host.ts new file mode 100644 index 0000000000..64f41bd146 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_host.ts @@ -0,0 +1,519 @@ +/** + * @license + * Copyright Google Inc. 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 {AfterContentInit, AfterViewInit, ChangeDetectionStrategy, Component, ContentChildren, ElementRef, EventEmitter, forwardRef, HostListener, Input, OnDestroy, Output, QueryList, ViewChild} from '@angular/core'; +import {Subscription} from 'rxjs'; + +import {EXPANDING_ROW_HOST_INJECTION_TOKEN, ExpandingRow, ExpandingRowHostBase} from './expanding_row'; + +/** + * We use this class in template to identify the row. + * The [cfcExpandingRowHost] directive also uses this class to check if a given + * HTMLElement is within an . + */ +const EXPANDING_ROW_CLASS_NAME = 'cfc-expanding-row'; + +/** Throttle duration in milliseconds for repeated key presses. */ +export const EXPANDING_ROW_KEYPRESS_THORTTLE_MS = 50; + +/** + * This type union is created to make arguments of handleUpOrDownPress* + * methods in ExpandingRowHost class more readable. + */ +type UpOrDown = 'up'|'down'; + +/** + * This is the wrapper directive for the cfc-expanding-row components. Note that + * we wanted to make this a directive instead of component because child + * cfc-expanding-row components does not have to be a direct child. + */ +@Component({ + selector: 'cfc-expanding-row-host', + template: ` +
+
+ +
+
`, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + {provide: EXPANDING_ROW_HOST_INJECTION_TOKEN, useExisting: ExpandingRowHost} + ], +}) +export class ExpandingRowHost implements AfterViewInit, OnDestroy, + ExpandingRowHostBase { + /** + * An HTML selector (e.g. "body") for the scroll element. We need this to + * make some scroll adjustments. + */ + @Input() scrollElementSelector = '.cfc-panel-body-scrollable'; + + /** + * An HTML selector (e.g. "body") for the click root. While the row is + * expanded, and user clicks outside of the expanded row, we collapse this row + * But to do this, we need to know the clickable area. + */ + @Input() clickRootElementSelector = 'cfc-panel-body'; + + /** + * The @Output will be triggered when the user wants to focus on the + * previously expanded row, and we are already at the first row. The logs team + * will use this to prepend data on demand. + */ + @Output() onPrepend = new EventEmitter(); + + /** A reference to the last focusable element in list of expanding rows. */ + @ViewChild('lastFocusable', {static: true}) lastFocusableElement!: ElementRef; + + /** A reference to the first focusable element in list of expanding rows. */ + @ViewChild('firstFocusable', {static: true}) + firstFocusableElement!: ElementRef; + + /** + * A reference to all child cfc-expanding-row elements. We will need for + * keyboard accessibility and scroll adjustments. For example, we need to know + * which row is previous row when user presses "left arrow" on a focused row. + */ + @ContentChildren(forwardRef(() => ExpandingRow), {descendants: true}) + contentRows!: QueryList; + + /** + * Keeps track of the last row that had focus before focus left the list + * of expanding rows. + */ + lastFocusedRow?: ExpandingRow = undefined; + + /** + * Focused rows just show a blue left border. This node is not expanded. We + * need to keep a reference to the focused row to unfocus when another row + * is focused. + */ + private focusedRow?: ExpandingRow = undefined; + + /** + * This is the expanded row. If there is an expanded row there shouldn't be + * any focused rows. We need a reference to this. For example we need to + * collapse the currently expanded row, if another row is expanded. + */ + private expandedRow?: ExpandingRow = undefined; + + /** + * This is just handleRootMouseUp.bind(this). handleRootMouseUp handles + * click events on root element (defined by clickRootElementSelector @Input) + * Since we attach the click listener dynamically, we need to keep this + * function around. This enables us to detach the click listener when + * component is destroyed. + */ + private handleRootMouseUpBound: EventListenerObject = + this.handleRootMouseUp.bind(this); + + /** + * 16px is the margin animation we have on cfc-expanding-row component. + * We need this value to compute scroll adjustments. + */ + private static rowMargin = 16; + + /** Subscription to changes in the expanding rows. */ + // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. + private rowChangeSubscription!: Subscription; + + /** + * When component initializes we need to attach click listener to the root + * element. This click listener will allows us to collapse the + * currently expanded row when user clicks outside of it. + */ + ngAfterViewInit(): void { + const clickRootElement: HTMLElement = this.getClickRootElement(); + + if (!clickRootElement) { + return; + } + + clickRootElement.addEventListener('mouseup', this.handleRootMouseUpBound); + + this.rowChangeSubscription = this.contentRows.changes.subscribe(() => { + this.recalcRowIndexes(); + }); + this.recalcRowIndexes(); + } + + /** + * Detaches the click listener on the root element. Note that we are attaching + * this listener on ngAfterViewInit function. + */ + ngOnDestroy(): void { + const clickRootElement: HTMLElement = this.getClickRootElement(); + + if (!clickRootElement) { + return; + } + + clickRootElement.removeEventListener( + 'mouseup', this.handleRootMouseUpBound); + + if (this.rowChangeSubscription) { + this.rowChangeSubscription.unsubscribe(); + } + } + + /** + * Handles caption element click on a cfc-expanding-row component. Note + * that caption element is visible only when the row is expanded. So this + * means we will collapse the expanded row. The scroll adjustment below + * makes sure that the mouse stays under the summary of the expanded row + * when the row collapses. + */ + handleRowCaptionClick(row: ExpandingRow): void { + const scrollAdjustment: number = -ExpandingRowHost.rowMargin; + const scrollElement: HTMLElement = this.getScrollElement() as HTMLElement; + if (!scrollElement) { + return; + } + + scrollElement.scrollTop += scrollAdjustment; + } + + /** + * Handles summary element click on a cfc-expanding-row component. Note + * that summary element is visible only when the row is collapsed. So this + * event will fired prior to expansion of a collapsed row. Scroll adjustment + * below makes sure mouse stays on the caption element when the collapsed + * row expands. + */ + handleRowSummaryClick(row: ExpandingRow): void { + const hadPreviousSelection: boolean = !!this.expandedRow; + const previousSelectedRowIndex: number = + this.getRowIndex(this.expandedRow as ExpandingRow); + const newSelectedRowIndex: number = this.getRowIndex(row); + const previousCollapsedHeight: number = + this.getSelectedRowCollapsedHeight(); + const previousExpansionHeight = this.getSelectedRowExpandedHeight(); + + if (this.expandedRow) { + return; + } + + let scrollAdjustment = 0; + const scrollElement: HTMLElement = this.getScrollElement() as HTMLElement; + if (!scrollElement) { + return; + } + + if (previousExpansionHeight > 0 && previousCollapsedHeight >= 0) { + scrollAdjustment = previousExpansionHeight - previousCollapsedHeight; + } + + const newSelectionIsInfrontOfPrevious: boolean = + newSelectedRowIndex > previousSelectedRowIndex; + const multiplier = newSelectionIsInfrontOfPrevious ? -1 : 0; + scrollAdjustment = + scrollAdjustment * multiplier + ExpandingRowHost.rowMargin; + + scrollElement.scrollTop += scrollAdjustment; + } + + /** + * Handles expansion of a row. When a new row expands, we need to remove + * previous expansion and collapse. We also need to save the currently + * expanded row so that we can collapse this row once another row expands. + */ + handleRowExpand(row: ExpandingRow): void { + this.removePreviousFocus(); + this.removePreviousExpansion(); + this.expandedRow = row; + } + + /** + * Handles focus on a row. When a new row gets focus (note that this is + * different from expansion), we need to remove previous focus and expansion. + * We need to save the reference to this focused row so that we can unfocus + * this row when another row is focused. + */ + handleRowFocus(row: ExpandingRow): void { + // Do not blur then refocus the row if it's already selected. + if (row === this.focusedRow) { + return; + } + + this.removePreviousFocus(); + this.removePreviousExpansion(); + this.focusedRow = row; + } + + /** + * Called when shift+tabbing from the first focusable element after the list + * of expanding rows or tabbing from the last focusable element before. + */ + focusOnLastFocusedRow(): void { + if (!this.lastFocusedRow) { + this.lastFocusedRow = this.contentRows.toArray()[0]; + } + this.lastFocusedRow.focus(); + } + + /** + * Function that is called by expanding row summary to focus on the last + * focusable element before the list of expanding rows. + */ + focusOnPreviousFocusableElement(): void { + this.lastFocusedRow = this.focusedRow; + } + + /** + * Function that is called by expanding row summary to focus on the next + * focusable element after the list of expanding rows. + */ + focusOnNextFocusableElement(): void { + this.lastFocusedRow = this.focusedRow; + } + + /** + * Handles keydown event on the host. We are just concerned with up, + * down arrow, ESC, and ENTER presses here. Note that Up/Down presses + * can be repeated. + * + * - Up: Focuses on the row above. + * - Down: Focuses on the row below. + * - Escape: Collapses the expanded row. + * - Enter: Expands the focused row. + */ + @HostListener('keydown', ['$event']) + handleKeyDown(event: KeyboardEvent) { + } + + /** + * Recursively returns true if target HTMLElement is within a + * cfc-expanding-row component. It will return false otherwise. + * We need this function in handleRootMouseUp to collapse the expanded row + * when user clicks outside of all expanded rows. + */ + private isTargetInRow(target: HTMLElement): boolean { + return target.classList.contains(EXPANDING_ROW_CLASS_NAME); + } + + /** + * Gets the click root element that is described by clickRootElementSelector + * @Input value. + */ + private getClickRootElement(): HTMLElement { + return document.querySelector(this.clickRootElementSelector) as HTMLElement; + } + + /** + * Handles all of the mouseup events on the click root. When user clicks + * outside of an expanded row, we need to collapse that row. + * We trigger collapse by calling handleCaptionClick() on the expanded row. + */ + private handleRootMouseUp(event: MouseEvent): void { + if (!this.expandedRow) { + return; + } + + if (!this.isTargetInRow(event.target as {} as HTMLElement)) { + this.expandedRow.handleCaptionClick(event); + } + } + + /** + * Check if element is blacklisted. Blacklisted elements will not collapse an + * open row when clicked. + */ + isBlacklisted(element: HTMLElement|null): boolean { + const clickRoot = this.getClickRootElement(); + while (element && element !== clickRoot) { + if (element.hasAttribute('cfcexpandingrowblacklist')) { + return true; + } + element = element.parentElement; + } + return false; + } + + /** + * Removes focus state from a previously focused row. We blur this row and + * set the focusedRow to undefined in this method. This usually happens when + * another row is focused. + */ + private removePreviousFocus(): void { + if (this.focusedRow) { + this.focusedRow.blur(); + this.focusedRow = undefined; + } + } + + /** + * Removes the expanded state from a previously expanded row. We collapse this + * row and set the expandedRow to undefined in this method. This usually + * happens when another row is expanded. + */ + private removePreviousExpansion(): void { + if (this.expandedRow) { + this.expandedRow.collapse(); + this.expandedRow = undefined; + } + } + + /** + * Gets the collapsed height of the currently expanded row. We need this for + * scroll adjustments. Note that collapsed height of a cfc-expanding-row + * component is equal to height of cfc-expanding-row-summary component within + * the row. + */ + private getSelectedRowCollapsedHeight(): number { + if (this.expandedRow) { + return this.expandedRow.collapsedHeight; + } else { + return -1; + } + } + + /** + * Gets the current height of the expanded row. We need this value for the + * scroll adjustment computation. + */ + private getSelectedRowExpandedHeight(): number { + if (this.expandedRow) { + return this.expandedRow.getHeight(); + } else { + return -1; + } + } + + /** + * Gets the HTML element described by scrollElementSelector @Input value. + * We need this value for scroll adjustments. + */ + private getScrollElement(): HTMLElement|undefined { + if (!this.scrollElementSelector) { + return undefined; + } + + return document.querySelector(this.scrollElementSelector) as HTMLElement; + } + + /** + * Handles escape presses on the host element. Escape removes previous focus + * if there is one. If there is an expanded row, escape row collapses this + * row and focuses on it. A subsequent escape press will blur this row. + */ + private handleEscapePress(): void { + this.removePreviousFocus(); + + if (this.expandedRow) { + this.expandedRow.collapse(); + this.expandedRow.focus(); + this.expandedRow = undefined; + } + } + + /** + * Handles enter keypress. If there is a focused row, an enter key press on + * host element will expand this row. + */ + private handleEnterPress(): void { + if (document.activeElement !== this.focusedRowSummary()) { + return; + } + + if (this.focusedRow) { + this.focusedRow.expand(); + } + } + + /** Returns the HTMLElement that is the currently focused row summary. */ + private focusedRowSummary(): HTMLElement|undefined { + return this.focusedRow ? + this.focusedRow.summaryViewChild.mainElementRef.nativeElement : + undefined; + } + + /** + * Returns the index of a given row. This enables us to figure out the row + * above/below the focused row. + */ + private getRowIndex(rowToLookFor: ExpandingRow): number { + return rowToLookFor ? rowToLookFor.index : -1; + } + + /** + * Handles up/down arrow presses on the host element. Up arrow press will + * focus/expand on the row above. Down arrow press will focus/expand the row + * below. If we have a focus on the current row, this function will focus on + * the computed (the one above or below) row. If host has an expanded row, + * this function will expand the computed row. + */ + private handleUpOrDownPressOnce(upOrDown: UpOrDown, event: KeyboardEvent): + void { + event.preventDefault(); + + // If row is expanded but focus is inside the expanded element, arrow + // key presses should not do anything. + if (this.expandedRow && + document.activeElement !== + this.expandedRow.expandingRowMainElement.nativeElement) { + return; + } + + // If focus is inside a collapsed row header, arrow key presses should not + // do anything. + if (this.focusedRow && + document.activeElement !== this.focusedRowSummary()) { + return; + } + // We only want screen reader to read the message the first time we enter + // the list of expanding rows, so we must reset the variable here + this.lastFocusedRow = undefined; + + const rowToLookFor: ExpandingRow|undefined = + this.expandedRow || this.focusedRow; + if (!rowToLookFor) { + return; + } + + const isFocus: boolean = (rowToLookFor === this.focusedRow); + + const rowIndex: number = this.getRowIndex(rowToLookFor); + const contentRowsArray: ExpandingRow[] = this.contentRows.toArray(); + + if (rowIndex < 0) { + return; + } + + const potentialIndex: number = (upOrDown === 'up' ? -1 : +1) + rowIndex; + if (potentialIndex < 0) { + this.onPrepend.emit(); + return; + } + + if (potentialIndex >= contentRowsArray.length) { + return; + } + + const potentialRow: ExpandingRow = contentRowsArray[potentialIndex]; + if (isFocus) { + potentialRow.focus(); + } else { + potentialRow.expand(); + } + } + + // Updates all of the rows with their new index. + private recalcRowIndexes() { + let index = 0; + setTimeout(() => { + this.contentRows.forEach((row: ExpandingRow) => { + row.index = index++; + }); + }); + } +} + diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_module.ts b/modules/benchmarks/src/expanding_rows/expanding_row_module.ts new file mode 100644 index 0000000000..13ced09aa4 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_module.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google Inc. 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 {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; + +import {ExpandingRow} from './expanding_row'; +import {ExpandingRowBlacklist} from './expanding_row_blacklist'; +import {ExpandingRowDetailsCaption} from './expanding_row_details_caption'; +import {ExpandingRowDetailsContent} from './expanding_row_details_content'; +import {ExpandingRowHost} from './expanding_row_host'; +import {ExpandingRowSummary} from './expanding_row_summary'; + +/** The main module for the cfc-expanding-row component. */ +@NgModule({ + declarations: [ + ExpandingRow, + ExpandingRowDetailsCaption, + ExpandingRowDetailsContent, + ExpandingRowHost, + ExpandingRowSummary, + ExpandingRowBlacklist, + ], + exports: [ + ExpandingRow, + ExpandingRowDetailsCaption, + ExpandingRowDetailsContent, + ExpandingRowHost, + ExpandingRowSummary, + ExpandingRowBlacklist, + ], + imports: [ + CommonModule, + ], +}) +export class ExpandingRowModule { +} diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_summary.ts b/modules/benchmarks/src/expanding_rows/expanding_row_summary.ts new file mode 100644 index 0000000000..2e724c889f --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_summary.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Host, HostListener, OnDestroy, ViewChild} from '@angular/core'; +import {Subscription} from 'rxjs'; + +import {ExpandingRow} from './expanding_row'; +import { expanding_row_css } from './expanding_row_css'; + +const KEY_CODE_TAB = 9; + +/** + * This component should be used within cfc-expanding-row component. Note that + * summary is visible only when the row is collapsed. + */ +@Component({ + selector: 'cfc-expanding-row-summary', + styles: [expanding_row_css], + template: ` +
+ +
.
+
+ Row {{expandingRow.index + 1}} in list of expanding rows. +
+
+ Use arrow keys to navigate. +
+
`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpandingRowSummary implements OnDestroy { + /** + * A reference to the main element. This element should be focusable. We need + * reference to compute collapsed height of the row. We also use this + * reference for focus and blur methods below. + */ + @ViewChild('expandingRowSummaryMainElement', {static: false}) + mainElementRef!: ElementRef; + + /** Subscription for changes in parent isExpanded property. */ + private isExpandedSubscription: Subscription; + + /** Subscription for changes in parent index property. */ + private indexSubscription: Subscription; + + /** + * We need the parent cfc-expanding-row component here to hide this element + * when the row is expanded. cfc-expanding-row-details-caption element + * will act as a header for expanded rows. We also need to relay tab-in and + * click events to the parent. + */ + constructor( + @Host() public expandingRow: ExpandingRow, + changeDetectorRef: ChangeDetectorRef) { + this.expandingRow.summaryViewChild = this; + this.isExpandedSubscription = + this.expandingRow.isExpandedChange.subscribe(() => { + changeDetectorRef.markForCheck(); + }); + + this.indexSubscription = this.expandingRow.indexChange.subscribe(() => { + changeDetectorRef.markForCheck(); + }); + } + + + /** When component is destroyed, unlisten to isExpanded. */ + ngOnDestroy(): void { + if (this.isExpandedSubscription) { + this.isExpandedSubscription.unsubscribe(); + } + if (this.indexSubscription) { + this.indexSubscription.unsubscribe(); + } + } + + /** + * Handles focus event on the element. We basically want to detect any focus + * in this component and relay this information to parent cfc-expanding-row + * component. + */ + handleFocus(): void { + // Clicking causes a focus event to occur before the click event. Filter + // out click events using the cdkFocusMonitor. + // + // TODO(b/62385992) Use the KeyboardFocusService to detect focus cause + // instead of creating multiple monitors on a page. + if (this.expandingRow.expandingRowMainElement.nativeElement.classList + .contains('cdk-mouse-focused')) { + return; + } + + if (!this.expandingRow.isFocused && !this.expandingRow.isExpanded) { + this.expandingRow.handleSummaryFocus(); + } + } + + /** + * Handles tab & shift+tab presses on expanding row summaries in case there + * are tabbable elements inside the summaries. + */ + @HostListener('keydown', ['$event']) + handleKeyDown(event: KeyboardEvent) { + const charCode = event.which || event.keyCode; + if (charCode === KEY_CODE_TAB) { + this.handleTabKeypress(event); + } + } + + /** + * Handles tab and shift+tab presses inside expanding row summaries; + * + * From inside collapsed row summary: + * - Tab: If focus was on the last focusable child, should shift focus to + * the next focusable element outside the list of expanding rows. + * - Shift+tab: If focus was on first focusable child, should shift focus to + * the main collapsed row summary element + * If focus was on main collapsed row summary element, should + * shift focus to the last focusable element before the list of + * expanding rows. + */ + handleTabKeypress(event: KeyboardEvent): void { + const focusableChildren = this.getFocusableChildren(); + + if (focusableChildren.length === 0) { + return; + } + + // Shift+tab on expanding row summary should focus on last focusable element + // before expanding row list. Otherwise, if shift+tab is pressed on first + // focusable child inside expanding row summary, it should focus on main + // expanding row summary element. + if (event.shiftKey && + document.activeElement === this.mainElementRef.nativeElement) { + event.preventDefault(); + this.expandingRow.expandingRowHost.focusOnPreviousFocusableElement(); + return; + } else if ( + event.shiftKey && document.activeElement === focusableChildren[0]) { + event.preventDefault(); + this.expandingRow.focus(); + } + + // If tab is pressed on the last focusable element inside an expanding row + // summary, focus should be set to the next focusable element after the list + // of expanding rows. + if (!event.shiftKey && + document.activeElement === + focusableChildren[focusableChildren.length - 1]) { + event.preventDefault(); + this.expandingRow.expandingRowHost.focusOnNextFocusableElement(); + } + } + + /** + * Finds the row that had focus before focus left the list of expanding rows + * and checks if the current row summary is that row. + */ + isPreviouslyFocusedRow(): boolean { + if (!this.expandingRow.expandingRowHost.contentRows) { + return false; + } + + const expandingRowHost = this.expandingRow.expandingRowHost; + + if (!this.mainElementRef || !expandingRowHost.lastFocusedRow) { + return false; + } + + if (!expandingRowHost.lastFocusedRow.summaryViewChild.mainElementRef) { + return false; + } + + // If the current expanding row summary was the last focused one before + // focus exited the list, then return true to trigger the screen reader + if (this.mainElementRef.nativeElement === + expandingRowHost.lastFocusedRow.summaryViewChild.mainElementRef + .nativeElement) { + return true; + } + return false; + } + + /** Puts the DOM focus on the main element. */ + focus(): void { + if (this.mainElementRef && + document.activeElement !== this.mainElementRef.nativeElement) { + this.mainElementRef.nativeElement.focus(); + } + } + + /** Removes the DOM focus on the main element. */ + blur(): void { + if (!this.mainElementRef) { + return; + } + + this.mainElementRef.nativeElement.blur(); + } + + /** Returns array of focusable elements within this component. */ + private getFocusableChildren(): HTMLElement[] { + return []; + } +} diff --git a/modules/benchmarks/src/expanding_rows/expanding_row_toggle_event.ts b/modules/benchmarks/src/expanding_rows/expanding_row_toggle_event.ts new file mode 100644 index 0000000000..e1b1e334fc --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/expanding_row_toggle_event.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google Inc. 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 + */ + +/** + * This interface is used to send toggle (expand/collapse) events to the user + * code. + */ +export interface ExpandingRowToggleEvent { + /** The identifier of the row that was toggled. */ + rowId: string; + + /** + * A boolean indicating whether or not this row was expanded. This is set to + * false if the row was collapsed. + */ + isExpand: boolean; +} diff --git a/modules/benchmarks/src/expanding_rows/index.html b/modules/benchmarks/src/expanding_rows/index.html new file mode 100644 index 0000000000..b4e258faa0 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + +

Change Detection Benchmark

+
...
+ + loading... + + + + + \ No newline at end of file diff --git a/modules/benchmarks/src/expanding_rows/index.ts b/modules/benchmarks/src/expanding_rows/index.ts new file mode 100644 index 0000000000..6dd9db9d86 --- /dev/null +++ b/modules/benchmarks/src/expanding_rows/index.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google Inc. 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 {platformBrowser} from '@angular/platform-browser'; + +import {ExpandingRowBenchmarkModule} from './benchmark'; +import {ExpandingRowBenchmarkModuleNgFactory} from './benchmark.ngfactory'; + +setMode(ExpandingRowBenchmarkModule.hasOwnProperty('ngModuleDef') ? 'Ivy' : 'ViewEngine'); +platformBrowser().bootstrapModuleFactory(ExpandingRowBenchmarkModuleNgFactory); + +function setMode(name: string): void { + document.querySelector('#rendererMode') !.textContent = `Render Mode: ${name}`; +} \ No newline at end of file