Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
/**
2
 * --------------------------------------------------------------------------
1441 ariadna 3
 * Bootstrap tooltip.js
1 efrain 4
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5
 * --------------------------------------------------------------------------
6
 */
7
 
1441 ariadna 8
import * as Popper from 'core/popper2'
9
import BaseComponent from './base-component'
10
import EventHandler from './dom/event-handler'
11
import Manipulator from './dom/manipulator'
12
import {
13
  defineJQueryPlugin, execute, findShadowRoot, getElement, getUID, isRTL, noop
14
} from './util/index'
15
import { DefaultAllowlist } from './util/sanitizer'
16
import TemplateFactory from './util/template-factory'
1 efrain 17
 
18
/**
19
 * Constants
20
 */
21
 
22
const NAME = 'tooltip'
1441 ariadna 23
const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
1 efrain 24
 
25
const CLASS_NAME_FADE = 'fade'
1441 ariadna 26
const CLASS_NAME_MODAL = 'modal'
1 efrain 27
const CLASS_NAME_SHOW = 'show'
28
 
29
const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
1441 ariadna 30
const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
1 efrain 31
 
1441 ariadna 32
const EVENT_MODAL_HIDE = 'hide.bs.modal'
33
 
1 efrain 34
const TRIGGER_HOVER = 'hover'
35
const TRIGGER_FOCUS = 'focus'
36
const TRIGGER_CLICK = 'click'
37
const TRIGGER_MANUAL = 'manual'
38
 
1441 ariadna 39
const EVENT_HIDE = 'hide'
40
const EVENT_HIDDEN = 'hidden'
41
const EVENT_SHOW = 'show'
42
const EVENT_SHOWN = 'shown'
43
const EVENT_INSERTED = 'inserted'
44
const EVENT_CLICK = 'click'
45
const EVENT_FOCUSIN = 'focusin'
46
const EVENT_FOCUSOUT = 'focusout'
47
const EVENT_MOUSEENTER = 'mouseenter'
48
const EVENT_MOUSELEAVE = 'mouseleave'
49
 
1 efrain 50
const AttachmentMap = {
51
  AUTO: 'auto',
52
  TOP: 'top',
1441 ariadna 53
  RIGHT: isRTL() ? 'left' : 'right',
1 efrain 54
  BOTTOM: 'bottom',
1441 ariadna 55
  LEFT: isRTL() ? 'right' : 'left'
1 efrain 56
}
57
 
58
const Default = {
1441 ariadna 59
  allowList: DefaultAllowlist,
1 efrain 60
  animation: true,
1441 ariadna 61
  boundary: 'clippingParents',
62
  container: false,
63
  customClass: '',
1 efrain 64
  delay: 0,
1441 ariadna 65
  fallbackPlacements: ['top', 'right', 'bottom', 'left'],
1 efrain 66
  html: false,
1441 ariadna 67
  offset: [0, 6],
1 efrain 68
  placement: 'top',
1441 ariadna 69
  popperConfig: null,
1 efrain 70
  sanitize: true,
71
  sanitizeFn: null,
1441 ariadna 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'
1 efrain 79
}
80
 
81
const DefaultType = {
1441 ariadna 82
  allowList: 'object',
1 efrain 83
  animation: 'boolean',
1441 ariadna 84
  boundary: '(string|element)',
85
  container: '(string|element|boolean)',
86
  customClass: '(string|function)',
1 efrain 87
  delay: '(number|object)',
1441 ariadna 88
  fallbackPlacements: 'array',
1 efrain 89
  html: 'boolean',
1441 ariadna 90
  offset: '(array|string|function)',
1 efrain 91
  placement: '(string|function)',
1441 ariadna 92
  popperConfig: '(null|object|function)',
1 efrain 93
  sanitize: 'boolean',
94
  sanitizeFn: '(null|function)',
1441 ariadna 95
  selector: '(string|boolean)',
96
  template: 'string',
97
  title: '(string|element|function)',
98
  trigger: 'string'
1 efrain 99
}
100
 
