Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions js/src/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const EVENT_FOCUSIN = 'focusin'
const EVENT_FOCUSOUT = 'focusout'
const EVENT_MOUSEENTER = 'mouseenter'
const EVENT_MOUSELEAVE = 'mouseleave'
const EVENT_POINTERDOWN = 'pointerdown'
const EVENT_POINTERUP = 'pointerup'
const EVENT_KEYDOWN = 'keydown'

const AttachmentMap: Record<string, string> = {
Expand Down Expand Up @@ -157,6 +159,9 @@ class Tooltip extends BaseComponent {
protected declare _activeTrigger: Record<string, boolean>
protected declare _floatingCleanup: (() => void) | null
protected declare _keydownHandler: ((event: KeyboardEvent) => void) | null
protected declare _outsidePointerHandler: ((event: PointerEvent) => void) | null
protected declare _tipPointerUpHandler: (() => void) | null
protected declare _tipPointerDown: boolean
protected declare _templateFactory: TemplateFactory | null
protected declare _newContent: Record<string, TemplateContentEntry> | null
protected declare _mediaQueryListeners: BreakpointListener[]
Expand All @@ -179,6 +184,9 @@ class Tooltip extends BaseComponent {
this._activeTrigger = {}
this._floatingCleanup = null
this._keydownHandler = null
this._outsidePointerHandler = null
this._tipPointerUpHandler = null
this._tipPointerDown = false
this._templateFactory = null
this._newContent = null
this._mediaQueryListeners = []
Expand Down Expand Up @@ -233,6 +241,7 @@ class Tooltip extends BaseComponent {
this._clearTimeout()

this._removeEscapeListener()
this._removeFocusOutsideListener()

EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)

Expand Down Expand Up @@ -290,6 +299,7 @@ class Tooltip extends BaseComponent {

// Allow dismissing the tooltip with the Escape key (WCAG 1.4.13)
this._setEscapeListener()
this._setFocusOutsideListener()

// If this is a touch-enabled device we add extra
// empty mouseover listeners to the body's immediate children;
Expand Down Expand Up @@ -325,6 +335,7 @@ class Tooltip extends BaseComponent {
}

this._removeEscapeListener()
this._removeFocusOutsideListener()

const tip = this._getTipElement()
tip.classList.remove(CLASS_NAME_SHOW)
Expand Down Expand Up @@ -401,6 +412,8 @@ class Tooltip extends BaseComponent {
tip.classList.add(this._getInstantClassName())
}

this._setFocusTipListeners(tip)

return tip
}

Expand Down Expand Up @@ -655,8 +668,13 @@ class Tooltip extends BaseComponent {
})
EventHandler.on(this._element, eventOut, this._config.selector as string, event => {
const context = this._initializeOnDelegatedTarget(event)
context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
context._element.contains(event.relatedTarget)

if (event.type === 'focusout') {
context._activeTrigger[TRIGGER_FOCUS] =
context._isInsideTipOrTrigger(event.relatedTarget) || context._tipPointerDown
} else {
context._activeTrigger[TRIGGER_HOVER] = context._element.contains(event.relatedTarget)
}

context._leave()
})
Expand All @@ -672,6 +690,80 @@ class Tooltip extends BaseComponent {
EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
}

protected _isInsideTipOrTrigger(target: EventTarget | null): boolean {
if (!(target instanceof Node)) {
return false
}

return this._element === target ||
this._element.contains(target) ||
Boolean(this.tip?.contains(target))
}

protected _hasFocusTrigger(): boolean {
return this._config.trigger.split(' ').includes(TRIGGER_FOCUS)
}

protected _setFocusTipListeners(tip: HTMLElement): void {
if (!this._hasFocusTrigger()) {
return
}

EventHandler.on(tip, this.constructor.eventName(EVENT_POINTERDOWN), () => {
this._tipPointerDown = true
this._activeTrigger[TRIGGER_FOCUS] = true
})

EventHandler.on(tip, this.constructor.eventName(EVENT_FOCUSIN), () => {
this._activeTrigger[TRIGGER_FOCUS] = true
})

EventHandler.on(tip, this.constructor.eventName(EVENT_FOCUSOUT), (event: BootstrapEvent) => {
this._activeTrigger[TRIGGER_FOCUS] =
this._isInsideTipOrTrigger(event.relatedTarget) || this._tipPointerDown
this._leave()
})
}

protected _setFocusOutsideListener(): void {
if (this._outsidePointerHandler || !this._hasFocusTrigger()) {
return
}

this._tipPointerUpHandler = () => {
this._tipPointerDown = false
}

this._outsidePointerHandler = event => {
if (!this._isShown() || this._isInsideTipOrTrigger(event.target)) {
return
}

this._activeTrigger[TRIGGER_FOCUS] = false
this.hide()
}

const doc = this._element.ownerDocument
doc.addEventListener(EVENT_POINTERUP, this._tipPointerUpHandler, true)
doc.addEventListener(EVENT_POINTERDOWN, this._outsidePointerHandler, true)
}

protected _removeFocusOutsideListener(): void {
const doc = this._element?.ownerDocument

if (this._tipPointerUpHandler && doc) {
doc.removeEventListener(EVENT_POINTERUP, this._tipPointerUpHandler, true)
}

if (this._outsidePointerHandler && doc) {
doc.removeEventListener(EVENT_POINTERDOWN, this._outsidePointerHandler, true)
}

this._tipPointerUpHandler = null
this._outsidePointerHandler = null
this._tipPointerDown = false
}

protected _setEscapeListener(): void {
if (this._keydownHandler) {
return
Expand Down Expand Up @@ -844,6 +936,7 @@ class Tooltip extends BaseComponent {
}

if (this.tip) {
EventHandler.off(this.tip, this.constructor.EVENT_KEY)
this.tip.remove()
this.tip = null
}
Expand Down
181 changes: 181 additions & 0 deletions js/tests/unit/popover.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,187 @@ describe('Popover', () => {
})
})

describe('dismiss on next click', () => {
it('should not hide when focus moves into the popover', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = '<a href="#" tabindex="0">Dismissible</a>'

const popoverEl = fixtureEl.querySelector('a')
const popover = new Popover(popoverEl, {
trigger: 'focus',
html: true,
content: '<a href="#inside" class="inside-link">Inside</a>'
})

popoverEl.addEventListener('shown.bs.popover', () => {
const tip = document.querySelector('.popover')
const insideLink = tip.querySelector('.inside-link')
const leaveSpy = spyOn(popover, '_leave').and.callThrough()

popoverEl.dispatchEvent(new FocusEvent('focusout', {
bubbles: true,
relatedTarget: insideLink
}))

expect(leaveSpy).toHaveBeenCalled()
expect(popover._activeTrigger.focus).toBeTrue()
expect(tip).toHaveClass('show')
resolve()
})

popoverEl.dispatchEvent(createEvent('focusin'))
})
})

it('should hide when focus leaves the trigger for outside content', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = '<a href="#" tabindex="0">Dismissible</a>'

const popoverEl = fixtureEl.querySelector('a')
// eslint-disable-next-line no-new
new Popover(popoverEl, {
trigger: 'focus',
content: 'Selectable text'
})

popoverEl.addEventListener('shown.bs.popover', () => {
popoverEl.addEventListener('hidden.bs.popover', () => {
expect(document.querySelector('.popover')).toBeNull()
resolve()
})

popoverEl.dispatchEvent(new FocusEvent('focusout', {
bubbles: true,
relatedTarget: document.body
}))
})

popoverEl.dispatchEvent(createEvent('focusin'))
})
})

