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

twbs / bootstrap / 9856469587

09 Jul 2024 11:49AM CUT coverage: 96.07%. Remained the same
9856469587

Pull #40619

github

web-flow
Merge ac79a1794 into 7c392498f
Pull Request #40619: Docs: Fix a minor accessibility issue (checkout example missing h1)

666 of 726 branches covered (91.74%)

Branch coverage included in aggregate %.

2023 of 2073 relevant lines covered (97.59%)

249.89 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') {
103!
108
      throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)')
×
109
    }
110

111
    super(element, config)
103✔
112

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

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

125
    this._setListeners()
103✔
126

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

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

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

141
  static get NAME() {
142
    return NAME
746✔
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') {
56✔
187
      throw new Error('Please use show on visible elements')
1✔
188
    }
189

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

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

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

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

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

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

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

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

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

218
    tip.classList.add(CLASS_NAME_SHOW)
50✔
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) {
50✔
225
      for (const element of [].concat(...document.body.children)) {
31✔
226
        EventHandler.on(element, 'mouseover', noop)
1,113✔
227
      }
228
    }
229

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

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

237
      this._isHovered = false
50✔
238
    }
239

240
    this._queueCallback(complete, this.tip, this._isAnimated())
50✔
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)) {
8✔
260
        EventHandler.off(element, 'mouseover', noop)
287✔
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) {
281✔
298
      this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
61✔
299
    }
300

301
    return this.tip
281✔
302
  }
303

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

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

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

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

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

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

324
    return tip
61✔
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) {
61✔
337
      this._templateFactory.changeContent(content)
6✔
338
    } else {
339
      this._templateFactory = new TemplateFactory({
55✔
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
61✔
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')
114✔
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))
124✔
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])
50✔
376
    const attachment = AttachmentMap[placement.toUpperCase()]
50✔
377
    return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
50✔
378
  }
379

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

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

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

391
    return offset
51✔
392
  }
393

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

398
  _getPopperConfig(attachment) {
399
    const defaultBsPopperConfig = {
52✔
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)
190✔
434
          }
435
        }
436
      ]
437
    }
438

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

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

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

462
        EventHandler.on(this._element, eventIn, this._config.selector, event => {
158✔
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 => {
158✔
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 = () => {
103✔
478
      if (this._element) {
×
479
        this.hide()
×
480
      }
481
    }
482

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

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

489
    if (!title) {
102✔
490
      return
27✔
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)
103✔
541

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

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

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

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

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

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

576
    return config
103✔
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) {
69✔
599
      this._popper.destroy()
17✔
600
      this._popper = null
17✔
601
    }
602

603
    if (this.tip) {
69✔
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