diff --git a/package-lock.json b/package-lock.json index 63b88173..50310c06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@ngxs/devtools-plugin": "^3.7.6", "@ngxs/logger-plugin": "^3.7.6", "@ngxs/store": "^3.7.6", + "@popperjs/core": "^2.11.6", "@villedemontreal/hochelaga": "^4.23.1", "rxjs": "~7.4.0", "tslib": "^2.3.0", @@ -4961,6 +4962,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@popperjs/core": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-json": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.0.0.tgz", @@ -40604,6 +40614,11 @@ } } }, + "@popperjs/core": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==" + }, "@rollup/plugin-json": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.0.0.tgz", diff --git a/package.json b/package.json index 8e556ced..c7344b30 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@ngxs/devtools-plugin": "^3.7.6", "@ngxs/logger-plugin": "^3.7.6", "@ngxs/store": "^3.7.6", + "@popperjs/core": "^2.11.6", "@villedemontreal/hochelaga": "^4.23.1", "rxjs": "~7.4.0", "tslib": "^2.3.0", diff --git a/projects/angular-ui/src/lib/bao.module.ts b/projects/angular-ui/src/lib/bao.module.ts index 214953c7..5ccba769 100644 --- a/projects/angular-ui/src/lib/bao.module.ts +++ b/projects/angular-ui/src/lib/bao.module.ts @@ -22,6 +22,7 @@ import { BaoModalModule } from './modal/module'; import { BaoHyperlinkModule } from './hyperlink'; import { BaoDropdownMenuModule } from './dropdown-menu'; import { BaoFileModule } from './file/module'; +import { BaoTooltipModule } from './tooltip'; import { BaoSnackBarModule } from './snack-bar/module'; @NgModule({ @@ -52,6 +53,7 @@ import { BaoSnackBarModule } from './snack-bar/module'; BaoHyperlinkModule, BaoDropdownMenuModule, BaoFileModule, + BaoTooltipModule, BaoSnackBarModule // TODO: reactivate once component does not depend on global css BaoBadgeModule, ] diff --git a/projects/angular-ui/src/lib/tooltip/index.ts b/projects/angular-ui/src/lib/tooltip/index.ts new file mode 100644 index 00000000..536297ab --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2023 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +export * from './module'; +export * from './tooltip.directive'; diff --git a/projects/angular-ui/src/lib/tooltip/module.ts b/projects/angular-ui/src/lib/tooltip/module.ts new file mode 100644 index 00000000..6946a47a --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/module.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2023 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { BaoTooltipComponent } from './tooltip.component'; +import { BaoTooltipDirective } from './tooltip.directive'; + +@NgModule({ + imports: [CommonModule], + declarations: [BaoTooltipComponent, BaoTooltipDirective], + exports: [BaoTooltipDirective] +}) +export class BaoTooltipModule {} diff --git a/projects/angular-ui/src/lib/tooltip/tooltip.component.html b/projects/angular-ui/src/lib/tooltip/tooltip.component.html new file mode 100644 index 00000000..e69de29b diff --git a/projects/angular-ui/src/lib/tooltip/tooltip.component.scss b/projects/angular-ui/src/lib/tooltip/tooltip.component.scss new file mode 100644 index 00000000..16008e1a --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/tooltip.component.scss @@ -0,0 +1,27 @@ +@import '../core/colors'; +@import '../core/typography'; + +.bao-tooltip { + position: absolute; + padding: 2px 8px; + max-width: 200px; + color: $neutral-primary-reversed; + background: $ground-reversed; + border-radius: 4px; + z-index: 1000; + visibility: hidden; + @include typo-interface-small; + + &.bao-tooltip-show { + visibility: visible; + } + &.bao-tooltip-center { + text-align: center; + } + &.bao-tooltip-left { + text-align: left; + } + &.bao-tooltip-right { + text-align: right; + } +} diff --git a/projects/angular-ui/src/lib/tooltip/tooltip.component.spec.ts b/projects/angular-ui/src/lib/tooltip/tooltip.component.spec.ts new file mode 100644 index 00000000..82d448b0 --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/tooltip.component.spec.ts @@ -0,0 +1,29 @@ +/* tslint:disable:no-unused-variable */ +import { ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { BaoTooltipComponent } from './tooltip.component'; + +describe('TooltipPopperComponent', () => { + let component: BaoTooltipComponent; + let fixture: ComponentFixture<BaoTooltipComponent>; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [BaoTooltipComponent] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(BaoTooltipComponent); + component = fixture.componentInstance; + component.content = 'test content'; + component.parentRef = new ElementRef(document.createElement('button')); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/angular-ui/src/lib/tooltip/tooltip.component.ts b/projects/angular-ui/src/lib/tooltip/tooltip.component.ts new file mode 100644 index 00000000..38fc45a5 --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/tooltip.component.ts @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2023 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnInit, + Renderer2, + ViewEncapsulation, +} from '@angular/core'; +import { BasePlacement, createPopper, Instance } from '@popperjs/core'; +import { + isPlacement, + isTextAlign, + BaoTooltipPlacement, + BaoTooltipTextAlign, +} from './tooltip.model'; + +export interface IPos { + top: number; + left: number; +} +/** + * Unique ID for each tooltip counter + */ +let baoTooltipNextUniqueId = 0; + +@Component({ + selector: 'bao-tooltip', + templateUrl: 'tooltip.component.html', + styleUrls: ['./tooltip.component.scss'], + providers: [], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'bao-tooltip', + '[class.bao-tooltip-show]': 'show', + '[class.bao-tooltip-center]': 'textAlign==="center"', + '[class.bao-tooltip-left]': 'textAlign==="left"', + '[class.bao-tooltip-right]': 'textAlign==="right"', + }, +}) +export class BaoTooltipComponent implements OnInit { + /** + * The tooltip content + */ + @Input() + public content!: string; + /** + * The tooltip placement + */ + @Input() + public placement!: BaoTooltipPlacement; + /** + * The text alignement + */ + @Input() + public textAlign!: BaoTooltipTextAlign; + /** + * The parent node reference + */ + @Input() + public parentRef!: ElementRef; + + offset = 10; + popperInstance!: Instance; + private _show = false; + private uniqueId = `bao-tooltip-${++baoTooltipNextUniqueId}`; + + constructor(private renderer: Renderer2, private tooltipRef: ElementRef) {} + + /** + * show or Hide tooltip + */ + @Input() + get show() { + return this._show; + } + + set show(value: boolean) { + if (value !== this.show) { + this._show = value; + if (value) { + this.renderer.setAttribute( + this.tooltipRef.nativeElement, + 'aria-hidden', + 'false' + ); + } else { + this.renderer.setAttribute( + this.tooltipRef.nativeElement, + 'aria-hidden', + 'true' + ); + } + } + } + + ngOnInit() { + this.placement = this.getPlacementValid(this.placement); + this.textAlign = this.getTextAlignValid(this.textAlign); + this.create(); + } + + destroy() { + this.popperInstance.destroy(); + } + + /** + * Valid the input placement an return a valid value (default top) + */ + private getPlacementValid( + placement: BaoTooltipPlacement + ): BaoTooltipPlacement { + if (isPlacement(placement)) { + return placement; + } + return 'top'; + } + + /** + * Valid the input textAlign an return a valid value (default center) + */ + private getTextAlignValid( + textAlign: BaoTooltipTextAlign + ): BaoTooltipTextAlign { + if (isTextAlign(textAlign)) { + return textAlign; + } + return 'center'; + } + + /** + * Prepare the content + * Set some attributes + * create the popper + */ + private create() { + this.cleanContentAndAddToTooltipRef(); + // Set the aria-describedby attribute on parent element ref + this.renderer.setAttribute( + this.parentRef.nativeElement, + 'aria-describedby', + this.uniqueId + ); + // Set the id attribute on tooltip element ref + this.renderer.setAttribute( + this.tooltipRef.nativeElement, + 'id', + this.uniqueId + ); + // Set the initial aria-hidden attribute on tooltip element ref + this.renderer.setAttribute( + this.tooltipRef.nativeElement, + 'aria-hidden', + 'true' + ); + // create the popper + this.popperInstance = createPopper( + this.parentRef.nativeElement, + this.tooltipRef.nativeElement, + { + placement: this.placement as BasePlacement, + modifiers: [ + { + name: 'offset', + options: { + offset: [0, this.offset], + }, + }, + { + name: 'flip', + options: { + fallbackPlacements: this.getFallbackPlacements( + this.placement as BasePlacement + ), + }, + }, + ], + } + ); + } + + /** + * Clean the content (HTML content) + * Add content to tooltip ElementRef + */ + private cleanContentAndAddToTooltipRef() { + const cleanContent = this.removeNotAllowedTags(this.content); + const domParsed = new DOMParser(); + const element = domParsed.parseFromString( + `<span>${cleanContent}</span>`, + 'text/html' + ).body.firstElementChild; + this.renderer.appendChild(this.tooltipRef.nativeElement, element); + } + + /** + * Return the fallback Placements on overflow depending of the initial placement + */ + private getFallbackPlacements(initPlacement: BasePlacement): BasePlacement[] { + switch (initPlacement) { + case 'bottom': + return ['right', 'left', 'top']; + case 'right': + return ['left', 'top', 'bottom']; + case 'left': + return ['top', 'bottom', 'right']; + default: + return ['bottom', 'right', 'left']; + } + } + + /** + * Remove all not allowed tags + * Allowed tags are : "div, b, i, u, p, ol, ul, li, br" + */ + private removeNotAllowedTags(input: string): string { + if (!input) return ''; + const allowed = '<div><b><i><u><p><ol><ul><li><br>'; + const allowedLowercase = ( + ((allowed || '') + '').toLowerCase().match(/<[a-z][a-z0-9]*>/g) || [] + ).join(''); + const tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi; + const comments = /<!--[\s\S]*?-->/gi; + return input.replace(comments, '').replace(tags, function ($0, $1) { + return allowedLowercase.indexOf('<' + $1.toLowerCase() + '>') > -1 + ? $0 + : ''; + }); + } +} diff --git a/projects/angular-ui/src/lib/tooltip/tooltip.directive.ts b/projects/angular-ui/src/lib/tooltip/tooltip.directive.ts new file mode 100644 index 00000000..dd352449 --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/tooltip.directive.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { + ComponentRef, + Directive, + ElementRef, + HostListener, + Input, + OnDestroy, + OnInit, + ViewContainerRef +} from '@angular/core'; +import { BaoTooltipComponent } from './tooltip.component'; +import { BaoTooltipPlacement, BaoTooltipTextAlign } from './tooltip.model'; + +@Directive({ + selector: '[bao-tooltip]' +}) +export class BaoTooltipDirective implements OnInit, OnDestroy { + /** + * The tooltip selector `bao-tooltip` is bind with the directive input `content`. + */ + @Input('bao-tooltip') + content: string = 'You must provide a tooltip content'; + /** + * To specify where the tooltip will appear relative to the parent `top` `right` `left` `bottom` + */ + @Input() + placement: BaoTooltipPlacement = 'top'; + /** + * To specify how the text will be align in the tooltip `left` `right` `center` + */ + @Input() + textAlign: BaoTooltipTextAlign = 'center'; + + componentRef!: ComponentRef<BaoTooltipComponent>; + + constructor( + private viewContainerRef: ViewContainerRef, + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + this.createComponent(); + } + + ngOnDestroy(): void { + this.viewContainerRef.clear(); + } + + @HostListener('mouseenter') private onMouseEnter() { + this.componentRef.instance.show = true; + } + + @HostListener('mouseleave') private onMouseLeave() { + window.setTimeout(() => { + this.componentRef.instance.show = false; + }, 200); + } + + @HostListener('focus') private onFocus() { + this.componentRef.instance.show = true; + } + + @HostListener('focusout') private onFocusOut() { + this.componentRef.instance.show = false; + } + + private createComponent() { + this.componentRef = + this.viewContainerRef.createComponent(BaoTooltipComponent); + this.componentRef.instance.placement = this.placement; + this.componentRef.instance.content = this.content; + this.componentRef.instance.textAlign = this.textAlign; + this.componentRef.instance.parentRef = this.elementRef; + this.componentRef.onDestroy(() => { + this.componentRef.instance.destroy(); + }); + } +} diff --git a/projects/angular-ui/src/lib/tooltip/tooltip.model.ts b/projects/angular-ui/src/lib/tooltip/tooltip.model.ts new file mode 100644 index 00000000..f397a076 --- /dev/null +++ b/projects/angular-ui/src/lib/tooltip/tooltip.model.ts @@ -0,0 +1,7 @@ +const textAlignValues = ['left','right','center'] as const +export type BaoTooltipTextAlign = typeof textAlignValues[number]; +export const isTextAlign= (x: any): x is BaoTooltipTextAlign => textAlignValues.includes(x); + +const placementValues = ['top','right','left','bottom'] as const +export type BaoTooltipPlacement = typeof placementValues[number]; +export const isPlacement= (x: any): x is BaoTooltipPlacement => placementValues.includes(x); \ No newline at end of file diff --git a/projects/angular-ui/src/public-api.ts b/projects/angular-ui/src/public-api.ts index dfd8518d..e30196d3 100644 --- a/projects/angular-ui/src/public-api.ts +++ b/projects/angular-ui/src/public-api.ts @@ -24,4 +24,5 @@ export * from './lib/modal'; export * from './lib/hyperlink'; export * from './lib/dropdown-menu'; export * from './lib/file'; +export * from './lib/tooltip'; export * from './lib/snack-bar'; diff --git a/projects/storybook-angular/src/stories/tooltip/Tooltip.stories.ts b/projects/storybook-angular/src/stories/tooltip/Tooltip.stories.ts new file mode 100644 index 00000000..7fa408ee --- /dev/null +++ b/projects/storybook-angular/src/stories/tooltip/Tooltip.stories.ts @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2023 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { Meta, Story } from '@storybook/angular/types-6-0'; +import { BaoTooltipDirective, BaoButtonModule } from 'angular-ui'; + +// const description = ` +// ## Documentation +// The full documentation of this component is available in the Hochelaga design system documentation under "[Info-bulle](https://zeroheight.com/575tugn0n/p/156564-info-bulle)". +// `; +const description = ` +Floating labels that briefly explain the function of an interface element. + +## Documentation +The full documentation of this component is available in the Hochelaga design system documentation under "[Info-bulle](https://zeroheight.com/575tugn0n/p/156564-info-bulle)". + +## Placements and text alignment +To modify the placement of the tooltip + +* \`top\` for positionning the tooltip on top of the parent element +* \`right\` for positionning the tooltip on right of the parent element +* \`bottom\` for positionning the tooltip on bottom of the parent element +* \`left\` for positionning the tooltip on left of the parent element + +To modify the text alignment of the text in the tooltip + +* \`center\` for positionning the tooltip on center of the parent element +* \`left\` for positionning the tooltip on left of the parent element +* \`right\` for positionning the tooltip on right of the parent element + +## Special text in the tooltip + +It's possible to use html tags to format text in the tooltip. The allowed tags are +\`<div>\` \`<b>\` \`<i>\` \`<u>\` \`<p>\` \`<ol>\` \`<ul>\` \`<li>\` \`<br>\` +<br><br><br> +`; +export default { + title: 'Directives/Tooltip', + component: BaoTooltipDirective, + argTypes: { + placement: { + options: ['top', 'right', 'left', 'bottom'], + control: { type: 'radio' }, + }, + textAlign: { + options: ['right', 'left', 'center'], + control: { type: 'radio' }, + }, + content: { + name: 'bao-tooltip', + description: 'The tooltip selector is bind with the directive input "content". Must contain a value of type<br><code>string</code>' + }, + "bao-tooltip": { + table: { + disable: true + } + }, + createComponent: { + table: { + disable: true + } + }, + componentRef: { + table: { + disable: true + } + }, + ngOnDestroy: { + table: { + disable: true + } + }, + ngOnInit: { + table: { + disable: true + } + }, + onFocus: { + table: { + disable: true + } + }, + onFocusOut: { + table: { + disable: true + } + }, + onMouseEnter: { + table: { + disable: true + } + }, + onMouseLeave: { + table: { + disable: true + } + } + }, + parameters: { + docs: { + description: { + component: description + } + } + } +} as Meta<BaoTooltipDirective>; + +const template: Story<BaoTooltipDirective> = (args: BaoTooltipDirective) => ({ + props: { + content: args.content, + placement: args.placement, + textAlign: args.textAlign + }, + template: ` + <div style="height: 120px; text-align: center; padding-top: 48px"> + <span [bao-tooltip]="content" [placement]="placement" [textAlign]="textAlign" >Hover over me</span> + </div> + ` +}); + +export const Primary = template.bind({}); + +Primary.args = { + content: 'You must provide a tooltip content' +}; + +export const TooltipPlacementAndTextAlign: Story = () => ({ + // props: args, + moduleMetadata: { + declarations: [BaoTooltipDirective], + imports: [BaoButtonModule] + }, + template: ` + <h4>Placement on TOP and text align CENTER</h4> + <button bao-button bao-tooltip="A beautiful tooltip on the <b><i>top</i></b> with text align <b><i>center</i></b>" placement="top" textAlign="center">Try me</button> + <h4 style="margin-top: 1.5rem;">Placement on RIGHT and text align LEFT</h4> + <button bao-button bao-tooltip="A beautiful tooltip on the <b><i>right</i></b> with text align <b><i>left</i></b>" placement="right" textAlign="left">Try me</button> + <h4 style="margin-top: 1.5rem;">Placement on BOTTOM and text align RIGHT</h4> + <button bao-button bao-tooltip="A beautiful tooltip on the <b><i>bottom</i></b> with text align <b><i>right</i></b>" placement="bottom" textAlign="right">Try me</button> + <h4 style="margin-top: 1.5rem;">Placement on LEFT and text align CENTER by default</h4> + <button bao-button bao-tooltip="A beautiful tooltip on the <b><i>left</i></b> with text align <b><i>center by default</i></b>" placement="left">Try me</button> + <h4 style="margin-top: 1.5rem;">Placement on TOP (default) and text align CENTER (default)</h4> + <button bao-button bao-tooltip="A beautiful tooltip on the <b><i>top by default</i></b> with text align <b><i>center by default</i></b>">Try me</button> + ` +}); + +TooltipPlacementAndTextAlign.storyName = 'Placements and text alignments'; +TooltipPlacementAndTextAlign.args = { + ...Primary.args +}; + +export const TooltipOnDifferentTags: Story = () => ({ + // props: args, + moduleMetadata: { + declarations: [BaoTooltipDirective], + imports: [BaoButtonModule] + }, + template: ` + <h4 >Tooltip on a part of the texte</h4> + <p>A tooltip <span bao-tooltip="A beautiful tooltip" placement="top" textAlign="center">THIS PART</span> on a part of the text</p> + + <h4 style="margin-top: 1.5rem;">Tooltip on a div with a 100% width</h4> + <div bao-tooltip="A beautiful tooltip">Try me all over the width</div> + + <h4 style="margin-top: 1.5rem;">Tooltip on a native input field</h4> + <input bao-tooltip="A beautiful tooltip on a native input field"/> + + <h4 style="margin-top: 1.5rem;">Tooltip on a bao-button (work on all bao component)</h4> + <button bao-button bao-tooltip="A beautiful tooltip on a bao-button">Try me</button> + + <h4 style="margin-top: 1.5rem;">Tooltip with special text</h4> + <pre style="margin-bottom: 1rem">HTML tags accepted are <div> <b> <i> <u> <p> <ol> <ul> <li> <br></pre> + <span bao-tooltip="A beautiful tooltip that accept <b>bold</b> or <i>italic</i> or <u>underline</u> or + <b><i><u>All together</b></i></u><br>accept line break<br><br><br> + Bulleted lists <ul style='text-align: left;'><li>first</li><li>second bullet with long description</li></ul> + Ordered list <ol style='text-align: left;'><li>first</li><li>second</li></ol>" + placement="right" textAlign="left">Hover over me</span> + ` +}); + +TooltipOnDifferentTags.storyName = 'Different tags and special content'; +TooltipOnDifferentTags.args = { + ...Primary.args +};