Skip to content
This repository was archived by the owner on Jan 13, 2025. It is now read-only.

Commit 9775856

Browse files
Shi Shucopybara-github
authored andcommitted
feat(tooltip): Added persistent variant for rich tooltips that shows/hides based on mouse clicks on the anchor element. Clicks on elements other than the anchor will also hide the persistent variant.
BREAKING CHANGE: Added adapter method: - anchorContainsElement(element: HTMLElement): boolean; Rich tooltips are currently in development and is not yet ready for use. PiperOrigin-RevId: 345221617
1 parent c4ab987 commit 9775856

File tree

6 files changed

+452
-56
lines changed

6 files changed

+452
-56
lines changed

packages/mdc-tooltip/adapter.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ export interface MDCTooltipAdapter {
9898
*/
9999
isRTL(): boolean;
100100

101+
/**
102+
* Checks if element is contained within the anchor element.
103+
*/
104+
anchorContainsElement(element: HTMLElement): boolean;
105+
101106
/**
102107
* Registers an event listener to the root element.
103108
*/

packages/mdc-tooltip/component.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@ export class MDCTooltip extends MDCComponent<MDCTooltipFoundation> {
3434
}
3535

3636
private anchorElem!: HTMLElement|null; // assigned in initialSyncWithDOM
37+
private isTooltipRich!: boolean; // assigned in initialSyncWithDOM
38+
private isTooltipPersistent!: boolean; // assigned in initialSyncWithDOM
3739

3840
private handleMouseEnter!: SpecificEventListener<'mouseenter'>;
3941
private handleFocus!: SpecificEventListener<'focus'>;
4042
private handleMouseLeave!: SpecificEventListener<'mouseleave'>;
4143
private handleBlur!: SpecificEventListener<'blur'>;
4244
private handleTransitionEnd!: SpecificEventListener<'transitionend'>;
45+
private handleClick!: SpecificEventListener<'click'>;
4346

4447
initialSyncWithDOM() {
4548
const tooltipId = this.root.getAttribute('id');
@@ -55,6 +58,9 @@ export class MDCTooltip extends MDCComponent<MDCTooltipFoundation> {
5558
'MDCTooltip: Tooltip component requires an anchor element annotated with [aria-describedby] or [data-tooltip-id] anchor element.');
5659
}
5760

61+
this.isTooltipRich = this.foundation.getIsRich();
62+
this.isTooltipPersistent = this.foundation.getIsPersistent();
63+
5864
this.handleMouseEnter = () => {
5965
this.foundation.handleAnchorMouseEnter();
6066
};
@@ -75,21 +81,35 @@ export class MDCTooltip extends MDCComponent<MDCTooltipFoundation> {
7581
this.foundation.handleTransitionEnd();
7682
};
7783

78-
this.anchorElem.addEventListener('mouseenter', this.handleMouseEnter);
79-
// TODO(b/157075286): Listening for a 'focus' event is too broad.
80-
this.anchorElem.addEventListener('focus', this.handleFocus);
81-
this.anchorElem.addEventListener('mouseleave', this.handleMouseLeave);
82-
this.anchorElem.addEventListener('blur', this.handleBlur);
84+
this.handleClick = () => {
85+
this.foundation.handleAnchorClick();
86+
};
87+
88+
if (this.isTooltipRich && this.isTooltipPersistent) {
89+
this.anchorElem.addEventListener('click', this.handleClick);
90+
} else {
91+
this.anchorElem.addEventListener('mouseenter', this.handleMouseEnter);
92+
// TODO(b/157075286): Listening for a 'focus' event is too broad.
93+
this.anchorElem.addEventListener('focus', this.handleFocus);
94+
this.anchorElem.addEventListener('mouseleave', this.handleMouseLeave);
95+
this.anchorElem.addEventListener('blur', this.handleBlur);
96+
}
8397

8498
this.listen('transitionend', this.handleTransitionEnd);
8599
}
86100

