Skip to content

Commit 461d539

Browse files
kseamonjosephperrott
authored andcommitted
feat(popover-edit): accessible row hover content (#15917)
1 parent ab76eaa commit 461d539

File tree

14 files changed

+669
-187
lines changed

14 files changed

+669
-187
lines changed

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

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

9-
import {Injectable} from '@angular/core';
10-
import {Observable, Subject, timer} from 'rxjs';
11-
import {audit, distinctUntilChanged, filter, map, share} from 'rxjs/operators';
9+
import {Injectable, NgZone} from '@angular/core';
10+
import {combineLatest, MonoTypeOperatorFunction, Observable, pipe, Subject} from 'rxjs';
11+
import {
12+
audit,
13+
auditTime,
14+
debounceTime,
15+
distinctUntilChanged,
16+
filter,
17+
map,
18+
share,
19+
skip,
20+
startWith,
21+
} from 'rxjs/operators';
1222

1323
import {CELL_SELECTOR, ROW_SELECTOR} from './constants';
1424
import {closest} from './polyfill';
1525
import {EditRef} from './edit-ref';
1626

17-
/** The delay between mouse out events and hiding hover content. */
18-
const DEFAULT_MOUSE_OUT_DELAY_MS = 30;
27+
/** The delay applied to mouse events before hiding or showing hover content. */
28+
const MOUSE_EVENT_DELAY_MS = 40;
29+
30+
/** The delay for reacting to focus/blur changes. */
31+
const FOCUS_DELAY = 0;
32+
33+
/**
34+
* The possible states for hover content:
35+
* OFF - Not rendered.
36+
* FOCUSABLE - Rendered in the dom and stylyed for its contents to be focusable but invisible.
37+
* ON - Rendered and fully visible.
38+
*/
39+
export const enum HoverContentState {
40+
OFF = 0,
41+
FOCUSABLE,
42+
ON,
43+
}
1944

2045
/**
2146
* Service for sharing delegated events and state for triggering table edits.
@@ -28,7 +53,13 @@ export class EditEventDispatcher {
2853
/** A subject that indicates which table row is currently hovered. */
2954
readonly hovering = new Subject<Element|null>();
3055

31-
/** A subject that emits mouse move events for table rows. */
56+
/** A subject that indicates which table row currently contains focus. */
57+
readonly focused = new Subject<Element|null>();
58+
59+
/** A subject that indicates all elements in the table matching ROW_SELECTOR. */
60+
readonly allRows = new Subject<NodeList>();
61+
62+
/** A subject that emits mouse move events from the table indicating the targeted row. */
3263
readonly mouseMove = new Subject<Element|null>();
3364

3465
/** The EditRef for the currently active edit lens (if any). */
@@ -37,13 +68,76 @@ export class EditEventDispatcher {
3768
}
3869
private _editRef: EditRef<any>|null = null;
3970

71+
// Optimization: Precompute common pipeable operators used per row/cell.
72+
private readonly _distinctUntilChanged =
73+
distinctUntilChanged<Element|HoverContentState|boolean|null>();
74+
private readonly _startWithNull = startWith<Element|null>(null);
75+
private readonly _distinctShare = pipe(
76+
this._distinctUntilChanged as MonoTypeOperatorFunction<HoverContentState>,
77+
share(),
78+
);
79+
private readonly _startWithNullDistinct = pipe(
80+
this._startWithNull,
81+
this._distinctUntilChanged as MonoTypeOperatorFunction<Element|null>,
82+
);
83+
84+
/** An observable that emits the row containing focus or an active edit. */
85+
readonly editingOrFocused = combineLatest(
86+
this.editing.pipe(
87+
map(cell => closest(cell, ROW_SELECTOR)),
88+
this._startWithNull,
89+
),
90+
this.focused.pipe(this._startWithNull),
91+
).pipe(
92+
map(([editingRow, focusedRow]) => focusedRow || editingRow),
93+
this._distinctUntilChanged as MonoTypeOperatorFunction<Element|null>,
94+
auditTime(FOCUS_DELAY), // Use audit to skip over blur events to the next focused element.
95+
this._distinctUntilChanged as MonoTypeOperatorFunction<Element|null>,
96+
share(),
97+
);
98+
99+
/** Tracks rows that contain hover content with a reference count. */
100+
private _rowsWithHoverContent = new WeakMap<Element, number>();
101+
40102
/** The table cell that has an active edit lens (or null). */
41103
private _currentlyEditing: Element|null = null;
42104

43-
private readonly _hoveringDistinct = this.hovering.pipe(distinctUntilChanged(), share());
44-
private readonly _editingDistinct = this.editing.pipe(distinctUntilChanged(), share());
105+
/** The combined set of row hover content states organized by row. */
106+
private readonly _hoveredContentStateDistinct = combineLatest(
107+
this._getFirstRowWithHoverContent(),
108+
this._getLastRowWithHoverContent(),
109+
this.editingOrFocused,
110+
this.hovering.pipe(
111+
distinctUntilChanged(),
112+
audit(row => this.mouseMove.pipe(
113+
filter(mouseMoveRow => row === mouseMoveRow),
114+
this._startWithNull,
115+
debounceTime(MOUSE_EVENT_DELAY_MS)),
116+
),
117+
this._startWithNullDistinct,
118+
),
119+
).pipe(
120+
skip(1), // Skip the initial emission of [null, null, null, null].
121+
map(computeHoverContentState),
122+
distinctUntilChanged(areMapEntriesEqual),
123+
// Optimization: Enter the zone before share() so that we trigger a single
124+
// ApplicationRef.tick for all row updates.
125+
this._enterZone(),
126+
share(),
127+
);
45128

46-
constructor() {
129+
private readonly _editingDistinct = this.editing.pipe(
130+
distinctUntilChanged(),
131+
this._enterZone(),
132+
share(),
133+
);
134+
135+
// Optimization: Share row events observable with subsequent callers.
136+
// At startup, calls will be sequential by row.
137+
private _lastSeenRow: Element|null = null;
138+
private _lastSeenRowHoverOrFocus: Observable<HoverContentState>|null = null;
139+
140+
constructor(private readonly _ngZone: NgZone) {
47141
this._editingDistinct.subscribe(cell => {
48142
this._currentlyEditing = cell;
49143
});
@@ -58,7 +152,7 @@ export class EditEventDispatcher {
58152

59153
return this._editingDistinct.pipe(
60154
map(editCell => editCell === (cell || (cell = closest(element, CELL_SELECTOR)))),
61-
distinctUntilChanged(),
155+
this._distinctUntilChanged as MonoTypeOperatorFunction<boolean>,
62156
);
63157
}
64158

@@ -88,20 +182,123 @@ export class EditEventDispatcher {
88182
this._editRef = null;
89183
}
90184

185+
/** Adds the specified table row to be tracked for first/last row comparisons. */
186+
registerRowWithHoverContent(row: Element): void {
187+
this._rowsWithHoverContent.set(row, (this._rowsWithHoverContent.get(row) || 0) + 1);
188+
}
189+
190+
/**
191+
* Reference decrements and ultimately removes the specified table row from first/last row
192+
* comparisons.
193+
*/
194+
deregisterRowWithHoverContent(row: Element): void {
195+
const refCount = this._rowsWithHoverContent.get(row) || 0;
196+
197+
if (refCount <= 1) {
198+
this._rowsWithHoverContent.delete(row);
199+
} else {
200+
this._rowsWithHoverContent.set(row, refCount - 1);
201+
}
202+
}
203+
91204
/**
92205
* Gets an Observable that emits true when the specified element's row
93-
* is being hovered over and false when not. Hovering is defined as when
94-
* the mouse has momentarily stopped moving over the cell.
206+
* contains the focused element or is being hovered over and false when not.
207+
* Hovering is defined as when the mouse has momentarily stopped moving over the cell.
208+
*/
209+
hoverOrFocusOnRow(row: Element): Observable<HoverContentState> {
210+
if (row !== this._lastSeenRow) {
211+
this._lastSeenRow = row;
212+
this._lastSeenRowHoverOrFocus = this._hoveredContentStateDistinct.pipe(
213+
map(state => state.get(row) || HoverContentState.OFF),
214+
this._distinctShare,
215+
);
216+
}
217+
218+
return this._lastSeenRowHoverOrFocus!;
219+
}
220+
221+
/**
222+
* RxJS operator that enters the Angular zone, used to reduce boilerplate in
223+
* re-entering the zone for stream pipelines.
95224
*/
96-
hoveringOnRow(element: Element|EventTarget): Observable<boolean> {
97-
let row: Element|null = null;
98-
99-
return this._hoveringDistinct.pipe(
100-
map(hoveredRow => hoveredRow === (row || (row = closest(element, ROW_SELECTOR)))),
101-
audit(
102-
(hovering) => hovering ? this.mouseMove.pipe(filter(hoveredRow => hoveredRow === row)) :
103-
timer(DEFAULT_MOUSE_OUT_DELAY_MS)),
104-
distinctUntilChanged(),
225+
private _enterZone<T>(): MonoTypeOperatorFunction<T> {
226+
return (source: Observable<T>) =>
227+
new Observable<T>((observer) => source.subscribe({
228+
next: (value) => this._ngZone.run(() => observer.next(value)),
229+
error: (err) => observer.error(err),
230+
complete: () => observer.complete()
231+
}));
232+
}
233+
234+
private _getFirstRowWithHoverContent(): Observable<Element|null> {
235+
return this._mapAllRowsToSingleRow(rows => {
236+
for (let i = 0, row; row = rows[i]; i++) {
237+
if (this._rowsWithHoverContent.has(row as Element)) {
238+
return row as Element;
239+
}
240+
}
241+
return null;
242+
});
243+
}
244+
245+
private _getLastRowWithHoverContent(): Observable<Element|null> {
246+
return this._mapAllRowsToSingleRow(rows => {
247+
for (let i = rows.length - 1, row; row = rows[i]; i--) {
248+
if (this._rowsWithHoverContent.has(row as Element)) {
249+
return row as Element;
250+
}
251+
}
252+
return null;
253+
});
254+
}
255+
256+
private _mapAllRowsToSingleRow(mapper: (rows: NodeList) => Element|null):
257+
Observable<Element|null> {
258+
return this.allRows.pipe(
259+
map(mapper),
260+
this._startWithNullDistinct,
105261
);
106262
}
107263
}
264+
265+
function computeHoverContentState([firstRow, lastRow, activeRow, hoverRow]: Array<Element|null>):
266+
Map<Element, HoverContentState> {
267+
const hoverContentState = new Map<Element, HoverContentState>();
268+
269+
// Add focusable rows.
270+
for (const focussableRow of [
271+
firstRow,
272+
lastRow,
273+
activeRow && activeRow.previousElementSibling,
274+
activeRow && activeRow.nextElementSibling,
275+
]) {
276+
if (focussableRow) {
277+
hoverContentState.set(focussableRow as Element, HoverContentState.FOCUSABLE);
278+
}
279+
}
280+
281+
// Add/overwrite with fully visible rows.
282+
for (const onRow of [activeRow, hoverRow]) {
283+
if (onRow) {
284+
hoverContentState.set(onRow, HoverContentState.ON);
285+
}
286+
}
287+
288+
return hoverContentState;
289+
}
290+
291+
function areMapEntriesEqual<K, V>(a: Map<K, V>, b: Map<K, V>): boolean {
292+
if (a.size !== b.size) {
293+
return false;
294+
}
295+
296+
// TODO: use Map.prototype.entries once we're off IE11.
297+
for (const aKey of Array.from(a.keys())) {
298+
if (b.get(aKey) !== a.get(aKey)) {
299+
return false;
300+
}
301+
}
302+
303+
return true;
304+
}

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,28 @@ export function matches(element: Element, selector: string): boolean {
1313
(element as any)['msMatchesSelector'](selector);
1414
}
1515

16-
/** IE 11 compatible closest implementation. */
17-
export function closest(element: EventTarget|Element|null|undefined, selector: string): Element|
18-
null {
16+
/** IE 11 compatible closest implementation that is able to start from non-Element Nodes. */
17+
export function closest(element: EventTarget|Element|null|undefined, selector: string):
18+
Element|null {
1919
if (!(element instanceof Node)) { return null; }
2020

21+
let curr: Node|null = element;
22+
while (curr != null && !(curr instanceof Element)) {
23+
curr = curr.parentNode;
24+
}
25+
26+
return curr && (hasNativeClosest ?
27+
curr.closest(selector) : polyfillClosest(curr, selector)) as Element|null;
28+
}
29+
30+
/** Polyfill for browsers without Element.closest. */
31+
function polyfillClosest(element: Element, selector: string): Element|null {
2132
let curr: Node|null = element;
2233
while (curr != null && !(curr instanceof Element && matches(curr, selector))) {
2334
curr = curr.parentNode;
2435
}
2536

2637
return (curr || null) as Element|null;
2738
}
39+
40+
const hasNativeClosest = !!Element.prototype.closest;

0 commit comments

Comments
 (0)