Skip to content

Commit aeaf07d

Browse files
committed
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.
1 parent 86da318 commit aeaf07d

File tree

20 files changed

+355
-38
lines changed

20 files changed

+355
-38
lines changed

src/cdk-experimental/dialog/dialog-container.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
BasePortalOutlet,
1313
ComponentPortal,
1414
PortalHostDirective,
15-
TemplatePortal
15+
TemplatePortal,
16+
DomPortal
1617
} from '@angular/cdk/portal';
1718
import {DOCUMENT} from '@angular/common';
1819
import {
@@ -176,6 +177,19 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
176177
return this._portalHost.attachTemplatePortal(portal);
177178
}
178179

180+
/**
181+
* Attaches a DOM portal to the dialog container.
182+
* @param portal Portal to be attached.
183+
*/
184+
attachDomPortal(portal: DomPortal) {
185+
if (this._portalHost.hasAttached()) {
186+
throwDialogContentAlreadyAttachedError();
187+
}
188+
189+
this._savePreviouslyFocusedElement();
190+
return this._portalHost.attachDomPortal(portal);
191+
}
192+
179193
/** Emit lifecycle events based on animation `start` callback. */
180194
_onAnimationStart(event: AnimationEvent) {
181195
if (event.toState === 'enter') {

src/cdk/overlay/overlay-directives.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {Direction, Directionality} from '@angular/cdk/bidi';
1010
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1111
import {ESCAPE} from '@angular/cdk/keycodes';
12-
import {TemplatePortal} from '@angular/cdk/portal';
12+
import {TemplatePortal, Portal} from '@angular/cdk/portal';
1313
import {
1414
Directive,
1515
ElementRef,
@@ -104,7 +104,6 @@ export class CdkOverlayOrigin {
104104
})
105105
export class CdkConnectedOverlay implements OnDestroy, OnChanges {
106106
private _overlayRef: OverlayRef;
107-
private _templatePortal: TemplatePortal;
108107
private _hasBackdrop = false;
109108
private _lockPosition = false;
110109
private _growAfterOpen = false;
@@ -198,6 +197,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
198197
get push() { return this._push; }
199198
set push(value: boolean) { this._push = coerceBooleanProperty(value); }
200199

200+
/**
201+
* Portal that should be projected into the overlay. If none is provided, one will be
202+
* created automatically from the overlay element's content.
203+
*/
204+
@Input('cdkConnectedOverlayPortal') portal: Portal<any>;
205+
201206
/** Event emitted when the backdrop is clicked. */
202207
@Output() backdropClick = new EventEmitter<MouseEvent>();
203208

@@ -217,11 +222,10 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
217222

218223
constructor(
219224
private _overlay: Overlay,
220-
templateRef: TemplateRef<any>,
221-
viewContainerRef: ViewContainerRef,
225+
private _templateRef: TemplateRef<any>,
226+
private _viewContainerRef: ViewContainerRef,
222227
@Inject(CDK_CONNECTED_OVERLAY_SCROLL_STRATEGY) scrollStrategyFactory: any,
223228
@Optional() private _dir: Directionality) {
224-
this._templatePortal = new TemplatePortal(templateRef, viewContainerRef);
225229
this._scrollStrategyFactory = scrollStrategyFactory;
226230
this.scrollStrategy = this._scrollStrategyFactory();
227231
}
@@ -359,8 +363,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
359363
this._overlayRef.getConfig().hasBackdrop = this.hasBackdrop;
360364
}
361365

366+
if (!this.portal) {
367+
this.portal = new TemplatePortal(this._templateRef, this._viewContainerRef);
368+
}
369+
362370
if (!this._overlayRef.hasAttached()) {
363-
this._overlayRef.attach(this._templatePortal);
371+
this._overlayRef.attach(this.portal);
364372
this.attach.emit();
365373
}
366374

src/cdk/portal/dom-portal-outlet.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
ApplicationRef,
1414
Injector,
1515
} from '@angular/core';
16-
import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal';
16+
import {BasePortalOutlet, ComponentPortal, TemplatePortal, DomPortal} from './portal';
1717

1818

1919
/**
@@ -93,6 +93,28 @@ export class DomPortalOutlet extends BasePortalOutlet {
9393
return viewRef;
9494
}
9595

96+
/**
97+
* Attaches a DOM portal by transferring its content into the outlet.
98+
* @param portal Portal to be attached.
99+
*/
100+
attachDomPortal(portal: DomPortal) {
101+
// Note that we need to convert this into an array, because `childNodes`
102+
// is a live collection which will be updated as we add/remove nodes.
103+
let transferredNodes = Array.from(portal.element.childNodes);
104+
105+
for (let i = 0; i < transferredNodes.length; i++) {
106+
this.outletElement.appendChild(transferredNodes[i]);
107+
}
108+
109+
super.setDisposeFn(() => {
110+
for (let i = 0; i < transferredNodes.length; i++) {
111+
portal.element.appendChild(transferredNodes[i]);
112+
}
113+
114+
transferredNodes = null!;
115+
});
116+
}
117+
96118
/**
97119
* Clears out a portal from the DOM.
98120
*/

src/cdk/portal/portal-directives.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
TemplateRef,
2020
ViewContainerRef,
2121
} from '@angular/core';
22-
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal} from './portal';
22+
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal, DomPortal} from './portal';
2323