it('should stay open when the pointer presses inside the tip', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = '<a href="#" tabindex="0">Dismissible</a>'

const popoverEl = fixtureEl.querySelector('a')
const popover = new Popover(popoverEl, {
trigger: 'focus',
content: 'Selectable text'
})

popoverEl.addEventListener('shown.bs.popover', () => {
const tip = document.querySelector('.popover')
const body = tip.querySelector('.popover-body')

body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
popoverEl.dispatchEvent(new FocusEvent('focusout', {
bubbles: true,
relatedTarget: null
}))

expect(popover._activeTrigger.focus).toBeTrue()
expect(tip).toHaveClass('show')
resolve()
})

popoverEl.dispatchEvent(createEvent('focusin'))
})
})

it('should dismiss on pointerdown outside the tip after tip interaction', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = '<a href="#" tabindex="0">Dismissible</a>'

const popoverEl = fixtureEl.querySelector('a')
// eslint-disable-next-line no-new
new Popover(popoverEl, {
trigger: 'focus',
content: 'Selectable text'
})

popoverEl.addEventListener('shown.bs.popover', () => {
const tip = document.querySelector('.popover')
const body = tip.querySelector('.popover-body')

popoverEl.addEventListener('hidden.bs.popover', () => {
expect(document.querySelector('.popover')).toBeNull()
resolve()
})

body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
popoverEl.dispatchEvent(new FocusEvent('focusout', {
bubbles: true,
relatedTarget: null
}))
document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
})

popoverEl.dispatchEvent(createEvent('focusin'))
})
})

