Skip to content

Commit

Permalink
fix(tooltip): keep tooltip content visible as user hovers
Browse files Browse the repository at this point in the history
Previously, the tooltip content was hidden as soon as the user moused
out of the trigger.

Now, the tooltip content will stay visible if the user mouses from the
trigger into the content. To support this, the tooltip content has an
expanded hover target provided by the `::after` pseudo element.

VPAT-617
  • Loading branch information
kevinbuhmann committed Jan 10, 2023
1 parent c3f61eb commit 9938c1e
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 10 deletions.
7 changes: 4 additions & 3 deletions projects/angular/clarity.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4379,7 +4379,8 @@ export class ClrTooltip {
// @public (undocumented)
export class ClrTooltipContent extends AbstractPopover implements OnInit {
// Warning: (ae-forgotten-export) The symbol "TooltipIdService" needs to be exported by the entry point index.d.ts
constructor(injector: Injector, parentHost: ElementRef, tooltipIdService: TooltipIdService);
// Warning: (ae-forgotten-export) The symbol "TooltipMouseService" needs to be exported by the entry point index.d.ts
constructor(injector: Injector, parentHost: ElementRef, tooltipIdService: TooltipIdService, tooltipMouseService: TooltipMouseService);
// (undocumented)
get id(): string;
set id(value: string);
Expand All @@ -4394,7 +4395,7 @@ export class ClrTooltipContent extends AbstractPopover implements OnInit {
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<ClrTooltipContent, "clr-tooltip-content", never, { "id": "id"; "position": "clrPosition"; "size": "clrSize"; }, {}, never, ["*"]>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<ClrTooltipContent, [null, { optional: true; }, null]>;
static ɵfac: i0.ɵɵFactoryDeclaration<ClrTooltipContent, [null, { optional: true; }, null, null]>;
}

// @public (undocumented)
Expand All @@ -4413,7 +4414,7 @@ export class ClrTooltipModule {

// @public (undocumented)
export class ClrTooltipTrigger {
constructor(toggleService: ClrPopoverToggleService, tooltipIdService: TooltipIdService);
constructor(toggleService: ClrPopoverToggleService, tooltipIdService: TooltipIdService, tooltipMouseService: TooltipMouseService);
// (undocumented)
ariaDescribedBy: string;
// (undocumented)
Expand Down
9 changes: 9 additions & 0 deletions projects/angular/src/popover/tooltip/_tooltips.clarity.scss
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@
transition: opacity 0.3s linear;
white-space: normal;
z-index: map-get($clr-layers, tooltips);

&::after {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
content: '';
}
}

&:hover > .tooltip-content,
Expand Down
2 changes: 2 additions & 0 deletions projects/angular/src/popover/tooltip/all.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
*/

import TooltipIdServiceSpecs from './providers/tooltip-id.service.spec';
import TooltipMouseServiceSpecs from './providers/tooltip-mouse.service.spec';
import TooltipContentSpecs from './tooltip-content.spec';
import TooltipTriggerSpecs from './tooltip-trigger.spec';
import TooltipSpecs from './tooltip.spec';

describe('Tooltip', () => {
TooltipIdServiceSpecs();
TooltipMouseServiceSpecs();
TooltipContentSpecs();
TooltipTriggerSpecs();
TooltipSpecs();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { fakeAsync, tick } from '@angular/core/testing';

import { ClrPopoverToggleService } from '../../../utils/popover/providers/popover-toggle.service';
import { TooltipMouseService } from './tooltip-mouse.service';

export default function (): void {
describe('Tooltip Mouse Service', () => {
let toggleService: ClrPopoverToggleService;
let mouseService: TooltipMouseService;

beforeEach(() => {
toggleService = new ClrPopoverToggleService();
mouseService = new TooltipMouseService(toggleService);
});

it('should show the tooltip when the mouse enters the trigger', () => {
mouseService.onMouseEnterTrigger();

expect(toggleService.open).toBe(true);
});

it('should hide the tooltip if the mouse leaves the trigger and does not enter the content', fakeAsync(() => {
toggleService.open = true;

mouseService.onMouseLeaveTrigger();
tick();

expect(toggleService.open).toBe(false);
}));

it('should hide the tooltip if the mouse leaves the content and does not enter the trigger', fakeAsync(() => {
toggleService.open = true;

mouseService.onMouseLeaveContent();
tick();

expect(toggleService.open).toBe(false);
}));

it('should not hide the tooltip as the mouse moves from the trigger to the content', fakeAsync(() => {
toggleService.open = true;

mouseService.onMouseLeaveTrigger();
mouseService.onMouseEnterContent();
tick();

expect(toggleService.open).toBe(true);
}));
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { Injectable } from '@angular/core';

import { ClrPopoverToggleService } from '../../../utils/popover/providers/popover-toggle.service';

@Injectable()
export class TooltipMouseService {
private mouseOverTrigger: boolean;
private mouseOverContent: boolean;

constructor(private readonly toggleService: ClrPopoverToggleService) {}

onMouseEnterTrigger() {
this.mouseOverTrigger = true;
this.toggleService.open = true;
}

onMouseLeaveTrigger() {
this.mouseOverTrigger = false;
this.hideIfMouseOut();
}

onMouseEnterContent() {
this.mouseOverContent = true;
}

onMouseLeaveContent() {
this.mouseOverContent = false;
this.hideIfMouseOut();
}

private hideIfMouseOut() {
// A zero timeout is used so that the code has a chance to update
// the `mouseOverContent` property after the user moves the mouse from the trigger to the content.
setTimeout(() => {
if (!this.mouseOverTrigger && !this.mouseOverContent) {
this.toggleService.open = false;
}
}, 0);
}
}
16 changes: 14 additions & 2 deletions projects/angular/src/popover/tooltip/tooltip-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { Component, ElementRef, Inject, Injector, Input, OnInit, Optional } from '@angular/core';
import { Component, ElementRef, HostListener, Inject, Injector, Input, OnInit, Optional } from '@angular/core';

import { assertNever } from '../../utils/assert/assert.helpers';
import { uniqueIdFactory } from '../../utils/id-generator/id-generator.service';
import { AbstractPopover } from '../common/abstract-popover';
import { Point } from '../common/popover';
import { POPOVER_HOST_ANCHOR } from '../common/popover-host-anchor.token';
import { TooltipIdService } from './providers/tooltip-id.service';
import { TooltipMouseService } from './providers/tooltip-mouse.service';

const POSITIONS = ['bottom-left', 'bottom-right', 'top-left', 'top-right', 'right', 'left'] as const;
type Position = typeof POSITIONS[number];
Expand All @@ -37,7 +38,8 @@ export class ClrTooltipContent extends AbstractPopover implements OnInit {
@Optional()
@Inject(POPOVER_HOST_ANCHOR)
parentHost: ElementRef,
private tooltipIdService: TooltipIdService
private tooltipIdService: TooltipIdService,
private tooltipMouseService: TooltipMouseService
) {
super(injector, parentHost);

Expand Down Expand Up @@ -127,6 +129,16 @@ export class ClrTooltipContent extends AbstractPopover implements OnInit {
this.position = this.position || defaultPosition;
}

@HostListener('mouseenter')
private onMouseEnter() {
this.tooltipMouseService.onMouseEnterContent();
}

@HostListener('mouseleave')
private onMouseLeave() {
this.tooltipMouseService.onMouseLeaveContent();
}

private updateCssClass({ oldClass, newClass }: { oldClass: string; newClass: string }) {
this.renderer.removeClass(this.el.nativeElement, oldClass);
this.renderer.addClass(this.el.nativeElement, newClass);
Expand Down
3 changes: 2 additions & 1 deletion projects/angular/src/popover/tooltip/tooltip-trigger.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Component } from '@angular/core';
import { ClrPopoverToggleService } from '../../utils/popover/providers/popover-toggle.service';
import { spec, TestContext } from '../../utils/testing/helpers.spec';
import { TooltipIdService } from './providers/tooltip-id.service';
import { TooltipMouseService } from './providers/tooltip-mouse.service';
import { ClrTooltipTrigger } from './tooltip-trigger';
import { ClrTooltipModule } from './tooltip.module';

Expand All @@ -27,7 +28,7 @@ interface TooltipContext extends TestContext<ClrTooltipTrigger, SimpleTest> {
export default function (): void {
describe('TooltipTrigger component', function (this: TooltipContext) {
spec(ClrTooltipTrigger, SimpleTest, ClrTooltipModule, {
providers: [ClrPopoverToggleService, TooltipIdService],
providers: [ClrPopoverToggleService, TooltipIdService, TooltipMouseService],
});

beforeEach(function () {
Expand Down
19 changes: 16 additions & 3 deletions projects/angular/src/popover/tooltip/tooltip-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Subscription } from 'rxjs';

import { ClrPopoverToggleService } from '../../utils/popover/providers/popover-toggle.service';
import { TooltipIdService } from './providers/tooltip-id.service';
import { TooltipMouseService } from './providers/tooltip-mouse.service';

@Directive({
selector: '[clrTooltipTrigger]',
Expand All @@ -23,7 +24,11 @@ export class ClrTooltipTrigger {
ariaDescribedBy: string;
private subs: Subscription[] = [];

constructor(private toggleService: ClrPopoverToggleService, private tooltipIdService: TooltipIdService) {
constructor(
private toggleService: ClrPopoverToggleService,
private tooltipIdService: TooltipIdService,
private tooltipMouseService: TooltipMouseService
) {
// The aria-described by comes from the id of content. It
this.subs.push(this.tooltipIdService.id.subscribe(tooltipId => (this.ariaDescribedBy = tooltipId)));
}
Expand All @@ -32,15 +37,23 @@ export class ClrTooltipTrigger {
this.subs.forEach(sub => sub.unsubscribe());
}

@HostListener('mouseenter')
@HostListener('focus')
showTooltip(): void {
this.toggleService.open = true;
}

@HostListener('mouseleave')
@HostListener('blur')
hideTooltip(): void {
this.toggleService.open = false;
}

@HostListener('mouseenter')
private onMouseEnter() {
this.tooltipMouseService.onMouseEnterTrigger();
}

@HostListener('mouseleave')
private onMouseLeave() {
this.tooltipMouseService.onMouseLeaveTrigger();
}
}
8 changes: 7 additions & 1 deletion projects/angular/src/popover/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import { Component, ElementRef } from '@angular/core';
import { ClrPopoverToggleService } from '../../utils/popover/providers/popover-toggle.service';
import { POPOVER_HOST_ANCHOR } from '../common/popover-host-anchor.token';
import { TooltipIdService } from './providers/tooltip-id.service';
import { TooltipMouseService } from './providers/tooltip-mouse.service';

@Component({
selector: 'clr-tooltip',
template: `<ng-content></ng-content>`,
host: {
'[class.tooltip]': 'true',
},
providers: [ClrPopoverToggleService, { provide: POPOVER_HOST_ANCHOR, useExisting: ElementRef }, TooltipIdService],
providers: [
ClrPopoverToggleService,
{ provide: POPOVER_HOST_ANCHOR, useExisting: ElementRef },
TooltipIdService,
TooltipMouseService,
],
})
export class ClrTooltip {}

0 comments on commit 9938c1e

Please sign in to comment.