tooltip.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. /**
  2. * --------------------------------------------------------------------------
  3. * Bootstrap (v5.0.2): tooltip.js
  4. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
  5. * --------------------------------------------------------------------------
  6. */
  7. import * as Popper from '@popperjs/core'
  8. import {
  9. defineJQueryPlugin,
  10. findShadowRoot,
  11. getElement,
  12. getUID,
  13. isElement,
  14. isRTL,
  15. noop,
  16. typeCheckConfig
  17. } from './util/index'
  18. import {
  19. DefaultAllowlist,
  20. sanitizeHtml
  21. } from './util/sanitizer'
  22. import Data from './dom/data'
  23. import EventHandler from './dom/event-handler'
  24. import Manipulator from './dom/manipulator'
  25. import SelectorEngine from './dom/selector-engine'
  26. import BaseComponent from './base-component'
  27. /**
  28. * ------------------------------------------------------------------------
  29. * Constants
  30. * ------------------------------------------------------------------------
  31. */
  32. const NAME = 'tooltip'
  33. const DATA_KEY = 'bs.tooltip'
  34. const EVENT_KEY = `.${DATA_KEY}`
  35. const CLASS_PREFIX = 'bs-tooltip'
  36. const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g')
  37. const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
  38. const DefaultType = {
  39. animation: 'boolean',
  40. template: 'string',
  41. title: '(string|element|function)',
  42. trigger: 'string',
  43. delay: '(number|object)',
  44. html: 'boolean',
  45. selector: '(string|boolean)',
  46. placement: '(string|function)',
  47. offset: '(array|string|function)',
  48. container: '(string|element|boolean)',
  49. fallbackPlacements: 'array',
  50. boundary: '(string|element)',
  51. customClass: '(string|function)',
  52. sanitize: 'boolean',
  53. sanitizeFn: '(null|function)',
  54. allowList: 'object',
  55. popperConfig: '(null|object|function)'
  56. }
  57. const AttachmentMap = {
  58. AUTO: 'auto',
  59. TOP: 'top',
  60. RIGHT: isRTL() ? 'left' : 'right',
  61. BOTTOM: 'bottom',
  62. LEFT: isRTL() ? 'right' : 'left'
  63. }
  64. const Default = {
  65. animation: true,
  66. template: '<div class="tooltip" role="tooltip">' +
  67. '<div class="tooltip-arrow"></div>' +
  68. '<div class="tooltip-inner"></div>' +
  69. '</div>',
  70. trigger: 'hover focus',
  71. title: '',
  72. delay: 0,
  73. html: false,
  74. selector: false,
  75. placement: 'top',
  76. offset: [0, 0],
  77. container: false,
  78. fallbackPlacements: ['top', 'right', 'bottom', 'left'],
  79. boundary: 'clippingParents',
  80. customClass: '',
  81. sanitize: true,
  82. sanitizeFn: null,
  83. allowList: DefaultAllowlist,
  84. popperConfig: null
  85. }
  86. const Event = {
  87. HIDE: `hide${EVENT_KEY}`,
  88. HIDDEN: `hidden${EVENT_KEY}`,
  89. SHOW: `show${EVENT_KEY}`,
  90. SHOWN: `shown${EVENT_KEY}`,
  91. INSERTED: `inserted${EVENT_KEY}`,
  92. CLICK: `click${EVENT_KEY}`,
  93. FOCUSIN: `focusin${EVENT_KEY}`,
  94. FOCUSOUT: `focusout${EVENT_KEY}`,
  95. MOUSEENTER: `mouseenter${EVENT_KEY}`,
  96. MOUSELEAVE: `mouseleave${EVENT_KEY}`
  97. }
  98. const CLASS_NAME_FADE = 'fade'
  99. const CLASS_NAME_MODAL = 'modal'
  100. const CLASS_NAME_SHOW = 'show'
  101. const HOVER_STATE_SHOW = 'show'
  102. const HOVER_STATE_OUT = 'out'
  103. const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
  104. const TRIGGER_HOVER = 'hover'
  105. const TRIGGER_FOCUS = 'focus'
  106. const TRIGGER_CLICK = 'click'
  107. const TRIGGER_MANUAL = 'manual'
  108. /**
  109. * ------------------------------------------------------------------------
  110. * Class Definition
  111. * ------------------------------------------------------------------------
  112. */
  113. class Tooltip extends BaseComponent {
  114. constructor(element, config) {
  115. if (typeof Popper === 'undefined') {
  116. throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)')
  117. }
  118. super(element)
  119. // private
  120. this._isEnabled = true
  121. this._timeout = 0
  122. this._hoverState = ''
  123. this._activeTrigger = {}
  124. this._popper = null
  125. // Protected
  126. this._config = this._getConfig(config)
  127. this.tip = null
  128. this._setListeners()
  129. }
  130. // Getters
  131. static get Default() {
  132. return Default
  133. }
  134. static get NAME() {
  135. return NAME
  136. }
  137. static get Event() {
  138. return Event
  139. }
  140. static get DefaultType() {
  141. return DefaultType
  142. }
  143. // Public
  144. enable() {
  145. this._isEnabled = true
  146. }
  147. disable() {
  148. this._isEnabled = false
  149. }
  150. toggleEnabled() {
  151. this._isEnabled = !this._isEnabled
  152. }
  153. toggle(event) {
  154. if (!this._isEnabled) {
  155. return
  156. }
  157. if (event) {
  158. const context = this._initializeOnDelegatedTarget(event)
  159. context._activeTrigger.click = !context._activeTrigger.click
  160. if (context._isWithActiveTrigger()) {
  161. context._enter(null, context)
  162. } else {
  163. context._leave(null, context)
  164. }
  165. } else {
  166. if (this.getTipElement().classList.contains(CLASS_NAME_SHOW)) {
  167. this._leave(null, this)
  168. return
  169. }
  170. this._enter(null, this)
  171. }
  172. }
  173. dispose() {
  174. clearTimeout(this._timeout)
  175. EventHandler.off(this._element.closest(`.${CLASS_NAME_MODAL}`), 'hide.bs.modal', this._hideModalHandler)
  176. if (this.tip) {
  177. this.tip.remove()
  178. }
  179. if (this._popper) {
  180. this._popper.destroy()
  181. }
  182. super.dispose()
  183. }
  184. show() {
  185. if (this._element.style.display === 'none') {
  186. throw new Error('Please use show on visible elements')
  187. }
  188. if (!(this.isWithContent() && this._isEnabled)) {
  189. return
  190. }
  191. const showEvent = EventHandler.trigger(this._element, this.constructor.Event.SHOW)
  192. const shadowRoot = findShadowRoot(this._element)
  193. const isInTheDom = shadowRoot === null ?
  194. this._element.ownerDocument.documentElement.contains(this._element) :
  195. shadowRoot.contains(this._element)
  196. if (showEvent.defaultPrevented || !isInTheDom) {
  197. return
  198. }
  199. const tip = this.getTipElement()
  200. const tipId = getUID(this.constructor.NAME)
  201. tip.setAttribute('id', tipId)
  202. this._element.setAttribute('aria-describedby', tipId)
  203. this.setContent()
  204. if (this._config.animation) {
  205. tip.classList.add(CLASS_NAME_FADE)
  206. }
  207. const placement = typeof this._config.placement === 'function' ?
  208. this._config.placement.call(this, tip, this._element) :
  209. this._config.placement
  210. const attachment = this._getAttachment(placement)
  211. this._addAttachmentClass(attachment)
  212. const { container } = this._config
  213. Data.set(tip, this.constructor.DATA_KEY, this)
  214. if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
  215. container.appendChild(tip)
  216. EventHandler.trigger(this._element, this.constructor.Event.INSERTED)
  217. }
  218. if (this._popper) {
  219. this._popper.update()
  220. } else {
  221. this._popper = Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
  222. }
  223. tip.classList.add(CLASS_NAME_SHOW)
  224. const customClass = typeof this._config.customClass === 'function' ? this._config.customClass() : this._config.customClass
  225. if (customClass) {
  226. tip.classList.add(...customClass.split(' '))
  227. }
  228. // If this is a touch-enabled device we add extra
  229. // empty mouseover listeners to the body's immediate children;
  230. // only needed because of broken event delegation on iOS
  231. // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
  232. if ('ontouchstart' in document.documentElement) {
  233. [].concat(...document.body.children).forEach(element => {
  234. EventHandler.on(element, 'mouseover', noop)
  235. })
  236. }
  237. const complete = () => {
  238. const prevHoverState = this._hoverState
  239. this._hoverState = null
  240. EventHandler.trigger(this._element, this.constructor.Event.SHOWN)
  241. if (prevHoverState === HOVER_STATE_OUT) {
  242. this._leave(null, this)
  243. }
  244. }
  245. const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE)
  246. this._queueCallback(complete, this.tip, isAnimated)
  247. }
  248. hide() {
  249. if (!this._popper) {
  250. return
  251. }
  252. const tip = this.getTipElement()
  253. const complete = () => {
  254. if (this._isWithActiveTrigger()) {
  255. return
  256. }
  257. if (this._hoverState !== HOVER_STATE_SHOW) {
  258. tip.remove()
  259. }
  260. this._cleanTipClass()
  261. this._element.removeAttribute('aria-describedby')
  262. EventHandler.trigger(this._element, this.constructor.Event.HIDDEN)
  263. if (this._popper) {
  264. this._popper.destroy()
  265. this._popper = null
  266. }
  267. }
  268. const hideEvent = EventHandler.trigger(this._element, this.constructor.Event.HIDE)
  269. if (hideEvent.defaultPrevented) {
  270. return
  271. }
  272. tip.classList.remove(CLASS_NAME_SHOW)
  273. // If this is a touch-enabled device we remove the extra
  274. // empty mouseover listeners we added for iOS support
  275. if ('ontouchstart' in document.documentElement) {
  276. [].concat(...document.body.children)
  277. .forEach(element => EventHandler.off(element, 'mouseover', noop))
  278. }
  279. this._activeTrigger[TRIGGER_CLICK] = false
  280. this._activeTrigger[TRIGGER_FOCUS] = false
  281. this._activeTrigger[TRIGGER_HOVER] = false
  282. const isAnimated = this.tip.classList.contains(CLASS_NAME_FADE)
  283. this._queueCallback(complete, this.tip, isAnimated)
  284. this._hoverState = ''
  285. }
  286. update() {
  287. if (this._popper !== null) {
  288. this._popper.update()
  289. }
  290. }
  291. // Protected
  292. isWithContent() {
  293. return Boolean(this.getTitle())
  294. }
  295. getTipElement() {
  296. if (this.tip) {
  297. return this.tip
  298. }
  299. const element = document.createElement('div')
  300. element.innerHTML = this._config.template
  301. this.tip = element.children[0]
  302. return this.tip
  303. }
  304. setContent() {
  305. const tip = this.getTipElement()
  306. this.setElementContent(SelectorEngine.findOne(SELECTOR_TOOLTIP_INNER, tip), this.getTitle())
  307. tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
  308. }
  309. setElementContent(element, content) {
  310. if (element === null) {
  311. return
  312. }
  313. if (isElement(content)) {
  314. content = getElement(content)
  315. // content is a DOM node or a jQuery
  316. if (this._config.html) {
  317. if (content.parentNode !== element) {
  318. element.innerHTML = ''
  319. element.appendChild(content)
  320. }
  321. } else {
  322. element.textContent = content.textContent
  323. }
  324. return
  325. }
  326. if (this._config.html) {
  327. if (this._config.sanitize) {
  328. content = sanitizeHtml(content, this._config.allowList, this._config.sanitizeFn)
  329. }
  330. element.innerHTML = content
  331. } else {
  332. element.textContent = content
  333. }
  334. }
  335. getTitle() {
  336. let title = this._element.getAttribute('data-bs-original-title')
  337. if (!title) {
  338. title = typeof this._config.title === 'function' ?
  339. this._config.title.call(this._element) :
  340. this._config.title
  341. }
  342. return title
  343. }
  344. updateAttachment(attachment) {
  345. if (attachment === 'right') {
  346. return 'end'
  347. }
  348. if (attachment === 'left') {
  349. return 'start'
  350. }
  351. return attachment
  352. }
  353. // Private
  354. _initializeOnDelegatedTarget(event, context) {
  355. const dataKey = this.constructor.DATA_KEY
  356. context = context || Data.get(event.delegateTarget, dataKey)
  357. if (!context) {
  358. context = new this.constructor(event.delegateTarget, this._getDelegateConfig())
  359. Data.set(event.delegateTarget, dataKey, context)
  360. }
  361. return context
  362. }
  363. _getOffset() {
  364. const { offset } = this._config
  365. if (typeof offset === 'string') {
  366. return offset.split(',').map(val => Number.parseInt(val, 10))
  367. }
  368. if (typeof offset === 'function') {
  369. return popperData => offset(popperData, this._element)
  370. }
  371. return offset
  372. }
  373. _getPopperConfig(attachment) {
  374. const defaultBsPopperConfig = {
  375. placement: attachment,
  376. modifiers: [
  377. {
  378. name: 'flip',
  379. options: {
  380. fallbackPlacements: this._config.fallbackPlacements
  381. }
  382. },
  383. {
  384. name: 'offset',
  385. options: {
  386. offset: this._getOffset()
  387. }
  388. },
  389. {
  390. name: 'preventOverflow',
  391. options: {
  392. boundary: this._config.boundary
  393. }
  394. },
  395. {
  396. name: 'arrow',
  397. options: {
  398. element: `.${this.constructor.NAME}-arrow`
  399. }
  400. },
  401. {
  402. name: 'onChange',
  403. enabled: true,
  404. phase: 'afterWrite',
  405. fn: data => this._handlePopperPlacementChange(data)
  406. }
  407. ],
  408. onFirstUpdate: data => {
  409. if (data.options.placement !== data.placement) {
  410. this._handlePopperPlacementChange(data)
  411. }
  412. }
  413. }
  414. return {
  415. ...defaultBsPopperConfig,
  416. ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig)
  417. }
  418. }
  419. _addAttachmentClass(attachment) {
  420. this.getTipElement().classList.add(`${CLASS_PREFIX}-${this.updateAttachment(attachment)}`)
  421. }
  422. _getAttachment(placement) {
  423. return AttachmentMap[placement.toUpperCase()]
  424. }
  425. _setListeners() {
  426. const triggers = this._config.trigger.split(' ')
  427. triggers.forEach(trigger => {
  428. if (trigger === 'click') {
  429. EventHandler.on(this._element, this.constructor.Event.CLICK, this._config.selector, event => this.toggle(event))
  430. } else if (trigger !== TRIGGER_MANUAL) {
  431. const eventIn = trigger === TRIGGER_HOVER ?
  432. this.constructor.Event.MOUSEENTER :
  433. this.constructor.Event.FOCUSIN
  434. const eventOut = trigger === TRIGGER_HOVER ?
  435. this.constructor.Event.MOUSELEAVE :
  436. this.constructor.Event.FOCUSOUT
  437. EventHandler.on(this._element, eventIn, this._config.selector, event => this._enter(event))
  438. EventHandler.on(this._element, eventOut, this._config.selector, event => this._leave(event))
  439. }
  440. })
  441. this._hideModalHandler = () => {
  442. if (this._element) {
  443. this.hide()
  444. }
  445. }
  446. EventHandler.on(this._element.closest(`.${CLASS_NAME_MODAL}`), 'hide.bs.modal', this._hideModalHandler)
  447. if (this._config.selector) {
  448. this._config = {
  449. ...this._config,
  450. trigger: 'manual',
  451. selector: ''
  452. }
  453. } else {
  454. this._fixTitle()
  455. }
  456. }
  457. _fixTitle() {
  458. const title = this._element.getAttribute('title')
  459. const originalTitleType = typeof this._element.getAttribute('data-bs-original-title')
  460. if (title || originalTitleType !== 'string') {
  461. this._element.setAttribute('data-bs-original-title', title || '')
  462. if (title && !this._element.getAttribute('aria-label') && !this._element.textContent) {
  463. this._element.setAttribute('aria-label', title)
  464. }
  465. this._element.setAttribute('title', '')
  466. }
  467. }
  468. _enter(event, context) {
  469. context = this._initializeOnDelegatedTarget(event, context)
  470. if (event) {
  471. context._activeTrigger[
  472. event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER
  473. ] = true
  474. }
  475. if (context.getTipElement().classList.contains(CLASS_NAME_SHOW) || context._hoverState === HOVER_STATE_SHOW) {
  476. context._hoverState = HOVER_STATE_SHOW
  477. return
  478. }
  479. clearTimeout(context._timeout)
  480. context._hoverState = HOVER_STATE_SHOW
  481. if (!context._config.delay || !context._config.delay.show) {
  482. context.show()
  483. return
  484. }
  485. context._timeout = setTimeout(() => {
  486. if (context._hoverState === HOVER_STATE_SHOW) {
  487. context.show()
  488. }
  489. }, context._config.delay.show)
  490. }
  491. _leave(event, context) {
  492. context = this._initializeOnDelegatedTarget(event, context)
  493. if (event) {
  494. context._activeTrigger[
  495. event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER
  496. ] = context._element.contains(event.relatedTarget)
  497. }
  498. if (context._isWithActiveTrigger()) {
  499. return
  500. }
  501. clearTimeout(context._timeout)
  502. context._hoverState = HOVER_STATE_OUT
  503. if (!context._config.delay || !context._config.delay.hide) {
  504. context.hide()
  505. return
  506. }
  507. context._timeout = setTimeout(() => {
  508. if (context._hoverState === HOVER_STATE_OUT) {
  509. context.hide()
  510. }
  511. }, context._config.delay.hide)
  512. }
  513. _isWithActiveTrigger() {
  514. for (const trigger in this._activeTrigger) {
  515. if (this._activeTrigger[trigger]) {
  516. return true
  517. }
  518. }
  519. return false
  520. }
  521. _getConfig(config) {
  522. const dataAttributes = Manipulator.getDataAttributes(this._element)
  523. Object.keys(dataAttributes).forEach(dataAttr => {
  524. if (DISALLOWED_ATTRIBUTES.has(dataAttr)) {
  525. delete dataAttributes[dataAttr]
  526. }
  527. })
  528. config = {
  529. ...this.constructor.Default,
  530. ...dataAttributes,
  531. ...(typeof config === 'object' && config ? config : {})
  532. }
  533. config.container = config.container === false ? document.body : getElement(config.container)
  534. if (typeof config.delay === 'number') {
  535. config.delay = {
  536. show: config.delay,
  537. hide: config.delay
  538. }
  539. }
  540. if (typeof config.title === 'number') {
  541. config.title = config.title.toString()
  542. }
  543. if (typeof config.content === 'number') {
  544. config.content = config.content.toString()
  545. }
  546. typeCheckConfig(NAME, config, this.constructor.DefaultType)
  547. if (config.sanitize) {
  548. config.template = sanitizeHtml(config.template, config.allowList, config.sanitizeFn)
  549. }
  550. return config
  551. }
  552. _getDelegateConfig() {
  553. const config = {}
  554. if (this._config) {
  555. for (const key in this._config) {
  556. if (this.constructor.Default[key] !== this._config[key]) {
  557. config[key] = this._config[key]
  558. }
  559. }
  560. }
  561. return config
  562. }
  563. _cleanTipClass() {
  564. const tip = this.getTipElement()
  565. const tabClass = tip.getAttribute('class').match(BSCLS_PREFIX_REGEX)
  566. if (tabClass !== null && tabClass.length > 0) {
  567. tabClass.map(token => token.trim())
  568. .forEach(tClass => tip.classList.remove(tClass))
  569. }
  570. }
  571. _handlePopperPlacementChange(popperData) {
  572. const { state } = popperData
  573. if (!state) {
  574. return
  575. }
  576. this.tip = state.elements.popper
  577. this._cleanTipClass()
  578. this._addAttachmentClass(this._getAttachment(state.placement))
  579. }
  580. // Static
  581. static jQueryInterface(config) {
  582. return this.each(function () {
  583. const data = Tooltip.getOrCreateInstance(this, config)
  584. if (typeof config === 'string') {
  585. if (typeof data[config] === 'undefined') {
  586. throw new TypeError(`No method named "${config}"`)
  587. }
  588. data[config]()
  589. }
  590. })
  591. }
  592. }
  593. /**
  594. * ------------------------------------------------------------------------
  595. * jQuery
  596. * ------------------------------------------------------------------------
  597. * add .Tooltip to jQuery only if jQuery is present
  598. */
  599. defineJQueryPlugin(Tooltip)
  600. export default Tooltip