it('should allow focusable tip content to receive focus without dismissing', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = '<a href="#" tabindex="0">Dismissible</a>'

const popoverEl = fixtureEl.querySelector('a')
const popover = new Popover(popoverEl, {
trigger: 'focus',
html: true,
content: '<a href="#action" class="inside-link">Action</a>'
})

popoverEl.addEventListener('shown.bs.popover', () => {
const tip = document.querySelector('.popover')
const link = tip.querySelector('.inside-link')

popoverEl.dispatchEvent(new FocusEvent('focusout', {
bubbles: true,
relatedTarget: link
}))

expect(popover._activeTrigger.focus).toBeTrue()
expect(tip).toHaveClass('show')
resolve()
})

popoverEl.dispatchEvent(createEvent('focusin'))
})
})

it('should hide when focus leaves focusable tip content for outside content', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = '<a href="#" tabindex="0">Dismissible</a>'

const popoverEl = fixtureEl.querySelector('a')
// eslint-disable-next-line no-new
new Popover(popoverEl, {
trigger: 'focus',
html: true,
content: '<a href="#action" class="inside-link">Action</a>'
})

popoverEl.addEventListener('shown.bs.popover', () => {
const tip = document.querySelector('.popover')

popoverEl.addEventListener('hidden.bs.popover', () => {
expect(document.querySelector('.popover')).toBeNull()
resolve()
})

tip.dispatchEvent(new FocusEvent('focusout', {
bubbles: true,
relatedTarget: document.body
}))
})

popoverEl.dispatchEvent(createEvent('focusin'))
})
})
})

describe('data-api', () => {
it('should toggle popover on click via data-api', () => {
return new Promise(resolve => {
Expand Down
3 changes: 2 additions & 1 deletion site/src/content/docs/components/popover.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ deps:

## Overview

Click the button below to trigger a popover:
Click the button below to show and close a popover.

<Example addStackblitzJs code={`<button type="button" class="btn-solid theme-primary"
data-bs-toggle="popover"
Expand Down Expand Up @@ -147,6 +147,7 @@ You can customize the appearance of popovers using [CSS variables](#variables).
### Dismiss on next click

Use the `focus` trigger to dismiss popovers on the user’s next click of an element other than the toggle element.
**Clicks and focus inside the popover itself do not dismiss it**, so users can select text or use controls in the content.

<Callout type="danger">
**Dismissing on next click requires specific HTML for proper cross-browser and cross-platform behavior.** You can only use `<a>` elements, not `<button>`s, and you must include a [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex).
Expand Down