101
/**
102
 * Class definition
103
 */
104
 
1441 ariadna 105
class Tooltip extends BaseComponent {
1 efrain 106
  constructor(element, config) {
107
    if (typeof Popper === 'undefined') {
108
      throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)')
109
    }
110
 
1441 ariadna 111
    super(element, config)
112
 
1 efrain 113
    // Private
114
    this._isEnabled = true
115
    this._timeout = 0
1441 ariadna 116
    this._isHovered = null
1 efrain 117
    this._activeTrigger = {}
118
    this._popper = null
1441 ariadna 119
    this._templateFactory = null
120
    this._newContent = null
1 efrain 121
 
122
    // Protected
123
    this.tip = null
124
 
125
    this._setListeners()
1441 ariadna 126
 
127
    if (!this._config.selector) {
128
      this._fixTitle()
129
    }
1 efrain 130
  }
131
 
132
  // Getters
133
  static get Default() {
134
    return Default
135
  }
136
 
1441 ariadna 137
  static get DefaultType() {
138
    return DefaultType
139
  }
140
 
1 efrain 141
  static get NAME() {
142
    return NAME
143
  }
144
 
145
  // Public
146
  enable() {
147
    this._isEnabled = true
148
  }
149
 
150
  disable() {
151
    this._isEnabled = false
152
  }
153
 
154
  toggleEnabled() {
155
    this._isEnabled = !this._isEnabled
156
  }
157
 
1441 ariadna 158
  toggle() {
1 efrain 159
    if (!this._isEnabled) {
160
      return
161
    }
162
 
1441 ariadna 163
    this._activeTrigger.click = !this._activeTrigger.click
164
    if (this._isShown()) {
165
      this._leave()
166
      return
167
    }
1 efrain 168
 
1441 ariadna 169
    this._enter()
1 efrain 170
  }
171
 
172
  dispose() {
173
    clearTimeout(this._timeout)
174
 
1441 ariadna 175
    EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
1 efrain 176
 
1441 ariadna 177
    if (this._element.getAttribute('data-bs-original-title')) {
178
      this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title'))
1 efrain 179
    }
180
 
1441 ariadna 181
    this._disposePopper()
182
    super.dispose()
1 efrain 183
  }
184
 
185
  show() {
1441 ariadna 186
    if (this._element.style.display === 'none') {
1 efrain 187
      throw new Error('Please use show on visible elements')
188
    }
189
 
1441 ariadna 190
    if (!(this._isWithContent() && this._isEnabled)) {
191
      return
192
    }
1 efrain 193
 
1441 ariadna 194
    const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))
195
    const shadowRoot = findShadowRoot(this._element)
196
    const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
1 efrain 197
 
1441 ariadna 198
    if (showEvent.defaultPrevented || !isInTheDom) {
199
      return
200
    }
1 efrain 201
 
1441 ariadna 202
    // TODO: v6 remove this or make it optional
203
    this._disposePopper()
1 efrain 204
 
1441 ariadna 205
    const tip = this._getTipElement()
1 efrain 206
 
1441 ariadna 207
    this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
1 efrain 208
 
1441 ariadna 209
    const { container } = this._config
1 efrain 210
 
1441 ariadna 211
    if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
212
      container.append(tip)
213
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
214
    }
1 efrain 215
 
1441 ariadna 216
    this._popper = this._createPopper(tip)
1 efrain 217
 
1441 ariadna 218
    tip.classList.add(CLASS_NAME_SHOW)
1 efrain 219
 
1441 ariadna 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) {
225
      for (const element of [].concat(...document.body.children)) {
226
        EventHandler.on(element, 'mouseover', noop)
1 efrain 227
      }
228
    }
229
 
230
    const complete = () => {
1441 ariadna 231
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))
1 efrain 232
 
1441 ariadna 233
      if (this._isHovered === false) {
234
        this._leave()
1 efrain 235
      }