87101
destroy() {
88102
if (this.anchorElem) {
89-
this.anchorElem.removeEventListener('mouseenter', this.handleMouseEnter);
90-
this.anchorElem.removeEventListener('focus', this.handleFocus);
91-
this.anchorElem.removeEventListener('mouseleave', this.handleMouseLeave);
92-
this.anchorElem.removeEventListener('blur', this.handleBlur);
103+
if (this.isTooltipRich && this.isTooltipPersistent) {
104+
this.anchorElem.removeEventListener('click', this.handleClick);
105+
} else {
106+
this.anchorElem.removeEventListener(
107+
'mouseenter', this.handleMouseEnter);
108+
this.anchorElem.removeEventListener('focus', this.handleFocus);
109+
this.anchorElem.removeEventListener(
110+
'mouseleave', this.handleMouseLeave);
111+
this.anchorElem.removeEventListener('blur', this.handleBlur);
112+
}
93113
}
94114

95115
this.unlisten('transitionend', this.handleTransitionEnd);
@@ -138,6 +158,10 @@ export class MDCTooltip extends MDCComponent<MDCTooltipFoundation> {
138158
this.anchorElem?.setAttribute(attr, value);
139159
},
140160
isRTL: () => getComputedStyle(this.root).direction === 'rtl',
161+
anchorContainsElement: (element) => {
162+
const hasAnchorElem = Boolean(this.anchorElem);
163+
return hasAnchorElem && this.anchorElem!.contains(element);
164+
},
141165
registerEventHandler: (evt, handler) => {
142166
if (this.root instanceof HTMLElement) {
143167
this.root.addEventListener(evt, handler);

packages/mdc-tooltip/constants.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ const numbers = {
4343
// LINT.ThenChange(_tooltip.scss:tooltip-dimensions)
4444
};
4545

46+
const attributes = {
47+
PERSISTENT: 'data-mdc-tooltip-persistent',
48+
};
49+
4650
const events = {
4751
HIDDEN: 'MDCTooltip:hidden',
4852
};
@@ -72,4 +76,12 @@ enum AnchorBoundaryType {
7276
UNBOUNDED = 1,
7377
}
7478

75-
export {CssClasses, numbers, events, XPosition, AnchorBoundaryType, YPosition};
79+
export {
80+
CssClasses,
81+
numbers,
82+
attributes,
83+
events,
84+
XPosition,
85+
AnchorBoundaryType,
86+
YPosition
87+
};

packages/mdc-tooltip/foundation.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ import {AnimationFrame} from '@material/animation/animationframe';
2525
import {MDCFoundation} from '@material/base/foundation';
2626
import {SpecificEventListener} from '@material/base/types';
2727
import {KEY, normalizeKey} from '@material/dom/keyboard';
28+
2829
import {MDCTooltipAdapter} from './adapter';
29-
import {AnchorBoundaryType, CssClasses, numbers, XPosition, YPosition} from './constants';
30+
import {AnchorBoundaryType, attributes, CssClasses, numbers, XPosition, YPosition} from './constants';
3031
import {ShowTooltipOptions} from './types';
3132

3233
const {
@@ -60,6 +61,7 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
6061
getAnchorAttribute: () => null,
6162
setAnchorAttribute: () => null,
6263
isRTL: () => false,
64+
anchorContainsElement: () => false,
6365
registerEventHandler: () => undefined,
6466
deregisterEventHandler: () => undefined,
6567
registerDocumentEventHandler: () => undefined,
@@ -71,6 +73,7 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
7173
}
7274

7375
private isRich!: boolean; // assigned in init()
76+
private isPersistent!: boolean; // assigned in init()
7477
private isShown = false;
7578
private anchorGap = numbers.BOUNDED_ANCHOR_GAP;
7679
private xTooltipPos = XPosition.DETECTED;
@@ -99,8 +102,8 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
99102
super({...MDCTooltipFoundation.defaultAdapter, ...adapter});
100103
this.animFrame = new AnimationFrame();
101104

102-
this.documentClickHandler = () => {
103-
this.handleClick();
105+
this.documentClickHandler = (evt) => {
106+
this.handleDocumentClick(evt);
104107
};
105108

106109
this.documentKeydownHandler = (evt) => {
@@ -126,6 +129,16 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
126129

127130
init() {
128131
this.isRich = this.adapter.hasClass(RICH);
132+
this.isPersistent =
133+
this.adapter.getAttribute(attributes.PERSISTENT) === 'true';
134+
}
135+
136+
getIsRich() {
137+
return this.isRich;
138+
}
139+
140+
getIsPersistent() {
141+
return this.isPersistent;
129142
}
130143

131144
handleAnchorMouseEnter() {
@@ -163,7 +176,25 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
163176
this.hide();
164177
}
165178

166-
handleClick() {
179+
handleAnchorClick() {
180+
if (this.isShown) {
181+
this.hide();
182+
} else {
183+
this.show();
184+
}
185+
}
186+
187+
handleDocumentClick(evt: MouseEvent) {
188+
const anchorContainsTargetElement = evt.target instanceof HTMLElement &&
189+
this.adapter.anchorContainsElement(evt.target);
190+
// For persistent rich tooltips, we will only hide if the click target is
191+
// not within the anchor element, otherwise both the anchor element's click
192+
// handler and this handler will handle the click (due to event
193+
// propagation), resulting in a shown tooltip being immediately hidden if
194+
// the tooltip was initially hidden.
195+
if (this.isRich && this.isPersistent && anchorContainsTargetElement) {
196+
return;
197+
}
167198
// Hide the tooltip immediately on click.
168199
this.hide();
169200
}
@@ -215,10 +246,12 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
215246
}
216247
if (this.isRich) {
217248
this.adapter.setAnchorAttribute('aria-expanded', 'true');
218-
this.adapter.registerEventHandler(
219-
'mouseenter', this.richTooltipMouseEnterHandler);
220-
this.adapter.registerEventHandler(
221-
'mouseleave', this.richTooltipMouseLeaveHandler);
249+
if (!this.isPersistent) {
250+
this.adapter.registerEventHandler(
251+
'mouseenter', this.richTooltipMouseEnterHandler);
252+
this.adapter.registerEventHandler(
253+
'mouseleave', this.richTooltipMouseLeaveHandler);
254+
}
222255
}
223256
this.adapter.removeClass(HIDE);
224257
this.adapter.addClass(SHOWING);
@@ -259,10 +292,12 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
259292
this.adapter.setAttribute('aria-hidden', 'true');
260293
if (this.isRich) {
261294
this.adapter.setAnchorAttribute('aria-expanded', 'false');
262-
this.adapter.deregisterEventHandler(
263-
'mouseenter', this.richTooltipMouseEnterHandler);
264-
this.adapter.deregisterEventHandler(
265-
'mouseleave', this.richTooltipMouseLeaveHandler);
295+
if (!this.isPersistent) {
296+
this.adapter.deregisterEventHandler(
297+
'mouseenter', this.richTooltipMouseEnterHandler);
298+
this.adapter.deregisterEventHandler(
299+
'mouseleave', this.richTooltipMouseLeaveHandler);
300+
}
266301
}
267302
this.clearAllAnimationClasses();
268303
this.adapter.addClass(HIDE);
@@ -589,7 +624,7 @@ export class MDCTooltipFoundation extends MDCFoundation<MDCTooltipAdapter> {
589624
this.adapter.removeClass(HIDE);
590625
this.adapter.removeClass(HIDE_TRANSITION);
591626

592-
if (this.isRich) {
627+
if (this.isRich && !this.isPersistent) {
593628
this.adapter.deregisterEventHandler(
594629
'mouseenter', this.richTooltipMouseEnterHandler);
595630
this.adapter.deregisterEventHandler(

packages/mdc-tooltip/test/component.test.ts

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ describe('MDCTooltip', () => {
256256
});
257257
});
258258

259-
describe('rich tooltip tests', () => {
259+
describe('default rich tooltip tests', () => {
260260
beforeEach(() => {
261261
fixture = getFixture(`<div>
262262
<button aria-describedby="tt0" aria-haspopup="true" aria-expanded="false">
@@ -283,7 +283,19 @@ describe('MDCTooltip', () => {
283283
.toEqual(jasmine.any(MDCTooltip));
284284
});
285285

286-
it('sets aria-expanded on anchor to true when showing rich tooltip`',
286+
it('sets aria-expanded on anchor to true when showing rich tooltip', () => {
287+
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
288+
const anchorElem =
289+
fixture.querySelector<HTMLElement>('[aria-describedby]')!;
290+
MDCTooltip.attachTo(tooltipElem);
291+
292+
emitEvent(anchorElem, 'mouseenter');
293+
jasmine.clock().tick(numbers.SHOW_DELAY_MS);
294+
295+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('true');
296+
});
297+
298+
it('aria-expanded remains true on anchor when mouseleave anchor and mouseenter rich tooltip',
287299
() => {
288300
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
289301
const anchorElem =
@@ -292,11 +304,13 @@ describe('MDCTooltip', () => {
292304

293305
emitEvent(anchorElem, 'mouseenter');
294306
jasmine.clock().tick(numbers.SHOW_DELAY_MS);
307+
emitEvent(anchorElem, 'mouseleave');
308+
emitEvent(tooltipElem, 'mouseenter');
295309

296310
expect(anchorElem.getAttribute('aria-expanded')).toEqual('true');
297311
});
298312

299-
it('aria-expanded remains true on anchor when mouseleave anchor and mouseenter rich tooltip`',
313+
it('aria-expanded remains true on anchor when mouseleave rich tooltip and mouseenter anchor`',
300314
() => {
301315
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
302316
const anchorElem =
@@ -305,25 +319,91 @@ describe('MDCTooltip', () => {
305319

306320
emitEvent(anchorElem, 'mouseenter');
307321
jasmine.clock().tick(numbers.SHOW_DELAY_MS);
308-
emitEvent(anchorElem, 'mouseleave');
309-
emitEvent(tooltipElem, 'mouseenter');
322+
emitEvent(tooltipElem, 'mouseleave');
323+
emitEvent(anchorElem, 'mouseenter');
310324

311325
expect(anchorElem.getAttribute('aria-expanded')).toEqual('true');
312326
});
327+
});
313328

314-
it('aria-expanded remains true on anchor when mouseleave rich tooltip and mouseenter anchor`',
329+
describe('persistent rich tooltip tests', () => {
330+
beforeEach(() => {
331+
fixture = getFixture(`<div>
332+
<button aria-describedby="tt0" aria-haspopup="true" aria-expanded="false">
333+
anchor
334+
</button>
335+
<div id="tt0" class="mdc-tooltip mdc-tooltip--rich" aria-role="dialog" aria-hidden="true" data-mdc-tooltip-persistent="true">
336+
<div class="mdc-tooltip__surface">
337+
<p class="mdc-tooltip__content">
338+
demo tooltip
339+
</p>
340+
</div>
341+
</div>
342+
</div>`);
343+
document.body.appendChild(fixture);
344+
});
345+
346+
afterEach(() => {
347+
document.body.removeChild(fixture);
348+
});
349+
350+
it('aria-expanded remains false on anchor when mouseenter anchor', () => {
351+
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
352+
const anchorElem =
353+
fixture.querySelector<HTMLElement>('[aria-describedby]')!;
354+
MDCTooltip.attachTo(tooltipElem);
355+
356+
emitEvent(anchorElem, 'mouseenter');
357+
jasmine.clock().tick(numbers.SHOW_DELAY_MS);
358+
359+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('false');
360+
});
361+
362+
it('set aria-expanded to true on anchor when anchor clicked while tooltip is hidden',
315363
() => {
316364
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
317365
const anchorElem =
318366
fixture.querySelector<HTMLElement>('[aria-describedby]')!;
319367
MDCTooltip.attachTo(tooltipElem);
368+
expect(tooltipElem.getAttribute('aria-hidden')).toEqual('true');
369+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('false');
320370

321-
emitEvent(anchorElem, 'mouseenter');
322-
jasmine.clock().tick(numbers.SHOW_DELAY_MS);
323-
emitEvent(tooltipElem, 'mouseleave');
324-
emitEvent(anchorElem, 'mouseenter');
371+
emitEvent(anchorElem, 'click');
372+
373+
expect(tooltipElem.getAttribute('aria-hidden')).toEqual('false');
374+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('true');
375+
});
376+
377+
it('set aria-expanded to false on anchor when anchor clicked while tooltip is shown',
378+
() => {
379+
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
380+
const anchorElem =
381+
fixture.querySelector<HTMLElement>('[aria-describedby]')!;
382+
MDCTooltip.attachTo(tooltipElem);
325383

384+
emitEvent(anchorElem, 'click');
385+
expect(tooltipElem.getAttribute('aria-hidden')).toEqual('false');
326386
expect(anchorElem.getAttribute('aria-expanded')).toEqual('true');
387+
emitEvent(anchorElem, 'click');
388+
389+
expect(tooltipElem.getAttribute('aria-hidden')).toEqual('true');
390+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('false');
391+
});
392+
393+
it('set aria-expanded to false on anchor when element other than anchor is clicked while tooltip is shown',
394+
() => {
395+
const tooltipElem = fixture.querySelector<HTMLElement>('#tt0')!;
396+
const anchorElem =
397+
fixture.querySelector<HTMLElement>('[aria-describedby]')!;
398+
MDCTooltip.attachTo(tooltipElem);
399+
400+
emitEvent(anchorElem, 'click');
401+
expect(tooltipElem.getAttribute('aria-hidden')).toEqual('false');
402+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('true');
403+
emitEvent(document.body, 'click');
404+
405+
expect(tooltipElem.getAttribute('aria-hidden')).toEqual('true');
406+
expect(anchorElem.getAttribute('aria-expanded')).toEqual('false');
327407
});
328408
});
329409
});

0 commit comments

Comments
 (0)