Skip to content

Commit 4e20f38

Browse files
committed
feat(popover-edit): allow tabbing from popup to next/previous cell
1 parent f6edba3 commit 4e20f38

File tree

9 files changed

+303
-7
lines changed

9 files changed

+303
-7
lines changed

src/cdk-experimental/popover-edit/edit-event-dispatcher.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {audit, distinctUntilChanged, filter, map, share} from 'rxjs/operators';
1212

1313
import {CELL_SELECTOR, ROW_SELECTOR} from './constants';
1414
import {closest} from './polyfill';
15+
import {EditRef} from './edit-ref';
1516

1617
/** The delay between mouse out events and hiding hover content. */
1718
const DEFAULT_MOUSE_OUT_DELAY_MS = 30;
@@ -30,6 +31,9 @@ export class EditEventDispatcher {
3031
/** A subject that emits mouse move events for table rows. */
3132
readonly mouseMove = new Subject<Element|null>();
3233

34+
/** The EditRef for the currently active edit lens (if any). */
35+
editRef: EditRef<any> | null = null;
36+
3337
/** The table cell that has an active edit lens (or null). */
3438
private _currentlyEditing: Element|null = null;
3539

src/cdk-experimental/popover-edit/edit-ref.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export class EditRef<FormValue> implements OnDestroy {
2323
private readonly _finalValueSubject = new Subject<FormValue>();
2424
readonly finalValue: Observable<FormValue> = this._finalValueSubject.asObservable();
2525

26+
/** Emits when the user tabs out of this edit lens before closing. */
27+
private readonly _blurredSubject = new Subject<void>();
28+
readonly blurred: Observable<void> = this._blurredSubject.asObservable();
29+
2630
/** The value to set the form back to on revert. */
2731
private _revertFormValue: FormValue;
2832

@@ -37,7 +41,9 @@ export class EditRef<FormValue> implements OnDestroy {
3741

3842
constructor(
3943
@Self() private readonly _form: ControlContainer,
40-
private readonly _editEventDispatcher: EditEventDispatcher) {}
44+
private readonly _editEventDispatcher: EditEventDispatcher) {
45+
this._editEventDispatcher.editRef = this;
46+
}
4147

4248
/**
4349
* Called by the host directive's OnInit hook. Reads the initial state of the
@@ -57,6 +63,7 @@ export class EditRef<FormValue> implements OnDestroy {
5763
}
5864

5965
ngOnDestroy(): void {
66+
this._editEventDispatcher.editRef = null;
6067
this._finalValueSubject.next(this._form.value);
6168
this._finalValueSubject.complete();
6269
}
@@ -76,6 +83,11 @@ export class EditRef<FormValue> implements OnDestroy {
7683
this._editEventDispatcher.editing.next(null);
7784
}
7885

86+
/** Notifies the active edit that the user has moved focus out of the lens. */
87+
blur(): void {
88+
this._blurredSubject.next();
89+
}
90+
7991
/**
8092
* Closes the edit if the enter key is not down.
8193
* Otherwise, sets _closePending to true so that the edit will close on the
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Inject, Injectable, NgZone} from '@angular/core';
10+
import {DOCUMENT} from '@angular/common';
11+
import {FocusTrap, InteractivityChecker} from '@angular/cdk/a11y';
12+
import {Observable, Subject} from 'rxjs';
13+
14+
/** Value indicating whether focus left the target area before or after the enclosed elements. */
15+
export const enum FocusEscapeNotifierDirection {
16+
START,
17+
END,
18+
}
19+
20+
/**
21+
* Like FocusTrap, but rather than trapping focus within a dom region, notifies subscribers when
22+
* focus leaves the region.
23+
*/
24+
export class FocusEscapeNotifier extends FocusTrap {
25+
private _escapeSubject = new Subject<FocusEscapeNotifierDirection>();
26+
27+
constructor(
28+
element: HTMLElement,
29+
checker: InteractivityChecker,
30+
ngZone: NgZone,
31+
document: Document) {
32+
super(element, checker, ngZone, document, true /* deferAnchors */);
33+
34+
this.startAnchorListener = () => {
35+
this._escapeSubject.next(FocusEscapeNotifierDirection.START);
36+
return true;
37+
};
38+
this.endAnchorListener = () => {
39+
this._escapeSubject.next(FocusEscapeNotifierDirection.END);
40+
return true;
41+
};
42+
43+
this.attachAnchors();
44+
}
45+
46+
escapes(): Observable<FocusEscapeNotifierDirection> {
47+
return this._escapeSubject.asObservable();
48+
}
49+
}
50+
51+
/** Factory that allows easy instantiation of focus escape notifiers. */
52+
@Injectable({providedIn: 'root'})
53+
export class FocusEscapeNotifierFactory {
54+
private _document: Document;
55+
56+
constructor(
57+
private _checker: InteractivityChecker,
58+
private _ngZone: NgZone,
59+
@Inject(DOCUMENT) _document: any) {
60+
61+
this._document = _document;
62+
}
63+
64+
/**
65+
* Creates a focus escape notifier region around the given element.
66+
* @param element The element around which focus will be monitored.
67+
* @returns The created focus escape notifier instance.
68+
*/
69+
create(element: HTMLElement): FocusEscapeNotifier {
70+
return new FocusEscapeNotifier(element, this._checker, this._ngZone, this._document);
71+
}
72+
}

src/cdk-experimental/popover-edit/lens-directives.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class CdkEditControl<FormValue> implements OnDestroy, OnInit {
6161
ngOnInit(): void {
6262
this.editRef.init(this.preservedFormValue);
6363
this.editRef.finalValue.subscribe(this.preservedFormValueChange);
64+
this.editRef.blurred.subscribe(() => this._handleBlur());
6465
}
6566

6667
ngOnDestroy(): void {
@@ -97,7 +98,7 @@ export class CdkEditControl<FormValue> implements OnDestroy, OnInit {
9798
switch (this.clickOutBehavior) {
9899
case 'submit':
99100
// Manually cause the form to submit before closing.
100-
this.elementRef.nativeElement!.dispatchEvent(new Event('submit'));
101+
this._triggerFormSubmit();
101102
// Fall through
102103
case 'close':
103104
this.editRef.close();
@@ -106,6 +107,18 @@ export class CdkEditControl<FormValue> implements OnDestroy, OnInit {
106107
break;
107108
}
108109
}
110+
111+
/** Triggers submit on tab out if clickOutBehavior is 'submit'. */
112+
private _handleBlur(): void {
113+
if (this.clickOutBehavior === 'submit') {
114+
// Manually cause the form to submit before closing.
115+
this._triggerFormSubmit();
116+
}
117+
}
118+
119+
private _triggerFormSubmit() {
120+
this.elementRef.nativeElement!.dispatchEvent(new Event('submit'));
121+
}
109122
}
110123

111124
/** Reverts the form to its initial or previously submitted state on click. */

src/cdk-experimental/popover-edit/popover-edit-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {NgModule} from '@angular/core';
1010
import {OverlayModule} from '@angular/cdk/overlay';
1111
import {
1212
CdkPopoverEdit,
13+
CdkPopoverEditTabOut,
1314
CdkRowHoverContent,
1415
CdkEditable,
1516
CdkEditOpen,
@@ -26,6 +27,7 @@ import {
2627

2728
const EXPORTED_DECLARATIONS = [
2829
CdkPopoverEdit,
30+
CdkPopoverEditTabOut,
2931
CdkRowHoverContent,
3032
CdkEditControl,
3133
CdkEditRevert,

src/cdk-experimental/popover-edit/table-directives.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import {debounceTime, filter, map, mapTo, startWith, takeUntil} from 'rxjs/opera
2626
import {CELL_SELECTOR, EDIT_PANE_CLASS, EDIT_PANE_SELECTOR, ROW_SELECTOR} from './constants';
2727
import {EditEventDispatcher} from './edit-event-dispatcher';
2828
import {FocusDispatcher} from './focus-dispatcher';
29+
import {
30+
FocusEscapeNotifier,
31+
FocusEscapeNotifierDirection,
32+
FocusEscapeNotifierFactory
33+
} from './focus-escape-notifier';
2934
import {closest} from './polyfill';
3035
import {PopoverEditPositionStrategyFactory} from './popover-edit-position-strategy-factory';
3136

@@ -112,7 +117,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy {
112117
* Makes the cell focusable.
113118
*/
114119
@Directive({
115-
selector: '[cdkPopoverEdit]',
120+
selector: '[cdkPopoverEdit]:not([cdkPopoverEditTabOut])',
116121
host: {
117122
'tabIndex': '0',
118123
'class': 'cdk-popover-edit-cell',
@@ -179,6 +184,14 @@ export class CdkPopoverEdit<C> implements AfterViewInit, OnDestroy {
179184
}
180185
}
181186

187+
protected initFocusTrap(): void {
188+
this.focusTrap = this.focusTrapFactory.create(this.overlayRef!.overlayElement);
189+
}
190+
191+
protected closeEditOverlay(): void {
192+
this.editEventDispatcher.doneEditingCell(this.elementRef.nativeElement!);
193+
}
194+
182195
private _startListeningToEditEvents(): void {
183196
this.editEventDispatcher.editingCell(this.elementRef.nativeElement!)
184197
.pipe(takeUntil(this.destroyed))
@@ -207,12 +220,10 @@ export class CdkPopoverEdit<C> implements AfterViewInit, OnDestroy {
207220
scrollStrategy: this.overlay.scrollStrategies.reposition(),
208221
});
209222

210-
this.focusTrap = this.focusTrapFactory.create(this.overlayRef.overlayElement);
223+
this.initFocusTrap();
211224
this.overlayRef.overlayElement.setAttribute('aria-role', 'dialog');
212225

213-
this.overlayRef.detachments().subscribe(() => {
214-
this.editEventDispatcher.doneEditingCell(this.elementRef.nativeElement!);
215-
});
226+
this.overlayRef.detachments().subscribe(() => this.closeEditOverlay());
216227
}
217228

218229
private _showEditOverlay(): void {
@@ -266,6 +277,60 @@ export class CdkPopoverEdit<C> implements AfterViewInit, OnDestroy {
266277
}
267278
}
268279

280+
/**
281+
* Attaches an ng-template to a cell and shows it when instructed to by the
282+
* EditEventDispatcher service.
283+
* Makes the cell focusable.
284+
*/
285+
@Directive({
286+
selector: '[cdkPopoverEdit] [cdkPopoverEditTabOut]',
287+
host: {
288+
'tabIndex': '0',
289+
'class': 'cdk-popover-edit-cell',
290+
'[attr.aria-haspopup]': 'true',
291+
}
292+
})
293+
export class CdkPopoverEditTabOut<C> extends CdkPopoverEdit<C> {
294+
protected focusTrap?: FocusEscapeNotifier;
295+
296+
constructor(
297+
editEventDispatcher: EditEventDispatcher,
298+
elementRef: ElementRef,
299+
focusTrapFactory: FocusTrapFactory,
300+
ngZone: NgZone,
301+
overlay: Overlay,
302+
positionFactory: PopoverEditPositionStrategyFactory,
303+
scrollDispatcher: ScrollDispatcher,
304+
viewContainerRef: ViewContainerRef,
305+
viewportRuler: ViewportRuler,
306+
protected readonly focusEscapeNotifierFactory: FocusEscapeNotifierFactory,
307+
protected readonly focusDispatcher: FocusDispatcher) {
308+
super(
309+
editEventDispatcher,
310+
elementRef,
311+
focusTrapFactory,
312+
ngZone,
313+
overlay,
314+
positionFactory,
315+
scrollDispatcher,
316+
viewContainerRef,
317+
viewportRuler);
318+
}
319+
320+
protected initFocusTrap(): void {
321+
this.focusTrap = this.focusEscapeNotifierFactory.create(this.overlayRef!.overlayElement);
322+
323+
this.focusTrap.escapes().pipe(takeUntil(this.destroyed)).subscribe(direction => {
324+
this.editEventDispatcher.editRef!.blur();
325+
this.focusDispatcher.moveFocusHorizontally(
326+
closest(this.elementRef.nativeElement!, CELL_SELECTOR) as HTMLElement,
327+
direction === FocusEscapeNotifierDirection.START ? -1 : 1);
328+
329+
this.closeEditOverlay();
330+
});
331+
}
332+
}
333+
269334
/**
270335
* A structural directive that shows its contents when the table row containing
271336
* it is hovered.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.example-table {
2+
width: 100%;
3+
}
4+
5+
.example-table th {
6+
text-align: left;
7+
}
8+
9+
.example-table td,
10+
.example-table th {
11+
min-width: 300px;
12+
width: 25%;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<table editable class="example-table">
2+
<!--
3+
This edit lens is specified outside of the cell and must explicitly declare
4+
its context. It could be reused in multiple cells.
5+
-->
6+
<ng-template #weightEdit let-element>
7+
<div style="background-color: white; width: 100%">
8+
<form #f="ngForm"
9+
cdkEditControl
10+
cdkEditControlClickOutBehavior="submit"
11+
(ngSubmit)="onSubmitWeight(element, f)"
12+
[cdkEditControlPreservedFormValue]="preservedWeightValues.get(element)"
13+
(cdkEditControlPreservedFormValueChange)="preservedWeightValues.set(element, $event)">
14+
<input type="number" [ngModel]="element.weight" name="weight" required>
15+
</form>
16+
</div>
17+
</ng-template>
18+
19+
<tr>
20+
<th> No. </th>
21+
<th> Name </th>
22+
<th> Weight </th>
23+
<th> Symbol </th>
24+
</tr>
25+
26+
<tr *ngFor="let element of elements">
27+
<td> {{element.position}} </td>
28+
29+
<td [cdkPopoverEdit]="nameEdit" cdkPopoverEditTabOut cdkEditOpen>
30+
{{element.name}}
31+
32+
<!-- This edit is defined in the cell and can implicitly access element -->
33+
<ng-template #nameEdit>
34+
<div style="background-color: white; width: 100%">
35+
<form #f="ngForm"
36+
cdkEditControl
37+
cdkEditControlClickOutBehavior="submit"
38+
(ngSubmit)="onSubmitName(element, f)"
39+
[cdkEditControlPreservedFormValue]="preservedNameValues.get(element)"
40+
(cdkEditControlPreservedFormValueChange)="preservedNameValues.set(element, $event)">
41+
<input [ngModel]="element.name" name="name" required>
42+
<br>
43+
<button type="submit">Confirm</button>
44+
</form>
45+
</div>
46+
</ng-template>
47+
</td>
48+
49+
<td [cdkPopoverEdit]="weightEdit" [cdkPopoverEditContext]="element"
50+
cdkPopoverEditTabOut cdkEditOpen>
51+
{{element.weight}}
52+
</td>
53+
54+
<td> {{element.symbol}} </td>
55+
</tr>
56+
</table>

0 commit comments

Comments
 (0)