diff --git a/README.md b/README.md index eef9add..a93b16a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ yarn add @untemps/svelte-use-tooltip border-radius: 6px; padding: 0.5rem; } - + :global(.tooltip::after) { content: ''; position: absolute; @@ -100,7 +100,7 @@ yarn add @untemps/svelte-use-tooltip ## API | Props | Type | Default | Description | -|----------------------|---------|---------|-----------------------------------------------------------------------------------------------------------------| +| -------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------- | | `contentSelector` | string | null | Selector of the content to display in the tooltip. | | `contentClone` | boolean | null | Flag to clone the content to display in the tooltip. If false, the content is removed from its previous parent. | | `contentActions` | object | null | Configuration of the tooltip actions (see [Content Actions](#content-actions)). | @@ -110,7 +110,7 @@ yarn add @untemps/svelte-use-tooltip ### Content Actions -The `contentActions` prop allow handling interactions within the tooltip content. +The `contentActions` prop allow handling interactions within the tooltip content. Each element inside the content parent may configure its own action since it can be queried using the key-selector. @@ -146,11 +146,11 @@ One event by element is possible so far as elements are referenced by selector. ``` | Props | Type | Default | Description | -|-------------------|----------|---------|----------------------------------------------------------------------------------------------------------| +| ----------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------- | --- | | `eventType` | string | null | Type of the event. All available [events](https://developer.mozilla.org/fr/docs/Web/Events) can be used. | | `callback` | function | null | Function to be used as event handler. | | `callbackParams` | array | null | List of arguments to pass to the event handler in. | -| `closeOnCallback` | boolean | false | Flag to automatically close the tooltip when the event handler is triggered. | | +| `closeOnCallback` | boolean | false | Flag to automatically close the tooltip when the event handler is triggered. | | ## Development @@ -169,4 +169,4 @@ Contributions are warmly welcomed: - Develop the feature AND write the tests (or write the tests AND develop the feature) - Commit your changes using [Angular Git Commit Guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) -- Submit a Pull Request \ No newline at end of file +- Submit a Pull Request diff --git a/dev/src/App.svelte b/dev/src/App.svelte index 204f071..20acc33 100644 --- a/dev/src/App.svelte +++ b/dev/src/App.svelte @@ -4,55 +4,84 @@ let tooltipPosition = 'top' let useCustomTooltipClass = false let isTooltipDisabled = false + let animateTooltip = false + let useCustomAnimationEnterClass = false + let useCustomAnimationLeaveClass = false - const _onTooltipClick = (arg, event) => { + const _onTooltipClick = (arg, event) => { console.log(arg) - } + }
-
Hover me
- Hi! I'm a fancy tooltip! + }, + contentClassName: useCustomTooltipClass ? 'tooltip' : null, + disabled: isTooltipDisabled, + animated: animateTooltip, + animationEnterClassName: useCustomAnimationEnterClass ? 'tooltip-enter' : null, + animationLeaveClassName: useCustomAnimationLeaveClass ? 'tooltip-leave' : null, + }} + class="target" + > + Hover me +
+ Hi! I'm a fancy tooltip!

Settings

-
- -
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
-
- -
@@ -75,20 +104,20 @@ .target { width: 10rem; - height: 3rem; - background-color: white; - color: black; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 0 5px 0 rgba(0,0,0,0.5); + height: 3rem; + background-color: white; + color: black; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.5); } - .target:hover { - cursor: pointer; - background-color: black; - color: white; - } + .target:hover { + cursor: pointer; + background-color: black; + color: white; + } .settings__form { display: flex; @@ -140,4 +169,29 @@ border-style: solid; border-color: #ee7008 transparent transparent transparent; } + + :global(.tooltip-enter) { + animation: fadeIn 0.2s linear forwards; + } + + :global(.tooltip-leave) { + animation: fadeOut 0.2s linear forwards; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateX(50px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + @keyframes fadeOut { + to { + opacity: 0; + transform: translateX(-50px); + } + } diff --git a/package.json b/package.json index f8b2023..f820e12 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "build:cjs": "cross-env NODE_ENV=production BABEL_ENV=cjs rollup -c", "build:es": "cross-env NODE_ENV=production BABEL_ENV=es rollup -c", "build:umd": "cross-env NODE_ENV=production BABEL_ENV=umd rollup -c", - "prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write && git add . && git status" + "prettier": "prettier \"*/**/*.js\" --ignore-path ./.prettierignore --write && git add . && git status", + "prepare": "husky install" } } diff --git a/src/__tests__/useTooltip.test.js b/src/__tests__/useTooltip.test.js index 66b646f..f363a06 100644 --- a/src/__tests__/useTooltip.test.js +++ b/src/__tests__/useTooltip.test.js @@ -48,7 +48,7 @@ describe('useTooltip', () => { action = useTooltip(target, options) await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before await fireEvent.mouseEnter(target) - expect(template).toBeVisible() + expect(template).toBeInTheDocument() }) it('Hides tooltip on mouse leave', async () => { @@ -56,7 +56,19 @@ describe('useTooltip', () => { await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before await fireEvent.mouseEnter(target) await fireEvent.mouseLeave(target) - expect(template).not.toBeVisible() + expect(template).not.toBeInTheDocument() + await fireEvent.animationEnd(template.parentNode) + expect(template).not.toBeInTheDocument() + }) + + it('Hides tooltip on mouse leave when animated', async () => { + action = useTooltip(target, { ...options, animated: true }) + await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before + await fireEvent.mouseEnter(target) + await fireEvent.mouseLeave(target) + expect(template).toBeInTheDocument() + await fireEvent.animationEnd(template.parentNode) + expect(template).not.toBeInTheDocument() }) }) @@ -70,7 +82,7 @@ describe('useTooltip', () => { const newTemplate = _createElement('new-template') action.update({ ...options, - contentSelector: '#new-template' + contentSelector: '#new-template', }) await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before await fireEvent.mouseEnter(target) @@ -97,9 +109,9 @@ describe('useTooltip', () => { await fireEvent.mouseEnter(target) await fireEvent.click(template) expect(contentAction.callback).toHaveBeenCalledWith(contentAction.callbackParams[0], expect.any(Event)) - expect(template).toBeVisible() + expect(template).toBeInTheDocument() }) - + it('Closes tooltip after triggering callback', async () => { action = useTooltip(target, options) options.contentActions['*'].closeOnCallback = true @@ -108,7 +120,22 @@ describe('useTooltip', () => { await fireEvent.mouseEnter(target) await fireEvent.click(template) expect(contentAction.callback).toHaveBeenCalledWith(contentAction.callbackParams[0], expect.any(Event)) - expect(template).not.toBeVisible() + expect(template).not.toBeInTheDocument() + await fireEvent.animationEnd(template.parentNode) + expect(template).not.toBeInTheDocument() + }) + + it('Closes tooltip after triggering callback when animated', async () => { + action = useTooltip(target, { ...options, animated: true }) + options.contentActions['*'].closeOnCallback = true + const contentAction = options.contentActions['*'] + await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before + await fireEvent.mouseEnter(target) + await fireEvent.click(template) + expect(contentAction.callback).toHaveBeenCalledWith(contentAction.callbackParams[0], expect.any(Event)) + expect(template).toBeInTheDocument() + await fireEvent.animationEnd(template.parentNode) + expect(template).not.toBeInTheDocument() }) it('Triggers new callback on tooltip click after update', async () => { @@ -142,14 +169,14 @@ describe('useTooltip', () => { action = useTooltip(target, options) await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before await fireEvent.mouseEnter(target) - expect(template.parentNode).toHaveClass('__tooltip__default') + expect(template.parentNode).toHaveClass('__tooltip') }) it('Sets new tooltip class after update', async () => { action = useTooltip(target, options) await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before await fireEvent.mouseEnter(target) - expect(template.parentNode).toHaveClass('__tooltip__default') + expect(template.parentNode).toHaveClass('__tooltip') action.update({ ...options, contentClassName: 'foo', diff --git a/src/index.js b/src/index.js index 6bd5208..5c529be 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1 @@ -export { default as useTooltip } from './useTooltip' \ No newline at end of file +export { default as useTooltip } from './useTooltip' diff --git a/src/useTooltip.css b/src/useTooltip.css index 068ca50..0af9a31 100644 --- a/src/useTooltip.css +++ b/src/useTooltip.css @@ -1,42 +1,67 @@ -.__tooltip__default { - position: absolute; - z-index: 9999; - max-width: 100%; - background-color: black; - color: #fff; - text-align: center; - border-radius: 6px; - padding: 0.5rem; +.__tooltip { + position: absolute; + z-index: 9999; + max-width: 100%; + background-color: black; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 0.5rem; } -.__tooltip__default::after { - content: ""; - position: absolute; - margin-left: -5px; - border-width: 5px; - border-style: solid; +.__tooltip::after { + content: ''; + position: absolute; + margin-left: -5px; + border-width: 5px; + border-style: solid; } -.__tooltip__top::after { - bottom: -10px; - left: 50%; - border-color: black transparent transparent transparent; +.__tooltip-top::after { + bottom: -10px; + left: 50%; + border-color: black transparent transparent transparent; } -.__tooltip__bottom::after { - top: -10px; - left: 50%; - border-color: transparent transparent black transparent; +.__tooltip-bottom::after { + top: -10px; + left: 50%; + border-color: transparent transparent black transparent; } -.__tooltip__left::after { - top: calc(50% - 5px); - right: -10px; - border-color: transparent transparent transparent black; +.__tooltip-left::after { + top: calc(50% - 5px); + right: -10px; + border-color: transparent transparent transparent black; } -.__tooltip__right::after { - top: calc(50% - 5px); - left: -5px; - border-color: transparent black transparent transparent; -} \ No newline at end of file +.__tooltip-right::after { + top: calc(50% - 5px); + left: -5px; + border-color: transparent black transparent transparent; +} + +.__tooltip-enter { + animation: fadeIn 0.2s linear forwards; +} + +.__tooltip-leave { + animation: fadeOut 0.2s linear forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/src/useTooltip.js b/src/useTooltip.js index df44a23..20184df 100644 --- a/src/useTooltip.js +++ b/src/useTooltip.js @@ -2,10 +2,32 @@ import { DOMObserver } from '@untemps/dom-observer' import './useTooltip.css' -const useTooltip = (node, { position, contentSelector, contentClone, contentActions, contentClassName, disabled }) => { +const useTooltip = ( + node, + { + position, + contentSelector, + contentClone, + contentActions, + contentClassName, + animated, + animationEnterClassName, + animationLeaveClassName, + disabled, + } +) => { Tooltip.init(contentSelector, contentClone) - const tooltip = new Tooltip(node, position, disabled, contentActions, contentClassName) + const tooltip = new Tooltip( + node, + position, + disabled, + contentActions, + contentClassName, + animated, + animationEnterClassName, + animationLeaveClassName + ) return { update: ({ @@ -15,10 +37,21 @@ const useTooltip = (node, { position, contentSelector, contentClone, contentActi contentActions: newContentActions, contentClassName: newContentClassName, disabled: newDisabled, + animated: newAnimated, + animationEnterClassName: newAnimationEnterClassName, + animationLeaveClassName: newAnimationLeaveClassName, }) => { Tooltip.update(newContentSelector, newContentClone) - tooltip.update(newPosition, newDisabled, newContentActions, newContentClassName) + tooltip.update( + newPosition, + newDisabled, + newContentActions, + newContentClassName, + newAnimated, + newAnimationEnterClassName, + newAnimationLeaveClassName + ) }, destroy: () => { tooltip.destroy() @@ -36,22 +69,37 @@ export class Tooltip { #target = null #position = null #actions = null + #animated = false + #animationEnterClassName = null + #animationLeaveClassName = null #container = null #events = [] #boundEnterHandler = null #boundLeaveHandler = null - constructor(target, position, disabled, actions, className) { + constructor( + target, + position, + disabled, + actions, + className, + animated, + animationEnterClassName, + animationLeaveClassName + ) { this.#target = target this.#position = position this.#actions = actions this.#container = Tooltip.#tooltip - - this.#container?.setAttribute('class', className || `__tooltip__default __tooltip__${this.#position}`) - + this.#animated = animated + this.#animationEnterClassName = animationEnterClassName || '__tooltip-enter' + this.#animationLeaveClassName = animationLeaveClassName || '__tooltip-leave' + + this.#container?.setAttribute('class', className || `__tooltip __tooltip-${this.#position}`) + disabled ? this.#disable() : this.#enable() - + this.#target.title = '' this.#target.setAttribute('style', 'position: relative') @@ -64,10 +112,12 @@ export class Tooltip { Tooltip.#tooltip.id = 'tooltip' Tooltip.#observer = new DOMObserver() - Tooltip.#observer.wait(contentSelector, null, { events: [DOMObserver.EXIST, DOMObserver.ADD] }).then(({ node }) => { - const child = contentClone ? node.cloneNode(true) : node - Tooltip.#tooltip.appendChild(child) - }) + Tooltip.#observer + .wait(contentSelector, null, { events: [DOMObserver.EXIST, DOMObserver.ADD] }) + .then(({ node }) => { + const child = contentClone ? node.cloneNode(true) : node + Tooltip.#tooltip.appendChild(child) + }) Tooltip.#contentSelector = contentSelector Tooltip.#isInitialized = true @@ -78,11 +128,13 @@ export class Tooltip { if (Tooltip.#isInitialized && contentSelector !== Tooltip.#contentSelector) { Tooltip.#contentSelector = contentSelector - Tooltip.#observer.wait(contentSelector, null, { events: [DOMObserver.EXIST, DOMObserver.ADD] }).then(({ node }) => { - Tooltip.#tooltip.innerHTML = '' - const child = contentClone ? node.cloneNode(true) : node - Tooltip.#tooltip.appendChild(child) - }) + Tooltip.#observer + .wait(contentSelector, null, { events: [DOMObserver.EXIST, DOMObserver.ADD] }) + .then(({ node }) => { + Tooltip.#tooltip.innerHTML = '' + const child = contentClone ? node.cloneNode(true) : node + Tooltip.#tooltip.appendChild(child) + }) } } @@ -101,42 +153,49 @@ export class Tooltip { Tooltip.#isInitialized = false } - update(position, disabled, actions, className) { + update(position, disabled, actions, className, animated, animationEnterClassName, animationLeaveClassName) { this.#position = position this.#actions = actions - - this.#container?.setAttribute('class', className || `__tooltip__default __tooltip__${this.#position}`) - - if(!disabled && !this.#boundEnterHandler) { + this.#animated = animated + this.#animationEnterClassName = animationEnterClassName || '__tooltip-enter' + this.#animationLeaveClassName = animationLeaveClassName || '__tooltip-leave' + + this.#container?.setAttribute('class', className || `__tooltip __tooltip-${this.#position}`) + + if (!disabled && !this.#boundEnterHandler) { this.#enable() - } else if(disabled && !!this.#boundEnterHandler) { + } else if (disabled && !!this.#boundEnterHandler) { this.#disable() } } destroy() { this.#removeContainerFromTarget() - + this.#disable() } - + #enable() { this.#boundEnterHandler = this.#onTargetEnter.bind(this) this.#boundLeaveHandler = this.#onTargetLeave.bind(this) - + this.#target.addEventListener('mouseenter', this.#boundEnterHandler) this.#target.addEventListener('mouseleave', this.#boundLeaveHandler) } - + #disable() { this.#target.removeEventListener('mouseenter', this.#boundEnterHandler) this.#target.removeEventListener('mouseleave', this.#boundLeaveHandler) - + this.#boundEnterHandler = null this.#boundLeaveHandler = null } - #appendContainerToTarget() { + async #appendContainerToTarget() { + if (this.#animated) { + await this.#manageTransition(1) + } + this.#target.appendChild(this.#container) if (this.#actions) { @@ -145,7 +204,7 @@ export class Tooltip { if (trigger) { const listener = (event) => { callback?.apply(null, [...callbackParams, event]) - if(closeOnCallback) { + if (closeOnCallback) { this.#removeContainerFromTarget() } } @@ -156,22 +215,54 @@ export class Tooltip { } } - #removeContainerFromTarget() { - if (this.#target.contains(this.#container)) { - this.#target.removeChild(this.#container) + async #removeContainerFromTarget() { + if (this.#animated) { + await this.#manageTransition(0) } + this.#container.remove() + this.#events.forEach(({ trigger, eventType, listener }) => trigger.removeEventListener(eventType, listener)) this.#events = [] } - #onTargetEnter() { - this.#appendContainerToTarget() + #manageTransition(direction) { + return new Promise((resolve) => { + let classToAdd, classToRemove + switch (direction) { + case 1: { + classToAdd = this.#animationEnterClassName + classToRemove = this.#animationLeaveClassName + break + } + default: { + classToAdd = this.#animationLeaveClassName + classToRemove = this.#animationEnterClassName + } + } + this.#container.classList.add(classToAdd) + this.#container.classList.remove(classToRemove) + + if (direction === 1) { + resolve() + } + + const onTransitionEnd = () => { + this.#container.removeEventListener('animationend', onTransitionEnd) + this.#container.classList.remove(classToAdd) + resolve() + } + this.#container.addEventListener('animationend', onTransitionEnd) + }) + } + + async #onTargetEnter() { + await this.#appendContainerToTarget() Tooltip.#observer.wait(`#tooltip`, null, { events: [DOMObserver.EXIST] }).then(({ node }) => { const { width: targetWidth, height: targetHeight } = this.#target.getBoundingClientRect() const { width: tooltipWidth, height: tooltipHeight } = this.#container.getBoundingClientRect() - switch(this.#position) { + switch (this.#position) { case 'left': { this.#container.style.top = `${-(tooltipHeight - targetHeight) >> 1}px` this.#container.style.bottom = null @@ -203,8 +294,8 @@ export class Tooltip { }) } - #onTargetLeave() { - this.#removeContainerFromTarget() + async #onTargetLeave() { + await this.#removeContainerFromTarget() } }