• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

twbs / bootstrap / 10793762334

10 Sep 2024 01:43PM CUT coverage: 96.07%. Remained the same
10793762334

Pull #40756

github

web-flow
Merge b549ebd79 into 429e0c3b9
Pull Request #40756: Docs: missing aria-hidden on some decorative SVGs

666 of 726 branches covered (91.74%)

Branch coverage included in aggregate %.

2023 of 2073 relevant lines covered (97.59%)

222.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.47
/js/src/tooltip.js
1
/**
2
 * --------------------------------------------------------------------------
3
 * Bootstrap tooltip.js
4
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5
 * --------------------------------------------------------------------------
6
 */
7

8
import * as Popper from '@popperjs/core'
9
import BaseComponent from './base-component.js'
10
import EventHandler from './dom/event-handler.js'
11
import Manipulator from './dom/manipulator.js'
12
import {
13
  defineJQueryPlugin, execute, findShadowRoot, getElement, getUID, isRTL, noop
14
} from './util/index.js'
15
import { DefaultAllowlist } from './util/sanitizer.js'
16
import TemplateFactory from './util/template-factory.js'
17

18
/**
19
 * Constants
20
 */
21

22
const NAME = 'tooltip'
2✔
23
const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
2✔
24

25
const CLASS_NAME_FADE = 'fade'
2✔
26
const CLASS_NAME_MODAL = 'modal'
2✔
27
const CLASS_NAME_SHOW = 'show'
2✔
28

29
const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
2✔
30
const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
2✔
31

32
const EVENT_MODAL_HIDE = 'hide.bs.modal'
2✔
33

34
const TRIGGER_HOVER = 'hover'
2✔
35
const TRIGGER_FOCUS = 'focus'
2✔
36
const TRIGGER_CLICK = 'click'
2✔
37
const TRIGGER_MANUAL = 'manual'
2✔
38

39
const EVENT_HIDE = 'hide'
2✔
40
const EVENT_HIDDEN = 'hidden'
2✔
41
const EVENT_SHOW = 'show'
2✔
42
const EVENT_SHOWN = 'shown'
2✔
43
const EVENT_INSERTED = 'inserted'
2✔
44
const EVENT_CLICK = 'click'
2✔
45
const EVENT_FOCUSIN = 'focusin'
2✔
46
const EVENT_FOCUSOUT = 'focusout'
2✔
47
const EVENT_MOUSEENTER = 'mouseenter'
2✔
48
const EVENT_MOUSELEAVE = 'mouseleave'
2✔
49

50
const AttachmentMap = {
2✔
51
  AUTO: 'auto',
52
  TOP: 'top',
53
  RIGHT: isRTL() ? 'left' : 'right',
2!
54
  BOTTOM: 'bottom',
55
  LEFT: isRTL() ? 'right' : 'left'
2!
56
}
57

58
const Default = {
2✔
59
  allowList: DefaultAllowlist,
60
  animation: true,
61
  boundary: 'clippingParents',
62
  container: false,
63
  customClass: '',
64
  delay: 0,
65
  fallbackPlacements: ['top', 'right', 'bottom', 'left'],
66
  html: false,
67
  offset: [0, 6],
68
  placement: 'top',
69
  popperConfig: null,
70
  sanitize: true,
71
  sanitizeFn: null,
72
  selector: false,
73
  template: '<div class="tooltip" role="tooltip">' +
74
            '<div class="tooltip-arrow"></div>' +
75
            '<div class="tooltip-inner"></div>' +
76
            '</div>',
77
  title: '',
78
  trigger: 'hover focus'
79
}
80

81
const DefaultType = {
2✔
82
  allowList: 'object',
83
  animation: 'boolean',
84
  boundary: '(string|element)',
85
  container: '(string|element|boolean)',
86
  customClass: '(string|function)',
87
  delay: '(number|object)',
88
  fallbackPlacements: 'array',
89
  html: 'boolean',
90
  offset: '(array|string|function)',
91
  placement: '(string|function)',
92
  popperConfig: '(null|object|function)',
93
  sanitize: 'boolean',
94
  sanitizeFn: '(null|function)',
95
  selector: '(string|boolean)',
96
  template: 'string',
97
  title: '(string|element|function)',
98
  trigger: 'string'
99
}
100

