From 52012a855768e5b28796e3ee49a2e19c799cb4d0 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Wed, 17 Dec 2025 09:23:57 -0800 Subject: [PATCH] Migrate from Popper to Floating UI (#41941) * Migrate to Floating UI for tooltips, popovers, dropdowns * Bump bundlewatch * Dropdown tests * add floating ui tests from claude * more * build sri * more tests while here --- .bundlewatch.config.json | 8 +- build/generate-sri.mjs | 4 +- build/rollup.config.mjs | 8 +- config.yml | 12 +- js/src/dropdown.js | 261 +++++-- js/src/popover.js | 38 + js/src/tooltip.js | 270 +++++-- js/src/util/floating-ui.js | 128 +++ js/tests/unit/dropdown.spec.js | 315 +++++--- js/tests/unit/popover.spec.js | 55 ++ js/tests/unit/tooltip.spec.js | 93 ++- js/tests/unit/util/floating-ui.spec.js | 261 +++++++ js/tests/visual/dropdown.html | 2 +- package-lock.json | 43 +- package.json | 8 +- scss/_dropdown.scss | 153 +--- scss/_popover.scss | 18 +- scss/_tooltip.scss | 22 +- scss/content/_prose.scss | 2 +- site/data/sidebar.yml | 4 +- .../examples/cheatsheet-rtl/index.astro | 4 +- .../assets/examples/cheatsheet/index.astro | 2 +- .../src/content/docs/components/dropdowns.mdx | 738 +++++------------- .../components/{popovers.mdx => popover.mdx} | 149 +++- .../components/{tooltips.mdx => tooltip.mdx} | 170 ++-- .../src/content/docs/customize/components.mdx | 4 +- .../content/docs/getting-started/download.mdx | 4 +- .../content/docs/getting-started/install.mdx | 4 +- .../docs/getting-started/javascript.mdx | 12 +- site/src/content/docs/getting-started/rtl.mdx | 2 +- site/src/content/docs/guides/quickstart.mdx | 4 +- site/src/libs/config.ts | 6 +- 32 files changed, 1650 insertions(+), 1154 deletions(-) create mode 100644 js/src/util/floating-ui.js create mode 100644 js/tests/unit/util/floating-ui.spec.js rename site/src/content/docs/components/{popovers.mdx => popover.mdx} (67%) rename site/src/content/docs/components/{tooltips.mdx => tooltip.mdx} (65%) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 3301f9a253..9e8e216ace 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,7 +34,7 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "43.0 kB" + "maxSize": "44.0 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", @@ -42,7 +42,7 @@ }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "28.0 kB" + "maxSize": "30.0 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", @@ -50,11 +50,11 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "28.75 kB" + "maxSize": "30.5 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "16.25 kB" + "maxSize": "16.5 kB" } ], "ci": { diff --git a/build/generate-sri.mjs b/build/generate-sri.mjs index 1daa44c8c1..ddd6dda567 100644 --- a/build/generate-sri.mjs +++ b/build/generate-sri.mjs @@ -38,8 +38,8 @@ const files = [ configPropertyName: 'js_bundle_hash' }, { - file: 'node_modules/@popperjs/core/dist/umd/popper.min.js', - configPropertyName: 'popper_hash' + file: 'node_modules/@floating-ui/dom/dist/floating-ui.dom.umd.min.js', + configPropertyName: 'floating_ui_hash' } ] diff --git a/build/rollup.config.mjs b/build/rollup.config.mjs index dd6c7d13e6..3483557f99 100644 --- a/build/rollup.config.mjs +++ b/build/rollup.config.mjs @@ -12,7 +12,7 @@ const BUNDLE = process.env.BUNDLE === 'true' const ESM = process.env.ESM === 'true' let destinationFile = `bootstrap${ESM ? '.esm' : ''}` -const external = ['@popperjs/core'] +const external = ['@floating-ui/dom'] const plugins = [ babel({ // Only transpile our source code @@ -22,14 +22,14 @@ const plugins = [ }) ] const globals = { - '@popperjs/core': 'Popper' + '@floating-ui/dom': 'FloatingUIDOM' } if (BUNDLE) { destinationFile += '.bundle' - // Remove last entry in external array to bundle Popper + // Remove last entry in external array to bundle Floating UI external.pop() - delete globals['@popperjs/core'] + delete globals['@floating-ui/dom'] plugins.push( replace({ 'process.env.NODE_ENV': '"production"', diff --git a/config.yml b/config.yml index de3b6767a7..5a51adac7e 100644 --- a/config.yml +++ b/config.yml @@ -35,14 +35,14 @@ download: cdn: # See https://www.srihash.org for info on how to generate the hashes css: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" - css_hash: "sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" + css_hash: "sha384-TDmpFhAO5TwSQwPF95I/odgwpTUuv0aaVm9/0fL7b+kKe7hFBp/+9cBCMkydgGOi" js: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.min.js" - js_hash: "sha384-G/EV+4j2dNv+tEPo3++6LCgdCROaejBqfUeNjuKAiuXbjrxilcCdDz6ZAVfHWe1Y" + js_hash: "sha384-Php492snRLTR5p+hMyxpV6gYwp1avWXn4AaX31MgANrvsjr9Dpodl3Nw60L7Pewl" js_bundle: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" - js_bundle_hash: "sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" - popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" - popper_hash: "sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" - popper_esm: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/esm/popper.min.js" + js_bundle_hash: "sha384-I2J4jlw924JZXHU9un9Mcuixq/rKhd5A8/B1NQ6ifPAiBFacZjwNcec8d6L38jQv" + floating_ui: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.0/dist/floating-ui.dom.umd.min.js" + floating_ui_hash: "sha384-R7p1RqabZNhI+RdPNIzTouzd/LBVorZ0Tn3ApcogSOk+HF3o+P0HIenrUw/n0MOj" + floating_ui_esm: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.0/dist/floating-ui.dom.esm.min.js" anchors: min: 2 diff --git a/js/src/dropdown.js b/js/src/dropdown.js index a31f801d5b..ecd0f48b05 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -5,7 +5,13 @@ * -------------------------------------------------------------------------- */ -import * as Popper from '@popperjs/core' +import { + computePosition, + flip, + shift, + offset, + autoUpdate +} from '@floating-ui/dom' import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' import Manipulator from './dom/manipulator.js' @@ -20,6 +26,12 @@ import { isVisible, noop } from './util/index.js' +import { + parseResponsivePlacement, + getResponsivePlacement, + createBreakpointListeners, + disposeBreakpointListeners +} from './util/floating-ui.js' /** * Constants @@ -45,34 +57,23 @@ const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}` const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}` const CLASS_NAME_SHOW = 'show' -const CLASS_NAME_DROPUP = 'dropup' -const CLASS_NAME_DROPEND = 'dropend' -const CLASS_NAME_DROPSTART = 'dropstart' -const CLASS_NAME_DROPUP_CENTER = 'dropup-center' -const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)' const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}` const SELECTOR_MENU = '.dropdown-menu' -const SELECTOR_NAVBAR = '.navbar' const SELECTOR_NAVBAR_NAV = '.navbar-nav' const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)' -const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start' -const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end' -const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start' -const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end' -const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start' -const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' -const PLACEMENT_TOPCENTER = 'top' -const PLACEMENT_BOTTOMCENTER = 'bottom' +// Default placement with RTL support +const DEFAULT_PLACEMENT = isRTL() ? 'bottom-end' : 'bottom-start' const Default = { autoClose: true, boundary: 'clippingParents', display: 'dynamic', offset: [0, 2], - popperConfig: null, + floatingConfig: null, + placement: DEFAULT_PLACEMENT, reference: 'toggle' } @@ -81,7 +82,8 @@ const DefaultType = { boundary: '(string|element)', display: 'string', offset: '(array|string|function)', - popperConfig: '(null|object|function)', + floatingConfig: '(null|object|function)', + placement: 'string', reference: '(string|element|object)' } @@ -91,15 +93,23 @@ const DefaultType = { class Dropdown extends BaseComponent { constructor(element, config) { + if (typeof computePosition === 'undefined') { + throw new TypeError('Bootstrap\'s dropdowns require Floating UI (https://floating-ui.com)') + } + super(element, config) - this._popper = null + this._floatingCleanup = null + this._mediaQueryListeners = [] + this._responsivePlacements = null this._parent = this._element.parentNode // dropdown wrapper // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] || SelectorEngine.prev(this._element, SELECTOR_MENU)[0] || SelectorEngine.findOne(SELECTOR_MENU, this._parent) - this._inNavbar = this._detectNavbar() + + // Parse responsive placements on init + this._parseResponsivePlacements() } // Getters @@ -135,7 +145,7 @@ class Dropdown extends BaseComponent { return } - this._createPopper() + this._createFloating() // If this is a touch-enabled device we add extra // empty mouseover listeners to the body's immediate children; @@ -168,17 +178,14 @@ class Dropdown extends BaseComponent { } dispose() { - if (this._popper) { - this._popper.destroy() - } - + this._disposeFloating() + this._disposeMediaQueryListeners() super.dispose() } update() { - this._inNavbar = this._detectNavbar() - if (this._popper) { - this._popper.update() + if (this._floatingCleanup) { + this._updateFloatingPosition() } } @@ -197,14 +204,13 @@ class Dropdown extends BaseComponent { } } - if (this._popper) { - this._popper.destroy() - } + this._disposeFloating() this._menu.classList.remove(CLASS_NAME_SHOW) this._element.classList.remove(CLASS_NAME_SHOW) this._element.setAttribute('aria-expanded', 'false') - Manipulator.removeDataAttribute(this._menu, 'popper') + Manipulator.removeDataAttribute(this._menu, 'placement') + Manipulator.removeDataAttribute(this._menu, 'display') EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget) } @@ -214,16 +220,17 @@ class Dropdown extends BaseComponent { if (typeof config.reference === 'object' && !isElement(config.reference) && typeof config.reference.getBoundingClientRect !== 'function' ) { - // Popper virtual elements require a getBoundingClientRect method + // Floating UI virtual elements require a getBoundingClientRect method throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`) } return config } - _createPopper() { - if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)') + _createFloating() { + if (this._config.display === 'static') { + Manipulator.setDataAttribute(this._menu, 'display', 'static') + return } let referenceElement = this._element @@ -236,45 +243,95 @@ class Dropdown extends BaseComponent { referenceElement = this._config.reference } - const popperConfig = this._getPopperConfig() - this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig) - } + // Initial position update + this._updateFloatingPosition(referenceElement) - _isShown() { - return this._menu.classList.contains(CLASS_NAME_SHOW) + // Set up auto-update for scroll/resize + this._floatingCleanup = autoUpdate( + referenceElement, + this._menu, + () => this._updateFloatingPosition(referenceElement) + ) } - _getPlacement() { - const parentDropdown = this._parent - - if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { - return PLACEMENT_RIGHT + async _updateFloatingPosition(referenceElement = null) { + // Check if menu exists and is still in the DOM + if (!this._menu || !this._menu.isConnected) { + return } - if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { - return PLACEMENT_LEFT + if (!referenceElement) { + if (this._config.reference === 'parent') { + referenceElement = this._parent + } else if (isElement(this._config.reference)) { + referenceElement = getElement(this._config.reference) + } else if (typeof this._config.reference === 'object') { + referenceElement = this._config.reference + } else { + referenceElement = this._element + } } - if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) { - return PLACEMENT_TOPCENTER + const placement = this._getPlacement() + const middleware = this._getFloatingMiddleware() + const floatingConfig = this._getFloatingConfig(placement, middleware) + + const { x, y, placement: finalPlacement } = await computePosition( + referenceElement, + this._menu, + floatingConfig + ) + + // Menu may have been disposed during the async computePosition call + if (!this._menu || !this._menu.isConnected) { + return } - if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) { - return PLACEMENT_BOTTOMCENTER + // Apply position to dropdown menu + Object.assign(this._menu.style, { + position: 'absolute', + left: `${x}px`, + top: `${y}px`, + margin: '0' + }) + + // Set placement attribute for CSS styling + Manipulator.setDataAttribute(this._menu, 'placement', finalPlacement) + } + + _isShown() { + return this._menu.classList.contains(CLASS_NAME_SHOW) + } + + _getPlacement() { + // If we have responsive placements, find the appropriate one for current viewport + if (this._responsivePlacements) { + return getResponsivePlacement(this._responsivePlacements, DEFAULT_PLACEMENT) } - // We need to trim the value because custom properties can also include spaces - const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end' + return this._config.placement + } - if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) { - return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP + _parseResponsivePlacements() { + this._responsivePlacements = parseResponsivePlacement(this._config.placement, DEFAULT_PLACEMENT) + + if (this._responsivePlacements) { + this._setupMediaQueryListeners() } + } - return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM + _setupMediaQueryListeners() { + this._disposeMediaQueryListeners() + this._mediaQueryListeners = createBreakpointListeners(() => { + if (this._isShown()) { + this._updateFloatingPosition() + } + }) } - _detectNavbar() { - return this._element.closest(SELECTOR_NAVBAR) !== null + _disposeMediaQueryListeners() { + disposeBreakpointListeners(this._mediaQueryListeners) + this._mediaQueryListeners = [] } _getOffset() { @@ -285,41 +342,79 @@ class Dropdown extends BaseComponent { } if (typeof offset === 'function') { - return popperData => offset(popperData, this._element) + // Floating UI passes different args, adapt the interface for offset function callbacks + return ({ placement, rects }) => { + const result = offset({ placement, reference: rects.reference, floating: rects.floating }, this._element) + return result + } } return offset } - _getPopperConfig() { - const defaultBsPopperConfig = { - placement: this._getPlacement(), - modifiers: [{ - name: 'preventOverflow', - options: { - boundary: this._config.boundary - } - }, - { - name: 'offset', - options: { - offset: this._getOffset() - } - }] + _getFloatingMiddleware() { + const offsetValue = this._getOffset() + + const middleware = [ + // Offset middleware - handles distance from reference + offset( + typeof offsetValue === 'function' ? + offsetValue : + { mainAxis: offsetValue[1] || 0, crossAxis: offsetValue[0] || 0 } + ), + // Flip middleware - handles fallback placements + flip({ + fallbackPlacements: this._getFallbackPlacements() + }), + // Shift middleware - prevents overflow + shift({ + boundary: this._config.boundary === 'clippingParents' ? 'clippingAncestors' : this._config.boundary + }) + ] + + return middleware + } + + _getFallbackPlacements() { + // Get appropriate fallback placements based on current placement + // Fallbacks should preserve alignment (start/end) when possible + const placement = this._getPlacement() + + // Handle all possible Floating UI placements + const fallbackMap = { + bottom: ['top', 'bottom-start', 'bottom-end', 'top-start', 'top-end'], + 'bottom-start': ['top-start', 'bottom-end', 'top-end'], + 'bottom-end': ['top-end', 'bottom-start', 'top-start'], + top: ['bottom', 'top-start', 'top-end', 'bottom-start', 'bottom-end'], + 'top-start': ['bottom-start', 'top-end', 'bottom-end'], + 'top-end': ['bottom-end', 'top-start', 'bottom-start'], + right: ['left', 'right-start', 'right-end', 'left-start', 'left-end'], + 'right-start': ['left-start', 'right-end', 'left-end', 'top-start', 'bottom-start'], + 'right-end': ['left-end', 'right-start', 'left-start', 'top-end', 'bottom-end'], + left: ['right', 'left-start', 'left-end', 'right-start', 'right-end'], + 'left-start': ['right-start', 'left-end', 'right-end', 'top-start', 'bottom-start'], + 'left-end': ['right-end', 'left-start', 'right-start', 'top-end', 'bottom-end'] } - // Disable Popper if we have a static display or Dropdown is in Navbar - if (this._inNavbar || this._config.display === 'static') { - Manipulator.setDataAttribute(this._menu, 'popper', 'static') // TODO: v6 remove - defaultBsPopperConfig.modifiers = [{ - name: 'applyStyles', - enabled: false - }] + return fallbackMap[placement] || ['top', 'bottom', 'right', 'left'] + } + + _getFloatingConfig(placement, middleware) { + const defaultConfig = { + placement, + middleware } return { - ...defaultBsPopperConfig, - ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) + ...defaultConfig, + ...execute(this._config.floatingConfig, [undefined, defaultConfig]) + } + } + + _disposeFloating() { + if (this._floatingCleanup) { + this._floatingCleanup() + this._floatingCleanup = null } } diff --git a/js/src/popover.js b/js/src/popover.js index b8383dc0d5..d11eaa3340 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -6,6 +6,7 @@ */ import Tooltip from './tooltip.js' +import EventHandler from './dom/event-handler.js' /** * Constants @@ -15,6 +16,11 @@ const NAME = 'popover' const SELECTOR_TITLE = '.popover-header' const SELECTOR_CONTENT = '.popover-body' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="popover"]' + +const EVENT_CLICK = 'click' +const EVENT_FOCUSIN = 'focusin' +const EVENT_MOUSEENTER = 'mouseenter' const Default = { ...Tooltip.Default, @@ -70,4 +76,36 @@ class Popover extends Tooltip { } } +/** + * Data API implementation - auto-initialize popovers + */ + +const initPopover = event => { + const target = event.target.closest(SELECTOR_DATA_TOGGLE) + if (!target) { + return + } + + // Prevent default for click events to avoid navigation + if (event.type === 'click') { + event.preventDefault() + } + + // Get or create instance + const popover = Popover.getOrCreateInstance(target) + + // Trigger the appropriate action based on event type + if (event.type === 'click') { + popover.toggle() + } else if (event.type === 'focusin') { + popover._activeTrigger.focus = true + popover._enter() + } +} + +// Support click (default), hover, and focus triggers +EventHandler.on(document, EVENT_CLICK, SELECTOR_DATA_TOGGLE, initPopover) +EventHandler.on(document, EVENT_FOCUSIN, SELECTOR_DATA_TOGGLE, initPopover) +EventHandler.on(document, EVENT_MOUSEENTER, SELECTOR_DATA_TOGGLE, initPopover) + export default Popover diff --git a/js/src/tooltip.js b/js/src/tooltip.js index ca603d20d0..531b359ac0 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -5,7 +5,14 @@ * -------------------------------------------------------------------------- */ -import * as Popper from '@popperjs/core' +import { + computePosition, + flip, + shift, + offset, + arrow, + autoUpdate +} from '@floating-ui/dom' import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' import Manipulator from './dom/manipulator.js' @@ -14,6 +21,12 @@ import { } from './util/index.js' import { DefaultAllowlist } from './util/sanitizer.js' import TemplateFactory from './util/template-factory.js' +import { + parseResponsivePlacement, + getResponsivePlacement, + createBreakpointListeners, + disposeBreakpointListeners +} from './util/floating-ui.js' /** * Constants @@ -28,6 +41,7 @@ const CLASS_NAME_SHOW = 'show' const SELECTOR_TOOLTIP_INNER = '.tooltip-inner' const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}` +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tooltip"]' const EVENT_MODAL_HIDE = 'hide.bs.modal' @@ -66,7 +80,7 @@ const Default = { html: false, offset: [0, 6], placement: 'top', - popperConfig: null, + floatingConfig: null, sanitize: true, sanitizeFn: null, selector: false, @@ -89,7 +103,7 @@ const DefaultType = { html: 'boolean', offset: '(array|string|function)', placement: '(string|function)', - popperConfig: '(null|object|function)', + floatingConfig: '(null|object|function)', sanitize: 'boolean', sanitizeFn: '(null|function)', selector: '(string|boolean)', @@ -104,8 +118,8 @@ const DefaultType = { class Tooltip extends BaseComponent { constructor(element, config) { - if (typeof Popper === 'undefined') { - throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)') + if (typeof computePosition === 'undefined') { + throw new TypeError('Bootstrap\'s tooltips require Floating UI (https://floating-ui.com)') } super(element, config) @@ -115,13 +129,16 @@ class Tooltip extends BaseComponent { this._timeout = 0 this._isHovered = null this._activeTrigger = {} - this._popper = null + this._floatingCleanup = null this._templateFactory = null this._newContent = null + this._mediaQueryListeners = [] + this._responsivePlacements = null // Protected this.tip = null + this._parseResponsivePlacements() this._setListeners() if (!this._config.selector) { @@ -177,11 +194,12 @@ class Tooltip extends BaseComponent { this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title')) } - this._disposePopper() + this._disposeFloating() + this._disposeMediaQueryListeners() super.dispose() } - show() { + async show() { if (this._element.style.display === 'none') { throw new Error('Please use show on visible elements') } @@ -198,8 +216,7 @@ class Tooltip extends BaseComponent { return } - // TODO: v6 remove this or make it optional - this._disposePopper() + this._disposeFloating() const tip = this._getTipElement() @@ -212,7 +229,7 @@ class Tooltip extends BaseComponent { EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED)) } - this._popper = this._createPopper(tip) + await this._createFloating(tip) tip.classList.add(CLASS_NAME_SHOW) @@ -271,7 +288,7 @@ class Tooltip extends BaseComponent { } if (!this._isHovered) { - this._disposePopper() + this._disposeFloating() } this._element.removeAttribute('aria-describedby') @@ -282,8 +299,8 @@ class Tooltip extends BaseComponent { } update() { - if (this._popper) { - this._popper.update() + if (this._floatingCleanup && this.tip) { + this._updateFloatingPosition() } } @@ -326,7 +343,7 @@ class Tooltip extends BaseComponent { setContent(content) { this._newContent = content if (this._isShown()) { - this._disposePopper() + this._disposeFloating() this.show() } } @@ -370,10 +387,114 @@ class Tooltip extends BaseComponent { return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW) } - _createPopper(tip) { + _getPlacement(tip) { + // If we have responsive placements, get the one for current viewport + if (this._responsivePlacements) { + const placement = getResponsivePlacement(this._responsivePlacements, 'top') + return AttachmentMap[placement.toUpperCase()] || placement + } + + // Execute placement (can be a function) const placement = execute(this._config.placement, [this, tip, this._element]) - const attachment = AttachmentMap[placement.toUpperCase()] - return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) + return AttachmentMap[placement.toUpperCase()] || placement + } + + _parseResponsivePlacements() { + // Only parse if placement is a string (not a function) + if (typeof this._config.placement !== 'string') { + this._responsivePlacements = null + return + } + + this._responsivePlacements = parseResponsivePlacement(this._config.placement, 'top') + + if (this._responsivePlacements) { + this._setupMediaQueryListeners() + } + } + + _setupMediaQueryListeners() { + this._disposeMediaQueryListeners() + this._mediaQueryListeners = createBreakpointListeners(() => { + if (this._isShown()) { + this._updateFloatingPosition() + } + }) + } + + _disposeMediaQueryListeners() { + disposeBreakpointListeners(this._mediaQueryListeners) + this._mediaQueryListeners = [] + } + + async _createFloating(tip) { + const placement = this._getPlacement(tip) + const arrowElement = tip.querySelector(`.${this.constructor.NAME}-arrow`) + + // Initial position update + await this._updateFloatingPosition(tip, placement, arrowElement) + + // Set up auto-update for scroll/resize + this._floatingCleanup = autoUpdate( + this._element, + tip, + () => this._updateFloatingPosition(tip, null, arrowElement) + ) + } + + async _updateFloatingPosition(tip = this.tip, placement = null, arrowElement = null) { + if (!tip) { + return + } + + if (!placement) { + placement = this._getPlacement(tip) + } + + if (!arrowElement) { + arrowElement = tip.querySelector(`.${this.constructor.NAME}-arrow`) + } + + const middleware = this._getFloatingMiddleware(arrowElement) + const floatingConfig = this._getFloatingConfig(placement, middleware) + + const { x, y, placement: finalPlacement, middlewareData } = await computePosition( + this._element, + tip, + floatingConfig + ) + + // Apply position to tooltip + Object.assign(tip.style, { + position: 'absolute', + left: `${x}px`, + top: `${y}px` + }) + + // Ensure arrow is absolutely positioned within tooltip + if (arrowElement) { + arrowElement.style.position = 'absolute' + } + + // Set placement attribute for CSS arrow styling + Manipulator.setDataAttribute(tip, 'placement', finalPlacement) + + // Position arrow along the edge (center it) if present + // The CSS handles which edge to place it on via data-bs-placement + if (arrowElement && middlewareData.arrow) { + const { x: arrowX, y: arrowY } = middlewareData.arrow + const isVertical = finalPlacement.startsWith('top') || finalPlacement.startsWith('bottom') + + // Only set the cross-axis position (centering along the edge) + // The main-axis position (which edge) is handled by CSS + Object.assign(arrowElement.style, { + left: isVertical && arrowX !== null ? `${arrowX}px` : '', + top: !isVertical && arrowY !== null ? `${arrowY}px` : '', + // Reset the other axis to let CSS handle it + right: '', + bottom: '' + }) + } } _getOffset() { @@ -384,7 +505,11 @@ class Tooltip extends BaseComponent { } if (typeof offset === 'function') { - return popperData => offset(popperData, this._element) + // Floating UI passes different args, adapt the interface for offset function callbacks + return ({ placement, rects }) => { + const result = offset({ placement, reference: rects.reference, floating: rects.floating }, this._element) + return result + } } return offset @@ -394,50 +519,43 @@ class Tooltip extends BaseComponent { return execute(arg, [this._element, this._element]) } - _getPopperConfig(attachment) { - const defaultBsPopperConfig = { - placement: attachment, - modifiers: [ - { - name: 'flip', - options: { - fallbackPlacements: this._config.fallbackPlacements - } - }, - { - name: 'offset', - options: { - offset: this._getOffset() - } - }, - { - name: 'preventOverflow', - options: { - boundary: this._config.boundary - } - }, - { - name: 'arrow', - options: { - element: `.${this.constructor.NAME}-arrow` - } - }, - { - name: 'preSetPlacement', - enabled: true, - phase: 'beforeMain', - fn: data => { - // Pre-set Popper's placement attribute in order to read the arrow sizes properly. - // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement - this._getTipElement().setAttribute('data-popper-placement', data.state.placement) - } - } - ] + _getFloatingMiddleware(arrowElement) { + const offsetValue = this._getOffset() + + const middleware = [ + // Offset middleware - handles distance from reference + offset( + typeof offsetValue === 'function' ? + offsetValue : + { mainAxis: offsetValue[1] || 0, crossAxis: offsetValue[0] || 0 } + ), + // Flip middleware - handles fallback placements + flip({ + fallbackPlacements: this._config.fallbackPlacements + }), + // Shift middleware - prevents overflow + shift({ + boundary: this._config.boundary === 'clippingParents' ? 'clippingAncestors' : this._config.boundary + }) + ] + + // Arrow middleware - positions the arrow element + if (arrowElement) { + middleware.push(arrow({ element: arrowElement })) + } + + return middleware + } + + _getFloatingConfig(placement, middleware) { + const defaultConfig = { + placement, + middleware } return { - ...defaultBsPopperConfig, - ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) + ...defaultConfig, + ...execute(this._config.floatingConfig, [undefined, defaultConfig]) } } @@ -594,10 +712,10 @@ class Tooltip extends BaseComponent { return config } - _disposePopper() { - if (this._popper) { - this._popper.destroy() - this._popper = null + _disposeFloating() { + if (this._floatingCleanup) { + this._floatingCleanup() + this._floatingCleanup = null } if (this.tip) { @@ -606,4 +724,28 @@ class Tooltip extends BaseComponent { } } } + +/** + * Data API implementation - auto-initialize tooltips + */ + +const initTooltip = event => { + const target = event.target.closest(SELECTOR_DATA_TOGGLE) + if (!target) { + return + } + + // Get or create instance and trigger the appropriate action + const tooltip = Tooltip.getOrCreateInstance(target) + + // For focus events, manually trigger enter to show + if (event.type === 'focusin') { + tooltip._activeTrigger.focus = true + tooltip._enter() + } +} + +EventHandler.on(document, EVENT_FOCUSIN, SELECTOR_DATA_TOGGLE, initTooltip) +EventHandler.on(document, EVENT_MOUSEENTER, SELECTOR_DATA_TOGGLE, initTooltip) + export default Tooltip diff --git a/js/src/util/floating-ui.js b/js/src/util/floating-ui.js new file mode 100644 index 0000000000..39527dab6a --- /dev/null +++ b/js/src/util/floating-ui.js @@ -0,0 +1,128 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap util/floating-ui.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { isRTL } from './index.js' + +/** + * Breakpoints for responsive placement (matches SCSS $grid-breakpoints) + */ +export const BREAKPOINTS = { + sm: 576, + md: 768, + lg: 1024, + xl: 1280, + '2xl': 1536 +} + +/** + * Default placement with RTL support + */ +export const getDefaultPlacement = (fallback = 'bottom') => { + if (fallback.includes('-start') || fallback.includes('-end')) { + const [side, alignment] = fallback.split('-') + const flippedAlignment = alignment === 'start' ? 'end' : 'start' + return isRTL() ? `${side}-${flippedAlignment}` : fallback + } + + return fallback +} + +/** + * Parse a placement string that may contain responsive prefixes + * Example: "bottom-start md:top-end lg:right" returns { xs: 'bottom-start', md: 'top-end', lg: 'right' } + * + * @param {string} placementString - The placement string to parse + * @param {string} defaultPlacement - The default placement to use for xs/base + * @returns {object|null} - Object with breakpoint keys and placement values, or null if not responsive + */ +export const parseResponsivePlacement = (placementString, defaultPlacement = 'bottom') => { + // Check if placement contains responsive prefixes (e.g., "bottom-start md:top-end") + if (!placementString || !placementString.includes(':')) { + return null + } + + // Parse the placement string into breakpoint-keyed object + const parts = placementString.split(/\s+/) + const placements = { xs: defaultPlacement } // Default fallback + + for (const part of parts) { + if (part.includes(':')) { + // Responsive placement like "md:top-end" + const [breakpoint, placement] = part.split(':') + if (BREAKPOINTS[breakpoint] !== undefined) { + placements[breakpoint] = placement + } + } else { + // Base placement (no prefix = xs/default) + placements.xs = part + } + } + + return placements +} + +/** + * Get the active placement for the current viewport width + * + * @param {object} responsivePlacements - Object with breakpoint keys and placement values + * @param {string} defaultPlacement - Fallback placement + * @returns {string} - The active placement for current viewport + */ +export const getResponsivePlacement = (responsivePlacements, defaultPlacement = 'bottom') => { + if (!responsivePlacements) { + return defaultPlacement + } + + // Get current viewport width + const viewportWidth = window.innerWidth + + // Find the largest breakpoint that matches + let activePlacement = responsivePlacements.xs || defaultPlacement + + // Check breakpoints in order (sm, md, lg, xl, 2xl) + const breakpointOrder = ['sm', 'md', 'lg', 'xl', '2xl'] + + for (const breakpoint of breakpointOrder) { + const minWidth = BREAKPOINTS[breakpoint] + if (viewportWidth >= minWidth && responsivePlacements[breakpoint]) { + activePlacement = responsivePlacements[breakpoint] + } + } + + return activePlacement +} + +/** + * Create media query listeners for responsive placement changes + * + * @param {Function} callback - Callback to run when breakpoint changes + * @returns {Array} - Array of { mql, handler } objects for cleanup + */ +export const createBreakpointListeners = callback => { + const listeners = [] + + for (const breakpoint of Object.keys(BREAKPOINTS)) { + const minWidth = BREAKPOINTS[breakpoint] + const mql = window.matchMedia(`(min-width: ${minWidth}px)`) + + mql.addEventListener('change', callback) + listeners.push({ mql, handler: callback }) + } + + return listeners +} + +/** + * Clean up media query listeners + * + * @param {Array} listeners - Array of { mql, handler } objects + */ +export const disposeBreakpointListeners = listeners => { + for (const { mql, handler } of listeners) { + mql.removeEventListener('change', handler) + } +} diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js index 3f8698c2f9..37d7cb83d8 100644 --- a/js/tests/unit/dropdown.spec.js +++ b/js/tests/unit/dropdown.spec.js @@ -96,18 +96,17 @@ describe('Dropdown', () => { const getOffset = jasmine.createSpy('getOffset').and.returnValue([10, 20]) const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') const dropdown = new Dropdown(btnDropdown, { - offset: getOffset, - popperConfig: { - onFirstUpdate(state) { - expect(getOffset).toHaveBeenCalledWith({ - popper: state.rects.popper, - reference: state.rects.reference, - placement: state.placement - }, btnDropdown) - resolve() - } - } + offset: getOffset }) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + // Floating UI calls offset function asynchronously + setTimeout(() => { + expect(getOffset).toHaveBeenCalled() + resolve() + }, 20) + }) + const offset = dropdown._getOffset() expect(typeof offset).toEqual('function') @@ -132,7 +131,7 @@ describe('Dropdown', () => { expect(dropdown._getOffset()).toEqual([10, 20]) }) - it('should allow to pass config to Popper with `popperConfig`', () => { + it('should allow to pass config to Floating UI with `floatingConfig`', () => { fixtureEl.innerHTML = [ ' + + `} /> -### Dropend +### Right -Trigger dropdown menus at the right of the elements by adding `.dropend` to the parent element. +Use `right`, `right-start`, or `right-end` to position the menu to the right of the toggle. - - -
- - + +
+ `} /> -```html - -
- - -
+### Responsive - -
- - - -
-``` +Change placement at different breakpoints using responsive prefixes. The syntax is `breakpoint:placement`, where breakpoint is one of `sm`, `md`, `lg`, `xl`, or `2xl`. Multiple breakpoints can be combined in a single attribute, space-separated. -### Dropstart +For example, `data-bs-placement="bottom-start md:bottom-end lg:right-start"` will: -Trigger dropdown menus at the left of the elements by adding `.dropstart` to the parent element. +- Show the menu at `bottom-start` on small screens (default) +- Switch to `bottom-end` at the `md` breakpoint +- Switch to `right-start` at the `lg` breakpoint - - -
- + +
+ `} /> -```html - -
- - -
- - -
- - - -
-``` +Resize your browser window to see the placement change at different breakpoints. ## Menu items You can use `` or ` `} /> -## Menu alignment - -By default, a dropdown menu is automatically positioned 100% from the top and along the left side of its parent. You can change this with the directional `.drop*` classes, but you can also control them with additional modifier classes. - -Add `.dropdown-menu-end` to a `.dropdown-menu` to right align the dropdown menu. Directions are mirrored when using Bootstrap in RTL, meaning `.dropdown-menu-end` will appear on the left side. - - -**Heads up!** Dropdowns are positioned thanks to Popper except when they are contained in a navbar. - - - - - - `} /> - -### Responsive alignment - -If you want to use responsive alignment, disable dynamic positioning by adding the `data-bs-display="static"` attribute and use the responsive variation classes. - -To align **right** the dropdown menu with the given breakpoint or larger, add `.dropdown-menu{-sm|-md|-lg|-xl|-2xl}-end`. - - - - - `} /> - -To align **left** the dropdown menu with the given breakpoint or larger, add `.dropdown-menu-end` and `.dropdown-menu{-sm|-md|-lg|-xl|-2xl}-start`. - - - - - `} /> - -Note that you don’t need to add a `data-bs-display="static"` attribute to dropdown buttons in navbars, since Popper isn’t used in navbars. - -### Alignment options - -Taking most of the options shown above, here’s a small kitchen sink demo of various dropdown alignment options in one place. - - - - - - -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
`} /> - ## Menu content ### Headers @@ -851,7 +451,7 @@ Use `data-bs-offset` or `data-bs-reference` to change the location of the dropdo
- - +
-
-
-
`} /> +Resize your browser to see the tooltip placement change at different breakpoints. -```html - - - - -``` +### Custom HTML And with custom HTML added: -```html - -``` + + Tooltip with HTML + `} /> With an SVG: - + `} /> + +### Disabled elements + +Elements with the `disabled` attribute aren’t interactive, meaning users cannot focus, hover, or click them to trigger a tooltip (or popover). As a workaround, you’ll want to trigger the tooltip from a wrapper `
` or ``, ideally made keyboard-focusable using `tabindex="0"`. + + + + `} /> ## CSS @@ -124,7 +160,7 @@ const tooltip = new bootstrap.Tooltip(exampleEl, options) ``` -Tooltips automatically attempt to change positions when a parent container has `overflow: auto` or `overflow: scroll`, but still keeps the original placement’s positioning. Set the [`boundary` option](https://popper.js.org/docs/v2/modifiers/flip/#boundary) (for the flip modifier using the `popperConfig` option) to any HTMLElement to override the default value, `'clippingParents'`, such as `document.body`: +Tooltips automatically attempt to change positions when a parent container has `overflow: auto` or `overflow: scroll`, but still keeps the original placement’s positioning. Set the [`boundary` option](https://floating-ui.com/docs/shift#boundary) (for the shift middleware using the `floatingConfig` option) to any HTMLElement to override the default value, `'clippingParents'`, such as `document.body`: ```js const tooltip = new bootstrap.Tooltip('#example', { @@ -155,13 +191,23 @@ The required markup for a tooltip is only a `data` attribute and `title` on the
``` -### Disabled elements +### Dependencies -Elements with the `disabled` attribute aren’t interactive, meaning users cannot focus, hover, or click them to trigger a tooltip (or popover). As a workaround, you’ll want to trigger the tooltip from a wrapper `
` or ``, ideally made keyboard-focusable using `tabindex="0"`. +The tooltip plugin requires the following JavaScript files if you're building Bootstrap's JS from source: - - - `} /> + +| File | Description | +| --- | --- | +| `js/src/tooltip.js` | Main tooltip component | +| `js/src/base-component.js` | Base component class | +| `js/src/dom/event-handler.js` | Event handling utilities | +| `js/src/dom/manipulator.js` | Data attribute manipulation | +| `js/src/util/index.js` | Core utility functions | +| `js/src/util/sanitizer.js` | HTML content sanitizer | +| `js/src/util/template-factory.js` | Template rendering utilities | +| `js/src/util/floating-ui.js` | Responsive placement utilities | +| `@floating-ui/dom` | Third-party positioning library | + ### Options @@ -176,15 +222,15 @@ Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` opt | --- | --- | --- | --- | | `allowList` | object | [Default value]([[docsref:/getting-started/javascript#sanitizer]]) | An object containing allowed tags and attributes. Those not explicitly allowed will be removed by [the content sanitizer]([[docsref:/getting-started/javascript#sanitizer]]). **Exercise caution when adding to this list.** Refer to [OWASP’s Cross Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) for more information. | | `animation` | boolean | `true` | Apply a CSS fade transition to the tooltip. | -| `boundary` | string, element | `'clippingParents'` | Overflow constraint boundary of the tooltip (applies only to Popper’s preventOverflow modifier). By default, it’s `'clippingParents'` and can accept an HTMLElement reference (via JavaScript only). For more information refer to Popper’s [detectOverflow docs](https://popper.js.org/docs/v2/utils/detect-overflow/#boundary). | +| `boundary` | string, element | `'clippingParents'` | Overflow constraint boundary of the tooltip (applies only to Floating UI's shift middleware). By default, it’s `'clippingParents'` and can accept an HTMLElement reference (via JavaScript only). For more information refer to Floating UI's [shift docs](https://floating-ui.com/docs/shift#boundary). | | `container` | string, element, false | `false` | Appends the tooltip to a specific element. Example: `container: 'body'`. This option is particularly useful in that it allows you to position the tooltip in the flow of the document near the triggering element - which will prevent the tooltip from floating away from the triggering element during a window resize. | | `customClass` | string, function | `''` | Add classes to the tooltip when it is shown. Note that these classes will be added in addition to any classes specified in the template. To add multiple classes, separate them with spaces: `'class-1 class-2'`. You can also pass a function that should return a single string containing additional class names. | | `delay` | number, object | `0` | Delay showing and hiding the tooltip (ms)—doesn’t apply to manual trigger type. If a number is supplied, delay is applied to both hide/show. Object structure is: `delay: { "show": 500, "hide": 100 }`. | -| `fallbackPlacements` | array | `['top', 'right', 'bottom', 'left']` | Define fallback placements by providing a list of placements in array (in order of preference). For more information refer to Popper’s [behavior docs](https://popper.js.org/docs/v2/modifiers/flip/#fallbackplacements). | +| `fallbackPlacements` | array | `['top', 'right', 'bottom', 'left']` | Define fallback placements by providing a list of placements in array (in order of preference). For more information refer to Floating UI's [flip docs](https://floating-ui.com/docs/flip#fallbackplacements). | | `html` | boolean | `false` | Allow HTML in the tooltip. If true, HTML tags in the tooltip’s `title` will be rendered in the tooltip. If false, `innerText` property will be used to insert content into the DOM. Prefer text when dealing with user-generated input to [prevent XSS attacks](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html). | -| `offset` | array, string, function | `[0, 6]` | Offset of the tooltip relative to its target. You can pass a string in data attributes with comma separated values like: `data-bs-offset="10,20"`. When a function is used to determine the offset, it is called with an object containing the popper placement, the reference, and popper rects as its first argument. The triggering element DOM node is passed as the second argument. The function must return an array with two numbers: [skidding](https://popper.js.org/docs/v2/modifiers/offset/#skidding-1), [distance](https://popper.js.org/docs/v2/modifiers/offset/#distance-1). For more information refer to Popper’s [offset docs](https://popper.js.org/docs/v2/modifiers/offset/#options). | -| `placement` | string, function | `'top'` | How to position the tooltip: auto, top, bottom, left, right. When `auto` is specified, it will dynamically reorient the tooltip. When a function is used to determine the placement, it is called with the tooltip DOM node as its first argument and the triggering element DOM node as its second. The `this` context is set to the tooltip instance. | -| `popperConfig` | null, object, function | `null` | To change Bootstrap’s default Popper config, see [Popper’s configuration](https://popper.js.org/docs/v2/constructors/#options). When a function is used to create the Popper configuration, it’s called with an object that contains the Bootstrap’s default Popper configuration. It helps you use and merge the default with your own configuration. The function must return a configuration object for Popper. | +| `offset` | array, string, function | `[0, 6]` | Offset of the tooltip relative to its target. You can pass a string in data attributes with comma separated values like: `data-bs-offset="10,20"`. When a function is used to determine the offset, it is called with an object containing the floating placement, the reference, and floating rects as its first argument. The triggering element DOM node is passed as the second argument. The function must return an array with two numbers: [crossAxis](https://floating-ui.com/docs/offset#crossaxis), [mainAxis](https://floating-ui.com/docs/offset#mainaxis). For more information refer to Floating UI's [offset docs](https://floating-ui.com/docs/offset). | +| `placement` | string, function | `'top'` | How to position the tooltip: top, bottom, left, right. Supports responsive prefixes like `'top md:right lg:bottom'` to change placement at different breakpoints. When a function is used to determine the placement, it is called with the tooltip DOM node as its first argument and the triggering element DOM node as its second. | +| `floatingConfig` | null, object, function | `null` | To change Bootstrap’s default Floating UI config, see [Floating UI's configuration](https://floating-ui.com/docs/computePosition). When a function is used to create the Floating UI configuration, it’s called with an object that contains the Bootstrap’s default Floating UI configuration. It helps you use and merge the default with your own configuration. The function must return a configuration object for Floating UI. | | `sanitize` | boolean | `true` | Enable [content sanitization]([[docsref:/getting-started/javascript#sanitizer]]). If true, the `template`, `content` and `title` options will be sanitized. **Exercise caution when disabling content sanitization.** Refer to [OWASP’s Cross Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) for more information. Vulnerabilities caused solely by disabling content sanitization are not considered within scope for Bootstrap’s security model. | | `sanitizeFn` | null, function | `null` | Provide an alternative [content sanitization]([[docsref:/getting-started/javascript#sanitizer]]) function. This can be useful if you prefer to use a dedicated library to perform sanitization. | | `selector` | string, false | `false` | If a selector is provided, tooltip objects will be delegated to the specified targets. In practice, this is used to also apply tooltips to dynamically added DOM elements (`jQuery.on` support). See [this issue]([[config:repo]]/issues/4215) and [an informative example](https://codepen.io/Johann-S/pen/djJYPb). **Note**: `title` attribute must not be used as a selector. | @@ -199,14 +245,14 @@ Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` opt Options for individual tooltips can alternatively be specified through the use of data attributes, as explained above. -#### Using function with `popperConfig` +#### Using function with `floatingConfig` ```js const tooltip = new bootstrap.Tooltip(element, { - popperConfig(defaultBsPopperConfig) { - // const newPopperConfig = {...} - // use defaultBsPopperConfig if needed... - // return newPopperConfig + floatingConfig(defaultBsFloatingConfig) { + // const newFloatingConfig = {...} + // use defaultBsFloatingConfig if needed... + // return newFloatingConfig } }) ``` diff --git a/site/src/content/docs/customize/components.mdx b/site/src/content/docs/customize/components.mdx index a3e9a59bdd..026a7426e1 100644 --- a/site/src/content/docs/customize/components.mdx +++ b/site/src/content/docs/customize/components.mdx @@ -20,9 +20,9 @@ Check out [our Sass maps and loops docs]([[docsref:/customize/sass#maps-and-loop ## Responsive -These Sass loops aren’t limited to color maps, either. You can also generate responsive variations of your components. Take for example our responsive alignment of the dropdowns where we mix an `@each` loop for the `$grid-breakpoints` Sass map with a media query include. +These Sass loops aren't limited to color maps, either. You can also generate responsive variations of your components. Take for example our responsive navbar expand classes where we mix an `@each` loop for the `$grid-breakpoints` Sass map with a media query include. - + Should you modify your `$grid-breakpoints`, your changes will apply to all the loops iterating over that map. diff --git a/site/src/content/docs/getting-started/download.mdx b/site/src/content/docs/getting-started/download.mdx index fbddc88fcf..60126bfa4f 100644 --- a/site/src/content/docs/getting-started/download.mdx +++ b/site/src/content/docs/getting-started/download.mdx @@ -41,10 +41,10 @@ Skip the download with [jsDelivr](https://www.jsdelivr.com/) to deliver cached v ``` -If you’re using our compiled JavaScript and prefer to include Popper separately, add Popper before our JS, via a CDN preferably. +If you’re using our compiled JavaScript and prefer to include Floating UI separately, add Popper before our JS, via a CDN preferably. ```html - + ``` diff --git a/site/src/content/docs/getting-started/install.mdx b/site/src/content/docs/getting-started/install.mdx index ebc6018090..ace55374ee 100644 --- a/site/src/content/docs/getting-started/install.mdx +++ b/site/src/content/docs/getting-started/install.mdx @@ -79,10 +79,10 @@ Here are our primary CDN links for CSS and JavaScript: ``` -If you’re using our compiled JavaScript and prefer to include Popper separately, add Popper before our JS, via a CDN preferably. +If you’re using our compiled JavaScript and prefer to include Floating UI separately, add Popper before our JS, via a CDN preferably. ```html - + ``` diff --git a/site/src/content/docs/getting-started/javascript.mdx b/site/src/content/docs/getting-started/javascript.mdx index 59f08110f9..d34af528bb 100644 --- a/site/src/content/docs/getting-started/javascript.mdx +++ b/site/src/content/docs/getting-started/javascript.mdx @@ -13,14 +13,14 @@ Curious which components explicitly require our JavaScript and Popper? - Buttons for toggling states and checkbox/radio functionality - Carousel for all slide behaviors, controls, and indicators - Collapse for toggling visibility of content -- Dropdowns for displaying and positioning (also requires [Popper](https://popper.js.org/docs/v2/)) +- Dropdowns for displaying and positioning (also requires [Floating UI](https://floating-ui.com/)) - Modals for displaying, positioning, and scroll behavior - Navbar for extending our Collapse and Offcanvas plugins to implement responsive behaviors - Navs with the Tab plugin for toggling content panes - Offcanvases for displaying, positioning, and scroll behavior - Scrollspy for scroll behavior and navigation updates - Toasts for displaying and dismissing -- Tooltips and popovers for displaying and positioning (also requires [Popper](https://popper.js.org/docs/v2/)) +- Tooltips and popovers for displaying and positioning (also requires [Floating UI](https://floating-ui.com/)) ## Individual or compiled @@ -62,13 +62,13 @@ We provide a version of Bootstrap built as `ESM` (`bootstrap.esm.js` and `bootst Compared to JS bundlers, using ESM in the browser requires you to use the full path and filename instead of the module name. [Read more about JS modules in the browser.](https://v8.dev/features/modules#specifiers) That’s why we use `'bootstrap.esm.min.js'` instead of `'bootstrap'` above. However, this is further complicated by our Popper dependency, which imports Popper into our JavaScript like so: ```js -import * as Popper from "@popperjs/core" +import * as Popper from "@floating-ui/dom" ``` If you try this as-is, you’ll see an error in the console like the following: ```text -Uncaught TypeError: Failed to resolve module specifier "@popperjs/core". Relative references must start with either "/", "./", or "../". +Uncaught TypeError: Failed to resolve module specifier "@floating-ui/dom". Relative references must start with either "/", "./", or "../". ``` To fix this, you can use an `importmap` to resolve the arbitrary module names to complete paths. If your [targeted browsers](https://caniuse.com/?search=importmap) do not support `importmap`, you’ll need to use the [es-module-shims](https://github.com/guybedford/es-module-shims) project. Here’s how it works for Bootstrap and Popper: @@ -90,7 +90,7 @@ To fix this, you can use an `importmap` to resolve the arbitrary module names to + ``` diff --git a/site/src/libs/config.ts b/site/src/libs/config.ts index d3fb83b759..0c5b252e8c 100644 --- a/site/src/libs/config.ts +++ b/site/src/libs/config.ts @@ -27,9 +27,9 @@ const configSchema = z.object({ js_hash: z.string(), js_bundle: z.string().url(), js_bundle_hash: z.string(), - popper: z.string().url(), - popper_esm: z.string().url(), - popper_hash: z.string() + floating_ui: z.string().url(), + floating_ui_esm: z.string().url(), + floating_ui_hash: z.string() }), current_version: zVersionSemver, current_ruby_version: zVersionSemver, -- 2.47.3