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

twbs / bootstrap / 13352096447

16 Feb 2025 05:27AM CUT coverage: 96.069% (+0.07%) from 95.999%
13352096447

Pull #39364

github

web-flow
Merge aebd50a2d into 0f13e1c2e
Pull Request #39364: Docs: improve progress bar labels markup and explanations for accessibility

666 of 726 branches covered (91.74%)

Branch coverage included in aggregate %.

2022 of 2072 relevant lines covered (97.59%)

348.44 hits per line

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

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

111
    super(element, config)
108✔
112

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

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

125
    this._setListeners()
108✔
126

127
    if (!this._config.selector) {
108✔
128
      this._fixTitle()
107✔
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) {
9✔
160
      return
1✔
161
    }
162

163
    if (this._isShown()) {
8✔
164
      this._leave()
3✔
165
      return
3✔
166
    }
167

168
    this._enter()
5✔
169
  }
170

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

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

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

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

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

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

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

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

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

204
    const tip = this._getTipElement()
53✔
205

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

208
    const { container } = this._config
53✔
209

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

215
    this._popper = this._createPopper(tip)
53✔
216

217
    tip.classList.add(CLASS_NAME_SHOW)
53✔
218

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

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

232
      if (this._isHovered === false) {
53✔
233
        this._leave()
7✔
234
      }
235

236
      this._isHovered = false
53✔
237
    }
238

239
    this._queueCallback(complete, this.tip, this._isAnimated())
53✔
240
  }
241

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

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

252
    const tip = this._getTipElement()
14✔
253
    tip.classList.remove(CLASS_NAME_SHOW)
14✔
254

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

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

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

273
      if (!this._isHovered) {
13✔
274
        this._disposePopper()
13✔
275
      }
276

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

281
    this._queueCallback(complete, this.tip, this._isAnimated())
14✔
282
  }
283

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

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

295
  _getTipElement() {
296
    if (!this.tip) {
253✔
297
      this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
64✔
298
    }
299

300
    return this.tip
253✔
301
  }
302

303
  _createTipElement(content) {
304
    const tip = this._getTemplateFactory(content).toHtml()
64✔
305

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

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

315
    const tipId = getUID(this.constructor.NAME).toString()
64✔
316

317
    tip.setAttribute('id', tipId)
64✔
318

319
    if (this._isAnimated()) {
64✔
320
      tip.classList.add(CLASS_NAME_FADE)
60✔
321
    }
322

323
    return tip
64✔
324
  }
325

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

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

347
    return this._templateFactory
64✔
348
  }
349

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

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

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

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

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

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

379
  _getOffset() {
380
    const { offset } = this._config
57✔
381

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

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

390
    return offset
54✔
391
  }
392

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

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

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

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

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

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

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

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

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

485
  _fixTitle() {
486
    const title = this._element.getAttribute('title')
107✔
487

488
    if (!title) {
107✔
489
      return
31✔
490
    }
491

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

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

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

506
    this._isHovered = true
12✔
507

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

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

520
    this._isHovered = false
11✔
521

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

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

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

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

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

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

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

560
    if (typeof config.delay === 'number') {
108✔
561
      config.delay = {
106✔
562
        show: config.delay,
563
        hide: config.delay
564
      }
565
    }
566

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

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

575
    return config
108✔
576
  }
577

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

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

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

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

596
  _disposePopper() {
597
    if (this._popper) {
73✔
598
      this._popper.destroy()
18✔
599
      this._popper = null
18✔
600
    }
601

602
    if (this.tip) {
73✔
603
      this.tip.remove()
19✔
604
      this.tip = null
19✔
605
    }
606
  }
607

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

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

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

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

626
/**
627
 * jQuery
628
 */
629

630
defineJQueryPlugin(Tooltip)
2✔
631

632
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