From aeaf07dde8d2694dbd9baa2db9cc1eaa084ab659 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 24 May 2019 07:36:16 +0200 Subject: [PATCH] feat(select): add alternate rendering strategy for improved accessibility Currently `mat-select` has some accessibility issues, because we don't keep the options in the DOM until the select is attached, making it harder for assistive technology to pick it up. These changes add an alternate, opt-in, rendering strategy that'll keep the options inside the DOM. The rendering strategy is controlled through the `MAT_SELECT_RENDERING_STRATEGY` injection token. In order to facilitate the new rendering strategy, these changes introduce a new kind of portal called an `InlinePortal`. It is a portal whose content is transferred from one place in the DOM to another when it is attached/detached. Another change that was necessary for the new rendering to work was being able to pass a portal to the `CdkConnectedOverlay` directive. If no portal is passed, the directive will fall back to the current behavior. --- .../dialog/dialog-container.ts | 16 ++++- src/cdk/overlay/overlay-directives.ts | 20 ++++-- src/cdk/portal/dom-portal-outlet.ts | 24 ++++++- src/cdk/portal/portal-directives.ts | 27 +++++++- src/cdk/portal/portal.spec.ts | 49 +++++++++++++- src/cdk/portal/portal.ts | 23 +++++++ src/dev-app/portal/portal-demo.html | 9 +++ src/dev-app/portal/portal-demo.scss | 4 ++ src/dev-app/portal/portal-demo.ts | 9 ++- .../bottom-sheet/bottom-sheet-container.ts | 9 +++ src/material/dialog/dialog-container.ts | 16 ++++- src/material/select/select.html | 35 ++++++---- src/material/select/select.spec.ts | 56 +++++++++++++++- src/material/select/select.ts | 66 +++++++++++++++++-- src/material/snack-bar/snack-bar-container.ts | 8 +++ tools/public_api_guard/cdk/overlay.d.ts | 3 +- .../material/bottom-sheet.d.ts | 1 + tools/public_api_guard/material/dialog.d.ts | 1 + tools/public_api_guard/material/select.d.ts | 16 ++++- .../public_api_guard/material/snack-bar.d.ts | 1 + 20 files changed, 355 insertions(+), 38 deletions(-) diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 4049e1234920..b07891e368f7 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -12,7 +12,8 @@ import { BasePortalOutlet, ComponentPortal, PortalHostDirective, - TemplatePortal + TemplatePortal, + DomPortal } from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { @@ -176,6 +177,19 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { return this._portalHost.attachTemplatePortal(portal); } + /** + * Attaches a DOM portal to the dialog container. + * @param portal Portal to be attached. + */ + attachDomPortal(portal: DomPortal) { + if (this._portalHost.hasAttached()) { + throwDialogContentAlreadyAttachedError(); + } + + this._savePreviouslyFocusedElement(); + return this._portalHost.attachDomPortal(portal); + } + /** Emit lifecycle events based on animation `start` callback. */ _onAnimationStart(event: AnimationEvent) { if (event.toState === 'enter') { diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts index c360c50bea7c..cb95122d28f6 100644 --- a/src/cdk/overlay/overlay-directives.ts +++ b/src/cdk/overlay/overlay-directives.ts @@ -9,7 +9,7 @@ import {Direction, Directionality} from '@angular/cdk/bidi'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {ESCAPE} from '@angular/cdk/keycodes'; -import {TemplatePortal} from '@angular/cdk/portal'; +import {TemplatePortal, Portal} from '@angular/cdk/portal'; import { Directive, ElementRef, @@ -104,7 +104,6 @@ export class CdkOverlayOrigin { }) export class CdkConnectedOverlay implements OnDestroy, OnChanges { private _overlayRef: OverlayRef; - private _templatePortal: TemplatePortal; private _hasBackdrop = false; private _lockPosition = false; private _growAfterOpen = false; @@ -198,6 +197,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { get push() { return this._push; } set push(value: boolean) { this._push = coerceBooleanProperty(value); } + /** + * Portal that should be projected into the overlay. If none is provided, one will be + * created automatically from the overlay element's content. + */ + @Input('cdkConnectedOverlayPortal') portal: Portal; + /** Event emitted when the backdrop is clicked. */ @Output() backdropClick = new EventEmitter(); @@ -217,11 +222,10 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { constructor( private _overlay: Overlay, - templateRef: TemplateRef, - viewContainerRef: ViewContainerRef, + private _templateRef: TemplateRef, + private _viewContainerRef: ViewContainerRef, @Inject(CDK_CONNECTED_OVERLAY_SCROLL_STRATEGY) scrollStrategyFactory: any, @Optional() private _dir: Directionality) { - this._templatePortal = new TemplatePortal(templateRef, viewContainerRef); this._scrollStrategyFactory = scrollStrategyFactory; this.scrollStrategy = this._scrollStrategyFactory(); } @@ -359,8 +363,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { this._overlayRef.getConfig().hasBackdrop = this.hasBackdrop; } + if (!this.portal) { + this.portal = new TemplatePortal(this._templateRef, this._viewContainerRef); + } + if (!this._overlayRef.hasAttached()) { - this._overlayRef.attach(this._templatePortal); + this._overlayRef.attach(this.portal); this.attach.emit(); } diff --git a/src/cdk/portal/dom-portal-outlet.ts b/src/cdk/portal/dom-portal-outlet.ts index d7eaadc79ed1..028bc32dbcad 100644 --- a/src/cdk/portal/dom-portal-outlet.ts +++ b/src/cdk/portal/dom-portal-outlet.ts @@ -13,7 +13,7 @@ import { ApplicationRef, Injector, } from '@angular/core'; -import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal'; +import {BasePortalOutlet, ComponentPortal, TemplatePortal, DomPortal} from './portal'; /** @@ -93,6 +93,28 @@ export class DomPortalOutlet extends BasePortalOutlet { return viewRef; } + /** + * Attaches a DOM portal by transferring its content into the outlet. + * @param portal Portal to be attached. + */ + attachDomPortal(portal: DomPortal) { + // Note that we need to convert this into an array, because `childNodes` + // is a live collection which will be updated as we add/remove nodes. + let transferredNodes = Array.from(portal.element.childNodes); + + for (let i = 0; i < transferredNodes.length; i++) { + this.outletElement.appendChild(transferredNodes[i]); + } + + super.setDisposeFn(() => { + for (let i = 0; i < transferredNodes.length; i++) { + portal.element.appendChild(transferredNodes[i]); + } + + transferredNodes = null!; + }); + } + /** * Clears out a portal from the DOM. */ diff --git a/src/cdk/portal/portal-directives.ts b/src/cdk/portal/portal-directives.ts index fab3606fc848..62f91d11ca81 100644 --- a/src/cdk/portal/portal-directives.ts +++ b/src/cdk/portal/portal-directives.ts @@ -19,7 +19,7 @@ import { TemplateRef, ViewContainerRef, } from '@angular/core'; -import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal} from './portal'; +import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal, DomPortal} from './portal'; /** @@ -141,7 +141,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr } /** - * Attach the given TemplatePortal to this PortlHost as an embedded View. + * Attach the given TemplatePortal to this PortalHost as an embedded View. * @param portal Portal to be attached. * @returns Reference to the created embedded view. */ @@ -156,6 +156,29 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr return viewRef; } + + /** + * Attaches the given DomPortal to this PortalHost by moving all of the portal content into it. + * @param portal Portal to be attached. + */ + attachDomPortal(portal: DomPortal) { + portal.setAttachedHost(this); + + const origin = portal.element; + const transferredNodes: Node[] = []; + const nativeElement: Node = this._viewContainerRef.element.nativeElement; + const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ? + nativeElement : nativeElement.parentNode!; + + while (origin.firstChild) { + transferredNodes.push(rootNode.appendChild(origin.firstChild)); + } + + super.setDisposeFn(() => { + transferredNodes.forEach(node => portal.element.appendChild(node)); + transferredNodes.length = 0; + }); + } } diff --git a/src/cdk/portal/portal.spec.ts b/src/cdk/portal/portal.spec.ts index 6b42ed759f81..b047bfa46ec2 100644 --- a/src/cdk/portal/portal.spec.ts +++ b/src/cdk/portal/portal.spec.ts @@ -12,10 +12,11 @@ import { ApplicationRef, TemplateRef, ComponentRef, + ElementRef, } from '@angular/core'; import {CommonModule} from '@angular/common'; import {CdkPortal, CdkPortalOutlet, PortalModule} from './portal-directives'; -import {Portal, ComponentPortal, TemplatePortal} from './portal'; +import {Portal, ComponentPortal, TemplatePortal, DomPortal} from './portal'; import {DomPortalOutlet} from './dom-portal-outlet'; @@ -76,6 +77,35 @@ describe('Portals', () => { .toHaveBeenCalledWith(testAppComponent.portalOutlet.attachedRef); }); + it('should load a DOM portal', () => { + const testAppComponent = fixture.componentInstance; + const hostContainer = fixture.nativeElement.querySelector('.portal-container'); + const innerContent = fixture.nativeElement.querySelector('.dom-portal-inner-content'); + const domPortal = new DomPortal(testAppComponent.domPortalContent); + + expect(innerContent).toBeTruthy('Expected portal content to be rendered.'); + expect(domPortal.element.contains(innerContent)) + .toBe(true, 'Expected content to be inside portal on init.'); + expect(hostContainer.contains(innerContent)) + .toBe(false, 'Expected content to be outside of portal outlet.'); + + testAppComponent.selectedPortal = domPortal; + fixture.detectChanges(); + + expect(domPortal.element.contains(innerContent)) + .toBe(false, 'Expected content to be out of the portal on attach.'); + expect(hostContainer.contains(innerContent)) + .toBe(true, 'Expected content to be inside the outlet on attach.'); + + testAppComponent.selectedPortal = undefined; + fixture.detectChanges(); + + expect(domPortal.element.contains(innerContent)) + .toBe(true, 'Expected content to be at initial position on detach.'); + expect(hostContainer.contains(innerContent)) + .toBe(false, 'Expected content to be removed from outlet on detach.'); + }); + it('should project template context bindings in the portal', () => { let testAppComponent = fixture.componentInstance; let hostContainer = fixture.nativeElement.querySelector('.portal-container'); @@ -502,6 +532,16 @@ describe('Portals', () => { expect(spy).toHaveBeenCalled(); }); + it('should attach and detach a DOM portal', () => { + const fixture = TestBed.createComponent(PortalTestApp); + fixture.detectChanges(); + const portal = new DomPortal(fixture.componentInstance.domPortalContent); + + portal.attach(host); + + expect(someDomElement.textContent).toContain('Hello there'); + }); + }); }); @@ -559,12 +599,17 @@ class ArbitraryViewContainerRefComponent { {{fruit}} - {{ data?.status }}! + +
+

Hello there

+
`, }) class PortalTestApp { @ViewChildren(CdkPortal) portals: QueryList; @ViewChild(CdkPortalOutlet, {static: true}) portalOutlet: CdkPortalOutlet; - @ViewChild('templateRef', { read: TemplateRef , static: true}) templateRef: TemplateRef; + @ViewChild('templateRef', {read: TemplateRef, static: true}) templateRef: TemplateRef; + @ViewChild('domPortalContent', {static: true}) domPortalContent: ElementRef; selectedPortal: Portal|undefined; fruit: string = 'Banana'; diff --git a/src/cdk/portal/portal.ts b/src/cdk/portal/portal.ts index 0e71a788b541..a6b7dc1a610e 100644 --- a/src/cdk/portal/portal.ts +++ b/src/cdk/portal/portal.ts @@ -153,6 +153,22 @@ export class TemplatePortal extends Portal> { } } +/** + * A `DomPortal` is a portal whose content will be taken from its current position + * in the DOM and moved into a portal outlet, when it is attached. On detach, the content + * will be restored to its original position. + */ +export class DomPortal extends Portal { + constructor(private _element: HTMLElement | ElementRef) { + super(); + } + + /** DOM node hosting the portal's content. */ + get element(): HTMLElement { + return this._element instanceof ElementRef ? this._element.nativeElement : this._element; + } +} + /** A `PortalOutlet` is an space that can contain a single `Portal`. */ export interface PortalOutlet { @@ -213,6 +229,10 @@ export abstract class BasePortalOutlet implements PortalOutlet { } else if (portal instanceof TemplatePortal) { this._attachedPortal = portal; return this.attachTemplatePortal(portal); + // @breaking-change 8.0.0 remove null check for `this.attachDomPortal`. + } else if (this.attachDomPortal && portal instanceof DomPortal) { + this._attachedPortal = portal; + return this.attachDomPortal(portal); } throwUnknownPortalTypeError(); @@ -222,6 +242,9 @@ export abstract class BasePortalOutlet implements PortalOutlet { abstract attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef; + // @breaking-change 8.0.0 `attachDomPortal` to become a required method. + abstract attachDomPortal?(portal: DomPortal): any; + /** Detaches a previously attached portal. */ detach(): void { if (this._attachedPortal) { diff --git a/src/dev-app/portal/portal-demo.html b/src/dev-app/portal/portal-demo.html index 1a19fe9bc91d..bdb0e648447c 100644 --- a/src/dev-app/portal/portal-demo.html +++ b/src/dev-app/portal/portal-demo.html @@ -15,6 +15,10 @@

The portal outlet is here:

Science joke + +