236
 
1441 ariadna 237
      this._isHovered = false
1 efrain 238
    }
239
 
1441 ariadna 240
    this._queueCallback(complete, this.tip, this._isAnimated())
241
  }
1 efrain 242
 
1441 ariadna 243
  hide() {
244
    if (!this._isShown()) {
1 efrain 245
      return
246
    }
247
 
1441 ariadna 248
    const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))
249
    if (hideEvent.defaultPrevented) {
250
      return
251
    }
1 efrain 252
 
1441 ariadna 253
    const tip = this._getTipElement()
254
    tip.classList.remove(CLASS_NAME_SHOW)
255
 
1 efrain 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) {
1441 ariadna 259
      for (const element of [].concat(...document.body.children)) {
260
        EventHandler.off(element, 'mouseover', noop)
261
      }
1 efrain 262
    }
263
 
264
    this._activeTrigger[TRIGGER_CLICK] = false
265
    this._activeTrigger[TRIGGER_FOCUS] = false
266
    this._activeTrigger[TRIGGER_HOVER] = false
1441 ariadna 267
    this._isHovered = null // it is a trick to support manual triggering
1 efrain 268
 
1441 ariadna 269
    const complete = () => {
270
      if (this._isWithActiveTrigger()) {
271
        return
272
      }
1 efrain 273
 
1441 ariadna 274
      if (!this._isHovered) {
275
        this._disposePopper()
276
      }
277
 
278
      this._element.removeAttribute('aria-describedby')
279
      EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))
1 efrain 280
    }
281
 
1441 ariadna 282
    this._queueCallback(complete, this.tip, this._isAnimated())
1 efrain 283
  }
284
 
285
  update() {
1441 ariadna 286
    if (this._popper) {
287
      this._popper.update()
1 efrain 288
    }
289
  }
290
 
291
  // Protected
1441 ariadna 292
  _isWithContent() {
293
    return Boolean(this._getTitle())
1 efrain 294
  }
295
 
1441 ariadna 296
  _getTipElement() {
297
    if (!this.tip) {
298
      this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
299
    }
1 efrain 300
 
301
    return this.tip
302
  }
303
 
1441 ariadna 304
  _createTipElement(content) {
305
    const tip = this._getTemplateFactory(content).toHtml()
1 efrain 306
 
1441 ariadna 307
    // TODO: remove this check in v6
308
    if (!tip) {
309
      return null
310
    }
1 efrain 311
 
1441 ariadna 312
    tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
313
    // TODO: v6 the following can be achieved with CSS only
314
    tip.classList.add(`bs-${this.constructor.NAME}-auto`)
315
 
316
    const tipId = getUID(this.constructor.NAME).toString()
317
 
318
    tip.setAttribute('id', tipId)
319
 
320
    if (this._isAnimated()) {
321
      tip.classList.add(CLASS_NAME_FADE)
1 efrain 322
    }
323
 
1441 ariadna 324
    return tip
325
  }
1 efrain 326
 
1441 ariadna 327
  setContent(content) {
328
    this._newContent = content
329
    if (this._isShown()) {
330
      this._disposePopper()
331
      this.show()
332
    }
333
  }
334
 
335
  _getTemplateFactory(content) {
336
    if (this._templateFactory) {
337
      this._templateFactory.changeContent(content)
1 efrain 338
    } else {
1441 ariadna 339
      this._templateFactory = new TemplateFactory({
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
      })
1 efrain 346
    }
1441 ariadna 347
 
348
    return this._templateFactory
1 efrain 349
  }
350
 
1441 ariadna 351
  _getContentForTemplate() {
352
    return {
353
      [SELECTOR_TOOLTIP_INNER]: this._getTitle()
1 efrain 354
    }
1441 ariadna 355
  }
1 efrain 356
 
1441 ariadna 357
  _getTitle() {
358
    return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')
1 efrain 359
  }
360
 
361
  // Private