2424

2525
/**
@@ -141,7 +141,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
141141
}
142142

143143
/**
144-
* Attach the given TemplatePortal to this PortlHost as an embedded View.
144+
* Attach the given TemplatePortal to this PortalHost as an embedded View.
145145
* @param portal Portal to be attached.
146146
* @returns Reference to the created embedded view.
147147
*/
@@ -156,6 +156,29 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
156156

157157
return viewRef;
158158
}
159+
160+
/**
161+
* Attaches the given DomPortal to this PortalHost by moving all of the portal content into it.
162+
* @param portal Portal to be attached.
163+
*/
164+
attachDomPortal(portal: DomPortal) {
165+
portal.setAttachedHost(this);
166+
167+
const origin = portal.element;
168+
const transferredNodes: Node[] = [];
169+
const nativeElement: Node = this._viewContainerRef.element.nativeElement;
170+
const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ?
171+
nativeElement : nativeElement.parentNode!;
172+
173+
while (origin.firstChild) {
174+
transferredNodes.push(rootNode.appendChild(origin.firstChild));
175+
}
176+
177+
super.setDisposeFn(() => {
178+
transferredNodes.forEach(node => portal.element.appendChild(node));
179+
transferredNodes.length = 0;
180+
});
181+
}
159182
}
160183

161184

src/cdk/portal/portal.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
ApplicationRef,
1313
TemplateRef,
1414
ComponentRef,
15+
ElementRef,
1516
} from '@angular/core';
1617
import {CommonModule} from '@angular/common';
1718
import {CdkPortal, CdkPortalOutlet, PortalModule} from './portal-directives';
18-
import {Portal, ComponentPortal, TemplatePortal} from './portal';
19+
import {Portal, ComponentPortal, TemplatePortal, DomPortal} from './portal';
1920
import {DomPortalOutlet} from './dom-portal-outlet';
2021

2122

@@ -76,6 +77,35 @@ describe('Portals', () => {
7677
.toHaveBeenCalledWith(testAppComponent.portalOutlet.attachedRef);
7778
});
7879

