Passed
Push — trunk ( 54c4dd...cf5ce9 )
by Christian
15:20 queued 20s
created

Tooltip   F

Complexity

Total Complexity 70

Size/Duplication

Total Lines 418
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 307
dl 0
loc 418
rs 2.8
c 0
b 0
f 0
wmc 70

18 Functions

Rating   Name   Duplication   Size   Complexity  
A _doesParentExist 0 11 3
A validatePlacement 0 11 2
F update 0 77 14
A createDOMElement 0 20 2
A _setDOMElementPosition 0 4 1
B init 0 12 6
F _placeTooltip 0 28 17
A registerEvents 0 11 2
A validateMessage 0 7 4
A validateWidth 0 12 3
A validateDelay 0 8 2
A _isElementInViewport 0 17 1
A createParentDOMElementWrapper 0 15 1
A _calculateTooltipPosition 0 27 2
A hideTooltip 0 11 2
A onMouseToggle 0 12 3
A _toggle 0 9 3
A showTooltip 0 12 2

How to fix   Complexity   

Complexity

Complex classes like Tooltip often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
/**
2
 * @package admin
3
 */
4
5
/* eslint-disable @typescript-eslint/no-non-null-assertion */
6
7
import type { VNode } from 'vue';
8
import Vue from 'vue';
9
10
const { Directive } = Shopware;
11
const { debug } = Shopware.Utils;
12
const utils = Shopware.Utils;
13
14
type Placements = 'top'|'right'|'bottom'|'left';
15
16
const availableTooltipPlacements: Placements[] = [
17
    'top',
18
    'right',
19
    'bottom',
20
    'left',
21
];
22
23
// eslint-disable-next-line no-use-before-define
24
const tooltipRegistry = new Map<string, Tooltip>();
25
26
/**
27
 * @deprecated tag:v6.6.0 - Will be private
28
 */