101
/**
102
 * Class definition
103
 */
104

105
class Tooltip extends BaseComponent {
106
  constructor(element, config) {
107
    if (typeof Popper === 'undefined') {
107!
108
      throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)')
×
109
    }
110

111
    super(element, config)
107✔
112

113
    // Private
114
    this._isEnabled = true
107✔
115
    this._timeout = 0
107✔
116
    this._isHovered = null
107✔
117
    this._activeTrigger = {}
107✔
118
    this._popper = null
107✔
119
    this._templateFactory = null
107✔
120
    this._newContent = null
107✔
121

122
    // Protected
123
    this.tip = null
107✔
124

125
    this._setListeners()
107✔
126

127
    if (!this._config.selector) {
107✔
128
      this._fixTitle()
106✔
129
    }
130
  }
131

132
  // Getters
133
  static get Default() {
134
    return Default
359✔
135
  }
136

137
  static get DefaultType() {
138
    return DefaultType
87✔
139
  }
140

141
  static get NAME() {
142
    return NAME
756✔
143
  }
144

145
  // Public
146
  enable() {
147
    this._isEnabled = true
1✔
148
  }
149

150
  disable() {
151
    this._isEnabled = false
2✔
152
  }
153

154
  toggleEnabled() {
155
    this._isEnabled = !this._isEnabled
1✔
156
  }
157

158
  toggle() {
159
    if (!this._isEnabled) {
8✔
160
      return
1✔
161
    }
162

163
    this._activeTrigger.click = !this._activeTrigger.click
7✔
164
    if (this._isShown()) {
7✔
165
      this._leave()
2✔
166
      return
2✔
167
    }
168

169
    this._enter()
5✔
170
  }
171

172
  dispose() {
173
    clearTimeout(this._timeout)
6✔
174

175
    EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
6✔
176

177
    if (this._element.getAttribute('data-bs-original-title')) {
6✔
178
      this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title'))
5✔
179
    }
180

181
    this._disposePopper()
6✔
182
    super.dispose()
6✔
183
  }
184

185
  show() {
186
    if (this._element.style.display === 'none') {
58✔
187
      throw new Error('Please use show on visible elements')
1✔
188
    }
189

190
    if (!(this._isWithContent() && this._isEnabled)) {
57✔
191
      return
2✔
192
    }
193

194
    const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))
55✔
195
    const shadowRoot = findShadowRoot(this._element)
55✔
196
    const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
55✔
197

198
    if (showEvent.defaultPrevented || !isInTheDom) {
55✔
199
      return
3✔
200
    }
201

202
    // TODO: v6 remove this or make it optional
203
    this._disposePopper()
52✔
204

205
    const tip = this._getTipElement()
52✔
206

207
    this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
52✔
208

209
    const { container } = this._config
52✔
210

211
    if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
52✔
212
      container.append(tip)
52✔
213
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
52✔
214
    }
215

216
    this._popper = this._createPopper(tip)
52✔
217

218
    tip.classList.add(CLASS_NAME_SHOW)
52✔
219

220
    // If this is a touch-enabled device we add extra
221
    // empty mouseover listeners to the body's immediate children;
222
    // only needed because of broken event delegation on iOS
223
    // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
224
    if ('ontouchstart' in document.documentElement) {
52✔
225
      for (const element of [].concat(...document.body.children)) {
41✔
226
        EventHandler.on(element, 'mouseover', noop)
1,473✔
227
      }
228
    }
229

230
    const complete = () => {
52✔
231
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))
52✔
232

233
      if (this._isHovered === false) {
52✔
234
        this._leave()
6✔
235
      }
236

237
      this._isHovered = false