80+
it('should load a DOM portal', () => {
81+
const testAppComponent = fixture.componentInstance;
82+
const hostContainer = fixture.nativeElement.querySelector('.portal-container');
83+
const innerContent = fixture.nativeElement.querySelector('.dom-portal-inner-content');
84+
const domPortal = new DomPortal(testAppComponent.domPortalContent);
85+
86+
expect(innerContent).toBeTruthy('Expected portal content to be rendered.');
87+
expect(domPortal.element.contains(innerContent))
88+
.toBe(true, 'Expected content to be inside portal on init.');
89+
expect(hostContainer.contains(innerContent))
90+
.toBe(false, 'Expected content to be outside of portal outlet.');
91+
92+
testAppComponent.selectedPortal = domPortal;
93+
fixture.detectChanges();
94+
95+
expect(domPortal.element.contains(innerContent))
96+
.toBe(false, 'Expected content to be out of the portal on attach.');
97+
expect(hostContainer.contains(innerContent))
98+
.toBe(true, 'Expected content to be inside the outlet on attach.');
99+
100+
testAppComponent.selectedPortal = undefined;
101+
fixture.detectChanges();
102+
103+
expect(domPortal.element.contains(innerContent))
104+
.toBe(true, 'Expected content to be at initial position on detach.');
105+
expect(hostContainer.contains(innerContent))
106+
.toBe(false, 'Expected content to be removed from outlet on detach.');
107+
});
108+
79109
it('should project template context bindings in the portal', () => {
80110
let testAppComponent = fixture.componentInstance;
81111
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
@@ -502,6 +532,16 @@ describe('Portals', () => {
502532
expect(spy).toHaveBeenCalled();
503533
});
504534

535+
it('should attach and detach a DOM portal', () => {
536+
const fixture = TestBed.createComponent(PortalTestApp);
537+
fixture.detectChanges();
538+
const portal = new DomPortal(fixture.componentInstance.domPortalContent);
539+
540+
portal.attach(host);
541+
542+
expect(someDomElement.textContent).toContain('Hello there');
543+
});
544+
505545
});
506546
});
507547