1441 ariadna 362
  _initializeOnDelegatedTarget(event) {
363
    return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())
364
  }
1 efrain 365
 
1441 ariadna 366
  _isAnimated() {
367
    return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE))
1 efrain 368
  }
369
 
1441 ariadna 370
  _isShown() {
371
    return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)
372
  }
373
 
374
  _createPopper(tip) {
375
    const placement = execute(this._config.placement, [this, tip, this._element])
376
    const attachment = AttachmentMap[placement.toUpperCase()]
377
    return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
378
  }
379
 
1 efrain 380
  _getOffset() {
1441 ariadna 381
    const { offset } = this._config
1 efrain 382
 
1441 ariadna 383
    if (typeof offset === 'string') {
384
      return offset.split(',').map(value => Number.parseInt(value, 10))
385
    }
1 efrain 386
 
1441 ariadna 387
    if (typeof offset === 'function') {
388
      return popperData => offset(popperData, this._element)
1 efrain 389
    }
390
 
391
    return offset
392
  }
393
 
1441 ariadna 394
  _resolvePossibleFunction(arg) {
395
    return execute(arg, [this._element])
396
  }
397
 
398
  _getPopperConfig(attachment) {
399
    const defaultBsPopperConfig = {
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)
434
          }
435
        }
436
      ]
1 efrain 437
    }
438
 
1441 ariadna 439
    return {
440
      ...defaultBsPopperConfig,
441
      ...execute(this._config.popperConfig, [defaultBsPopperConfig])
1 efrain 442
    }
443
  }
444
 
445
  _setListeners() {
1441 ariadna 446
    const triggers = this._config.trigger.split(' ')
1 efrain 447
 
1441 ariadna 448
    for (const trigger of triggers) {
1 efrain 449
      if (trigger === 'click') {
1441 ariadna 450
        EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => {
451
          const context = this._initializeOnDelegatedTarget(event)
452
          context.toggle()
453
        })
1 efrain 454
      } else if (trigger !== TRIGGER_MANUAL) {
455
        const eventIn = trigger === TRIGGER_HOVER ?
1441 ariadna 456
          this.constructor.eventName(EVENT_MOUSEENTER) :
457
          this.constructor.eventName(EVENT_FOCUSIN)
1 efrain 458
        const eventOut = trigger === TRIGGER_HOVER ?
1441 ariadna 459
          this.constructor.eventName(EVENT_MOUSELEAVE) :
460
          this.constructor.eventName(EVENT_FOCUSOUT)
1 efrain 461
 
1441 ariadna 462
        EventHandler.on(this._element, eventIn, this._config.selector, event => {
463
          const context = this._initializeOnDelegatedTarget(event)
464
          context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
465
          context._enter()
466
        })
467
        EventHandler.on(this._element, eventOut, this._config.selector, event => {
468
          const context = this._initializeOnDelegatedTarget(event)
469
          context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
470
            context._element.contains(event.relatedTarget)
471
 
472
          context._leave()
473
        })
1 efrain 474
      }
1441 ariadna 475
    }
1 efrain 476
 
477
    this._hideModalHandler = () => {
1441 ariadna 478
      if (this._element) {
1 efrain 479
        this.hide()
480
      }
481
    }
482
 
1441 ariadna 483
    EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
1 efrain 484
  }
485
 
486
  _fixTitle() {
1441 ariadna 487
    const title = this._element.getAttribute('title')
1 efrain 488
 
1441 ariadna 489
    if (!title) {
490
      return
1 efrain 491
    }
492
 
1441 ariadna 493
    if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) {
494
      this._element.setAttribute('aria-label', title)
1 efrain 495
    }
496
 
1441 ariadna 497
    this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility
498
    this._element.removeAttribute('title')
499
  }
1 efrain 500
 
1441 ariadna 501
  _enter() {
502
    if (this._isShown() || this._isHovered) {
503
      this._isHovered = true
1 efrain 504
      return
505
    }
506
 
1441 ariadna 507
    this._isHovered = true
1 efrain 508
 
1441 ariadna 509
    this._setTimeout(() => {
510
      if (this._isHovered) {
511
        this.show()
1 efrain 512
      }
1441 ariadna 513
    }, this._config.delay.show)
1 efrain 514
  }
515
 
1441 ariadna 516
  _leave() {
517
    if (this._isWithActiveTrigger()) {
1 efrain 518
      return
519
    }
520
 
1441 ariadna 521
    this._isHovered = false
1 efrain 522
 
1441 ariadna 523
    this._setTimeout(() => {
524
      if (!this._isHovered) {
525
        this.hide()
1 efrain 526
      }
1441 ariadna 527
    }, this._config.delay.hide)
1 efrain 528
  }
529
 
1441 ariadna 530
  _setTimeout(handler, timeout) {
531
    clearTimeout(this._timeout)
532
    this._timeout = setTimeout(handler, timeout)
533
  }
534
 
1 efrain 535
  _isWithActiveTrigger() {
1441 ariadna 536
    return Object.values(this._activeTrigger).includes(true)
1 efrain 537
  }
538
 
539
  _getConfig(config) {
1441 ariadna 540
    const dataAttributes = Manipulator.getDataAttributes(this._element)
1 efrain 541
 
1441 ariadna 542
    for (const dataAttribute of Object.keys(dataAttributes)) {
543
      if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
544
        delete dataAttributes[dataAttribute]
545
      }
546
    }
1 efrain 547
 
548
    config = {
549
      ...dataAttributes,
550
      ...(typeof config === 'object' && config ? config : {})
551
    }
1441 ariadna 552
    config = this._mergeConfigObj(config)
553
    config = this._configAfterMerge(config)
554
    this._typeCheckConfig(config)
555
    return config
556
  }
1 efrain 557
 
1441 ariadna 558
  _configAfterMerge(config) {
559
    config.container = config.container === false ? document.body : getElement(config.container)
560
 
1 efrain 561
    if (typeof config.delay === 'number') {
562
      config.delay = {
563
        show: config.delay,
564
        hide: config.delay
565
      }
566
    }
567
 
568
    if (typeof config.title === 'number') {
569
      config.title = config.title.toString()
570
    }
571
 
572
    if (typeof config.content === 'number') {
573
      config.content = config.content.toString()
574
    }
575
 
576
    return config
577
  }
578
 
579
  _getDelegateConfig() {
580
    const config = {}
581
 
1441 ariadna 582
    for (const [key, value] of Object.entries(this._config)) {
583
      if (this.constructor.Default[key] !== value) {
584
        config[key] = value
1 efrain 585
      }
586
    }
587
 
1441 ariadna 588
    config.selector = false
589
    config.trigger = 'manual'
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)`
1 efrain 594
    return config
595
  }
596
 
1441 ariadna 597
  _disposePopper() {
598
    if (this._popper) {
599
      this._popper.destroy()
600
      this._popper = null
1 efrain 601
    }
602
 
1441 ariadna 603
    if (this.tip) {
604
      this.tip.remove()
605
      this.tip = null
1 efrain 606
    }
607
  }
608
 
609
  // Static
1441 ariadna 610
  static jQueryInterface(config) {
1 efrain 611
    return this.each(function () {
1441 ariadna 612
      const data = Tooltip.getOrCreateInstance(this, config)
1 efrain 613
 
1441 ariadna 614
      if (typeof config !== 'string') {
1 efrain 615
        return
616
      }
617
 
1441 ariadna 618
      if (typeof data[config] === 'undefined') {
619
        throw new TypeError(`No method named "${config}"`)
1 efrain 620
      }
621
 
1441 ariadna 622
      data[config]()
1 efrain 623
    })
624
  }
625
}
626
 
627
/**
628
 * jQuery
629
 */
630
 
1441 ariadna 631
defineJQueryPlugin(Tooltip)
1 efrain 632
 
633
export default Tooltip