From 4f416a88394de1dd73422bd5d049a9d7cd508da1 Mon Sep 17 00:00:00 2001 From: Patric Eberle Date: Tue, 2 Jan 2024 15:08:12 +0100 Subject: [PATCH 1/4] #311 refactors c-tooltip for better performance and smaller DOM impact, fixes some TS related issues --- src/directives/price.ts | 9 +- src/plugins/tooltip/c-tooltip.vue | 127 ++++----- src/plugins/tooltip/directives/directive.ts | 278 +++++++++++++------- src/plugins/tooltip/styles/styles.scss | 54 +++- src/plugins/viewport.ts | 2 +- src/setup/directives.ts | 2 +- src/setup/scss/_elements.scss | 7 + src/styleguide/routes/r-tooltips.vue | 20 +- src/types/custom-directive.d.ts | 3 +- tests/unit/specs/directives/price.test.ts | 4 +- 10 files changed, 300 insertions(+), 206 deletions(-) diff --git a/src/directives/price.ts b/src/directives/price.ts index d22e61ae..2b8d60dd 100644 --- a/src/directives/price.ts +++ b/src/directives/price.ts @@ -31,8 +31,7 @@ function format(el: HTMLElement, binding: DirectiveBinding): void { */ export default { name: 'price', - directive: { - beforeMount: format, - updated: format, - }, -} as CustomDirective; + + beforeMount: format, + updated: format, +} satisfies CustomDirective; diff --git a/src/plugins/tooltip/c-tooltip.vue b/src/plugins/tooltip/c-tooltip.vue index a45ca67d..ade29c1d 100644 --- a/src/plugins/tooltip/c-tooltip.vue +++ b/src/plugins/tooltip/c-tooltip.vue @@ -2,24 +2,29 @@ - -
-
- - + + +
+
+ + +
-
+ @@ -33,9 +38,7 @@ } from 'vue'; import { createPopper, Instance, Options } from '@popperjs/core'; import { - CLASS_TOOLTIP_WRAPPER_ACTIVE, - CLASS_TOOLTIP_WRAPPER_VISIBLE, - DEBOUNCE_CLOSE, + BEM_BLOCK_NAME, DEFAULT_POPPER_OPTIONS, } from '@/plugins/tooltip/shared'; @@ -44,23 +47,16 @@ } interface Data { - - /** - * Holds the component instance related popper instance. - */ popperInstance: Instance | null; - - /** - * Holds a debounce timeout for the mouseleave event. - */ - mouseLeaveDebounce: ReturnType | null; + tooltipInitialized: boolean; + tooltipVisible: boolean; } /** * Renders a tooltip for the elements inside its slot. */ export default defineComponent({ - name: 'c-tooltip', + name: BEM_BLOCK_NAME, // components: {}, @@ -80,6 +76,14 @@ type: String, default: 'div', }, + + /** + * Allows to disable the tooltip. + */ + disabled: { + type: Boolean, + default: false, + }, }, // emits: {}, @@ -93,7 +97,8 @@ data(): Data { return { popperInstance: null, - mouseLeaveDebounce: null, + tooltipInitialized: false, + tooltipVisible: false, }; }, @@ -115,25 +120,25 @@ popperOptions(): void { this.popperInstance?.setOptions(this.mergedPopperOptions); }, + + /** + * Creates or updates the popper instance when the visibility changes. + */ + tooltipVisible(visible): void { + this.createPopperInstance(); + this.enableEventListeners(visible); + }, }, // beforeCreate() {}, // created() {}, // beforeMount() {}, - mounted() { - setTimeout(() => { // Delay popper create, since it won't be visible initially anyway. - this.createPopperInstance(); - }, 500); - }, + // mounted() {}, // beforeUpdate() {}, // updated() {}, // activated() {}, // deactivated() {}, beforeUnmount() { - const { tooltip } = this; - - tooltip?.removeEventListener('mouseenter', this.clearDebounce); - tooltip?.removeEventListener('mouseleave', this.onMouseLeave); this.popperInstance?.destroy(); }, // unmounted() {}, @@ -143,27 +148,15 @@ * Creates a new popper instance for the current component instance. */ createPopperInstance(): void { - const { tooltip } = this; + const { tooltip, popperInstance } = this; - if (!tooltip) { + if (popperInstance || !tooltip) { return; } - tooltip.addEventListener('mouseenter', this.clearDebounce); - tooltip.addEventListener('mouseleave', this.onMouseLeave); - this.popperInstance = createPopper(this.$el, this.tooltip, this.mergedPopperOptions); }, - /** - * Clears the mouseLeaveDebounce for the tooltip. - */ - clearDebounce(): void { - if (this.mouseLeaveDebounce) { - clearTimeout(this.mouseLeaveDebounce); - } - }, - /** * Enables the event listener for the popper instance. */ @@ -172,7 +165,7 @@ ...options, modifiers: [ ...options.modifiers || [], - { name: 'eventListeners', enabled }, + { name: 'eventListeners', enabled }, // Auto observes scroll and resize events. ], })); }, @@ -180,42 +173,22 @@ /** * Handles the mouseenter event of the toggle. */ - onMouseEnter(): void { - const { tooltip } = this; + showTooltip(): void { + if (this.disabled) { + return; + } - this.clearDebounce(); - this.enableEventListeners(); - this.popperInstance?.update(); - tooltip?.classList.add(CLASS_TOOLTIP_WRAPPER_ACTIVE); + this.tooltipInitialized = true; this.$nextTick(() => { - tooltip?.classList.add(CLASS_TOOLTIP_WRAPPER_VISIBLE); + this.tooltipVisible = true; }); }, - /** - * Handles the mouseleave event of the toggle. - */ - onMouseLeave(): void { - this.clearDebounce(); - - this.mouseLeaveDebounce = setTimeout(() => { - const { tooltip } = this; - - tooltip.addEventListener('transitionend', () => { - tooltip?.classList.remove(CLASS_TOOLTIP_WRAPPER_ACTIVE); - }, { once: true }); - - tooltip?.classList.remove(CLASS_TOOLTIP_WRAPPER_VISIBLE); - - this.enableEventListeners(false); - }, DEBOUNCE_CLOSE); - }, - /** * Refreshes the popper if images did load inside of it. */ - onLoad(): void { + updatePopperInstance(): void { this.popperInstance?.update(); }, }, diff --git a/src/plugins/tooltip/directives/directive.ts b/src/plugins/tooltip/directives/directive.ts index e1d237a6..405108a8 100644 --- a/src/plugins/tooltip/directives/directive.ts +++ b/src/plugins/tooltip/directives/directive.ts @@ -11,41 +11,83 @@ import { DEBOUNCE_CLOSE, DEFAULT_POPPER_OPTIONS, } from '@/plugins/tooltip/shared'; +import type { CustomDirective } from '@/types/custom-directive'; const storageKey = Symbol('Tooltip directive instance'); +const tooltipAnchor = Symbol('The current tooltip anchor'); type TooltipEvent = { [key: string]: EventListener; } -interface TooltipElement extends HTMLElement { +interface AnchorElement extends HTMLElement { [storageKey]: { isHidden: boolean; popper: Instance; events: TooltipEvent[]; + content: string; }; } +interface Tooltip extends HTMLDivElement { + [tooltipAnchor]?: HTMLElement; +} + +let tooltip: Tooltip; +let tooltipInner: HTMLDivElement; +let hideDebounceTimeout: ReturnType; + /** - * Creates a tooltip element and attaches it to the DOM. + * Removes the active class after the tooltips hide transition ended. */ -function createTooltipElement(content: string): HTMLElement { - const tooltipWrapper = document.createElement('div'); - const tooltip = document.createElement('div'); - - tooltip.innerText = content; - tooltip.classList.add(CLASS_TOOLTIP); +function onCloseTransitionend(): void { + tooltip.classList.remove(CLASS_TOOLTIP_WRAPPER_ACTIVE); +} - tooltipWrapper.classList.add(CLASS_TOOLTIP_WRAPPER); - tooltipWrapper.appendChild(tooltip); +/** + * Hides the tooltip. + */ +function hideTooltip(debounce = true): void { + console.info('hide'); + + if (hideDebounceTimeout) { + clearTimeout(hideDebounceTimeout); + } + + hideDebounceTimeout = setTimeout(() => { + tooltip.addEventListener('transitionend', onCloseTransitionend, { once: true }); + tooltip.classList.remove(CLASS_TOOLTIP_WRAPPER_VISIBLE); + tooltip[tooltipAnchor] = undefined; + }, debounce ? DEBOUNCE_CLOSE : 0); +} - document.body.appendChild(tooltipWrapper); +function setTooltipInnerText(content: string): void { + tooltipInner.innerText = content; +} - return tooltipWrapper; +/** + * Shows the tooltip. + */ +function showTooltip(el: AnchorElement): void { + if (hideDebounceTimeout) { + console.info('clear'); + clearTimeout(hideDebounceTimeout); + } + + setTooltipInnerText(el[storageKey].content || ''); + tooltip.removeEventListener('transitionend', onCloseTransitionend); + tooltip.classList.add(CLASS_TOOLTIP_WRAPPER_ACTIVE); + tooltip[tooltipAnchor] = el; + + el[storageKey].popper.update().then(() => { // Makes sure the position matches the current toggle size. + setTimeout(() => { + tooltip.classList.add(CLASS_TOOLTIP_WRAPPER_VISIBLE); + }); + }); } /** - * Bind the given array of event definitions tho the given element. + * Bind the given array of event definitions to the given element. */ function bindEvents(element: HTMLElement, events: TooltipEvent[], bind = true): void { events.forEach(event => Object.entries(event).forEach(([type, callback]) => { @@ -57,6 +99,56 @@ function bindEvents(element: HTMLElement, events: TooltipEvent[], bind = true): })); } +/** + * Creates a tooltip element and attaches it to the DOM. + */ +function createTooltipElement(): HTMLDivElement { + tooltip = document.createElement('div'); + tooltipInner = document.createElement('div'); + + tooltipInner.classList.add(CLASS_TOOLTIP); + + tooltip.classList.add(CLASS_TOOLTIP_WRAPPER); + tooltip.appendChild(tooltipInner); + + tooltip.addEventListener('pointerenter', () => { + if (hideDebounceTimeout) { + clearTimeout(hideDebounceTimeout); + } + }); + tooltip.addEventListener('mouseleave', () => hideTooltip()); + + document.body.appendChild(tooltip); + + return tooltip; +} + +/** + * Defines the tooltip placement based on the binding value. + */ +function getTooltipPlacement(binding: DirectiveBinding): Placement | undefined { + return binding.arg === 'hidden' ? 'bottom' : binding.arg as Placement; +} + +/** + * Handles global click events and checks if it was an outside click. + */ +function onGlobalClick(event: MouseEvent): void { + const isOutsideClick = event.target !== tooltip && !tooltip.contains(event.target as Node); + const isAnyAnchorElement = !!event.target && storageKey in event.target; + + if (isOutsideClick && !isAnyAnchorElement) { + hideTooltip(false); + } +} + +/** + * Handles global scroll events. + */ +function onScroll(): void { + hideTooltip(false); +} + /** * Will create a tooltip for the bound element. * @@ -65,109 +157,95 @@ function bindEvents(element: HTMLElement, events: TooltipEvent[], bind = true): * tooltipPosition: 'top[-start|end]', 'right[-start|end]', 'bottom[-start|end]', 'left[-start|end]', 'hidden' * tooltipTrigger: 'mouseover' */ -export default { - name: 'tooltip', - - beforeMount(el: TooltipElement, binding: DirectiveBinding): void { - const isHidden = binding.arg === 'hidden'; - const placement = isHidden ? 'bottom' : binding.arg as Placement; - const content = binding.value; - const modifiers = Object.keys(binding.modifiers || {}); - const triggers = modifiers.length ? modifiers : ['mouseenter']; - const tooltip = createTooltipElement(content); - let hideDebounce: ReturnType | null = null; - - const popper = createPopper(el, tooltip, { - ...DEFAULT_POPPER_OPTIONS, - placement, - }); +export default (function(): CustomDirective { + createTooltipElement(); - el.classList.add(CLASS_ANCHOR); + window.addEventListener('click', onGlobalClick, { passive: true }); - /** - * Removes the active class after the tooltips hide transition ended. - */ - function onCloseTransitionend(): void { - tooltip?.classList.remove(CLASS_TOOLTIP_WRAPPER_ACTIVE); - } + // Note: this is a workaround, because the tooltip was aligned with a random element on scroll instead of the last active one. Could be improved. + window.addEventListener('scroll', onScroll, { passive: true }); - /** - * Shows the tooltip. - */ - function show(): void { - if (el[storageKey].isHidden) { - return; - } + return { + name: 'tooltip', - if (hideDebounce) { - clearTimeout(hideDebounce); - } + beforeMount(el: AnchorElement, binding: DirectiveBinding): void { + const isHidden = binding.arg === 'hidden'; + const placement = getTooltipPlacement(binding) || 'bottom'; + const content = binding.value; + const modifiers = Object.keys(binding.modifiers || {}); + const triggers = modifiers.length ? modifiers : ['pointerenter']; - tooltip.removeEventListener('transitionend', onCloseTransitionend); - tooltip.classList.add(CLASS_TOOLTIP_WRAPPER_ACTIVE); + const popper = createPopper(el, tooltip, { + ...DEFAULT_POPPER_OPTIONS, + placement, + }); - popper.update().then(() => { // Makes sure the position matches the current toggle size. - setTimeout(() => { - tooltip.classList.add(CLASS_TOOLTIP_WRAPPER_VISIBLE); - }); + el.classList.add(CLASS_ANCHOR); + + const events = triggers.map((trigger): TooltipEvent => { + switch (trigger) { + case 'click': + return { + click(event): void { + if (!el[storageKey].isHidden) { + if (tooltip[tooltipAnchor] === event.target && tooltip.classList.contains(CLASS_TOOLTIP_WRAPPER_VISIBLE)) { + hideTooltip(false); + } else { + showTooltip(el); + } + } + }, + }; + + default: // mouseover + return { + pointerenter(): void { + if (!el[storageKey].isHidden) { + showTooltip(el); + } + }, + mouseleave: () => hideTooltip(), + }; + } }); - } - /** - * Hides the tooltip. - */ - function hide(): void { - if (hideDebounce) { - clearTimeout(hideDebounce); - } + bindEvents(el, events, !isHidden); - hideDebounce = setTimeout(() => { - tooltip.addEventListener('transitionend', onCloseTransitionend, { once: true }); + el[storageKey] = { + popper, + events, + isHidden, + content, + }; + }, - tooltip.classList.remove(CLASS_TOOLTIP_WRAPPER_VISIBLE); - }, DEBOUNCE_CLOSE); - } + updated(el: AnchorElement, binding: DirectiveBinding): void { + const instance = el[storageKey]; + const placement = getTooltipPlacement(binding) || 'bottom'; - const events = triggers.map((trigger): TooltipEvent => { - switch (trigger) { - default: // mouseover - return { - mouseenter: show, - mouseleave: hide, - }; - } - }); + instance.isHidden = binding.arg === 'hidden'; - tooltip.addEventListener('mouseenter', () => { - if (hideDebounce) { - clearTimeout(hideDebounce); + if (binding.value !== binding.oldValue) { + el[storageKey].content = binding.value; } - }); - tooltip.addEventListener('mouseleave', hide); - - bindEvents(el, events, !isHidden); - el[storageKey] = { - popper, - events, - isHidden, - }; - }, - - updated(el: TooltipElement, binding: DirectiveBinding):void { - const instance = el[storageKey]; + el[storageKey].popper.setOptions({ + placement, + }); - instance.isHidden = binding.arg === 'hidden'; + bindEvents(el, instance.events, !instance.isHidden); + }, - bindEvents(el, instance.events, !instance.isHidden); - }, + beforeUnmount(el: AnchorElement): void { + const instance = el[storageKey]; - beforeUnmount(el: TooltipElement): void { - const instance = el[storageKey]; + bindEvents(el, instance.events, false); - bindEvents(el, instance.events, false); + el.classList.remove(CLASS_ANCHOR); + instance.popper.destroy(); - el.classList.remove(CLASS_ANCHOR); - instance.popper.destroy(); - }, -}; + window.removeEventListener('click', onGlobalClick); + window.removeEventListener('scroll', onScroll); + }, + }; +}()); diff --git a/src/plugins/tooltip/styles/styles.scss b/src/plugins/tooltip/styles/styles.scss index 32ebeb1d..a7c8f579 100644 --- a/src/plugins/tooltip/styles/styles.scss +++ b/src/plugins/tooltip/styles/styles.scss @@ -4,8 +4,13 @@ .c-tooltip { $this: &; - $pointer-width: 6px; - $wrapper-padding: $pointer-width + 6px; + $background-color: $color-grayscale--500; + $color: $color-grayscale--0; + $box-shadow-color: rgba($color-grayscale--0, 0.2); + $border-radius: $border-radius--500; + $pointer__width: 6px; + $wrapper__padding: $pointer__width + 6px; + $content__padding: $spacing--5 $spacing--10; position: relative; display: block; @@ -22,12 +27,11 @@ } &__tooltip-wrapper { - @include z-index(front); // Was required for IE11. - position: absolute; display: none; max-width: 80vw; opacity: 0; + color: $color; transition: opacity $transition-duration--100; pointer-events: none; @@ -42,12 +46,12 @@ &[data-popper-placement*='right'], &[data-popper-placement*='left'] { - padding: 0 $wrapper-padding; + padding: 0 $wrapper__padding; } &[data-popper-placement*='top'], &[data-popper-placement*='bottom'] { - padding: $wrapper-padding 0; + padding: $wrapper__padding 0; } &--active { // Popper not initialized @@ -58,14 +62,20 @@ opacity: 1; pointer-events: all; } + + &--component { + display: block; + opacity: 1; + pointer-events: all; + } } &__tooltip { position: relative; - padding: 10px; - border-radius: $border-radius--500; - background: $color-grayscale--500; - box-shadow: 0 0 6px rgba($color-grayscale--0, 0.2); + padding: $content__padding; + border-radius: $border-radius; + background: $background-color; + box-shadow: 0 0 6px $box-shadow-color; pointer-events: none; &::before, @@ -75,10 +85,10 @@ content: ''; width: 0; height: 0; - border: $pointer-width solid transparent; + border: $pointer__width solid transparent; border-bottom-color: $color-grayscale--500; border-left-color: $color-grayscale--500; - background: $color-grayscale--500; + background: $background-color; // Left center [data-popper-placement='right'] & { @@ -104,7 +114,7 @@ // Top start [data-popper-placement='top-start'] &, [data-popper-placement='bottom-start'] & { - left: $wrapper-padding + $spacing--10; + left: $wrapper__padding + $spacing--10; } // Top center @@ -118,7 +128,23 @@ &::after { @include z-index(back); - box-shadow: 0 2px 6px rgba($color-grayscale--0, 0.2); + box-shadow: 0 2px 6px $box-shadow-color; + } + } + + &__transition { + &--fade-enter-active, + &--fade-leave-active { + transition: opacity $transition-duration--200 ease; + } + + &--fade-enter-from, + &--fade-leave-to { + opacity: 0; + } + + &--fade-leave-to { + transition-delay: 200ms; // Delays hiding of the tooltip. } } } diff --git a/src/plugins/viewport.ts b/src/plugins/viewport.ts index 5c40c7da..b89082a4 100644 --- a/src/plugins/viewport.ts +++ b/src/plugins/viewport.ts @@ -18,7 +18,7 @@ export interface Viewport { } /** - * Adds an viewport instance to Vue itself, which can be used by calling this.$viewport. + * Adds a viewport instance to Vue itself, which can be used by calling this.$viewport. */ const plugin: Plugin = { install(app) { diff --git a/src/setup/directives.ts b/src/setup/directives.ts index 57717ce3..30f08079 100644 --- a/src/setup/directives.ts +++ b/src/setup/directives.ts @@ -6,7 +6,7 @@ const directives = import.meta.glob('../directives/*.ts', { eager: true, import: const plugin: Plugin = { install(app) { Object.values(directives).forEach((module) => { - app.directive(module.name, module.directive); + app.directive(module.name, module); }); }, }; diff --git a/src/setup/scss/_elements.scss b/src/setup/scss/_elements.scss index 67392c50..e57029cf 100644 --- a/src/setup/scss/_elements.scss +++ b/src/setup/scss/_elements.scss @@ -51,3 +51,10 @@ a { text-decoration: none; } } + +// Show browser based input style, if no classes available. +input:not([class]), +textarea:not([class]), +select:not([class]) { + all: revert; +} diff --git a/src/styleguide/routes/r-tooltips.vue b/src/styleguide/routes/r-tooltips.vue index 9ab78a02..fd059047 100644 --- a/src/styleguide/routes/r-tooltips.vue +++ b/src/styleguide/routes/r-tooltips.vue @@ -6,9 +6,17 @@ Disable/Enable Tooltip -
- Hover me to see the tooltip. -
+
    +
  • + Hover me to see the tooltip. +
  • +
  • + Hover me to see the tooltip. +
  • +
  • + Hover me to see the tooltip. +
  • +