52✔
238
    }
239

240
    this._queueCallback(complete, this.tip, this._isAnimated())
52✔
241
  }
242

243
  hide() {
244
    if (!this._isShown()) {
16✔
245
      return
2✔
246
    }
247

248
    const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))
14✔
249
    if (hideEvent.defaultPrevented) {
14✔
250
      return
1✔
251
    }
252

253
    const tip = this._getTipElement()
13✔
254
    tip.classList.remove(CLASS_NAME_SHOW)
13✔
255

256
    // If this is a touch-enabled device we remove the extra
257
    // empty mouseover listeners we added for iOS support
258
    if ('ontouchstart' in document.documentElement) {
13✔
259
      for (const element of [].concat(...document.body.children)) {
10✔
260
        EventHandler.off(element, 'mouseover', noop)
358✔
261
      }
262
    }
263

264
    this._activeTrigger[TRIGGER_CLICK] = false
13✔
265
    this._activeTrigger[TRIGGER_FOCUS] = false
13✔
266
    this._activeTrigger[TRIGGER_HOVER] = false
13✔
267
    this._isHovered = null // it is a trick to support manual triggering
13✔
268

269
    const complete = () => {
13✔
270
      if (this._isWithActiveTrigger()) {
13✔
271
        return
1✔
272
      }
273

274
      if (!this._isHovered) {
12✔
275
        this._disposePopper()
12✔
276
      }
277

278
      this._element.removeAttribute('aria-describedby')
12✔
279
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))
12✔
280
    }
281

282
    this._queueCallback(complete, this.tip, this._isAnimated())
13✔
283
  }
284

285
  update() {
286
    if (this._popper) {
2✔
287
      this._popper.update()
1✔
288
    }
289
  }
290

291
  // Protected
292
  _isWithContent() {
293
    return Boolean(this._getTitle())
46✔
294
  }
295

296
  _getTipElement() {
297
    if (!this.tip) {
242✔
298
      this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
63✔
299
    }
300

301
    return this.tip
242✔
302
  }
303

304
  _createTipElement(content) {
305
    const tip = this._getTemplateFactory(content).toHtml()
63✔
306

307
    // TODO: remove this check in v6
308
    if (!tip) {
63!
309
      return null
×
310
    }
311

312
    tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
63✔
313
    // TODO: v6 the following can be achieved with CSS only
314
    tip.classList.add(`bs-${this.constructor.NAME}-auto`)
63✔
315

316
    const tipId = getUID(this.constructor.NAME).toString()
63✔
317

318
    tip.setAttribute('id', tipId)
63✔
319

320
    if (this._isAnimated()) {
63✔
321
      tip.classList.add(CLASS_NAME_FADE)
59✔
322
    }
323

324
    return tip
63✔
325
  }
326

327
  setContent(content) {
328
    this._newContent = content
12✔
329
    if (this._isShown()) {
12✔
330
      this._disposePopper()
1✔
331
      this.show()
1✔
332
    }
333
  }
334

335
  _getTemplateFactory(content) {
336
    if (this._templateFactory) {
63✔
337
      this._templateFactory.changeContent(content)
6✔
338
    } else {
339
      this._templateFactory = new TemplateFactory({
57✔
340
        ...this._config,
341
        // the `content` var has to be after `this._config`
342
        // to override config.content in case of popover
343
        content,
344
        extraClass: this._resolvePossibleFunction(this._config.customClass)
345
      })
346
    }
347

348
    return this._templateFactory
63✔
349
  }
350

351
  _getContentForTemplate() {
352
    return {
43✔
353
      [SELECTOR_TOOLTIP_INNER]: this._getTitle()
354
    }
355
  }
356

357
  _getTitle() {
358
    return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')
120✔
359
  }
360

361
  // Private
362
  _initializeOnDelegatedTarget(event) {
363
    return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())
16✔
364
  }
365

366
  _isAnimated() {
367
    return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE))
128✔
368
  }
369

