diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index bd3e64805fd9..1bedb3d6f5c2 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -1,4 +1,5 @@ entryPoints = [ + "mat-feature-highlight", "mdc-autocomplete", "mdc-button", "mdc-button/testing", diff --git a/src/material-experimental/mat-feature-highlight/BUILD.bazel b/src/material-experimental/mat-feature-highlight/BUILD.bazel new file mode 100644 index 000000000000..3ea19fe71e78 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/BUILD.bazel @@ -0,0 +1,86 @@ +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") +load( + "//tools:defaults.bzl", + "ng_e2e_test_library", + "ng_module", + "ng_test_library", + "ng_web_test_suite", + "sass_binary", + "sass_library", +) + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "mat-feature-highlight", + srcs = glob( + ["**/*.ts"], + exclude = [ + "**/*.spec.ts", + ], + ), + assets = [":feature_highlight_container_css"] + glob(["**/*.html"]), + module_name = "@angular/material-experimental/mat-feature-highlight", + deps = [ + "//src/cdk/bidi", + "//src/cdk/coercion", + "//src/cdk/overlay", + "//src/cdk/portal", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//rxjs", + ], +) + +sass_library( + name = "mat_feature_highlight_scss_lib", + srcs = glob(["**/_*.scss"]), +) + +sass_binary( + name = "feature_highlight_container_css", + src = "feature-highlight-container.scss", + include_paths = [ + "external/npm/node_modules", + ], +) + +ng_test_library( + name = "feature_highlight_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":mat-feature-highlight", + "//src/cdk/bidi", + "//src/cdk/overlay", + "//src/cdk/portal", + "//src/cdk/testing/private", + "//src/cdk/testing/testbed", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [ + ":feature_highlight_tests_lib", + ], +) + +ng_e2e_test_library( + name = "e2e_test_sources", + srcs = glob(["**/*.e2e.spec.ts"]), + deps = [ + "//src/cdk/testing/private/e2e", + ], +) + +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/testing/private/e2e", + ], +) diff --git a/src/material-experimental/mat-feature-highlight/_feature-highlight.scss b/src/material-experimental/mat-feature-highlight/_feature-highlight.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.ng.html b/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.ng.html new file mode 100644 index 000000000000..215f0d9c55b3 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.ng.html @@ -0,0 +1 @@ + diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.spec.ts new file mode 100644 index 000000000000..52f3806cbc3a --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.spec.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ComponentPortal, PortalModule, TemplatePortal} from '@angular/cdk/portal'; +import {Component, NgModule, TemplateRef, ViewChild} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {async} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; + +import {FeatureHighlightCalloutContainer} from './feature-highlight-callout-container'; + +describe('FeatureHighlightCalloutContainer', () => { + let fixture: ComponentFixture; + let comp: CalloutHostComponent; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CalloutContainerTestModule, + ], + }); + + TestBed.compileComponents(); + fixture = TestBed.createComponent(CalloutHostComponent); + comp = fixture.debugElement.componentInstance; + })); + + it('can attach template portal', () => { + const portal = new TemplatePortal(comp.calloutTemplate, null!); + comp.calloutContainer.attachTemplatePortal(portal); + + expect(fixture.debugElement.query(By.css('.callout-template-div'))) + .not.toBeNull(); + }); + + it('can attach component portal', () => { + const portal = new ComponentPortal(CalloutTestComponent); + comp.calloutContainer.attachComponentPortal(portal); + + expect(fixture.debugElement.query(By.css('.callout-component-div'))) + .not.toBeNull(); + }); + + it('cannot attach multiple template portals', () => { + const portal = new TemplatePortal(comp.calloutTemplate, null!); + comp.calloutContainer.attachTemplatePortal(portal); + expect(() => comp.calloutContainer.attachTemplatePortal(portal)) + .toThrowError(); + }); + + it('cannot attach multiple component portals', () => { + const portal = new ComponentPortal(CalloutTestComponent); + comp.calloutContainer.attachComponentPortal(portal); + expect(() => comp.calloutContainer.attachComponentPortal(portal)) + .toThrowError(); + }); + + it('detaches template portal on destroy', () => { + const portal = new TemplatePortal(comp.calloutTemplate, null!); + comp.calloutContainer.attachTemplatePortal(portal); + fixture.destroy(); + + expect(comp.calloutContainer.hasAttached()).toBe(false); + }); + + it('detaches component portal on destroy', () => { + const portal = new ComponentPortal(CalloutTestComponent); + comp.calloutContainer.attachComponentPortal(portal); + fixture.destroy(); + + expect(comp.calloutContainer.hasAttached()).toBe(false); + }); +}); + +@Component({ + template: ` + + + +
+
+ `, +}) +class CalloutHostComponent { + @ViewChild('calloutContainer', {static: true}) + calloutContainer!: FeatureHighlightCalloutContainer; + @ViewChild('calloutTemplate', {static: true}) + calloutTemplate!: TemplateRef<{}>; +} + +@Component({template: '
'}) +class CalloutTestComponent { +} + +@NgModule({ + declarations: [ + CalloutHostComponent, + CalloutTestComponent, + FeatureHighlightCalloutContainer, + ], + imports: [ + PortalModule, + ], + entryComponents: [ + CalloutTestComponent, + ], +}) +class CalloutContainerTestModule { +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.ts new file mode 100644 index 000000000000..4468643634fd --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-callout-container.ts @@ -0,0 +1,43 @@ +/** + * @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 {BasePortalOutlet, CdkPortalOutlet, ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; +import {Component, ComponentRef, EmbeddedViewRef, OnDestroy, ViewChild} from '@angular/core'; + +/** Container for the callout component of feature highlight. */ +@Component({ + selector: 'feature-highlight-callout-container', + templateUrl: './feature-highlight-callout-container.ng.html', +}) +export class FeatureHighlightCalloutContainer extends BasePortalOutlet + implements OnDestroy { + @ViewChild(CdkPortalOutlet, {static: true}) portalOutlet!: CdkPortalOutlet; + + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + this._assertNotAttached(); + return this.portalOutlet.attachTemplatePortal(portal); + } + + attachComponentPortal(portal: ComponentPortal): ComponentRef { + this._assertNotAttached(); + return this.portalOutlet.attachComponentPortal(portal); + } + + private _assertNotAttached() { + if (this.portalOutlet.hasAttached()) { + throw new Error( + 'Cannot attach feature highlight callout. There is already a ' + + 'callout attached.'); + } + } + + /** @override */ + ngOnDestroy() { + super.dispose(); + } +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-config.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-config.spec.ts new file mode 100644 index 000000000000..b1d602207d92 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-config.spec.ts @@ -0,0 +1,41 @@ +/** + * @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 {ViewContainerRef} from '@angular/core'; + +import {FeatureHighlightConfig} from './feature-highlight-config'; + +describe('FeatureHighlightConfig', () => { + it('can override configurations from constructor', () => { + const mockTargetViewContainerRef = + jasmine.createSpyObj('viewContainerRef', ['get']); + + const overriddenConfig: FeatureHighlightConfig<{}> = { + calloutPosition: 'top_start', + calloutLeft: '100px', + calloutTop: '200px', + innerCircleDiameter: '300px', + outerCircleDiameter: '400px', + isOuterCircleBounded: true, + targetViewContainerRef: mockTargetViewContainerRef, + data: { + someProperty: 'someValue', + }, + ariaDescribedBy: 'ariaDescribedByValue', + ariaLabel: 'ariaLabel', + ariaLabelledBy: 'ariaLabeledByValue', + }; + + const config = new FeatureHighlightConfig<{}>(overriddenConfig); + + for (const k of Object.keys(config)) { + const key = k as keyof FeatureHighlightConfig<{}>; + expect(config[key]).toEqual(overriddenConfig[key]); + } + }); +}); diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-config.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-config.ts new file mode 100644 index 000000000000..f7ed7eeb1121 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-config.ts @@ -0,0 +1,87 @@ +/** + * @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 {ViewContainerRef} from '@angular/core'; + +/** + * Type for callout position, relative to the target element. 'start' and 'end' + * refer to left and right in an LTR context and vice versa in an RTL context. + */ +export type FeatureHighlightCalloutPosition = + 'top_start'|'top_end'|'bottom_start'|'bottom_end'; + +/** + * Configurations for enabling feature highlight with the FeatureHighlight + * service. + */ +export class FeatureHighlightConfig { + /** + * Determines where the callout is positioned relative to the target element. + * Used only when isOuterCircleBounded is true. + */ + readonly calloutPosition?: FeatureHighlightCalloutPosition = 'top_start'; + + /** + * Left value of the callout, relative to the target element. Used only when + * isOuterCircleBounded is not true. + */ + readonly calloutLeft?: string|number; + + /** + * Top value of the callout, relative to the target element. Used only when + * isOuterCircleBounded is not true. + */ + readonly calloutTop?: string|number; + + /** Diameter for the inner circle. */ + readonly innerCircleDiameter?: string|number; + + /** + * Diameter for the outer circle. If not set, the diameter will be auto + * calculated based on the size and position of the callout. + */ + readonly outerCircleDiameter?: string|number; + + /** + * True if the outer circle is bounded by a parent element. False if feature + * highlight is opened in an overlay on the screen. + */ + readonly isOuterCircleBounded?: boolean; + + /** + * View container ref for the target element. Used for creating a sibling + * container element for the target. + */ + readonly targetViewContainerRef!: ViewContainerRef; + + /** Data being used in the child components. */ + readonly data?: D; + + /** + * ID of the element that describes the feature highlight container element. + */ + readonly ariaDescribedBy?: string|null = null; + + /** Aria label to assign to the feature highlight container element. */ + readonly ariaLabel?: string|null = null; + + /** ID of the element that labels the feature highlight container element. */ + readonly ariaLabelledBy?: string|null = null; + + constructor(config?: FeatureHighlightConfig) { + if (config) { + for (const k of Object.keys(config)) { + const key = k as keyof FeatureHighlightConfig; + + if (typeof config[key] !== 'undefined') { + (this as any)[key] = config[key]; + } + } + } + } +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-container.ng.html b/src/material-experimental/mat-feature-highlight/feature-highlight-container.ng.html new file mode 100644 index 000000000000..0bde99ee828e --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-container.ng.html @@ -0,0 +1,26 @@ +
+
+
+ +
+
+ +
+
+ +
+ +
+
diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-container.scss b/src/material-experimental/mat-feature-highlight/feature-highlight-container.scss new file mode 100644 index 000000000000..184da3f5e294 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-container.scss @@ -0,0 +1,129 @@ +$open-animation-time: 350ms; +$close-animation-time: 200ms; + +@keyframes feature-highlight-radial-pulse-expand { + 0%, 33.333333% { + opacity: .54; + transform: scale(1, 1); + } + + 66.666666%, 100% { + opacity: 0; + transform: scale(2, 2); + } +} + +@keyframes feature-highlight-inner-circle-expand { + 0% { + transform: scale(1, 1); + } + + 33.333333% { + transform: scale(1.1, 1.1); + } + + 66.666666%, 100% { + transform: scale(1, 1); + } +} + +:host { + display: block; + position: relative; +} + +.feature-highlight-container-div { + position: absolute; + outline: none; +} + +.feature-highlight-inner-circle { + cursor: pointer; + position: absolute; + transition: opacity $open-animation-time cubic-bezier(0, 0, .2, 1), + transform $open-animation-time cubic-bezier(0, 0, .2, 1); + border-radius: 50%; + opacity: 0; + transform: scale(0, 0); + z-index: 2; +} + +:host(.active) .feature-highlight-inner-circle { + opacity: 1; + transform: scale(1, 1); +} + +:host(.active-with-animation) .feature-highlight-inner-circle { + // Delay: 350ms for open + 800ms wait time = 1150ms. + animation: 1500ms cubic-bezier(.4, 0, .2, 1) + 1150ms feature-highlight-inner-circle-expand; + animation-iteration-count: infinite; +} + +:host(.fade) .feature-highlight-inner-circle { + transition: opacity $close-animation-time cubic-bezier(.4, 0, 1, 1), + transform $close-animation-time cubic-bezier(.4, 0, 1, 1); + opacity: 0; + transform: scale(0, 0); +} + +:host(.fade-accept) .feature-highlight-inner-circle { + opacity: 0; + transform: scale(1.125, 1.125); +} + +.feature-highlight-radial-pulse { + position: absolute; + border-radius: 50%; + opacity: 0; + transform: scale(0, 0); + z-index: 2; +} + +:host(.active-with-animation) .feature-highlight-radial-pulse { + animation: 1500ms cubic-bezier(.4, 0, .2, 1) + 1150ms feature-highlight-radial-pulse-expand; + animation-iteration-count: infinite; +} + +.feature-highlight-callout { + position: absolute; + z-index: 2; + opacity: 0; + transition: opacity $open-animation-time cubic-bezier(0, 0, .2, 1); + will-change: opacity; +} + +:host(.active) .feature-highlight-callout { + opacity: 1; + pointer-events: auto; +} + +:host(.fade) .feature-highlight-callout { + transition: opacity $close-animation-time cubic-bezier(.4, 0, 1, 1); +} + +.feature-highlight-outer-circle { + position: absolute; + transition: opacity $open-animation-time cubic-bezier(0, 0, .2, 1), + transform $open-animation-time cubic-bezier(0, 0, .2, 1); + border-radius: 50%; + opacity: 0; + transform: scale(0, 0); + z-index: 1; +} + +:host(.active) .feature-highlight-outer-circle { + opacity: 1; + transform: scale(1, 1); +} + +:host(.fade) .feature-highlight-outer-circle { + transition: opacity $close-animation-time cubic-bezier(.4, 0, 1, 1), + transform $close-animation-time cubic-bezier(.4, 0, 1, 1); +} + +:host(.fade-accept) .feature-highlight-outer-circle { + opacity: 0; + transform: scale(1.125, 1.125); +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-container.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-container.spec.ts new file mode 100644 index 000000000000..87460772cd26 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-container.spec.ts @@ -0,0 +1,502 @@ +/** + * @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 {Directionality} from '@angular/cdk/bidi'; +import {Component, NgModule, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {async} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +import {FeatureHighlight} from './feature-highlight'; +import {FeatureHighlightCalloutPosition, FeatureHighlightConfig} from './feature-highlight-config'; +import {FeatureHighlightModule} from './module'; + +describe('FeatureHighlightContainer', () => { + let fixture: ComponentFixture; + let featureHighlight: FeatureHighlight; + let comp: TestComponent; + + let isOuterCircleBounded: boolean; + let innerCircleDiameter: number; + let directionality: {value: string}; + + const CALLOUT_WIDTH = 400; + const CALLOUT_HEIGHT = 270; + + beforeEach(async(() => { + // All the tests below depend on math that is much nice to read when the + // body does not have a margin. + document.body.style.margin = '0'; + directionality = {value: 'ltr'}; + + TestBed + .configureTestingModule({ + imports: [ + FeatureHighlightContainerTestModule, + ], + providers: [ + { + provide: Directionality, + useFactory: () => directionality, + }, + ], + }) + .compileComponents(); + })); + + for (const direction of ['ltr', 'rtl']) { + describe(`with ${direction}`, () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + comp = fixture.debugElement.componentInstance; + featureHighlight = TestBed.inject(FeatureHighlight); + + directionality.value = direction; + document.dir = direction; + innerCircleDiameter = 60; + fixture.detectChanges(); + }); + + describe('with bounded outer circle', () => { + beforeEach(() => { + isOuterCircleBounded = true; + fixture.detectChanges(); + }); + + runSharedTestsForBoundedAndUnboundedOuterCircle(); + + it('positions root element to overlay with the target element', + async(() => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + }); + fixture.detectChanges(); + const rootDivElement = getRootDiv(); + const targetElement = + comp.targetViewContainerRef.element.nativeElement; + + // By default target's parent has display: block, so the root div + // of feature highlight is positioned under the target element. + // Thus the left of the root div is the same as the target, and + // the top is moved up with a negative value of target height. + expect(getOffsetLeft(rootDivElement)) + .toBe(getOffsetLeft(targetElement)); + expect(rootDivElement.offsetTop).toBe(-20); + })); + + it('positions callout at top start against the target element', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + calloutPosition: 'top_start', + }); + fixture.detectChanges(); + + const callout = getCallout(); + expect(callout.offsetWidth).toBe(CALLOUT_WIDTH); + expect(callout.offsetHeight).toBe(CALLOUT_HEIGHT); + // Align the end of the callout with the center of the target. + expect(getOffsetLeft(callout)).toBe(-380); // = -400 + 40 / 2 + // Align the bottom of the callout with the top of the inner circle. + expect(callout.offsetTop).toBe(-290); // = -270 - (60 - 20) / 2 + }); + + it('positions callout at top end against the target element', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + calloutPosition: 'top_end', + }); + fixture.detectChanges(); + + const callout = getCallout(); + expect(callout.offsetWidth).toBe(CALLOUT_WIDTH); + expect(callout.offsetHeight).toBe(CALLOUT_HEIGHT); + // Align the start of the callout with the center of the target. + expect(getOffsetLeft(callout)).toBe(20); // = 40 / 2 + // Align the bottom of the callout with the top of the inner circle. + expect(callout.offsetTop).toBe(-290); // = -270 - (60 - 20) / 2 + }); + + it('positions callout at bottom start against the target element', + () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + calloutPosition: 'bottom_start', + }); + fixture.detectChanges(); + + const callout = getCallout(); + expect(callout.offsetWidth).toBe(CALLOUT_WIDTH); + expect(callout.offsetHeight).toBe(CALLOUT_HEIGHT); + // Align the end of the callout with the center of the target. + expect(getOffsetLeft(callout)).toBe(-380); // = -400 + 40 / 2 + // Align the top of the callout with the bottom of the inner + // circle. + expect(callout.offsetTop).toBe(40); // = 60 / 2 + 20 / 2 + }); + + it('positions callout at bottom end against the target element', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + calloutPosition: 'bottom_end', + }); + fixture.detectChanges(); + + const callout = getCallout(); + expect(callout.offsetWidth).toBe(CALLOUT_WIDTH); + expect(callout.offsetHeight).toBe(CALLOUT_HEIGHT); + // Align the start of the callout with the center of the target. + expect(getOffsetLeft(callout)).toBe(20); // = 40 / 2 + // Align the top of the callout with the bottom of the inner circle. + expect(callout.offsetTop).toBe(40); // = 60 / 2 + 20 / 2 + }); + + for (const calloutPosition + of ['top_start', 'top_end', 'bottom_start', 'bottom_end']) { + it('centers outer circle with the target element when ' + + `calloutPosition is ${calloutPosition}`, + () => { + const config = new FeatureHighlightConfig({ + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + calloutPosition: 'top_start', + }); + const ref = + featureHighlight.open(comp.calloutTemplateRef, config); + fixture.detectChanges(); + + const outerCircle = getOuterCircle(); + + const position = + calloutPosition as FeatureHighlightCalloutPosition; + ref.updateLayout({calloutPosition: position}); + fixture.detectChanges(); + + // Radius is Math.hypot(400, 270 + 60 / 2) = 500 so diameter is + // 1000. + expect(outerCircle.offsetWidth).toBe(1000); + expect(outerCircle.offsetHeight).toBe(1000); + expect(getOffsetLeft(outerCircle)) + .toBe(-480); // = -500 + 40 / 2 = -480 + expect(outerCircle.offsetTop) + .toBe(-490); // = -500 + 20 / 2 = -490 + }); + } + }); + + describe('with unbounded outer circle', () => { + beforeEach(() => { + isOuterCircleBounded = false; + fixture.detectChanges(); + }); + + runSharedTestsForBoundedAndUnboundedOuterCircle(); + + it('positions callout based on config values', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + calloutLeft: -200, + calloutTop: 100, + }); + fixture.detectChanges(); + + const callout = getCallout(); + expect(callout.offsetWidth).toBe(CALLOUT_WIDTH); + expect(callout.offsetHeight).toBe(CALLOUT_HEIGHT); + expect(callout.offsetLeft).toBe(-200); + expect(callout.offsetTop).toBe(100); + }); + + it('positions outer circle based on an inscribed rectangle that ' + + 'includes both inner circle and callout', + () => { + const ref = featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + // Callout is at the bottom end position against the target + // element. + calloutLeft: -10, + calloutTop: 10, + }); + fixture.detectChanges(); + + const innerCircle = getInnerCircle(); + const outerCircle = getOuterCircle(); + + // Since target element is at the center of inner circle, and top + // left of target element is (0, 0), innerCircle offsetLeft is: + // (targetWidth - innerCircleDiameter) / 2 = (40 - 60) / 2 = -10 + // innerCircle offsetTop is: + // (targetHeight - innerCircleDiameter) / 2 = (20 - 60) / 2 = -20. + expect(getOffsetLeft(innerCircle)).toBe(-10); + expect(innerCircle.offsetTop).toBe(-20); + + // Position of the inscribed rectangle is calculated as follows: + // left = min(innerCircle.offsetLeft, calloutLeft) + // = min(-10, -10) + // = -10 + // top = min(innerCircle.offsetTop, calloutTop) + // = min(-20, 10) + // = -20 + // right + // = max( + // innerCircle.offsetLeft + innerCircle.offsetWidth, + // callout.offsetLeft + callout.offsetWidth) + // = max(-10 + 60, -10 + 400) + // = 390 + // bottom + // = max( + // innerCircle.offsetTop + innerCircle.offsetHeight, + // callout.offsetTop + callout.offsetHeight, + // = max(-20 + 60, 10 + 270)) + // = 280 + // width = right - left = 390 - (-10) = 400 + // height = bottom - top = 280 - (-20) = 300 + // + // Therefore, for outer circle: + // diameter = Math.hypot(rectangle.width, rectangle.height) = 500 + // left = rectangle.center.left - outerCircle.radius + // = rectangle.left + rectangle.width / 2 - outerCircle.radius + // = -10 + 400 / 2 - 500 / 2 + // = -60 + // top = rectangle.center.top - outerCircle.radius + // = rectangle.center.top + rectangle.height / 2 - + // outerCircle.radius + // = -20 + 300 / 2 - 500 / 2 + // = -120 + expect(outerCircle.offsetWidth).toBe(500); + expect(outerCircle.offsetHeight).toBe(500); + expect(getOffsetLeft(outerCircle)).toBe(-60); + expect(outerCircle.offsetTop).toBe(-120); + + // Callout is at the bottom start position against the target + // element. + ref.updateLayout({ + calloutLeft: -350, + calloutTop: 10, + }); + fixture.detectChanges(); + + expect(outerCircle.offsetWidth).toBe(500); + expect(outerCircle.offsetHeight).toBe(500); + expect(getOffsetLeft(outerCircle)).toBe(-400); + expect(outerCircle.offsetTop).toBe(-120); + + // Callout is at the top start position against the target element. + ref.updateLayout({ + calloutLeft: -350, + calloutTop: -260, + }); + fixture.detectChanges(); + + expect(outerCircle.offsetWidth).toBe(500); + expect(outerCircle.offsetHeight).toBe(500); + expect(getOffsetLeft(outerCircle)).toBe(-400); + expect(outerCircle.offsetTop).toBe(-360); + + // Callout is at the top end position against the target element. + ref.updateLayout({ + calloutLeft: -10, + calloutTop: -260, + }); + fixture.detectChanges(); + + expect(outerCircle.offsetWidth).toBe(500); + expect(outerCircle.offsetHeight).toBe(500); + expect(getOffsetLeft(outerCircle)).toBe(-60); + expect(outerCircle.offsetTop).toBe(-360); + }); + }); + }); + } + + /** + * Run shared tests, regardless of whether the outer circle is bounded. + */ + function runSharedTestsForBoundedAndUnboundedOuterCircle() { + it('allows outer circle diameter to be specified', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + outerCircleDiameter: 1000, + }); + fixture.detectChanges(); + + const outerCircle = getOuterCircle(); + + expect(outerCircle.offsetWidth).toBe(1000); + expect(outerCircle.offsetHeight).toBe(1000); + }); + + it('dismisses feature highlight if body is clicked', async(() => { + const ref = featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + isOuterCircleBounded, + }); + fixture.detectChanges(); + + const afterDismissedCallback = jasmine.createSpy('afterDismissed'); + ref.afterDismissed().subscribe(afterDismissedCallback); + document.body.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterDismissedCallback).toHaveBeenCalledTimes(1); + + // Ensure that the event listener on body is removed. + document.body.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterDismissedCallback).toHaveBeenCalledTimes(1); + }); + }); + })); + + it('accepts feature highlgiht if target element is clicked', () => { + const ref = featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + isOuterCircleBounded, + }); + fixture.detectChanges(); + + const afterAcceptedCallback = jasmine.createSpy('afterAccepted'); + ref.afterAccepted().subscribe(afterAcceptedCallback); + + const targetElement = comp.targetViewContainerRef.element.nativeElement; + targetElement.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterAcceptedCallback).toHaveBeenCalledTimes(1); + + // Ensure that the event listener on the target element is removed. + targetElement.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterAcceptedCallback).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('centers inner circle with the target element', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + }); + fixture.detectChanges(); + + const innerCircle = getInnerCircle(); + + expect(innerCircle.offsetWidth).toBe(innerCircleDiameter); + expect(innerCircle.offsetHeight).toBe(innerCircleDiameter); + expect(getOffsetLeft(innerCircle)).toBe(-10); // = (-60 + 40) / 2 + expect(innerCircle.offsetTop).toBe(-20); // = (-60 + 20) / 2; + }); + + it('centers radial pulse with the target element', () => { + featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + innerCircleDiameter, + isOuterCircleBounded, + }); + fixture.detectChanges(); + + const radialPulse = getRadialPulse(); + expect(radialPulse.offsetWidth).toBe(innerCircleDiameter); + expect(radialPulse.offsetHeight).toBe(innerCircleDiameter); + expect(getOffsetLeft(radialPulse)).toBe(-10); // (-60 + 40) / 2 = -10 + expect(radialPulse.offsetTop).toBe(-20); // = (-60 + 20) / 2; + }); + } + + function getRootDiv(): HTMLElement { + return fixture.debugElement + .query(By.css('.feature-highlight-container-div')) + .nativeElement; + } + + function getInnerCircle(): HTMLElement { + return fixture.debugElement.query(By.css('.feature-highlight-inner-circle')) + .nativeElement; + } + + function getRadialPulse(): HTMLElement { + return fixture.debugElement.query(By.css('.feature-highlight-radial-pulse')) + .nativeElement; + } + + function getCallout(): HTMLElement { + return fixture.debugElement.query(By.css('.feature-highlight-callout')) + .nativeElement; + } + + function getOuterCircle(): HTMLElement { + return fixture.debugElement.query(By.css('.feature-highlight-outer-circle')) + .nativeElement; + } + + /** + * Return the offset left in LTR, and "offset right" in RTL, which should have + * the same value. + */ + function getOffsetLeft(element: HTMLElement): number { + if (directionality.value === 'ltr') { + return element.offsetLeft; + } else { + return -(element.offsetLeft + element.offsetWidth); + } + } +}); + +@Component({ + template: ` +
+ +
+ Add your favorite thing +
+
+ `, +}) +class TestComponent { + @ViewChild('calloutTemplate', {static: true}) + calloutTemplateRef!: TemplateRef<{}>; + @ViewChild('target', {read: ViewContainerRef, static: true}) + targetViewContainerRef!: ViewContainerRef; +} + +@NgModule({ + declarations: [ + TestComponent, + ], + imports: [ + FeatureHighlightModule, + NoopAnimationsModule, + ], + entryComponents: [ + TestComponent, + ], +}) +class FeatureHighlightContainerTestModule { +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-container.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-container.ts new file mode 100644 index 000000000000..e30aacd8246b --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-container.ts @@ -0,0 +1,533 @@ +/** + * @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 {Directionality} from '@angular/cdk/bidi'; +import {coerceCssPixelValue} from '@angular/cdk/coercion'; +import {ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; +import {AfterViewInit, ChangeDetectorRef, Component, ComponentRef, ElementRef, EmbeddedViewRef, EventEmitter, OnDestroy, Output, ViewChild} from '@angular/core'; + +import {FeatureHighlightCalloutContainer} from './feature-highlight-callout-container'; +import {FeatureHighlightConfig} from './feature-highlight-config'; + +interface Coordinate { + x: number; + y: number; +} + +type State = 'none'|'active_with_animation'|'active_without_animation'| + 'fade_accept'|'fade_dismiss'; + +const OPEN_ANIMATION_TIME_MS = 350; +const CLOSE_ANIMATION_TIME_MS = 200; + +const DISMISS_EVENT = 'click'; + +/** + * Internal component that holds feature highlight elements as well as + * user-provided feature highlight content.hostelement + */ +@Component({ + selector: 'feature-highlight-container', + templateUrl: './feature-highlight-container.ng.html', + styleUrls: ['./feature-highlight-container.css'], + host: { + '[attr.aria-describedby]': 'config.ariaDescribedBy', + '[attr.aria-label]': 'config.ariaLabel', + '[attr.aria-labelledby]': 'config.ariaLabel ? null : config.ariaLabelledBy', + '[attr.role]': '"alertdialog"', + '[class.fade-accept]': 'state === "fade_accept"', + '[class.fade-dismiss]': 'state === "fade_dismiss"', + '[class.fade]': 'isInFadeState()', + '[class.active-with-animation]': 'state === "active_with_animation"', + '[class.active-without-animation]': 'state === "active_without_animation"', + '[class.active]': 'isInActiveState()', + }, +}) +export class FeatureHighlightContainer implements AfterViewInit, OnDestroy { + @Output() afterOpened = new EventEmitter(); + @Output() afterAccepted = new EventEmitter(); + @Output() afterDismissed = new EventEmitter(); + + @ViewChild('rootDiv', {static: true}) rootDiv!: ElementRef; + @ViewChild('innerCircle', {static: true}) + innerCircle!: ElementRef; + @ViewChild('radialPulse', {static: true}) + radialPulse!: ElementRef; + @ViewChild('outerCircle', {static: true}) + outerCircle!: ElementRef; + + @ViewChild('callout', {static: true}) callout!: ElementRef; + @ViewChild('calloutContainer', {static: true}) + calloutContainer!: FeatureHighlightCalloutContainer; + + state: State = 'none'; + private _targetCenter: Coordinate = {x: 0, y: 0}; + + constructor( + private readonly _elementRef: ElementRef, + private readonly _directionality: Directionality, + private readonly _changeDetectorRef: ChangeDetectorRef, + public config: FeatureHighlightConfig, + ) {} + + /** + * Override the existing configuration with new values. + * @param newConfig Partial new configurations to override existing ones. + */ + updateConfig(newConfig: Partial) { + this.config = {...this.config, ...newConfig}; + } + + /** Return the current configurations being used. */ + getConfig(): FeatureHighlightConfig { + return this.config; + } + + /** + * Attach a template portal for the callout content to feature highlight + * container. + * @param portal Template portal to be attached as the callout content. + */ + attachCalloutTemplatePortal(portal: TemplatePortal): + EmbeddedViewRef { + return this.calloutContainer.attachTemplatePortal(portal); + } + + /** + * Attach a component portal for the callout content to feature highlight + * container. + * @param portal Component portal to be attached as the callout content. + */ + attachCalloutComponentPortal(portal: ComponentPortal): ComponentRef { + return this.calloutContainer.attachComponentPortal(portal); + } + + /** + * Return true if feature highlight is in one of the fading states, i.e. after + * a user clicks on somewhere to accept or dismiss the feature highlight, but + * before the fading animation is done. + */ + isInFadeState(): boolean { + return this.state === 'fade_accept' || this.state === 'fade_dismiss'; + } + + /** + * Return true if feature highlight is in one of the active states. + */ + isInActiveState(): boolean { + return this.state === 'active_with_animation' || + this.state === 'active_without_animation'; + } + + private _getTargetElement(): HTMLElement { + return this.config.targetViewContainerRef.element.nativeElement as + HTMLElement; + } + + /** + * Prevent interactions with the elements in feature highlight (outer circle, + * inner circle, callout) from dismissing the feature highlight. + */ + stopClickPropagation(event: Event) { + event.stopPropagation(); + } + + private readonly _targetEventListener = (event: Event) => { + event.stopPropagation(); + this.accept(); + }; + + private readonly _bodyEventListener = () => { + this.dismiss(); + }; + + /** @override */ + ngAfterViewInit() { + this._activate(); + } + + /** @override */ + ngOnDestroy() { + this._removeEventListeners(); + } + + /** Position all feature highlight elements. */ + layout() { + this._computeTargetCenter(); + this._positionInnerCircle(); + this._positionRadialPulse(); + this._positionCallout(); + this._positionOuterCircle(); + this._positionRootDivElement(); + } + + /** + * Compute the center of the target. In LTR, the coordinate of the top left + * corner of the target is (0, 0) and in RTL, the coordinate of the top right + * corner of the target is (0, 0). + */ + private _computeTargetCenter() { + this._targetCenter = { + x: this._getTargetElement().offsetWidth / 2, + y: this._getTargetElement().offsetHeight / 2, + }; + } + + /** Update left value in LTR or right value in RTL for an element. */ + private _updateLeft(style: CSSStyleDeclaration, left?: string|number) { + const leftInPixel = coerceCssPixelValue(left); + + if (this._directionality.value === 'ltr') { + style.left = leftInPixel; + } else { + style.right = leftInPixel; + } + } + + /** Update top value for an element. */ + private _updateTop(style: CSSStyleDeclaration, top?: string|number) { + const topInPixel = coerceCssPixelValue(top); + style.top = topInPixel; + } + + /** + * Get left offset for an element. For RTL, return the right offset instead. + * Using this function can ensure we return the same value for both LTR + * and RTL context. + */ + private _getOffsetLeft(element: HTMLElement) { + return this._directionality.value === 'ltr' ? + element.offsetLeft : + -(element.offsetLeft + element.offsetWidth); + } + + /** + * Position the inner circle. The inner circle has the same center as the + * target element. + */ + private _positionInnerCircle() { + const style = this.innerCircle.nativeElement.style; + + style.width = coerceCssPixelValue(this.config.innerCircleDiameter); + style.height = coerceCssPixelValue(this.config.innerCircleDiameter); + + const left = + this._targetCenter.x - this.innerCircle.nativeElement.offsetWidth / 2; + const top = + this._targetCenter.y - this.innerCircle.nativeElement.offsetHeight / 2; + this._updateLeft(style, left); + this._updateTop(style, top); + } + + /** + * Position the radial pulse. Similar to the inner circle, the radial pulse + * has the same center as the target element. + */ + private _positionRadialPulse() { + const style = this.radialPulse.nativeElement.style; + + style.width = coerceCssPixelValue(this.config.innerCircleDiameter); + style.height = coerceCssPixelValue(this.config.innerCircleDiameter); + style.top = this.innerCircle.nativeElement.style.top; + + if (this._directionality.value === 'ltr') { + style.left = this.innerCircle.nativeElement.style.left; + } else { + style.right = this.innerCircle.nativeElement.style.right; + } + } + + /** + * Position the callout. When the outer circle is bounded, position it based + * automatically based on the calloutPosition config value so that: + * (1) The top/bottom of the callout aligns with the bottom/top of the inner + * circle. + * (2) The end of the callout aligns with the center of the target element. + */ + private _positionCallout() { + // Callout in unbounded circle should be positioned manually by setting + // left and top. + let left = 0; + let top = 0; + const style = this.callout.nativeElement.style; + + if (!this.config.isOuterCircleBounded) { + this._updateLeft(style, this.config.calloutLeft); + this._updateTop(style, this.config.calloutTop); + return; + } + + const calloutPosition = this.config.calloutPosition; + switch (calloutPosition) { + case 'top_start': + case 'bottom_start': + left = this._targetCenter.x - this.callout.nativeElement.offsetWidth; + break; + case 'top_end': + case 'bottom_end': + left = this._targetCenter.x; + break; + default: + throw new Error(`Cannot handle callout position ${calloutPosition}.`); + } + + switch (calloutPosition) { + case 'top_start': + case 'top_end': + top = this._targetCenter.y - + this.innerCircle.nativeElement.offsetHeight / 2 - + this.callout.nativeElement.offsetHeight; + break; + case 'bottom_start': + case 'bottom_end': + top = this._targetCenter.y + + this.innerCircle.nativeElement.offsetHeight / 2; + break; + default: + throw new Error(`Cannot handle callout position ${calloutPosition}.`); + } + + this._updateLeft(style, left); + this._updateTop(style, top); + } + + /** + * Position outer circle. When the outer circle is bounded, the outer circle + * has the same center as the target element. When it's not bounded, we + * compute the minimum box that includes both target element and callout. + * The center of the box is the center of the outer circle, and the diameter + * is equal to the diagonal length of the box, should the diameter be auto + * determined. + */ + private _positionOuterCircle() { + let left = 0; + let top = 0; + const style = this.outerCircle.nativeElement.style; + + if (this.config.isOuterCircleBounded) { + const diameter = this._computeBoundedOuterCircleDiameter(); + style.width = diameter; + style.height = diameter; + + left = Math.floor( + this._targetCenter.x - + this.outerCircle.nativeElement.offsetWidth / 2); + top = Math.floor( + this._targetCenter.y - + this.outerCircle.nativeElement.offsetHeight / 2); + this._updateLeft(style, left); + this._updateTop(style, top); + + return; + } + + const calloutElement = this.callout.nativeElement; + const innerCircleElement = this.innerCircle.nativeElement; + + const calloutLeft = this._getOffsetLeft(calloutElement); + const calloutTop = calloutElement.offsetTop; + const calloutWidth = calloutElement.offsetWidth; + const calloutHeight = calloutElement.offsetHeight; + + const innerCircleLeft = this._getOffsetLeft(innerCircleElement); + const innerCircleTop = innerCircleElement.offsetTop; + const innerCircleWidth = innerCircleElement.offsetWidth; + const innerCircleHeight = innerCircleElement.offsetHeight; + + const boxLeft = Math.min(calloutLeft, innerCircleLeft); + const boxRight = Math.max( + calloutLeft + calloutWidth, innerCircleLeft + innerCircleWidth); + const boxTop = Math.min(calloutTop, innerCircleTop); + const boxBottom = Math.max( + calloutTop + calloutHeight, innerCircleTop + innerCircleHeight); + const boxWidth = boxRight - boxLeft; + const boxHeight = boxBottom - boxTop; + + let diameter = ''; + if (this.config.outerCircleDiameter) { + diameter = coerceCssPixelValue(this.config.outerCircleDiameter); + } else { + diameter = `${Math.floor(Math.hypot(boxWidth, boxHeight))}px`; + } + + style.width = diameter; + style.height = diameter; + style.transformOrigin = 'center center'; + + left = Math.floor( + boxLeft + boxWidth / 2 - + this.outerCircle.nativeElement.offsetWidth / 2); + top = Math.floor( + boxTop + boxHeight / 2 - + this.outerCircle.nativeElement.offsetHeight / 2); + + this._updateLeft(style, left); + this._updateTop(style, top); + } + + private _getDistance(c1: Coordinate, c2: Coordinate): number { + return Math.floor(Math.hypot(c2.x - c1.x, c2.y - c1.y)); + } + + /** + * Compute the diameter for the outer circle, if it's bounded. The center of + * the outer circle is the same as the target element. The furthest corner + * of the callout box from the target center should be on the outer circle, + * so the radius of the outer circle is equal to the distance between the + * center of the target element, and the furthest corner of the callout box. + */ + private _computeBoundedOuterCircleDiameter(): string { + if (this.config.outerCircleDiameter) { + return coerceCssPixelValue(this.config.outerCircleDiameter); + } + + const calloutElement = this.callout.nativeElement; + + const calloutLeft = this._getOffsetLeft(calloutElement); + const calloutTop = calloutElement.offsetTop; + const calloutWidth = calloutElement.offsetWidth; + const calloutHeight = calloutElement.offsetHeight; + + let radius = 0; + switch (this.config.calloutPosition) { + case 'top_start': + radius = this._getDistance(this._targetCenter, { + x: calloutLeft, + y: calloutTop, + }); + break; + case 'top_end': + radius = this._getDistance(this._targetCenter, { + x: calloutLeft + calloutWidth, + y: calloutTop, + }); + break; + case 'bottom_start': + radius = this._getDistance(this._targetCenter, { + x: calloutLeft, + y: calloutTop + calloutHeight, + }); + break; + case 'bottom_end': + radius = this._getDistance(this._targetCenter, { + x: calloutLeft + calloutWidth, + y: calloutTop + calloutHeight, + }); + break; + default: + throw new Error( + `Cannot handle callout position ${this.config.calloutPosition}.`); + } + + return `${Math.floor(radius * 2)}px`; + } + + /** + * Position the root div element so that it's overlayed with the target + * element. + */ + private _positionRootDivElement() { + // Remove the left and top first so that the offsetLeft and offsetTop can + // be computed correctly. + const style = this.rootDiv.nativeElement.style; + this._updateLeft(style, undefined); + this._updateTop(style, undefined); + + // Only need to position the root element when the outer circle is bounded, + // i.e. overlay is not needed. + if (!this.config.isOuterCircleBounded) { + return; + } + + const targetOffsetLeft = this._getOffsetLeft(this._getTargetElement()); + const containerOffsetLeft = + this._getOffsetLeft(this._elementRef.nativeElement); + const left = targetOffsetLeft - containerOffsetLeft; + const top = this._getTargetElement().offsetTop - + this._elementRef.nativeElement.offsetTop; + + this._updateLeft(style, left); + this._updateTop(style, top); + } + + /** Activate feature highlight and display the animation. */ + private _activate() { + this.layout(); + + if (this.state === 'active_with_animation') { + return; + } + + this._changeAnimationState('active_without_animation'); + this._addEventListeners(); + + // TODO(b/138400499) - Ideally we should emit afterOpened event when the + // transitionend is triggered. However it doesn't look like catalyst flush() + // triggers the transitionend event so there is no way to test the behavior. + // We may be able to use flushMicrotasks() when migrating the component to + // Angular upstream. + setTimeout(() => { + this._changeAnimationState('active_with_animation'); + this.afterOpened.emit(); + }, OPEN_ANIMATION_TIME_MS); + + // Mark the container for check so that it can react if the view container + // is usign OnPush change detection. + this._changeDetectorRef.markForCheck(); + } + + private _changeAnimationState(newState: State) { + requestAnimationFrame(() => { + this.state = newState; + this._changeDetectorRef.markForCheck(); + }); + } + + /** Accept feature highlight programmatically. */ + accept() { + this._deactivate(true); + } + + /** Dismiss feature highlight programmatically. */ + dismiss() { + this._deactivate(false); + } + + /** Deactivate feature highlight and stop the animation. */ + private _deactivate(accepted: boolean) { + this._changeAnimationState(accepted ? 'fade_accept' : 'fade_dismiss'); + this._removeEventListeners(); + + setTimeout(() => { + this._changeAnimationState('none'); + + // Emit events after the animation is done. + if (accepted) { + this.afterAccepted.emit(); + } else { + this.afterDismissed.emit(); + } + }, CLOSE_ANIMATION_TIME_MS); + + // Mark the container for check so that it can react if the view container + // is usign OnPush change detection. + this._changeDetectorRef.markForCheck(); + } + + private _addEventListeners() { + document.body.addEventListener(DISMISS_EVENT, this._bodyEventListener); + this._getTargetElement().addEventListener( + DISMISS_EVENT, this._targetEventListener); + } + + private _removeEventListeners() { + this._getTargetElement().removeEventListener( + DISMISS_EVENT, this._targetEventListener); + document.body.removeEventListener(DISMISS_EVENT, this._bodyEventListener); + } +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-content-directives.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-content-directives.spec.ts new file mode 100644 index 000000000000..77774151a030 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-content-directives.spec.ts @@ -0,0 +1,132 @@ +/** + * @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, NgModule, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +import {FeatureHighlight} from './feature-highlight'; +import {FeatureHighlightRef} from './feature-highlight-ref'; +import {FeatureHighlightModule} from './module'; + +describe('FeatureHighlightContentDirectives', () => { + let fixture: ComponentFixture; + let featureHighlight: FeatureHighlight; + let comp: TestComponent; + let ref: FeatureHighlightRef; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [ + FeatureHighlightContentDirectivesTestModule, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + comp = fixture.debugElement.componentInstance; + featureHighlight = TestBed.inject(FeatureHighlight); + + ref = featureHighlight.open(comp.calloutTemplateRef, { + targetViewContainerRef: comp.targetViewContainerRef, + }); + fixture.detectChanges(); + }); + + it('sets zIndex and position for the target element when enabled', () => { + comp.enabled = true; + fixture.detectChanges(); + + const targetElement = + comp.targetViewContainerRef.element.nativeElement as HTMLElement; + expect(targetElement.style.zIndex).toBe('1001'); + expect(targetElement.style.position).toBe('relative'); + }); + + it('does not set zIndex or position for the target element when disabled', + () => { + comp.enabled = false; + fixture.detectChanges(); + + const targetElement = + comp.targetViewContainerRef.element.nativeElement as HTMLElement; + expect(targetElement.style.zIndex).toBe(''); + expect(targetElement.style.position).toBe(''); + }); + + it('closes feature highlight by clicking on the close button', () => { + const afterDismissedCallback = jasmine.createSpy('afterDismissed'); + ref.afterDismissed().subscribe(afterDismissedCallback); + + fixture.debugElement.query(By.css('button[featureHighlightClose]')) + .nativeElement.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterDismissedCallback).toHaveBeenCalledTimes(1); + }); + }); + + it('sets aria labelled by for feature highlight container', async(() => { + fixture.whenStable().then(() => { + const container = + fixture.debugElement.query(By.css('feature-highlight-container')) + .nativeElement; + + expect(container.getAttribute('aria-labelledby')) + .toBe('feature-highlight-title-id'); + expect(container.getAttribute('aria-describedby')) + .toBe('feature-highlight-content-id'); + }); + })); +}); + +@Component({ + template: ` +
+
+ +
+ Add your favorite thing +
+
+ Tap the add icon to add your favorites +
+ +
+ `, +}) +class TestComponent { + @ViewChild('calloutTemplate', {static: true}) + calloutTemplateRef!: TemplateRef<{}>; + + @ViewChild('target', {read: ViewContainerRef, static: true}) + targetViewContainerRef!: ViewContainerRef; + + enabled = true; +} + +@NgModule({ + declarations: [ + TestComponent, + ], + imports: [ + FeatureHighlightModule, + NoopAnimationsModule, + ], + entryComponents: [ + TestComponent, + ], +}) +class FeatureHighlightContentDirectivesTestModule { +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-content-directives.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-content-directives.ts new file mode 100644 index 000000000000..1f3e53a475e7 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-content-directives.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, Input, OnInit, Optional} from '@angular/core'; + +import {FeatureHighlight} from './feature-highlight'; +import {FeatureHighlightRef} from './feature-highlight-ref'; + +/** + * Target featured element to be highlighted. Use it to style the z-index and + * position correctly so that the target element is displayed on top of other + * feature highlight elements. + */ +@Directive({ + selector: `[feature-highlight-target], + [featureHighlightTarget], + feature-highlight-target`, + exportAs: 'featureHighlightTarget', + host: { + '[style.zIndex]': 'zIndex', + '[style.position]': 'position', + }, +}) +export class FeatureHighlightTarget { + @Input() featureHighlightEnabled = true; + + /** + * Return the zIndex value for the target element. When outer circle is not + * bounded, zIndex of the target element needs to be 1001 as the overlay + * container has zIndex of 1000. When outer circle is bounded, zIndex only + * needs to be 3. + */ + get zIndex(): string|null { + return this.featureHighlightEnabled ? '1001' : null; + } + + /** + * Return the position value for the target element. This is a key hack to + * make sure that our target element shows above the inner circle, as z-index + * only works for non static positioned elements. + */ + get position(): string|null { + return this.featureHighlightEnabled ? 'relative' : null; + } +} + +/** Button that closes the feature highlight. */ +@Directive({ + selector: 'button[feature-highlight-close], button[featureHighlightClose]', + exportAs: 'featureHighlightClose', + host: { + '(click)': 'featureHighlightRef.dismiss()', + 'type': 'button', + } +}) +export class FeatureHighlightClose implements OnInit { + constructor( + @Optional() private _featureHighlightRef: FeatureHighlightRef, + private readonly _featureHighlight: FeatureHighlight) {} + + + ngOnInit() { + this._featureHighlightRef = this._featureHighlightRef || + this._featureHighlight.getFeatureHighlightRef(); + } +} + +/** + * Title of the feature highlight. Use it to assign aria-labelledby attribute + * to the container element. + */ +@Directive({ + selector: `[feature-highlight-title], + [featureHighlightTitle], + feature-highlight-title`, + exportAs: 'featureHighlightTitle', + host: { + 'class': 'feature-highlight-title', + '[id]': 'id', + }, +}) +export class FeatureHighlightTitle implements OnInit { + @Input() id: string|null = 'feature-highlight-title-id'; + + constructor( + @Optional() private readonly _featureHighlightRef: FeatureHighlightRef, + private readonly _featureHighlight: FeatureHighlight) {} + + + ngOnInit() { + const ref = this._featureHighlightRef || + this._featureHighlight.getFeatureHighlightRef(); + + if (ref) { + // Use Promise.resolve() to avoid setting aria attributes multile times + // in the same life cycle hook. + Promise.resolve().then(() => { + ref.containerInstance.updateConfig({ariaLabelledBy: this.id}); + }); + } + } +} + +/** + * Content of the feature highlight. Use it to assign aria-describedby attribute + * to the container element. + */ +@Directive({ + selector: `[feature-highlight-content], + [featureHighlightContent], + feature-highlight-content`, + host: { + 'class': 'feature-highlight-content', + '[id]': 'id', + }, +}) +export class FeatureHighlightContent implements OnInit { + @Input() id: string|null = 'feature-highlight-content-id'; + + constructor( + @Optional() private readonly _featureHighlightRef: FeatureHighlightRef, + private readonly _featureHighlight: FeatureHighlight) {} + + /** @override */ + ngOnInit() { + const ref = this._featureHighlightRef || + this._featureHighlight.getFeatureHighlightRef(); + if (ref) { + // Use Promise.resolve() to avoid setting aria attributes multile times + // in the same life cycle hook. + Promise.resolve().then(() => { + ref.containerInstance.updateConfig({ariaDescribedBy: this.id}); + }); + } + } +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-overlay-container.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay-container.spec.ts new file mode 100644 index 000000000000..2292082537b6 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay-container.spec.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {OverlayModule} from '@angular/cdk/overlay'; +import {Component} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; + +import {FeatureHighlightOverlayContainer} from './feature-highlight-overlay-container'; + +describe('FeatureHighlightOverlayContainer', () => { + let comp: TestComponent; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + declarations: [ + TestComponent, + ], + imports: [ + OverlayModule, + ], + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + + comp = fixture.debugElement.componentInstance; + })); + + it('does not create a container element by default', () => { + expect(comp.overlayContainer.getContainerElement()).toBeUndefined(); + }); + + it('can pass a container element', () => { + const element = document.createElement('div'); + comp.overlayContainer.setContainerElement(element); + + expect(comp.overlayContainer.getContainerElement()).toEqual(element); + }); +}); + +@Component({template: ''}) +class TestComponent { + constructor(readonly overlayContainer: FeatureHighlightOverlayContainer) {} +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-overlay-container.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay-container.ts new file mode 100644 index 000000000000..38ca5c88e7cb --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay-container.ts @@ -0,0 +1,43 @@ +/** + * @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 {OverlayContainer} from '@angular/cdk/overlay'; +import {DOCUMENT} from '@angular/common'; +import {Inject, Injectable} from '@angular/core'; + +/** + * An overlay container for hosting feature highlight component. Allow a + * container element to be set instead of creating a container element under + * which is done in {@link OverlayContainer}. + */ +@Injectable({providedIn: 'root'}) +export class FeatureHighlightOverlayContainer extends OverlayContainer { + constructor( + @Inject(DOCUMENT) document: Document, + ) { + super(document); + } + + setContainerElement(element: HTMLElement) { + this._containerElement = element; + } + + /** @override */ + getContainerElement(): HTMLElement { + return this._containerElement; + } + + /** + * Prevent a cdk overlay container element being created under body tag. + * @override + */ + // tslint:disable-next-line:enforce-name-casing function is overridden. + _createContainer() { + return; + } +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-overlay.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay.spec.ts new file mode 100644 index 000000000000..653db37538bf --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay.spec.ts @@ -0,0 +1,45 @@ +/** + * @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 {OverlayModule} from '@angular/cdk/overlay'; +import {Component} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; + +import {FeatureHighlightOverlay} from './feature-highlight-overlay'; + +describe('FeatureHighlightOverlay', () => { + let comp: TestComponent; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + declarations: [ + TestComponent, + ], + imports: [ + OverlayModule, + ], + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + comp = fixture.debugElement.componentInstance; + })); + + it('can pass a container element', () => { + const element = document.createElement('div'); + comp.overlay.setContainerElement(element); + + expect(comp.overlay.getContainerElement()).toBe(element); + }); +}); + +@Component({template: ''}) +class TestComponent { + constructor(readonly overlay: FeatureHighlightOverlay) {} +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-overlay.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay.ts new file mode 100644 index 000000000000..2b7fd3957ec0 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-overlay.ts @@ -0,0 +1,54 @@ +/** + * @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 {Directionality} from '@angular/cdk/bidi'; +import {Overlay, OverlayKeyboardDispatcher, OverlayPositionBuilder, ScrollStrategyOptions} from '@angular/cdk/overlay'; +import {DOCUMENT} from '@angular/common'; +import {ComponentFactoryResolver, Inject, Injectable, Injector, NgZone} from '@angular/core'; + +import {FeatureHighlightOverlayContainer} from './feature-highlight-overlay-container'; + +/** + * An overlay for hosting feature highlight component. Allow a container + * element to be passed into {@link FeatureHighlightOverlayContainer}. + */ +@Injectable({providedIn: 'root'}) +export class FeatureHighlightOverlay extends Overlay { + constructor( + readonly scrollStrategies: ScrollStrategyOptions, + private readonly _featureHighlightOverlayContainer: + FeatureHighlightOverlayContainer, + readonly componentFactoryResolver: ComponentFactoryResolver, + readonly positionBuilder: OverlayPositionBuilder, + readonly keyboardDispatcher: OverlayKeyboardDispatcher, + readonly injector: Injector, + readonly ngZone: NgZone, + @Inject(DOCUMENT) readonly document: Document, + readonly directionality: Directionality, + ) { + super( + scrollStrategies, + _featureHighlightOverlayContainer, + componentFactoryResolver, + positionBuilder, + keyboardDispatcher, + injector, + ngZone, + document, + directionality, + ); + } + + setContainerElement(element: HTMLElement) { + this._featureHighlightOverlayContainer.setContainerElement(element); + } + + getContainerElement(): HTMLElement { + return this._featureHighlightOverlayContainer.getContainerElement(); + } +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-ref.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-ref.spec.ts new file mode 100644 index 000000000000..13a0f6c1290e --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-ref.spec.ts @@ -0,0 +1,285 @@ +import {Component, Inject, Injector, NgModule, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +import {FEATURE_HIGHLIGHT_DATA, FeatureHighlight} from './feature-highlight'; +import {FeatureHighlightOverlayContainer} from './feature-highlight-overlay-container'; +import {FeatureHighlightRef} from './feature-highlight-ref'; +import {FeatureHighlightModule} from './module'; + +describe('FeatureHighlightRef', () => { + let featureHighlight: FeatureHighlight; + let overlayContainer: FeatureHighlightOverlayContainer; + + beforeEach(async(() => { + TestBed + .configureTestingModule({ + imports: [ + FeatureHighlightTestModule, + ], + }) + .compileComponents(); + + featureHighlight = TestBed.inject(FeatureHighlight); + overlayContainer = TestBed.inject(FeatureHighlightOverlayContainer); + })); + + it('opens feature highlight with a template', () => { + const fixture = TestBed.createComponent(ComponentWithCalloutTemplate); + const ref = featureHighlight.open( + fixture.debugElement.componentInstance.calloutTemplateRef, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + }); + fixture.detectChanges(); + + const calloutElement = ref.containerInstance.callout.nativeElement; + expect(calloutElement.textContent).toContain('Add your favorite thing'); + }); + + it('opens feature highlight with a component', () => { + const fixture = TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + }); + fixture.detectChanges(); + + const calloutElement = ref.containerInstance.callout.nativeElement; + expect(calloutElement.textContent).toContain('Add your favorite thing'); + expect(ref.calloutInstance instanceof CalloutComponent).toBe(true); + }); + + it('opens feature highlight with overlay if outer circle is not bounded', + () => { + const fixture = TestBed.createComponent(ComponentWithCalloutTemplate); + featureHighlight.open( + fixture.debugElement.componentInstance.calloutTemplateRef, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + isOuterCircleBounded: false, + }); + fixture.detectChanges(); + + const overlayContainerElement = overlayContainer.getContainerElement(); + + expect( + overlayContainerElement.classList.contains('cdk-overlay-container')) + .toBe(true); + expect(overlayContainerElement.textContent) + .toContain('Add your favorite thing'); + }); + + it('removes overlay container after feature highlight is dismissed', () => { + const fixture = TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + isOuterCircleBounded: false, + }); + + ref.dismiss(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.cdk-overlay-container'))) + .toBeNull(); + }); + + it('removes overlay container after feature highlight is accepted', () => { + const fixture = TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + isOuterCircleBounded: false, + }); + + ref.accept(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.cdk-overlay-container'))) + .toBeNull(); + }); + + it('opens feature highlight without overlay if outer circle is bounded', + () => { + const fixture = TestBed.createComponent(ComponentWithCalloutTemplate); + featureHighlight.open( + fixture.debugElement.componentInstance.calloutTemplateRef, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + isOuterCircleBounded: true, + }); + fixture.detectChanges(); + + expect(overlayContainer.getContainerElement()).toBeUndefined(); + }); + + it('uses injector from target view container ref', () => { + const fixture = TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + }); + fixture.detectChanges(); + + expect(ref.calloutInstance.featureHighlightRef).toBe(ref); + + const calloutInjector = ref.calloutInstance.calloutInjector; + expect(calloutInjector.get(CalloutComponent)) + .toBeTruthy( + 'Expect the callout component to be created with the injector ' + + 'from the target view container'); + }); + + it('notifies observers after feature highlight is opened', async(() => { + const fixture = + TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + }); + + const afterOpenedCallback = jasmine.createSpy('afterOpenedCallback'); + ref.afterOpened().subscribe(afterOpenedCallback); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterOpenedCallback).toHaveBeenCalledTimes(1); + }); + })); + + it('notifies observers after feature highlight is dismissed', async(() => { + const fixture = + TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + }); + + const afterDismissedCallback = + jasmine.createSpy('afterDismissedCallback'); + ref.afterDismissed().subscribe(afterDismissedCallback); + ref.dismiss(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterDismissedCallback).toHaveBeenCalledTimes(1); + }); + })); + + it('notifies observers after feature highlight is accepted', async(() => { + const fixture = + TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + }); + + const afterAcceptedCallback = jasmine.createSpy('afterAcceptedCallback'); + ref.afterAccepted().subscribe(afterAcceptedCallback); + ref.accept(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterAcceptedCallback).toHaveBeenCalledTimes(1); + }); + })); + + it('should be able to pass data to callout component', () => { + const fixture = TestBed.createComponent(ComponentWithTargetViewContainer); + const data = { + stringParam: 'hello', + numberParam: 123, + }; + const ref = featureHighlight.open(CalloutWithInjectedData, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + data, + }); + + expect(ref.calloutInstance.data).toEqual(data); + }); + + it('should dismiss feature highlight with overlay via escape key', + async(() => { + const fixture = + TestBed.createComponent(ComponentWithTargetViewContainer); + const ref = featureHighlight.open(CalloutComponent, { + targetViewContainerRef: + fixture.debugElement.componentInstance.targetViewContainerRef, + isOuterCircleBounded: false, + }); + + const afterDismissedCallback = + jasmine.createSpy('afterDismissedCallback'); + ref.afterDismissed().subscribe(afterDismissedCallback); + + // Trigger an escape key press event. + document.body.dispatchEvent(new KeyboardEvent( + /* type= */ 'keydown', {key: 'Escape', bubbles: true})); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(afterDismissedCallback).toHaveBeenCalledTimes(1); + }); + })); +}); + +@Component({ + template: ` +
+ +

Add your favorite thing

+
+ `, +}) +class ComponentWithCalloutTemplate { + @ViewChild('calloutTemplate', {static: true}) + calloutTemplateRef!: TemplateRef<{}>; + @ViewChild('target', {read: ViewContainerRef, static: true}) + targetViewContainerRef!: ViewContainerRef; +} + +@Component({ + template: ` +

Add your favorite thing

+ `, +}) +class CalloutComponent { + constructor( + readonly calloutInjector: Injector, + readonly featureHighlightRef: FeatureHighlightRef) {} +} + +@Component({ + template: ` +
+ `, +}) +class ComponentWithTargetViewContainer { + @ViewChild('target', {read: ViewContainerRef, static: true}) + targetViewContainerRef!: ViewContainerRef; +} + +@Component({template: ''}) +class CalloutWithInjectedData { + constructor(@Inject(FEATURE_HIGHLIGHT_DATA) readonly data: {}) {} +} + +@NgModule({ + declarations: [ + CalloutComponent, + CalloutWithInjectedData, + ComponentWithCalloutTemplate, + ComponentWithTargetViewContainer, + ], + imports: [ + FeatureHighlightModule, + NoopAnimationsModule, + ], + entryComponents: [ + CalloutWithInjectedData, + CalloutComponent, + ComponentWithCalloutTemplate, + ComponentWithTargetViewContainer, + ], +}) +class FeatureHighlightTestModule { +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight-ref.ts b/src/material-experimental/mat-feature-highlight/feature-highlight-ref.ts new file mode 100644 index 000000000000..29388e01b93a --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight-ref.ts @@ -0,0 +1,130 @@ +/** + * @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 {OverlayRef} from '@angular/cdk/overlay'; +import {Observable, Subject} from 'rxjs'; +import {filter} from 'rxjs/operators'; + +import {FeatureHighlightConfig} from './feature-highlight-config'; +import {FeatureHighlightContainer} from './feature-highlight-container'; + +/** Reference to a feature highlight component opened via the service. */ +export class FeatureHighlightRef implements + FeatureHighlightRefBase { + /** + * The instance of the component making up the content of the feature + * highlight. + */ + calloutInstance!: C; + + private readonly afterAcceptedSubject = new Subject(); + private readonly afterDismissedSubject = new Subject(); + + constructor( + readonly containerInstance: FeatureHighlightContainer, + private readonly _overlayRef: OverlayRef|undefined, + ) { + this.containerInstance.afterAccepted.subscribe(() => { + this.afterAcceptedSubject.next(); + this.afterAcceptedSubject.complete(); + if (_overlayRef) { + _overlayRef.dispose(); + } + }); + + this.containerInstance.afterDismissed.subscribe(() => { + this.afterDismissedSubject.next(); + this.afterDismissedSubject.complete(); + if (_overlayRef) { + _overlayRef.dispose(); + } + }); + + if (_overlayRef) { + _overlayRef.detachments().subscribe(() => { + this.afterAcceptedSubject.complete(); + this.afterDismissedSubject.next(); + this.afterDismissedSubject.complete(); + this.calloutInstance = null!; + _overlayRef.dispose(); + }); + + // Support hitting escape button to dismiss feature highlight when outer + // circle is unbounded. In the case of bounded outer circle, users of + // feature highlight should add a close button in the callout template + // or component, and have the button auto focused after feature highlight + // is opened to achieve better a11y. + _overlayRef.keydownEvents() + .pipe(filter(event => event.key === 'Escape')) + .subscribe(() => { + this.dismiss(); + }); + } + } + + /** + * Close feature highlight by accepting it, i.e. clicking on the inner circle. + */ + accept() { + this.containerInstance.accept(); + } + + /** Dismiss feature highlight. */ + dismiss() { + this.containerInstance.dismiss(); + } + + /** + * Return an observable that emits when feature highlight has finished + * the opening animation. + */ + afterOpened: + () => Observable = () => this.containerInstance.afterOpened; + + /** + * Return an observable that emits when feature highlight has finished + * the accepting animation. + */ + afterAccepted: () => Observable = () => this.afterAcceptedSubject; + + /** + * Return an observable that emits when feature highlight has finished + * the dismissing animation. + */ + afterDismissed: () => Observable = () => this.afterDismissedSubject; + + /** + * Update layout of feature highlight, e.g. position of the elements, while + * it's still activated. + */ + updateLayout(newConfig: Partial) { + this.containerInstance.updateConfig(newConfig); + this.containerInstance.layout(); + if (this._overlayRef) { + this._overlayRef.updatePosition(); + } + } +} + +/** Base interface for referencing to feature highlight. */ +export interface FeatureHighlightRefBase { + /** Close feature highlight by accepting it. */ + accept(): void; + + /** Dismiss feature highlight. */ + dismiss(): void; + + /** Triggered after feature highlight is opened. */ + afterOpened(): void; + + /** Triggered after feature highlight is closed by accepting it. */ + afterAccepted(): void; + + /** Triggered after feature highlight is dismissed. */ + afterDismissed(): void; +} diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight.e2e.spec.ts b/src/material-experimental/mat-feature-highlight/feature-highlight.e2e.spec.ts new file mode 100644 index 000000000000..8ccf9d913543 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight.e2e.spec.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// TODO write an E2E test. diff --git a/src/material-experimental/mat-feature-highlight/feature-highlight.ts b/src/material-experimental/mat-feature-highlight/feature-highlight.ts new file mode 100644 index 000000000000..51c0ae407934 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/feature-highlight.ts @@ -0,0 +1,258 @@ +/** + * @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 {Directionality} from '@angular/cdk/bidi'; +import {OverlayConfig, OverlayRef} from '@angular/cdk/overlay'; +import {ComponentPortal, ComponentType, PortalInjector, TemplatePortal} from '@angular/cdk/portal'; +import {ComponentFactoryResolver, ComponentRef, Injectable, InjectionToken, Renderer2, RendererFactory2, TemplateRef} from '@angular/core'; + +import {FeatureHighlightConfig} from './feature-highlight-config'; +import {FeatureHighlightContainer} from './feature-highlight-container'; +import {FeatureHighlightOverlay} from './feature-highlight-overlay'; +import {FeatureHighlightRef, FeatureHighlightRefBase} from './feature-highlight-ref'; + +/** + * Injection token that can be used to access the data passed into a feature + * highlight. + */ +export const FEATURE_HIGHLIGHT_DATA = + new InjectionToken('FeatureHighlightData'); + +/** Service for implementing feature highlight. */ +@Injectable({providedIn: 'root'}) +export class FeatureHighlight implements FeatureHighlightBase { + private readonly renderer: Renderer2; + private featureHighlightRef?: FeatureHighlightRef; + + constructor( + private readonly componentFactoryResolver: ComponentFactoryResolver, + private readonly overlay: FeatureHighlightOverlay, + private readonly directionality: Directionality, + readonly rendererFactory: RendererFactory2, + ) { + this.renderer = rendererFactory.createRenderer(null, null); + } + + /** + * Open a feature highlight with callout as a component. + * + * @param callout a component for the callout. Make sure that the component + * is added as an entry component in your module. + * @param config config values for feature highlight. + * @return a reference to the feature highlight which has access to callout + * instance and feature highlight component APIs. + */ + open( + callout: ComponentType, + config: FeatureHighlightConfig): FeatureHighlightRef; + + /** + * Open a feature highlight with callout as a template ref. + * + * @param callout a template ref for the callout. + * @param config config values for feature highlight. + * @return a reference to the feature highlight which has access to callout + * instance and feature highlight component APIs. + */ + open( + callout: TemplateRef<{}>, + config: FeatureHighlightConfig): FeatureHighlightRef<{}>; + + open( + callout: ComponentType|TemplateRef<{}>, + config: FeatureHighlightConfig): + FeatureHighlightRef|FeatureHighlightRef<{}> { + config = {...new FeatureHighlightConfig(), ...config}; + + let overlayRef: OverlayRef|undefined = undefined; + if (!config.isOuterCircleBounded) { + overlayRef = this.createOverlay(config); + } + + const container = this.attachFeatureHighlightContainer(config, overlayRef); + const featureHighlightRef = + this.attachContent(callout, container, overlayRef, config); + this.featureHighlightRef = featureHighlightRef; + + featureHighlightRef.afterAccepted().subscribe(() => { + this.removeOverlayContainer(config); + }); + featureHighlightRef.afterDismissed().subscribe(() => { + this.removeOverlayContainer(config); + }); + + return featureHighlightRef; + } + + /** Return the current reference of feature highlight. */ + getFeatureHighlightRef(): FeatureHighlightRef|undefined { + return this.featureHighlightRef; + } + + /** Return an overlay reference when the outer circle is unbounded. */ + private createOverlay(config: FeatureHighlightConfig): OverlayRef { + // The position strategy ensures that the top left corner of the overlay + // is located at the same position of the top left corner of the target + // element. + const positionStrategy = + this.overlay.position() + .flexibleConnectedTo(config.targetViewContainerRef.element) + .withPositions([ + { + originX: 'start', + originY: 'top', + overlayX: 'center', + overlayY: 'center', + }, + ]); + const overlayConfig = new OverlayConfig({ + positionStrategy, + direction: this.directionality, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + disposeOnNavigation: true, + }); + + // Create the overlay container div that hosts feature highlight container, + // and insert it into the DOM as a sibling of the target element. + const overlayContainerElement = document.createElement('div'); + overlayContainerElement.classList.add('cdk-overlay-container'); + this.overlay.setContainerElement(overlayContainerElement); + + const targetParent = this.getTargetParent(config); + this.renderer.appendChild(targetParent, overlayContainerElement); + + return this.overlay.create(overlayConfig); + } + + /** Attach a feature highlight container to the DOM. */ + private attachFeatureHighlightContainer( + config: FeatureHighlightConfig, + overlayRef?: OverlayRef): FeatureHighlightContainer { + let containerRef: ComponentRef; + + const injector = this.createInjector(config, [ + [FeatureHighlightConfig, config], + ]); + + // If the outer circle is bounded, feature highlight container is created + // as a sibling element of the target, otherwise the container is attached + // to the overlay. + if (config.isOuterCircleBounded) { + containerRef = config.targetViewContainerRef.createComponent( + this.componentFactoryResolver.resolveComponentFactory( + FeatureHighlightContainer), + undefined, injector); + } else { + const containerPortal = new ComponentPortal( + FeatureHighlightContainer, config.targetViewContainerRef, injector); + containerRef = + overlayRef!.attach(containerPortal); + } + + containerRef.instance.afterAccepted.subscribe(() => { + containerRef.destroy(); + }); + containerRef.instance.afterDismissed.subscribe(() => { + containerRef.destroy(); + }); + + return containerRef.instance; + } + + /** + * Create a reference of feature highlight and attach callout to the + * feature highlight component. + */ + private attachContent( + callout: ComponentType|TemplateRef<{}>, + container: FeatureHighlightContainer, + overlayRef: OverlayRef|undefined, + config: FeatureHighlightConfig, + ): FeatureHighlightRef { + const featureHighlightRef = + new FeatureHighlightRef(container, overlayRef); + + if (callout instanceof TemplateRef) { + const portal = new TemplatePortal<{}>( + callout, + /* viewContainerRef= */ null!, + { + $implicit: config.data, + featureHighlightRef, + }, + ); + featureHighlightRef.calloutInstance = + container.attachCalloutTemplatePortal(portal).context; + } else { + const injector = this.createInjector(config, [ + [FeatureHighlightConfig, config], + [FEATURE_HIGHLIGHT_DATA, config.data], + [FeatureHighlightRef, featureHighlightRef], + ]); + const portal = new ComponentPortal( + callout, /* viewContainerRef= */ undefined, injector); + featureHighlightRef.calloutInstance = + container.attachCalloutComponentPortal(portal).instance; + } + + return featureHighlightRef; + } + + /** Clean up the overlay container element created, if exists. */ + private removeOverlayContainer(config: FeatureHighlightConfig) { + if (!config.isOuterCircleBounded) { + const targetParent = this.getTargetParent(config); + this.renderer.removeChild( + targetParent, this.overlay.getContainerElement()); + } + } + + private createInjector( + config: FeatureHighlightConfig, + dependencies: Array<[{}, unknown]>): PortalInjector { + const targetInjector = config.targetViewContainerRef.injector; + + return new PortalInjector( + targetInjector, new WeakMap<{}, unknown>(dependencies)); + } + + private getTargetParent(config: FeatureHighlightConfig) { + return this.renderer.parentNode( + config.targetViewContainerRef.element.nativeElement); + } +} + +/** Base interface of defining a feature highlight service. */ +export interface FeatureHighlightBase { + /** + * Open a feature highlight with callout as a template ref. + * + * @param callout a template ref for the callout. + * @param config config values for feature highlight. + * @return a reference to the feature highlight which has access to APIs + * for manipulating feature highlight component. + */ + open( + callout: ComponentType, + config: FeatureHighlightConfig): FeatureHighlightRefBase; + + /** + * Open a feature highlight with callout as a template ref. + * + * @param callout a template ref for the callout. + * @param config config values for feature highlight. + * @return a reference to the feature highlight which has access to APIs + * for manipulating feature highlight component. + */ + open( + callout: TemplateRef<{}>, + config: FeatureHighlightConfig): FeatureHighlightRefBase; + + /** Return the current reference of feature highlight. */ + getFeatureHighlightRef(): FeatureHighlightRefBase|undefined; +} diff --git a/src/material-experimental/mat-feature-highlight/index.ts b/src/material-experimental/mat-feature-highlight/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/material-experimental/mat-feature-highlight/module.ts b/src/material-experimental/mat-feature-highlight/module.ts new file mode 100644 index 000000000000..8f23f35593e6 --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/module.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {BidiModule} from '@angular/cdk/bidi'; +import {PortalModule} from '@angular/cdk/portal'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; + +import {FeatureHighlight} from './feature-highlight'; +import {FeatureHighlightCalloutContainer} from './feature-highlight-callout-container'; +import {FeatureHighlightContainer} from './feature-highlight-container'; +import {FeatureHighlightClose, FeatureHighlightContent, FeatureHighlightTarget, FeatureHighlightTitle} from './feature-highlight-content-directives'; +import {FeatureHighlightOverlay} from './feature-highlight-overlay'; +import {FeatureHighlightOverlayContainer} from './feature-highlight-overlay-container'; + +@NgModule({ + declarations: [ + FeatureHighlightCalloutContainer, + FeatureHighlightClose, + FeatureHighlightContainer, + FeatureHighlightContent, + FeatureHighlightTarget, + FeatureHighlightTitle, + ], + imports: [ + BidiModule, + CommonModule, + PortalModule, + ], + exports: [ + FeatureHighlightClose, + FeatureHighlightContent, + FeatureHighlightTarget, + FeatureHighlightTitle, + ], + providers: [ + FeatureHighlight, + FeatureHighlightOverlay, + FeatureHighlightOverlayContainer, + ], + entryComponents: [ + FeatureHighlightContainer, + FeatureHighlightCalloutContainer, + ], +}) +export class FeatureHighlightModule { +} diff --git a/src/material-experimental/mat-feature-highlight/public-api.ts b/src/material-experimental/mat-feature-highlight/public-api.ts new file mode 100644 index 000000000000..b8c82789d1ff --- /dev/null +++ b/src/material-experimental/mat-feature-highlight/public-api.ts @@ -0,0 +1,13 @@ +/** + * @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 './feature-highlight'; +export * from './feature-highlight-config'; +export * from './feature-highlight-content-directives'; +export * from './feature-highlight-ref'; +export * from './module'; diff --git a/test/karma-system-config.js b/test/karma-system-config.js index 2217f451db57..75ba3b2d824f 100644 --- a/test/karma-system-config.js +++ b/test/karma-system-config.js @@ -188,6 +188,8 @@ System.config({ 'dist/packages/material/form-field/testing/shared.spec.js', '@angular/material/input/testing': 'dist/packages/material/input/testing/index.js', + '@angular/material-experimental/mat-feature-highlight': + 'dist/packages/material-experimental/mat-feature-highlight/index.js', '@angular/material-experimental/mdc-autocomplete': 'dist/packages/material-experimental/mdc-autocomplete/index.js', '@angular/material-experimental/mdc-button':