29
class Tooltip {
30
    private _id?: string;
31
32
    private _placement?: Placements;
33
34
    private _message: string;
35
36
    private _width: number|string;
37
38
    private _parentDOMElement: HTMLElement;
39
40
    private _showDelay: number;
41
42
    private _hideDelay: number;
43
44
    private _disabled: boolean;
45
46
    private _appearance: string;
47
48
    private _showOnDisabledElements: boolean;
49
50
    private _zIndex: number|null;
51
52
    private _isShown: boolean;
53
54
    private _state: boolean;
55
56
    private _DOMElement: HTMLElement|null;
57
58
    private _vue: Vue|null;
59
60
    private _parentDOMElementWrapper: HTMLElement|null;
61
62
    private _actualTooltipPlacement: Placements|null;
63
64
    private _timeout?: ReturnType<typeof setTimeout>;
65
66
67
    constructor({
68
        id = utils.createId(),
69
        placement = 'top',
70
        message,
71
        width = 200,
72
        element,
73
        showDelay = 100,
74
        hideDelay = showDelay,
75
        disabled = false,
76
        appearance = 'dark',
77
        showOnDisabledElements = false,
78
        zIndex = null,
79
    }: {
80
        id?: string,
81
        placement?: Placements,
82
        message?: string,
83
        width?: number | string,
84
        element: HTMLElement,
85
        showDelay?: number,
86
        hideDelay?: number,
87
        disabled: boolean,
88
        appearance?: string,
89
        showOnDisabledElements?: boolean,
90
        zIndex?: number|null,
91
    }) {
92
        this._id = id;
93
        this._placement = Tooltip.validatePlacement(placement);
94
        this._message = Tooltip.validateMessage(message);
95
        this._width = Tooltip.validateWidth(width);
96
        this._parentDOMElement = element;
97
        this._showDelay = showDelay ?? 100;
98
        this._hideDelay = hideDelay ?? 100;
99
        this._disabled = disabled;
100
        this._appearance = appearance;
101
        this._showOnDisabledElements = showOnDisabledElements;
102
        this._zIndex = zIndex;
103
104
        // initialize tooltip variables
105
        this._isShown = false;
106
        this._state = false;
107
        this._DOMElement = null;
108
        this._vue = null;
109
        this._parentDOMElementWrapper = null;
110
        this._actualTooltipPlacement = null;
111
    }
112
113
    /**
114
     * @returns {String}
115
     */
116
    get id() {
117
        return this._id;
118
    }
119
120
    /**
121
     * Initializes the tooltip.
122
     * Needs to be called after the parent DOM Element is inserted to the DOM.
123
     */
124
    init(node: VNode) {
125
        this._DOMElement = this.createDOMElement(node);
126
127
        if (this._showOnDisabledElements) {
128
            this._parentDOMElementWrapper = this.createParentDOMElementWrapper();
129
        }
130
131
        this.registerEvents();
132
    }
133
134
    /**
135
     * Updates the styles and/or text of the tooltip
136
     */
137
    update({
138
        message,
139
        placement,
140
        width,
141
        showDelay,
142
        hideDelay,
143
        disabled,
144
        appearance,
145
        showOnDisabledElements,
146
        zIndex,
147
    }: {
148
        message?: string,
149
        placement?: Placements,
150
        width?: number | string,
151
        showDelay?: number,
152
        hideDelay?: number,
153
        disabled?: boolean,
154
        appearance?: string,
155
        showOnDisabledElements?: boolean,
156
        zIndex?: number|null,
157
    }) {
158
        if (message && this._message !== message) {
159
            this._message = Tooltip.validateMessage(message);
160
161
            if (this._DOMElement) {
162
                this._DOMElement.innerHTML = this._message;
163
            }
164
165
            this._vue?.$destroy();
166
            this._vue = new Vue({
167
                el: this._DOMElement!,
168
                parent: this._vue?.$parent,
169
                template: this._DOMElement?.outerHTML,
170
            });
171
172
            this._DOMElement = this._vue.$el as HTMLElement;
173
            this.registerEvents();
174
        }
175
176
        if (width && this._width !== width) {
177
            this._width = Tooltip.validateWidth(width);
178
            this._DOMElement!.style.width = `${this._width}px`;
179
        }
180
181
        if (placement && this._placement !== placement) {
182
            this._placement = Tooltip.validatePlacement(placement);
183
            this._placeTooltip();
184
        }
185
186
        if (showDelay && this._showDelay !== showDelay) {
187
            this._showDelay = showDelay;
188
        }
189
190
        if (hideDelay && this._hideDelay !== hideDelay) {
191
            this._hideDelay = hideDelay;
192
        }
193
194
        if (disabled !== undefined && this._disabled !== disabled) {
195
            this._disabled = disabled;
196
        }
197
198
        if (appearance && this._appearance !== appearance) {
199
            this._DOMElement!.classList.remove(`sw-tooltip--${this._appearance}`);
200
            this._appearance = appearance;
201
            this._DOMElement!.classList.add(`sw-tooltip--${this._appearance}`);
202
        }
203
204
        if (showOnDisabledElements !== undefined && this._showOnDisabledElements !== showOnDisabledElements) {
205
            this._showOnDisabledElements = showOnDisabledElements;
206
        }
207
208
        if (zIndex !== this._zIndex && zIndex !== undefined) {
209
            this._zIndex = zIndex;
210
        }
211
    }
212
213
    /**
214
     * Creates a wrapper around the original DOMElement.
215
     * This is needed because a disabled input field does not fire any mouse events and prevents the tooltip
216
     * therefore from working.
217
     * @returns {HTMLElement}
218
     */
219
    createParentDOMElementWrapper() {
220
        const element = document.createElement('div');
221
        element.classList.add('sw-tooltip--wrapper');
222
223
        this._parentDOMElement.parentNode!.insertBefore(element, this._parentDOMElement);
224
        element.appendChild(this._parentDOMElement);
225
226
        return element;
227
    }
228
229
    createDOMElement(node: VNode): HTMLElement {
230
        const element = document.createElement('div');
231
        element.innerHTML = this._message;
232
        element.style.width = `${this._width}px`;
233
        element.setAttribute('aria-hidden', 'false');
234
        element.classList.add('sw-tooltip');
235
        element.classList.add(`sw-tooltip--${this._appearance}`);
236
237
        if (this._zIndex !== null) {
238
            element.style.zIndex = this._zIndex.toFixed(0);
239
        }
240
241
        this._vue = new Vue({
242
            el: element,
243
            parent: node.context,
244
            template: element.outerHTML,
245
        });
246
247
        return this._vue.$el as HTMLElement;
248
    }
249
250
    registerEvents() {
251
        if (this._parentDOMElementWrapper) {
252
            this._parentDOMElementWrapper.addEventListener('mouseenter', this.onMouseToggle.bind(this));
253
            this._parentDOMElementWrapper.addEventListener('mouseleave', this.onMouseToggle.bind(this));
254
        } else {
255
            this._parentDOMElement.addEventListener('mouseenter', this.onMouseToggle.bind(this));
256
            this._parentDOMElement.addEventListener('mouseleave', this.onMouseToggle.bind(this));
257
        }
258
        this._DOMElement!.addEventListener('mouseenter', this.onMouseToggle.bind(this));
259
        this._DOMElement!.addEventListener('mouseleave', this.onMouseToggle.bind(this));
260
    }
261
262
    /**
263
     * Sets the state and triggers the toggle.
264
     */
265
    onMouseToggle(event: MouseEvent) {
266
        this._state = (event.type === 'mouseenter');
267
268
        if (this._timeout) {
269
            clearTimeout(this._timeout);
270
        }
271
272
        this._timeout = setTimeout(this._toggle.bind(this), (this._state ? this._showDelay : this._hideDelay));
273
    }
274
275
    _toggle() {
276
        if (this._state && !this._isShown && this._doesParentExist()) {
277
            this.showTooltip();
278
            return;
279
        }
280
281
        if (!this._state && this._isShown) {
282
            this.hideTooltip();
283
        }
284
    }
285
286
    /**
287
     * Gets the parent element by tag name and tooltip id and returns true or false whether the element exists.
288
     * @returns {boolean}
289
     * @private
290
     */
291
    _doesParentExist() {
292
        const tooltipIdOfParentElement = this._parentDOMElement.getAttribute('tooltip-id') ?? '';
293
        const htmlTagOfParentElement = this._parentDOMElement.tagName.toLowerCase();
294
295
        return !!document.querySelector(`${htmlTagOfParentElement}[tooltip-id="${tooltipIdOfParentElement}"]`);
296
    }
297
298
    /**
299
     * Appends the tooltip to the DOM and sets a suitable position
300
     */
301
    showTooltip() {
302
        if (this._disabled) {
303
            return;
304
        }
305
        document.body.appendChild(this._DOMElement!);
306
307
        this._placeTooltip();
308
        this._isShown = true;
309
    }
310
311
    /**
312
     * Removes the tooltip from the DOM
313
     */
314
    hideTooltip() {
315
        if (this._disabled) {
316
            return;
317
        }
318
        this._DOMElement!.remove();
319
        this._vue!.$destroy();
320
        this._isShown = false;
321
    }
322
323
    _placeTooltip() {
324
        let possiblePlacements = availableTooltipPlacements;
325
        let placement = this._placement;
326
        possiblePlacements = possiblePlacements.filter((pos) => pos !== placement);
327
328
        // Remove previous placement class if it exists
329
        this._DOMElement!.classList.remove(`sw-tooltip--${this._actualTooltipPlacement!}`);
330
331
        // Set the tooltip to the desired place
332
        this._setDOMElementPosition(this._calculateTooltipPosition(placement ?? 'top'));
333
        this._actualTooltipPlacement = placement ?? null;
334
335
        // Check if the tooltip is fully visible in viewport and change position if not
336
        while (!this._isElementInViewport(this._DOMElement!)) {
337
            // The tooltip wont fit in any position
338
            if (possiblePlacements.length < 1) {
339
                this._actualTooltipPlacement = this._placement ?? null;
340
                this._setDOMElementPosition(this._calculateTooltipPosition(this._placement ?? 'top'));
341
                break;
342
            }
343
            // try the next position in the possiblePositions array
344
            placement = possiblePlacements.shift();
345
            this._setDOMElementPosition(this._calculateTooltipPosition(placement ?? 'top'));
346
            this._actualTooltipPlacement = placement ?? null;
347
        }
348
349
        this._DOMElement!.classList.add(`sw-tooltip--${this._actualTooltipPlacement ?? ''}`);
350
    }
351
352
    _setDOMElementPosition({ top, left }: { top: string, left: string }) {
353
        this._DOMElement!.style.top = top;
354
        this._DOMElement!.style.left = left;
355
    }
356
357
    _calculateTooltipPosition(placement: Placements) {
358
        const boundingBox = this._parentDOMElement.getBoundingClientRect();
359
        const secureOffset = 10;
360
361
        let top;
362
        let left;
363
364
        switch (placement) {
365
            case 'bottom':
366
                top = `${boundingBox.top + boundingBox.height + secureOffset}px`;
367
                left = `${boundingBox.left + (boundingBox.width / 2) - this._DOMElement!.offsetWidth / 2}px`;
368
                break;
369
            case 'left':
370
                top = `${boundingBox.top + boundingBox.height / 2 - this._DOMElement!.offsetHeight / 2}px`;
371
                left = `${boundingBox.left - secureOffset - this._DOMElement!.offsetWidth}px`;
372
                break;
373
            case 'right':
374
                top = `${boundingBox.top + boundingBox.height / 2 - this._DOMElement!.offsetHeight / 2}px`;
375
                left = `${boundingBox.right + secureOffset}px`;
376
                break;
377
            case 'top':
378
            default:
379
                top = `${boundingBox.top - this._DOMElement!.offsetHeight - secureOffset}px`;
380
                left = `${boundingBox.left + (boundingBox.width / 2) - this._DOMElement!.offsetWidth / 2}px`;
381
        }
382
        return { top: top, left: left };
383
    }
384
385
    _isElementInViewport(element: HTMLElement) {
386
        // get position
387
        const boundingClientRect = element.getBoundingClientRect();
388
        const windowHeight =
389
            window.innerHeight || document.documentElement.clientHeight;
390
        const windowWidth = window.innerWidth || document.documentElement.clientWidth;
391
392
        // calculate which borders are in viewport
393
        const visibleBorders = {
394
            top: boundingClientRect.top > 0,
395
            right: boundingClientRect.right < windowWidth,
396
            bottom: boundingClientRect.bottom < windowHeight,
397
            left: boundingClientRect.left > 0,
398
        };
399
400
        return visibleBorders.top && visibleBorders.right && visibleBorders.bottom && visibleBorders.left;
401
    }
402
403
    static validatePlacement<P extends Placements>(placement: P): Placements {
404
        if (!availableTooltipPlacements.includes(placement)) {
405
            debug.warn(
406
                'Tooltip Directive',
407
                `The modifier has to be one of these "${availableTooltipPlacements.join(',')}"`,
408
            );
409
410
            return 'top';
411
        }
412
        return placement;
413
    }
414
415
    static validateMessage(message?: string): string {
416
        if (typeof message !== 'string') {
417
            debug.warn('Tooltip Directive', 'The tooltip needs a message with type string');
418
        }
419
420
        return message ?? '';
421
    }
422
423
    static validateWidth(width: number|string): number|string {
424
        if (width === 'auto') {
425
            return width;
426
        }
427
428
        if (typeof width !== 'number' || width < 1) {
429
            debug.warn('Tooltip Directive', 'The tooltip width has to be a number greater 0');
430
            return 200;
431
        }
432
433
        return width;
434
    }
435
436
    static validateDelay(delay: number): number {
437
        if (typeof delay !== 'number' || delay < 1) {
438
            debug.warn('Tooltip Directive', 'The tooltip delay has to be a number greater 0');
439
            return 100;
440
        }
441
442
        return delay;
443
    }
444
}
445
446
/**
447
 * Helper function for creating or updating a tooltip instance
448
 */