370
  _isShown() {
371
    return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)
49✔
372
  }
373

374
  _createPopper(tip) {
375
    const placement = execute(this._config.placement, [this, tip, this._element])
52✔
376
    const attachment = AttachmentMap[placement.toUpperCase()]
52✔
377
    return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
52✔
378
  }
379

380
  _getOffset() {
381
    const { offset } = this._config
56✔
382

383
    if (typeof offset === 'string') {
56✔
384
      return offset.split(',').map(value => Number.parseInt(value, 10))
2✔
385
    }
386

387
    if (typeof offset === 'function') {
55✔
388
      return popperData => offset(popperData, this._element)
60✔
389
    }
390

391
    return offset
53✔
392
  }
393

394
  _resolvePossibleFunction(arg) {
395
    return execute(arg, [this._element, this._element])
193✔
396
  }
397

398
  _getPopperConfig(attachment) {
399
    const defaultBsPopperConfig = {
54✔
400
      placement: attachment,
401
      modifiers: [
402
        {
403
          name: 'flip',
404
          options: {
405
            fallbackPlacements: this._config.fallbackPlacements
406
          }
407
        },
408
        {
409
          name: 'offset',
410
          options: {
411
            offset: this._getOffset()
412
          }
413
        },
414
        {
415
          name: 'preventOverflow',
416
          options: {
417
            boundary: this._config.boundary
418
          }
419
        },
420
        {
421
          name: 'arrow',
422
          options: {
423
            element: `.${this.constructor.NAME}-arrow`
424
          }
425
        },
426
        {
427
          name: 'preSetPlacement',
428
          enabled: true,
429
          phase: 'beforeMain',
430
          fn: data => {
431
            // Pre-set Popper's placement attribute in order to read the arrow sizes properly.
432
            // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement
433
            this._getTipElement().setAttribute('data-popper-placement', data.state.placement)
149✔
434
          }
435
        }
436
      ]
437
    }
438

439
    return {
54✔
440
      ...defaultBsPopperConfig,
441
      ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
442
    }
443
  }
444

445
  _setListeners() {
446
    const triggers = this._config.trigger.split(' ')
107✔
447

448
    for (const trigger of triggers) {
107✔
449
      if (trigger === 'click') {
188✔
450
        EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => {
25✔
451
          const context = this._initializeOnDelegatedTarget(event)
4✔
452
          context.toggle()
4✔
453
        })
454
      } else if (trigger !== TRIGGER_MANUAL) {
163✔
455
        const eventIn = trigger === TRIGGER_HOVER ?
162✔
456
          this.constructor.eventName(EVENT_MOUSEENTER) :
162✔
457
          this.constructor.eventName(EVENT_FOCUSIN)
458
        const eventOut = trigger === TRIGGER_HOVER ?
162✔
459
          this.constructor.eventName(EVENT_MOUSELEAVE) :
162✔
460
          this.constructor.eventName(EVENT_FOCUSOUT)
461

462
        EventHandler.on(this._element, eventIn, this._config.selector, event => {
162✔
463
          const context = this._initializeOnDelegatedTarget(event)
9✔
464
          context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
9!
465
          context._enter()
9✔
466
        })
467
        EventHandler.on(this._element, eventOut, this._config.selector, event => {
162✔
468
          const context = this._initializeOnDelegatedTarget(event)
3✔
469
          context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
3!
470
            context._element.contains(event.relatedTarget)
471

472
          context._leave()
3✔
473
        })
474
      }
475
    }
476

477
    this._hideModalHandler = () => {
107✔
478
      if (this._element) {
×
479
        this.hide()
×
480
      }
481
    }
482

483
    EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
107✔
484
  }
485

486
  _fixTitle() {
487
    const title = this._element.getAttribute('title')
106✔
488

489
    if (!title) {
106✔
490
      return
31✔
491
    }
492

493
    if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) {
75✔
494
      this._element.setAttribute('aria-label', title)
58✔
495
    }
