theme/boost/amd/src/bootstrap/tooltip.js

  1. /**
  2. * --------------------------------------------------------------------------
  3. * Bootstrap tooltip.js
  4. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
  5. * --------------------------------------------------------------------------
  6. */
  7. import * as Popper from 'core/popper2'
  8. import BaseComponent from './base-component'
  9. import EventHandler from './dom/event-handler'
  10. import Manipulator from './dom/manipulator'
  11. import {
  12. defineJQueryPlugin, execute, findShadowRoot, getElement, getUID, isRTL, noop
  13. } from './util/index'
  14. import { DefaultAllowlist } from './util/sanitizer'
  15. import TemplateFactory from './util/template-factory'
  16. /**
  17. * Constants
  18. */
  19. const NAME = 'tooltip'
  20. const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
  21. const CLASS_NAME_FADE = 'fade'
  22. const CLASS_NAME_MODAL = 'modal'
  23. const CLASS_NAME_SHOW = 'show'
  24. const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
  25. const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
  26. const EVENT_MODAL_HIDE = 'hide.bs.modal'
  27. const TRIGGER_HOVER = 'hover'
  28. const TRIGGER_FOCUS = 'focus'
  29. const TRIGGER_CLICK = 'click'
  30. const TRIGGER_MANUAL = 'manual'
  31. const EVENT_HIDE = 'hide'
  32. const EVENT_HIDDEN = 'hidden'
  33. const EVENT_SHOW = 'show'
  34. const EVENT_SHOWN = 'shown'
  35. const EVENT_INSERTED = 'inserted'
  36. const EVENT_CLICK = 'click'
  37. const EVENT_FOCUSIN = 'focusin'
  38. const EVENT_FOCUSOUT = 'focusout'
  39. const EVENT_MOUSEENTER = 'mouseenter'
  40. const EVENT_MOUSELEAVE = 'mouseleave'
  41. const AttachmentMap = {
  42. AUTO: 'auto',
  43. TOP: 'top',
  44. RIGHT: isRTL() ? 'left' : 'right',
  45. BOTTOM: 'bottom',
  46. LEFT: isRTL() ? 'right' : 'left'
  47. }
  48. const Default = {
  49. allowList: DefaultAllowlist,
  50. animation: true,
  51. boundary: 'clippingParents',
  52. container: false,
  53. customClass: '',
  54. delay: 0,
  55. fallbackPlacements: ['top', 'right', 'bottom', 'left'],
  56. html: false,
  57. offset: [0, 6],
  58. placement: 'top',
  59. popperConfig: null,
  60. sanitize: true,
  61. sanitizeFn: null,
  62. selector: false,
  63. template: '<div class="tooltip" role="tooltip">' +
  64. '<div class="tooltip-arrow"></div>' +
  65. '<div class="tooltip-inner"></div>' +
  66. '</div>',
  67. title: '',
  68. trigger: 'hover focus'
  69. }
  70. const DefaultType = {
  71. allowList: 'object',
  72. animation: 'boolean',
  73. boundary: '(string|element)',
  74. container: '(string|element|boolean)',
  75. customClass: '(string|function)',
  76. delay: '(number|object)',
  77. fallbackPlacements: 'array',
  78. html: 'boolean',
  79. offset: '(array|string|function)',
  80. placement: '(string|function)',
  81. popperConfig: '(null|object|function)',
  82. sanitize: 'boolean',
  83. sanitizeFn: '(null|function)',
  84. selector: '(string|boolean)',
  85. template: 'string',
  86. title: '(string|element|function)',
  87. trigger: 'string'
  88. }
  89. /**
  90. * Class definition
  91. */
  92. class Tooltip extends BaseComponent {
  93. constructor(element, config) {
  94. if (typeof Popper === 'undefined') {
  95. throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)')
  96. }
  97. super(element, config)
  98. // Private
  99. this._isEnabled = true
  100. this._timeout = 0
  101. this._isHovered = null
  102. this._activeTrigger = {}
  103. this._popper = null
  104. this._templateFactory = null
  105. this._newContent = null
  106. // Protected
  107. this.tip = null
  108. this._setListeners()
  109. if (!this._config.selector) {
  110. this._fixTitle()
  111. }
  112. }
  113. // Getters
  114. static get Default() {
  115. return Default
  116. }
  117. static get DefaultType() {
  118. return DefaultType
  119. }
  120. static get NAME() {
  121. return NAME
  122. }
  123. // Public
  124. enable() {
  125. this._isEnabled = true
  126. }
  127. disable() {
  128. this._isEnabled = false
  129. }
  130. toggleEnabled() {
  131. this._isEnabled = !this._isEnabled
  132. }
  133. toggle() {
  134. if (!this._isEnabled) {
  135. return
  136. }
  137. this._activeTrigger.click = !this._activeTrigger.click
  138. if (this._isShown()) {
  139. this._leave()
  140. return
  141. }
  142. this._enter()
  143. }
  144. dispose() {
  145. clearTimeout(this._timeout)
  146. EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
  147. if (this._element.getAttribute('data-bs-original-title')) {
  148. this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title'))
  149. }
  150. this._disposePopper()
  151. super.dispose()
  152. }
  153. show() {
  154. if (this._element.style.display === 'none') {
  155. throw new Error('Please use show on visible elements')
  156. }
  157. if (!(this._isWithContent() && this._isEnabled)) {
  158. return
  159. }
  160. const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))
  161. const shadowRoot = findShadowRoot(this._element)
  162. const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
  163. if (showEvent.defaultPrevented || !isInTheDom) {
  164. return
  165. }
  166. // TODO: v6 remove this or make it optional
  167. this._disposePopper()
  168. const tip = this._getTipElement()
  169. this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
  170. const { container } = this._config
  171. if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
  172. container.append(tip)
  173. EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
  174. }
  175. this._popper = this._createPopper(tip)
  176. tip.classList.add(CLASS_NAME_SHOW)
  177. // If this is a touch-enabled device we add extra
  178. // empty mouseover listeners to the body's immediate children;
  179. // only needed because of broken event delegation on iOS
  180. // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
  181. if ('ontouchstart' in document.documentElement) {
  182. for (const element of [].concat(...document.body.children)) {
  183. EventHandler.on(element, 'mouseover', noop)
  184. }
  185. }
  186. const complete = () => {
  187. EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))
  188. if (this._isHovered === false) {
  189. this._leave()
  190. }
  191. this._isHovered = false
  192. }
  193. this._queueCallback(complete, this.tip, this._isAnimated())
  194. }
  195. hide() {
  196. if (!this._isShown()) {
  197. return
  198. }
  199. const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))
  200. if (hideEvent.defaultPrevented) {
  201. return
  202. }
  203. const tip = this._getTipElement()
  204. tip.classList.remove(CLASS_NAME_SHOW)
  205. // If this is a touch-enabled device we remove the extra
  206. // empty mouseover listeners we added for iOS support
  207. if ('ontouchstart' in document.documentElement) {
  208. for (const element of [].concat(...document.body.children)) {
  209. EventHandler.off(element, 'mouseover', noop)
  210. }
  211. }
  212. this._activeTrigger[TRIGGER_CLICK] = false
  213. this._activeTrigger[TRIGGER_FOCUS] = false
  214. this._activeTrigger[TRIGGER_HOVER] = false
  215. this._isHovered = null // it is a trick to support manual triggering
  216. const complete = () => {
  217. if (this._isWithActiveTrigger()) {
  218. return
  219. }
  220. if (!this._isHovered) {
  221. this._disposePopper()
  222. }
  223. this._element.removeAttribute('aria-describedby')
  224. EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))
  225. }
  226. this._queueCallback(complete, this.tip, this._isAnimated())
  227. }
  228. update() {
  229. if (this._popper) {
  230. this._popper.update()
  231. }
  232. }
  233. // Protected
  234. _isWithContent() {
  235. return Boolean(this._getTitle())
  236. }
  237. _getTipElement() {
  238. if (!this.tip) {
  239. this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
  240. }
  241. return this.tip
  242. }
  243. _createTipElement(content) {
  244. const tip = this._getTemplateFactory(content).toHtml()
  245. // TODO: remove this check in v6
  246. if (!tip) {
  247. return null
  248. }
  249. tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
  250. // TODO: v6 the following can be achieved with CSS only
  251. tip.classList.add(`bs-${this.constructor.NAME}-auto`)
  252. const tipId = getUID(this.constructor.NAME).toString()
  253. tip.setAttribute('id', tipId)
  254. if (this._isAnimated()) {
  255. tip.classList.add(CLASS_NAME_FADE)
  256. }
  257. return tip
  258. }
  259. setContent(content) {
  260. this._newContent = content
  261. if (this._isShown()) {
  262. this._disposePopper()
  263. this.show()
  264. }
  265. }
  266. _getTemplateFactory(content) {
  267. if (this._templateFactory) {
  268. this._templateFactory.changeContent(content)
  269. } else {
  270. this._templateFactory = new TemplateFactory({
  271. ...this._config,
  272. // the `content` var has to be after `this._config`
  273. // to override config.content in case of popover
  274. content,
  275. extraClass: this._resolvePossibleFunction(this._config.customClass)
  276. })
  277. }
  278. return this._templateFactory
  279. }
  280. _getContentForTemplate() {
  281. return {
  282. [SELECTOR_TOOLTIP_INNER]: this._getTitle()
  283. }
  284. }
  285. _getTitle() {
  286. return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')
  287. }
  288. // Private
  289. _initializeOnDelegatedTarget(event) {
  290. return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())
  291. }
  292. _isAnimated() {
  293. return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE))
  294. }
  295. _isShown() {
  296. return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)
  297. }
  298. _createPopper(tip) {
  299. const placement = execute(this._config.placement, [this, tip, this._element])
  300. const attachment = AttachmentMap[placement.toUpperCase()]
  301. return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
  302. }
  303. _getOffset() {
  304. const { offset } = this._config
  305. if (typeof offset === 'string') {
  306. return offset.split(',').map(value => Number.parseInt(value, 10))
  307. }
  308. if (typeof offset === 'function') {
  309. return popperData => offset(popperData, this._element)
  310. }
  311. return offset
  312. }
  313. _resolvePossibleFunction(arg) {
  314. return execute(arg, [this._element])
  315. }
  316. _getPopperConfig(attachment) {
  317. const defaultBsPopperConfig = {
  318. placement: attachment,
  319. modifiers: [
  320. {
  321. name: 'flip',
  322. options: {
  323. fallbackPlacements: this._config.fallbackPlacements
  324. }
  325. },
  326. {
  327. name: 'offset',
  328. options: {
  329. offset: this._getOffset()
  330. }
  331. },
  332. {
  333. name: 'preventOverflow',
  334. options: {
  335. boundary: this._config.boundary
  336. }
  337. },
  338. {
  339. name: 'arrow',
  340. options: {
  341. element: `.${this.constructor.NAME}-arrow`
  342. }
  343. },
  344. {
  345. name: 'preSetPlacement',
  346. enabled: true,
  347. phase: 'beforeMain',
  348. fn: data => {
  349. // Pre-set Popper's placement attribute in order to read the arrow sizes properly.
  350. // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement
  351. this._getTipElement().setAttribute('data-popper-placement', data.state.placement)
  352. }
  353. }
  354. ]
  355. }
  356. return {
  357. ...defaultBsPopperConfig,
  358. ...execute(this._config.popperConfig, [defaultBsPopperConfig])
  359. }
  360. }
  361. _setListeners() {
  362. const triggers = this._config.trigger.split(' ')
  363. for (const trigger of triggers) {
  364. if (trigger === 'click') {
  365. EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => {
  366. const context = this._initializeOnDelegatedTarget(event)
  367. context.toggle()
  368. })
  369. } else if (trigger !== TRIGGER_MANUAL) {
  370. const eventIn = trigger === TRIGGER_HOVER ?
  371. this.constructor.eventName(EVENT_MOUSEENTER) :
  372. this.constructor.eventName(EVENT_FOCUSIN)
  373. const eventOut = trigger === TRIGGER_HOVER ?
  374. this.constructor.eventName(EVENT_MOUSELEAVE) :
  375. this.constructor.eventName(EVENT_FOCUSOUT)
  376. EventHandler.on(this._element, eventIn, this._config.selector, event => {
  377. const context = this._initializeOnDelegatedTarget(event)
  378. context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
  379. context._enter()
  380. })
  381. EventHandler.on(this._element, eventOut, this._config.selector, event => {
  382. const context = this._initializeOnDelegatedTarget(event)
  383. context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
  384. context._element.contains(event.relatedTarget)
  385. context._leave()
  386. })
  387. }
  388. }
  389. this._hideModalHandler = () => {
  390. if (this._element) {
  391. this.hide()
  392. }
  393. }
  394. EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
  395. }
  396. _fixTitle() {
  397. const title = this._element.getAttribute('title')
  398. if (!title) {
  399. return
  400. }
  401. if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) {
  402. this._element.setAttribute('aria-label', title)
  403. }
  404. this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility
  405. this._element.removeAttribute('title')
  406. }
  407. _enter() {
  408. if (this._isShown() || this._isHovered) {
  409. this._isHovered = true
  410. return
  411. }
  412. this._isHovered = true
  413. this._setTimeout(() => {
  414. if (this._isHovered) {
  415. this.show()
  416. }
  417. }, this._config.delay.show)
  418. }
  419. _leave() {
  420. if (this._isWithActiveTrigger()) {
  421. return
  422. }
  423. this._isHovered = false
  424. this._setTimeout(() => {
  425. if (!this._isHovered) {
  426. this.hide()
  427. }
  428. }, this._config.delay.hide)
  429. }
  430. _setTimeout(handler, timeout) {
  431. clearTimeout(this._timeout)
  432. this._timeout = setTimeout(handler, timeout)
  433. }
  434. _isWithActiveTrigger() {
  435. return Object.values(this._activeTrigger).includes(true)
  436. }
  437. _getConfig(config) {
  438. const dataAttributes = Manipulator.getDataAttributes(this._element)
  439. for (const dataAttribute of Object.keys(dataAttributes)) {
  440. if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
  441. delete dataAttributes[dataAttribute]
  442. }
  443. }
  444. config = {
  445. ...dataAttributes,
  446. ...(typeof config === 'object' && config ? config : {})
  447. }
  448. config = this._mergeConfigObj(config)
  449. config = this._configAfterMerge(config)
  450. this._typeCheckConfig(config)
  451. return config
  452. }
  453. _configAfterMerge(config) {
  454. config.container = config.container === false ? document.body : getElement(config.container)
  455. if (typeof config.delay === 'number') {
  456. config.delay = {
  457. show: config.delay,
  458. hide: config.delay
  459. }
  460. }
  461. if (typeof config.title === 'number') {
  462. config.title = config.title.toString()
  463. }
  464. if (typeof config.content === 'number') {
  465. config.content = config.content.toString()
  466. }
  467. return config
  468. }
  469. _getDelegateConfig() {
  470. const config = {}
  471. for (const [key, value] of Object.entries(this._config)) {
  472. if (this.constructor.Default[key] !== value) {
  473. config[key] = value
  474. }
  475. }
  476. config.selector = false
  477. config.trigger = 'manual'
  478. // In the future can be replaced with:
  479. // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]])
  480. // `Object.fromEntries(keysWithDifferentValues)`
  481. return config
  482. }
  483. _disposePopper() {
  484. if (this._popper) {
  485. this._popper.destroy()
  486. this._popper = null
  487. }
  488. if (this.tip) {
  489. this.tip.remove()
  490. this.tip = null
  491. }
  492. }
  493. // Static
  494. static jQueryInterface(config) {
  495. return this.each(function () {
  496. const data = Tooltip.getOrCreateInstance(this, config)
  497. if (typeof config !== 'string') {
  498. return
  499. }
  500. if (typeof data[config] === 'undefined') {
  501. throw new TypeError(`No method named "${config}"`)
  502. }
  503. data[config]()
  504. })
  505. }
  506. }
  507. /**
  508. * jQuery
  509. */
  510. defineJQueryPlugin(Tooltip)
  511. export default Tooltip