449
function createOrUpdateTooltip(el: HTMLElement, { value, modifiers }: {
450
    value: {
451
        message: string,
452
        position: Placements,
453
        showDelay: number,
454
        hideDelay: number,
455
        disabled: boolean,
456
        appearance: string,
457
        width: number|string,
458
        showOnDisabledElements: boolean,
459
        zIndex: number,
460
    },
461
    modifiers: {
462
        [key: string]: unknown,
463
    }
464
}) {
465
    let message: string = typeof value === 'string' ? value : value.message;
466
    message = message ? message.trim() : '';
467
468
    const placement = value.position || Object.keys(modifiers)[0];
469
    const showDelay = value.showDelay;
470
    const hideDelay = value.hideDelay;
471
    const disabled = value.disabled;
472
    const appearance = value.appearance;
473
    const width = value.width;
474
    const showOnDisabledElements = value.showOnDisabledElements;
475
    const zIndex = value.zIndex;
476
477
    const configuration = {
478
        element: el,
479
        message: message,
480
        placement: placement,
481
        width: width,
482
        showDelay: showDelay,
483
        hideDelay: hideDelay,
484
        disabled: disabled,
485
        appearance: appearance,
486
        showOnDisabledElements: showOnDisabledElements,
487
        zIndex: zIndex,
488
    };
489
490
    if (el.hasAttribute('tooltip-id')) {
491
        const tooltip = tooltipRegistry.get(el.getAttribute('tooltip-id')!);
492
        tooltip!.update(configuration);
493
494
        return;
495
    }
496
497
    const tooltip = new Tooltip(configuration);
498
499
    tooltipRegistry.set(tooltip.id ?? '', tooltip);
500
    el.setAttribute('tooltip-id', tooltip.id!);
501
}
502
503
/**
504
 * Directive for tooltips
505
 * Usage:
506
 * v-tooltip="{configuration}"
507
 * // configuration options:
508
 *  message: The text to be displayed.
509
 *  position: Position of the tooltip relative to the original element(top, bottom etc.).
510
 *  width: The width of the tooltip.
511
 *  showDelay: The delay before the tooltip is shown when the original element is hovered.
512
 *  hideDelay: The delay before the tooltip is removed when the original element is not hovered.
513
 *  disabled: Disables the tooltip and it wont be shown.
514
 *  appearance: Sets a additional css class "sw-tooltip--$appearance" for styling
515
 *  showOnDisabledElements: Shows the tooltip also if the original element is disabled. To achieve
516
 *      this a wrapper div element is created around the original element because the original element
517
 *      prevents mouse events when disabled.
518
 *
519
 * Examples:
520
 * // tooltip with default width of 200px and default position top:
521
 * v-tooltip="'Some text'"
522
 * // tooltip with position bottom by modifier:
523
 * v-tooltip.bottom="'Some text'"
524
 * // tooltip with position bottom and width 300px:
525
 * v-tooltip="{ message: 'Some Text', width: 200, position: 'bottom' }"
526
 * // Alternative tooltip with position bottom and width 300px:
527
 * v-tooltip.bottom="{ message: 'Some Text', width: 200 }"
528
 * // adjusting the delay:
529
 * v-tooltip.bottom="{ message: 'Some Text', width: 200, showDelay: 200, hideDelay: 300 }"
530
 *
531
 * *Note that the position variable has a higher priority as the modifier
532
 */
533
Directive.register('tooltip', {
534
    bind: (el, binding) => {
535
        // @ts-expect-error - tooltip binding has some other required properties
536
        createOrUpdateTooltip(el, binding);
537
    },
538
539
    unbind: (el) => {
540
        if (el.hasAttribute('tooltip-id')) {
541
            const tooltip = tooltipRegistry.get(el.getAttribute('tooltip-id')!);
542
            tooltip!.hideTooltip();
543
        }
544
    },
545
546
    update: (el, binding) => {
547
        // @ts-expect-error - tooltip binding has some other required properties
548
        createOrUpdateTooltip(el, binding);
549
    },
550
551
    /**
552
     * Initialize the tooltip once it has been inserted to the DOM.
553
     * @param el
554
     * @param binding
555
     * @param node
556
     */
557
    inserted: (el, binding, node) => {
558
        if (el.hasAttribute('tooltip-id')) {
559
            const tooltip = tooltipRegistry.get(el.getAttribute('tooltip-id')!);
560
            tooltip!.init(node);
561
        }
562
    },
563
});
564