496

497
    this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility
75✔
498
    this._element.removeAttribute('title')
75✔
499
  }
500

501
  _enter() {
502
    if (this._isShown() || this._isHovered) {
14✔
503
      this._isHovered = true
2✔
504
      return
2✔
505
    }
506

507
    this._isHovered = true
12✔
508

509
    this._setTimeout(() => {
12✔
510
      if (this._isHovered) {
12✔
511
        this.show()
12✔
512
      }
513
    }, this._config.delay.show)
514
  }
515

516
  _leave() {
517
    if (this._isWithActiveTrigger()) {
11✔
518
      return
2✔
519
    }
520

521
    this._isHovered = false
9✔
522

523
    this._setTimeout(() => {
9✔
524
      if (!this._isHovered) {
7✔
525
        this.hide()
6✔
526
      }
527
    }, this._config.delay.hide)
528
  }
529

530
  _setTimeout(handler, timeout) {
531
    clearTimeout(this._timeout)
21✔
532
    this._timeout = setTimeout(handler, timeout)
21✔
533
  }
534

535
  _isWithActiveTrigger() {
536
    return Object.values(this._activeTrigger).includes(true)
24✔
537
  }
538

539
  _getConfig(config) {
540
    const dataAttributes = Manipulator.getDataAttributes(this._element)
107✔
541

542
    for (const dataAttribute of Object.keys(dataAttributes)) {
107✔
543
      if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
22✔
544
        delete dataAttributes[dataAttribute]
1✔
545
      }
546
    }
547

548
    config = {
107✔
549
      ...dataAttributes,
550
      ...(typeof config === 'object' && config ? config : {})
260✔
551
    }
552
    config = this._mergeConfigObj(config)
107✔
553
    config = this._configAfterMerge(config)
107✔
554
    this._typeCheckConfig(config)
107✔
555
    return config
107✔
556
  }
557

558
  _configAfterMerge(config) {
559
    config.container = config.container === false ? document.body : getElement(config.container)
107✔
560

561
    if (typeof config.delay === 'number') {
107✔
562
      config.delay = {
105✔
563
        show: config.delay,
564
        hide: config.delay
565
      }
566
    }
567

568
    if (typeof config.title === 'number') {
107✔
569
      config.title = config.title.toString()
1✔
570
    }
571

572
    if (typeof config.content === 'number') {
107✔
573
      config.content = config.content.toString()
1✔
574
    }
575

576
    return config
107✔
577
  }
578

579
  _getDelegateConfig() {
580
    const config = {}
16✔
581

582
    for (const [key, value] of Object.entries(this._config)) {
16✔
583
      if (this.constructor.Default[key] !== value) {
272✔
584
        config[key] = value
37✔
585
      }
586
    }
587

588
    config.selector = false
16✔
589
    config.trigger = 'manual'
16✔
590

591
    // In the future can be replaced with:
592
    // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]])
593
    // `Object.fromEntries(keysWithDifferentValues)`
594
    return config
16✔
595
  }
596

597
  _disposePopper() {
598
    if (this._popper) {
71✔
599
      this._popper.destroy()
17✔
600
      this._popper = null
17✔
601
    }
602

603
    if (this.tip) {
71✔
604
      this.tip.remove()
18✔
605
      this.tip = null
18✔
606
    }
607
  }
608

609
  // Static
610
  static jQueryInterface(config) {
611
    return this.each(function () {
4✔
612
      const data = Tooltip.getOrCreateInstance(this, config)
4✔
613

614
      if (typeof config !== 'string') {
4✔
615
        return
2✔
616
      }
617

618
      if (typeof data[config] === 'undefined') {
2✔
619
        throw new TypeError(`No method named "${config}"`)
1✔
620
      }
621

622
      data[config]()
1✔
623
    })
624
  }
625
}
626

627
/**
628
 * jQuery
629
 */
630

631
defineJQueryPlugin(Tooltip)
2✔
632

633
export default Tooltip
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc