Blame view

node_modules/bootstrap-vue/src/components/tooltip/helpers/bv-tooltip.js 33.6 KB
4cd4fd28   郭伟龙   feat: 初始化项目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
// Tooltip "Class" (Built as a renderless Vue instance)
//
// Handles trigger events, etc.
// Instantiates template on demand

import { COMPONENT_UID_KEY, extend } from '../../../vue'
import { NAME_MODAL, NAME_TOOLTIP_HELPER } from '../../../constants/components'
import {
  EVENT_NAME_DISABLE,
  EVENT_NAME_DISABLED,
  EVENT_NAME_ENABLE,
  EVENT_NAME_ENABLED,
  EVENT_NAME_FOCUSIN,
  EVENT_NAME_FOCUSOUT,
  EVENT_NAME_HIDDEN,
  EVENT_NAME_HIDE,
  EVENT_NAME_MOUSEENTER,
  EVENT_NAME_MOUSELEAVE,
  EVENT_NAME_SHOW,
  EVENT_NAME_SHOWN,
  EVENT_OPTIONS_NO_CAPTURE,
  HOOK_EVENT_NAME_BEFORE_DESTROY,
  HOOK_EVENT_NAME_DESTROYED
} from '../../../constants/events'
import { useParentMixin } from '../../../mixins/use-parent'
import { arrayIncludes, concat, from as arrayFrom } from '../../../utils/array'
import { getInstanceFromElement } from '../../../utils/element-to-vue-instance-registry'
import {
  attemptFocus,
  closest,
  contains,
  getAttr,
  getById,
  hasAttr,
  hasClass,
  isDisabled,
  isElement,
  isVisible,
  removeAttr,
  requestAF,
  select,
  setAttr
} from '../../../utils/dom'
import {
  eventOff,
  eventOn,
  eventOnOff,
  getRootActionEventName,
  getRootEventName
} from '../../../utils/events'
import { getScopeId } from '../../../utils/get-scope-id'
import { identity } from '../../../utils/identity'
import {
  isFunction,
  isNumber,
  isPlainObject,
  isString,
  isUndefined,
  isUndefinedOrNull
} from '../../../utils/inspect'
import { looseEqual } from '../../../utils/loose-equal'
import { mathMax } from '../../../utils/math'
import { noop } from '../../../utils/noop'
import { toInteger } from '../../../utils/number'
import { keys } from '../../../utils/object'
import { warn } from '../../../utils/warn'
import { BvEvent } from '../../../utils/bv-event.class'
import { createNewChildComponent } from '../../../utils/create-new-child-component'
import { listenOnRootMixin } from '../../../mixins/listen-on-root'
import { BVTooltipTemplate } from './bv-tooltip-template'

// --- Constants ---

// Modal container selector for appending tooltip/popover
const MODAL_SELECTOR = '.modal-content'

// Modal `$root` hidden event
const ROOT_EVENT_NAME_MODAL_HIDDEN = getRootEventName(NAME_MODAL, EVENT_NAME_HIDDEN)

// Sidebar container selector for appending tooltip/popover
const SIDEBAR_SELECTOR = '.b-sidebar'

// For finding the container to append to
const CONTAINER_SELECTOR = [MODAL_SELECTOR, SIDEBAR_SELECTOR].join(', ')

// For dropdown sniffing
const DROPDOWN_CLASS = 'dropdown'
const DROPDOWN_OPEN_SELECTOR = '.dropdown-menu.show'

// Data attribute to temporary store the `title` attribute's value
const DATA_TITLE_ATTR = 'data-original-title'

// Data specific to popper and template
// We don't use props, as we need reactivity (we can't pass reactive props)
const templateData = {
  // Text string or Scoped slot function
  title: '',
  // Text string or Scoped slot function
  content: '',
  // String
  variant: null,
  // String, Array, Object
  customClass: null,
  // String or array of Strings (overwritten by BVPopper)
  triggers: '',
  // String (overwritten by BVPopper)
  placement: 'auto',
  // String or array of strings
  fallbackPlacement: 'flip',
  // Element or Component reference (or function that returns element) of
  // the element that will have the trigger events bound, and is also
  // default element for positioning
  target: null,
  // HTML ID, Element or Component reference
  container: null, // 'body'
  // Boolean
  noFade: false,
  // 'scrollParent', 'viewport', 'window', Element, or Component reference
  boundary: 'scrollParent',
  // Tooltip/popover will try and stay away from
  // boundary edge by this many pixels (Number)
  boundaryPadding: 5,
  // Arrow offset (Number)
  offset: 0,
  // Hover/focus delay (Number or Object)
  delay: 0,
  // Arrow of Tooltip/popover will try and stay away from
  // the edge of tooltip/popover edge by this many pixels
  arrowPadding: 6,
  // Interactive state (Boolean)
  interactive: true,
  // Disabled state (Boolean)
  disabled: false,
  // ID to use for tooltip/popover
  id: null,
  // Flag used by directives only, for HTML content
  html: false
}

// --- Main component ---

// @vue/component
export const BVTooltip = /*#__PURE__*/ extend({
  name: NAME_TOOLTIP_HELPER,
  mixins: [listenOnRootMixin, useParentMixin],
  data() {
    return {
      // BTooltip/BPopover/VBTooltip/VBPopover will update this data
      // Via the exposed updateData() method on this instance
      // BVPopover will override some of these defaults
      ...templateData,
      // State management data
      activeTrigger: {
        // manual: false,
        hover: false,
        click: false,
        focus: false
      },
      localShow: false
    }
  },
  computed: {
    templateType() {
      // Overwritten by BVPopover
      return 'tooltip'
    },
    computedId() {
      return this.id || `__bv_${this.templateType}_${this[COMPONENT_UID_KEY]}__`
    },
    computedDelay() {
      // Normalizes delay into object form
      const delay = { show: 0, hide: 0 }
      if (isPlainObject(this.delay)) {
        delay.show = mathMax(toInteger(this.delay.show, 0), 0)
        delay.hide = mathMax(toInteger(this.delay.hide, 0), 0)
      } else if (isNumber(this.delay) || isString(this.delay)) {
        delay.show = delay.hide = mathMax(toInteger(this.delay, 0), 0)
      }
      return delay
    },
    computedTriggers() {
      // Returns the triggers in sorted array form
      // TODO: Switch this to object form for easier lookup
      return concat(this.triggers)
        .filter(identity)
        .join(' ')
        .trim()
        .toLowerCase()
        .split(/\s+/)
        .sort()
    },
    isWithActiveTrigger() {
      for (const trigger in this.activeTrigger) {
        if (this.activeTrigger[trigger]) {
          return true
        }
      }
      return false
    },
    computedTemplateData() {
      const { title, content, variant, customClass, noFade, interactive } = this
      return { title, content, variant, customClass, noFade, interactive }
    }
  },
  watch: {
    computedTriggers(newTriggers, oldTriggers) {
      // Triggers have changed, so re-register them
      /* istanbul ignore next */
      if (!looseEqual(newTriggers, oldTriggers)) {
        this.$nextTick(() => {
          // Disable trigger listeners
          this.unListen()
          // Clear any active triggers that are no longer in the list of triggers
          oldTriggers.forEach(trigger => {
            if (!arrayIncludes(newTriggers, trigger)) {
              if (this.activeTrigger[trigger]) {
                this.activeTrigger[trigger] = false
              }
            }
          })
          // Re-enable the trigger listeners
          this.listen()
        })
      }
    },
    computedTemplateData() {
      // If any of the while open reactive "props" change,
      // ensure that the template updates accordingly
      this.handleTemplateUpdate()
    },
    title(newValue, oldValue) {
      // Make sure to hide the tooltip when the title is set empty
      if (newValue !== oldValue && !newValue) {
        this.hide()
      }
    },
    disabled(newValue) {
      if (newValue) {
        this.disable()
      } else {
        this.enable()
      }
    }
  },
  created() {
    // Create non-reactive properties
    this.$_tip = null
    this.$_hoverTimeout = null
    this.$_hoverState = ''
    this.$_visibleInterval = null
    this.$_enabled = !this.disabled
    this.$_noop = noop.bind(this)

    // Destroy ourselves when the parent is destroyed
    if (this.bvParent) {
      this.bvParent.$once(HOOK_EVENT_NAME_BEFORE_DESTROY, () => {
        this.$nextTick(() => {
          // In a `requestAF()` to release control back to application
          requestAF(() => {
            this.$destroy()
          })
        })
      })
    }

    this.$nextTick(() => {
      const target = this.getTarget()
      if (target && contains(document.body, target)) {
        // Copy the parent's scoped style attribute
        this.scopeId = getScopeId(this.bvParent)
        // Set up all trigger handlers and listeners
        this.listen()
      } else {
        /* istanbul ignore next */
        warn(
          isString(this.target)
            ? `Unable to find target element by ID "#${this.target}" in document.`
            : 'The provided target is no valid HTML element.',
          this.templateType
        )
      }
    })
  },
  /* istanbul ignore next */
  updated() {
    // Usually called when the slots/data changes
    this.$nextTick(this.handleTemplateUpdate)
  },
  /* istanbul ignore next */
  deactivated() {
    // In a keepalive that has been deactivated, so hide
    // the tooltip/popover if it is showing
    this.forceHide()
  },
  beforeDestroy() {
    // Remove all handler/listeners
    this.unListen()
    this.setWhileOpenListeners(false)
    // Clear any timeouts/intervals
    this.clearHoverTimeout()
    this.clearVisibilityInterval()
    // Destroy the template
    this.destroyTemplate()
    // Remove any other private properties created during create
    this.$_noop = null
  },
  methods: {
    // --- Methods for creating and destroying the template ---
    getTemplate() {
      // Overridden by BVPopover
      return BVTooltipTemplate
    },
    updateData(data = {}) {
      // Method for updating popper/template data
      // We only update data if it exists, and has not changed
      let titleUpdated = false
      keys(templateData).forEach(prop => {
        if (!isUndefined(data[prop]) && this[prop] !== data[prop]) {
          this[prop] = data[prop]
          if (prop === 'title') {
            titleUpdated = true
          }
        }
      })
      // If the title has updated, we may need to handle the `title`
      // attribute on the trigger target
      // We only do this while the template is open
      if (titleUpdated && this.localShow) {
        this.fixTitle()
      }
    },
    createTemplateAndShow() {
      // Creates the template instance and show it
      const container = this.getContainer()
      const Template = this.getTemplate()
      const $tip = (this.$_tip = createNewChildComponent(this, Template, {
        // The following is not reactive to changes in the props data
        propsData: {
          // These values cannot be changed while template is showing
          id: this.computedId,
          html: this.html,
          placement: this.placement,
          fallbackPlacement: this.fallbackPlacement,
          target: this.getPlacementTarget(),
          boundary: this.getBoundary(),
          // Ensure the following are integers
          offset: toInteger(this.offset, 0),
          arrowPadding: toInteger(this.arrowPadding, 0),
          boundaryPadding: toInteger(this.boundaryPadding, 0)
        }
      }))
      // We set the initial reactive data (values that can be changed while open)
      this.handleTemplateUpdate()
      // Template transition phase events (handled once only)
      // When the template has mounted, but not visibly shown yet
      $tip.$once(EVENT_NAME_SHOW, this.onTemplateShow)
      // When the template has completed showing
      $tip.$once(EVENT_NAME_SHOWN, this.onTemplateShown)
      // When the template has started to hide
      $tip.$once(EVENT_NAME_HIDE, this.onTemplateHide)
      // When the template has completed hiding
      $tip.$once(EVENT_NAME_HIDDEN, this.onTemplateHidden)
      // When the template gets destroyed for any reason
      $tip.$once(HOOK_EVENT_NAME_DESTROYED, this.destroyTemplate)
      // Convenience events from template
      // To save us from manually adding/removing DOM
      // listeners to tip element when it is open
      $tip.$on(EVENT_NAME_FOCUSIN, this.handleEvent)
      $tip.$on(EVENT_NAME_FOCUSOUT, this.handleEvent)
      $tip.$on(EVENT_NAME_MOUSEENTER, this.handleEvent)
      $tip.$on(EVENT_NAME_MOUSELEAVE, this.handleEvent)
      // Mount (which triggers the `show`)
      $tip.$mount(container.appendChild(document.createElement('div')))
      // Template will automatically remove its markup from DOM when hidden
    },
    hideTemplate() {
      // Trigger the template to start hiding
      // The template will emit the `hide` event after this and
      // then emit the `hidden` event once it is fully hidden
      // The `hook:destroyed` will also be called (safety measure)
      this.$_tip && this.$_tip.hide()
      // Clear out any stragging active triggers
      this.clearActiveTriggers()
      // Reset the hover state
      this.$_hoverState = ''
    },
    // Destroy the template instance and reset state
    destroyTemplate() {
      this.setWhileOpenListeners(false)
      this.clearHoverTimeout()
      this.$_hoverState = ''
      this.clearActiveTriggers()
      this.localPlacementTarget = null
      try {
        this.$_tip.$destroy()
      } catch {}
      this.$_tip = null
      this.removeAriaDescribedby()
      this.restoreTitle()
      this.localShow = false
    },
    getTemplateElement() {
      return this.$_tip ? this.$_tip.$el : null
    },
    handleTemplateUpdate() {
      // Update our template title/content "props"
      // So that the template updates accordingly
      const $tip = this.$_tip
      if ($tip) {
        const props = ['title', 'content', 'variant', 'customClass', 'noFade', 'interactive']
        // Only update the values if they have changed
        props.forEach(prop => {
          if ($tip[prop] !== this[prop]) {
            $tip[prop] = this[prop]
          }
        })
      }
    },
    // --- Show/Hide handlers ---
    // Show the tooltip
    show() {
      const target = this.getTarget()
      if (
        !target ||
        !contains(document.body, target) ||
        !isVisible(target) ||
        this.dropdownOpen() ||
        ((isUndefinedOrNull(this.title) || this.title === '') &&
          (isUndefinedOrNull(this.content) || this.content === ''))
      ) {
        // If trigger element isn't in the DOM or is not visible, or
        // is on an open dropdown toggle, or has no content, then
        // we exit without showing
        return
      }
      // If tip already exists, exit early
      if (this.$_tip || this.localShow) {
        /* istanbul ignore next */
        return
      }
      // In the process of showing
      this.localShow = true
      // Create a cancelable BvEvent
      const showEvent = this.buildEvent(EVENT_NAME_SHOW, { cancelable: true })
      this.emitEvent(showEvent)
      // Don't show if event cancelled
      /* istanbul ignore if */
      if (showEvent.defaultPrevented) {
        // Destroy the template (if for some reason it was created)
        this.destroyTemplate()
        return
      }
      // Fix the title attribute on target
      this.fixTitle()
      // Set aria-describedby on target
      this.addAriaDescribedby()
      // Create and show the tooltip
      this.createTemplateAndShow()
    },
    hide(force = false) {
      // Hide the tooltip
      const tip = this.getTemplateElement()
      /* istanbul ignore if */
      if (!tip || !this.localShow) {
        this.restoreTitle()
        return
      }

      // Emit cancelable BvEvent 'hide'
      // We disable cancelling if `force` is true
      const hideEvent = this.buildEvent(EVENT_NAME_HIDE, { cancelable: !force })
      this.emitEvent(hideEvent)
      /* istanbul ignore if: ignore for now */
      if (hideEvent.defaultPrevented) {
        // Don't hide if event cancelled
        return
      }

      // Tell the template to hide
      this.hideTemplate()
    },
    forceHide() {
      // Forcefully hides/destroys the template, regardless of any active triggers
      const tip = this.getTemplateElement()
      if (!tip || !this.localShow) {
        /* istanbul ignore next */
        return
      }
      // Disable while open listeners/watchers
      // This is also done in the template `hide` event handler
      this.setWhileOpenListeners(false)
      // Clear any hover enter/leave event
      this.clearHoverTimeout()
      this.$_hoverState = ''
      this.clearActiveTriggers()
      // Disable the fade animation on the template
      if (this.$_tip) {
        this.$_tip.noFade = true
      }
      // Hide the tip (with force = true)
      this.hide(true)
    },
    enable() {
      this.$_enabled = true
      // Create a non-cancelable BvEvent
      this.emitEvent(this.buildEvent(EVENT_NAME_ENABLED))
    },
    disable() {
      this.$_enabled = false
      // Create a non-cancelable BvEvent
      this.emitEvent(this.buildEvent(EVENT_NAME_DISABLED))
    },
    // --- Handlers for template events ---
    // When template is inserted into DOM, but not yet shown
    onTemplateShow() {
      // Enable while open listeners/watchers
      this.setWhileOpenListeners(true)
    },
    // When template show transition completes
    onTemplateShown() {
      const prevHoverState = this.$_hoverState
      this.$_hoverState = ''
      /* istanbul ignore next: occasional Node 10 coverage error */
      if (prevHoverState === 'out') {
        this.leave(null)
      }
      // Emit a non-cancelable BvEvent 'shown'
      this.emitEvent(this.buildEvent(EVENT_NAME_SHOWN))
    },
    // When template is starting to hide
    onTemplateHide() {
      // Disable while open listeners/watchers
      this.setWhileOpenListeners(false)
    },
    // When template has completed closing (just before it self destructs)
    onTemplateHidden() {
      // Destroy the template
      this.destroyTemplate()
      // Emit a non-cancelable BvEvent 'shown'
      this.emitEvent(this.buildEvent(EVENT_NAME_HIDDEN))
    },
    // --- Helper methods ---
    getTarget() {
      let { target } = this
      if (isString(target)) {
        target = getById(target.replace(/^#/, ''))
      } else if (isFunction(target)) {
        target = target()
      } else if (target) {
        target = target.$el || target
      }
      return isElement(target) ? target : null
    },
    getPlacementTarget() {
      // This is the target that the tooltip will be placed on, which may not
      // necessarily be the same element that has the trigger event listeners
      // For now, this is the same as target
      // TODO:
      //   Add in child selector support
      //   Add in visibility checks for this element
      //   Fallback to target if not found
      return this.getTarget()
    },
    getTargetId() {
      // Returns the ID of the trigger element
      const target = this.getTarget()
      return target && target.id ? target.id : null
    },
    getContainer() {
      // Handle case where container may be a component ref
      const container = this.container ? this.container.$el || this.container : false
      const body = document.body
      const target = this.getTarget()
      // If we are in a modal, we append to the modal, If we
      // are in a sidebar, we append to the sidebar, else append
      // to body, unless a container is specified
      // TODO:
      //   Template should periodically check to see if it is in dom
      //   And if not, self destruct (if container got v-if'ed out of DOM)
      //   Or this could possibly be part of the visibility check
      return container === false
        ? closest(CONTAINER_SELECTOR, target) || body
        : /*istanbul ignore next */ isString(container)
          ? /*istanbul ignore next */ getById(container.replace(/^#/, '')) || body
          : /*istanbul ignore next */ body
    },
    getBoundary() {
      return this.boundary ? this.boundary.$el || this.boundary : 'scrollParent'
    },
    isInModal() {
      const target = this.getTarget()
      return target && closest(MODAL_SELECTOR, target)
    },
    isDropdown() {
      // Returns true if trigger is a dropdown
      const target = this.getTarget()
      return target && hasClass(target, DROPDOWN_CLASS)
    },
    dropdownOpen() {
      // Returns true if trigger is a dropdown and the dropdown menu is open
      const target = this.getTarget()
      return this.isDropdown() && target && select(DROPDOWN_OPEN_SELECTOR, target)
    },
    clearHoverTimeout() {
      clearTimeout(this.$_hoverTimeout)
      this.$_hoverTimeout = null
    },
    clearVisibilityInterval() {
      clearInterval(this.$_visibleInterval)
      this.$_visibleInterval = null
    },
    clearActiveTriggers() {
      for (const trigger in this.activeTrigger) {
        this.activeTrigger[trigger] = false
      }
    },
    addAriaDescribedby() {
      // Add aria-describedby on trigger element, without removing any other IDs
      const target = this.getTarget()
      let desc = getAttr(target, 'aria-describedby') || ''
      desc = desc
        .split(/\s+/)
        .concat(this.computedId)
        .join(' ')
        .trim()
      // Update/add aria-described by
      setAttr(target, 'aria-describedby', desc)
    },
    removeAriaDescribedby() {
      // Remove aria-describedby on trigger element, without removing any other IDs
      const target = this.getTarget()
      let desc = getAttr(target, 'aria-describedby') || ''
      desc = desc
        .split(/\s+/)
        .filter(d => d !== this.computedId)
        .join(' ')
        .trim()
      // Update or remove aria-describedby
      if (desc) {
        /* istanbul ignore next */
        setAttr(target, 'aria-describedby', desc)
      } else {
        removeAttr(target, 'aria-describedby')
      }
    },
    fixTitle() {
      // If the target has a `title` attribute,
      // remove it and store it on a data attribute
      const target = this.getTarget()
      if (hasAttr(target, 'title')) {
        // Get `title` attribute value and remove it from target
        const title = getAttr(target, 'title')
        setAttr(target, 'title', '')
        // Only set the data attribute when the value is truthy
        if (title) {
          setAttr(target, DATA_TITLE_ATTR, title)
        }
      }
    },
    restoreTitle() {
      // If the target had a `title` attribute,
      // restore it and remove the data attribute
      const target = this.getTarget()
      if (hasAttr(target, DATA_TITLE_ATTR)) {
        // Get data attribute value and remove it from target
        const title = getAttr(target, DATA_TITLE_ATTR)
        removeAttr(target, DATA_TITLE_ATTR)
        // Only restore the `title` attribute when the value is truthy
        if (title) {
          setAttr(target, 'title', title)
        }
      }
    },
    // --- BvEvent helpers ---
    buildEvent(type, options = {}) {
      // Defaults to a non-cancellable event
      return new BvEvent(type, {
        cancelable: false,
        target: this.getTarget(),
        relatedTarget: this.getTemplateElement() || null,
        componentId: this.computedId,
        vueTarget: this,
        // Add in option overrides
        ...options
      })
    },
    emitEvent(bvEvent) {
      const { type } = bvEvent
      this.emitOnRoot(getRootEventName(this.templateType, type), bvEvent)
      this.$emit(type, bvEvent)
    },
    // --- Event handler setup methods ---
    listen() {
      // Enable trigger event handlers
      const el = this.getTarget()
      if (!el) {
        /* istanbul ignore next */
        return
      }
      // Listen for global show/hide events
      this.setRootListener(true)
      // Set up our listeners on the target trigger element
      this.computedTriggers.forEach(trigger => {
        if (trigger === 'click') {
          eventOn(el, 'click', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
        } else if (trigger === 'focus') {
          eventOn(el, 'focusin', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
          eventOn(el, 'focusout', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
        } else if (trigger === 'blur') {
          // Used to close $tip when element loses focus
          /* istanbul ignore next */
          eventOn(el, 'focusout', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
        } else if (trigger === 'hover') {
          eventOn(el, 'mouseenter', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
          eventOn(el, 'mouseleave', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
        }
      }, this)
    },
    /* istanbul ignore next */
    unListen() {
      // Remove trigger event handlers
      const events = ['click', 'focusin', 'focusout', 'mouseenter', 'mouseleave']
      const target = this.getTarget()

      // Stop listening for global show/hide/enable/disable events
      this.setRootListener(false)

      // Clear out any active target listeners
      events.forEach(event => {
        target && eventOff(target, event, this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
      }, this)
    },
    setRootListener(on) {
      // Listen for global `bv::{hide|show}::{tooltip|popover}` hide request event
      const method = on ? 'listenOnRoot' : 'listenOffRoot'
      const type = this.templateType
      this[method](getRootActionEventName(type, EVENT_NAME_HIDE), this.doHide)
      this[method](getRootActionEventName(type, EVENT_NAME_SHOW), this.doShow)
      this[method](getRootActionEventName(type, EVENT_NAME_DISABLE), this.doDisable)
      this[method](getRootActionEventName(type, EVENT_NAME_ENABLE), this.doEnable)
    },
    setWhileOpenListeners(on) {
      // Events that are only registered when the template is showing
      // Modal close events
      this.setModalListener(on)
      // Dropdown open events (if we are attached to a dropdown)
      this.setDropdownListener(on)
      // Periodic $element visibility check
      // For handling when tip target is in <keepalive>, tabs, carousel, etc
      this.visibleCheck(on)
      // On-touch start listeners
      this.setOnTouchStartListener(on)
    },
    // Handler for periodic visibility check
    visibleCheck(on) {
      this.clearVisibilityInterval()
      const target = this.getTarget()
      if (on) {
        this.$_visibleInterval = setInterval(() => {
          const tip = this.getTemplateElement()
          if (tip && this.localShow && (!target.parentNode || !isVisible(target))) {
            // Target element is no longer visible or not in DOM, so force-hide the tooltip
            this.forceHide()
          }
        }, 100)
      }
    },
    setModalListener(on) {
      // Handle case where tooltip/target is in a modal
      if (this.isInModal()) {
        // We can listen for modal hidden events on `$root`
        this[on ? 'listenOnRoot' : 'listenOffRoot'](ROOT_EVENT_NAME_MODAL_HIDDEN, this.forceHide)
      }
    },
    /* istanbul ignore next: JSDOM doesn't support `ontouchstart` */
    setOnTouchStartListener(on) {
      // If this is a touch-enabled device we add extra empty
      // `mouseover` listeners to the body's immediate children
      // Only needed because of broken event delegation on iOS
      // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
      if ('ontouchstart' in document.documentElement) {
        arrayFrom(document.body.children).forEach(el => {
          eventOnOff(on, el, 'mouseover', this.$_noop)
        })
      }
    },
    setDropdownListener(on) {
      const target = this.getTarget()
      if (!target || !this.bvEventRoot || !this.isDropdown) {
        return
      }
      // We can listen for dropdown shown events on its instance
      // TODO:
      //   We could grab the ID from the dropdown, and listen for
      //   $root events for that particular dropdown id
      //   Dropdown shown and hidden events will need to emit
      //   Note: Dropdown auto-ID happens in a `$nextTick()` after mount
      //         So the ID lookup would need to be done in a `$nextTick()`
      const instance = getInstanceFromElement(target)

      if (instance) {
        instance[on ? '$on' : '$off'](EVENT_NAME_SHOWN, this.forceHide)
      }
    },
    // --- Event handlers ---
    handleEvent(event) {
      // General trigger event handler
      // target is the trigger element
      const target = this.getTarget()
      if (!target || isDisabled(target) || !this.$_enabled || this.dropdownOpen()) {
        // If disabled or not enabled, or if a dropdown that is open, don't do anything
        // If tip is shown before element gets disabled, then tip will not
        // close until no longer disabled or forcefully closed
        return
      }
      const type = event.type
      const triggers = this.computedTriggers

      if (type === 'click' && arrayIncludes(triggers, 'click')) {
        this.click(event)
      } else if (type === 'mouseenter' && arrayIncludes(triggers, 'hover')) {
        // `mouseenter` is a non-bubbling event
        this.enter(event)
      } else if (type === 'focusin' && arrayIncludes(triggers, 'focus')) {
        // `focusin` is a bubbling event
        // `event` includes `relatedTarget` (element losing focus)
        this.enter(event)
      } else if (
        (type === 'focusout' &&
          (arrayIncludes(triggers, 'focus') || arrayIncludes(triggers, 'blur'))) ||
        (type === 'mouseleave' && arrayIncludes(triggers, 'hover'))
      ) {
        // `focusout` is a bubbling event
        // `mouseleave` is a non-bubbling event
        // `tip` is the template (will be null if not open)
        const tip = this.getTemplateElement()
        // `eventTarget` is the element which is losing focus/hover and
        const eventTarget = event.target
        // `relatedTarget` is the element gaining focus/hover
        const relatedTarget = event.relatedTarget
        /* istanbul ignore next */
        if (
          // From tip to target
          (tip && contains(tip, eventTarget) && contains(target, relatedTarget)) ||
          // From target to tip
          (tip && contains(target, eventTarget) && contains(tip, relatedTarget)) ||
          // Within tip
          (tip && contains(tip, eventTarget) && contains(tip, relatedTarget)) ||
          // Within target
          (contains(target, eventTarget) && contains(target, relatedTarget))
        ) {
          // If focus/hover moves within `tip` and `target`, don't trigger a leave
          return
        }
        // Otherwise trigger a leave
        this.leave(event)
      }
    },
    doHide(id) {
      // Programmatically hide tooltip or popover
      if (!id || (this.getTargetId() === id || this.computedId === id)) {
        // Close all tooltips or popovers, or this specific tip (with ID)
        this.forceHide()
      }
    },
    doShow(id) {
      // Programmatically show tooltip or popover
      if (!id || (this.getTargetId() === id || this.computedId === id)) {
        // Open all tooltips or popovers, or this specific tip (with ID)
        this.show()
      }
    },
    /*istanbul ignore next: ignore for now */
    doDisable(id) /*istanbul ignore next: ignore for now */ {
      // Programmatically disable tooltip or popover
      if (!id || (this.getTargetId() === id || this.computedId === id)) {
        // Disable all tooltips or popovers (no ID), or this specific tip (with ID)
        this.disable()
      }
    },
    /*istanbul ignore next: ignore for now */
    doEnable(id) /*istanbul ignore next: ignore for now */ {
      // Programmatically enable tooltip or popover
      if (!id || (this.getTargetId() === id || this.computedId === id)) {
        // Enable all tooltips or popovers (no ID), or this specific tip (with ID)
        this.enable()
      }
    },
    click(event) {
      if (!this.$_enabled || this.dropdownOpen()) {
        /* istanbul ignore next */
        return
      }
      // Get around a WebKit bug where `click` does not trigger focus events
      // On most browsers, `click` triggers a `focusin`/`focus` event first
      // Needed so that trigger 'click blur' works on iOS
      // https://github.com/bootstrap-vue/bootstrap-vue/issues/5099
      // We use `currentTarget` rather than `target` to trigger on the
      // element, not the inner content
      attemptFocus(event.currentTarget)
      this.activeTrigger.click = !this.activeTrigger.click
      if (this.isWithActiveTrigger) {
        this.enter(null)
      } else {
        /* istanbul ignore next */
        this.leave(null)
      }
    },
    /* istanbul ignore next */
    toggle() {
      // Manual toggle handler
      if (!this.$_enabled || this.dropdownOpen()) {
        /* istanbul ignore next */
        return
      }
      // Should we register as an active trigger?
      // this.activeTrigger.manual = !this.activeTrigger.manual
      if (this.localShow) {
        this.leave(null)
      } else {
        this.enter(null)
      }
    },
    enter(event = null) {
      // Opening trigger handler
      // Note: Click events are sent with event === null
      if (event) {
        this.activeTrigger[event.type === 'focusin' ? 'focus' : 'hover'] = true
      }
      /* istanbul ignore next */
      if (this.localShow || this.$_hoverState === 'in') {
        this.$_hoverState = 'in'
        return
      }
      this.clearHoverTimeout()
      this.$_hoverState = 'in'
      if (!this.computedDelay.show) {
        this.show()
      } else {
        // Hide any title attribute while enter delay is active
        this.fixTitle()
        this.$_hoverTimeout = setTimeout(() => {
          /* istanbul ignore else */
          if (this.$_hoverState === 'in') {
            this.show()
          } else if (!this.localShow) {
            this.restoreTitle()
          }
        }, this.computedDelay.show)
      }
    },
    leave(event = null) {
      // Closing trigger handler
      // Note: Click events are sent with event === null
      if (event) {
        this.activeTrigger[event.type === 'focusout' ? 'focus' : 'hover'] = false
        /* istanbul ignore next */
        if (event.type === 'focusout' && arrayIncludes(this.computedTriggers, 'blur')) {
          // Special case for `blur`: we clear out the other triggers
          this.activeTrigger.click = false
          this.activeTrigger.hover = false
        }
      }
      /* istanbul ignore next: ignore for now */
      if (this.isWithActiveTrigger) {
        return
      }
      this.clearHoverTimeout()
      this.$_hoverState = 'out'
      if (!this.computedDelay.hide) {
        this.hide()
      } else {
        this.$_hoverTimeout = setTimeout(() => {
          if (this.$_hoverState === 'out') {
            this.hide()
          }
        }, this.computedDelay.hide)
      }
    }
  }
})