2019-04-04 15:03:40 -07:00
|
|
|
/**
|
|
|
|
* @license
|
2020-05-19 12:08:49 -07:00
|
|
|
* Copyright Google LLC All Rights Reserved.
|
2019-04-04 15:03:40 -07:00
|
|
|
*
|
|
|
|
* 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';
|
|
|
|
|
2019-05-14 09:39:11 +02:00
|
|
|
import {expanding_row_css} from './expanding_row_css';
|
2019-04-04 15:03:40 -07:00
|
|
|
import {ExpandingRowSummary} from './expanding_row_summary';
|
|
|
|
import {ExpandingRowToggleEvent} from './expanding_row_toggle_event';
|
2019-05-14 09:39:11 +02:00
|
|
|
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Injection token to break cylic dependency between ExpandingRow and
|
|
|
|
* ExpandingRowHost
|
|
|
|
*/
|
|
|
|
export const EXPANDING_ROW_HOST_INJECTION_TOKEN =
|
|
|
|
new InjectionToken<ExpandingRowHostBase>('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<ExpandingRow>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
|
|
|
|
/**
|
2020-09-21 14:06:31 -07:00
|
|
|
* Check if element is collapsible. Elements marked as uncollapsible will not collapse an
|
2019-04-04 15:03:40 -07:00
|
|
|
* open row when clicked.
|
|
|
|
*/
|
2020-09-21 14:06:31 -07:00
|
|
|
isCollapsible(element: HTMLElement|null): boolean;
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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: `
|
|
|
|
<div #expandingRowMainElement
|
|
|
|
class="cfc-expanding-row"
|
|
|
|
cdkMonitorSubtreeFocus
|
|
|
|
[attr.tabindex]="isExpanded ? '0' : '-1'"
|
|
|
|
[class.cfc-expanding-row-has-focus]="isFocused"
|
|
|
|
[class.cfc-expanding-row-is-expanded]="isExpanded"
|
|
|
|
ve="CfcExpandingRow">
|
|
|
|
<ng-content></ng-content>
|
|
|
|
</div>`,
|
|
|
|
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.
|
|
|
|
*/
|
2020-04-13 16:40:21 -07:00
|
|
|
@Input() rowId!: string;
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2020-04-13 16:40:21 -07:00
|
|
|
@ViewChild('expandingRowMainElement', {static: true}) expandingRowMainElement!: ElementRef;
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* This @Output event emitter will be triggered when the user expands or
|
|
|
|
* collapses this node.
|
|
|
|
*/
|
|
|
|
@Output() onToggle = new EventEmitter<ExpandingRowToggleEvent>();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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. */
|
2020-04-13 16:40:21 -07:00
|
|
|
get isExpanded(): boolean {
|
|
|
|
return this.isExpandedInternal;
|
|
|
|
}
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/** Triggered when isExpanded property changes. */
|
|
|
|
isExpandedChange = new EventEmitter<void>();
|
|
|
|
|
|
|
|
/** Triggered when index property changes. */
|
|
|
|
indexChange = new EventEmitter<void>();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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. */
|
2020-04-13 16:40:21 -07:00
|
|
|
get isFocused(): boolean {
|
|
|
|
return this.isFocusedInternal;
|
|
|
|
}
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/** 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. */
|
2020-04-13 16:40:21 -07:00
|
|
|
get index(): number {
|
|
|
|
return this.indexInternal;
|
|
|
|
}
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2020-04-13 16:40:21 -07:00
|
|
|
summaryViewChild!: ExpandingRowSummary;
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2020-04-13 16:40:21 -07:00
|
|
|
private indexInternal!: number;
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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,
|
2019-05-14 09:39:11 +02:00
|
|
|
@Inject(EXPANDING_ROW_HOST_INJECTION_TOKEN) public expandingRowHost: ExpandingRowHostBase,
|
2019-04-04 15:03:40 -07:00
|
|
|
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 {
|
2019-05-14 09:39:11 +02:00
|
|
|
this.collapsedHeight =
|
|
|
|
this.elementRef.nativeElement.querySelector('.cfc-expanding-row-summary').offsetHeight;
|
2019-04-04 15:03:40 -07:00
|
|
|
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.
|
|
|
|
*/
|
2020-04-13 16:40:21 -07:00
|
|
|
handleSummaryFocus(): void {
|
|
|
|
this.focus();
|
|
|
|
}
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2020-09-21 14:06:31 -07:00
|
|
|
* If an uncollapsible element exists in the caption, clicking that element will
|
2019-04-04 15:03:40 -07:00
|
|
|
* not trigger the row collapse.
|
|
|
|
*/
|
|
|
|
handleCaptionClick(event: MouseEvent): void {
|
2020-09-21 14:06:31 -07:00
|
|
|
if (this.expandingRowHost.isCollapsible(event.target as {} as HTMLElement)) {
|
|
|
|
this.expandingRowHost.handleRowCaptionClick(this);
|
|
|
|
this.collapse();
|
|
|
|
this.focus();
|
2019-04-04 15:03:40 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the height of this component. This height is used in parent
|
|
|
|
* [cfcExpandingRowHost] directive to compute scroll adjustment.
|
|
|
|
*/
|
2020-04-13 16:40:21 -07:00
|
|
|
getHeight(): number {
|
|
|
|
return this.expandingRowMainElement.nativeElement.offsetHeight;
|
|
|
|
}
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2020-04-13 16:40:21 -07:00
|
|
|
setTimeout(() => {
|
|
|
|
this.expandingRowMainElement.nativeElement.focus();
|
|
|
|
});
|
2019-04-04 15:03:40 -07:00
|
|
|
|
|
|
|
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.
|
2020-04-13 16:40:21 -07:00
|
|
|
setTimeout(() => {
|
|
|
|
this.summaryViewChild.focus();
|
|
|
|
});
|
2019-04-04 15:03:40 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|