@@ -559,12 +599,17 @@ class ArbitraryViewContainerRefComponent {
559599
</ng-template>
560600
561601
<ng-template #templateRef let-data> {{fruit}} - {{ data?.status }}!</ng-template>
602+
603+
<div #domPortalContent>
604+
<p class="dom-portal-inner-content">Hello there</p>
605+
</div>
562606
`,
563607
})
564608
class PortalTestApp {
565609
@ViewChildren(CdkPortal) portals: QueryList<CdkPortal>;
566610
@ViewChild(CdkPortalOutlet, {static: true}) portalOutlet: CdkPortalOutlet;
567-
@ViewChild('templateRef', { read: TemplateRef , static: true}) templateRef: TemplateRef<any>;
611+
@ViewChild('templateRef', {read: TemplateRef, static: true}) templateRef: TemplateRef<any>;
612+
@ViewChild('domPortalContent', {static: true}) domPortalContent: ElementRef<HTMLElement>;
568613

569614
selectedPortal: Portal<any>|undefined;
570615
fruit: string = 'Banana';

src/cdk/portal/portal.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,22 @@ export class TemplatePortal<C = any> extends Portal<EmbeddedViewRef<C>> {
153153
}
154154
}
155155

156+
/**
157+
* A `DomPortal` is a portal whose content will be taken from its current position
158+
* in the DOM and moved into a portal outlet, when it is attached. On detach, the content
159+
* will be restored to its original position.
160+
*/
161+
export class DomPortal extends Portal<HTMLElement> {
162+
constructor(private _element: HTMLElement | ElementRef<HTMLElement>) {
163+
super();
164+
}
165+
166+
/** DOM node hosting the portal's content. */
167+
get element(): HTMLElement {
168+
return this._element instanceof ElementRef ? this._element.nativeElement : this._element;
169+
}
170+
}
171+
156172

157173
/** A `PortalOutlet` is an space that can contain a single `Portal`. */
158174
export interface PortalOutlet {
@@ -213,6 +229,10 @@ export abstract class BasePortalOutlet implements PortalOutlet {
213229
} else if (portal instanceof TemplatePortal) {
214230
this._attachedPortal = portal;
215231
return this.attachTemplatePortal(portal);
232+
// @breaking-change 8.0.0 remove null check for `this.attachDomPortal`.
233+
} else if (this.attachDomPortal && portal instanceof DomPortal) {
234+
this._attachedPortal = portal;
235+
return this.attachDomPortal(portal);
216236
}
217237

218238
throwUnknownPortalTypeError();
@@ -222,6 +242,9 @@ export abstract class BasePortalOutlet implements PortalOutlet {
222242

223243
abstract attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;
224244

245+
// @breaking-change 8.0.0 `attachDomPortal` to become a required method.
246+
abstract attachDomPortal?(portal: DomPortal): any;
247+
225248
/** Detaches a previously attached portal. */
226249
detach(): void {
227250
if (this._attachedPortal) {

src/dev-app/portal/portal-demo.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ <h2> The portal outlet is here: </h2>
1515
Science joke
1616
</button>
1717

18+
<button type="button" (click)="selectedPortal = dadJoke">
19+
Dad joke
20+
</button>
21+
1822
<!-- Template vars on <ng-template> elements can't be accessed _in_ the template because Angular
1923
doesn't support grabbing the instance / TemplateRef this way because the variable may be
2024
referring to something *in* the template (such as #item in ngFor). As such, the component
@@ -29,3 +33,8 @@ <h2> The portal outlet is here: </h2>
2933
<p> - Did you hear about this year's Fibonacci Conference? </p>
3034
<p> - It's going to be as big as the last two put together. </p>
3135
</div>
36+
37+
<div class="demo-dad-joke" #domPortalSource>
38+
<p> - Scientists got bored of watching the moon for 24 hours </p>
39+
<p> - So they called it a day. </p>
40+
</div>

src/dev-app/portal/portal-demo.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@
55
width: 500px;
66
height: 100px;
77
}
8+
9+
.demo-dad-joke {
10+
opacity: 0.25;
11+
}

src/dev-app/portal/portal-demo.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ComponentPortal, Portal, CdkPortal} from '@angular/cdk/portal';
10-
import {Component, QueryList, ViewChildren} from '@angular/core';
9+
import {ComponentPortal, Portal, CdkPortal, DomPortal} from '@angular/cdk/portal';
10+
import {Component, QueryList, ViewChildren, ElementRef, ViewChild} from '@angular/core';
1111

1212

1313
@Component({
@@ -18,6 +18,7 @@ import {Component, QueryList, ViewChildren} from '@angular/core';
1818
})
1919
export class PortalDemo {
2020
@ViewChildren(CdkPortal) templatePortals: QueryList<Portal<any>>;
21+
@ViewChild('domPortalSource', {static: false}) domPortalSource: ElementRef<HTMLElement>;
2122

2223
selectedPortal: Portal<any>;
2324

@@ -32,6 +33,10 @@ export class PortalDemo {
3233
get scienceJoke() {
3334
return new ComponentPortal(ScienceJoke);
3435
}
36+
37+
get dadJoke() {
38+
return new DomPortal(this.domPortalSource);
39+
}
3540
}
3641

3742

src/material/bottom-sheet/bottom-sheet-container.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
ComponentPortal,
2727
TemplatePortal,
2828
CdkPortalOutlet,
29+
DomPortal,
2930
} from '@angular/cdk/portal';
3031
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
3132
import {MatBottomSheetConfig} from './bottom-sheet-config';
@@ -122,6 +123,14 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr
122123
return this._portalOutlet.attachTemplatePortal(portal);
123124
}
124125

126+
/** Attaches a DOM portal to the bottom sheet container. */
127+
attachDomPortal(portal: DomPortal) {
128+
this._validatePortalAttached();
129+
this._setPanelClass();
130+
this._savePreviouslyFocusedElement();
131+
return this._portalOutlet.attachDomPortal(portal);
132+
}
133+
125134
/** Begin animation of bottom sheet entrance into view. */
126135
enter(): void {
127136
if (!this._destroyed) {

0 commit comments

Comments
 (0)