diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index dd28fc625f..cb7004da41 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -42,7 +42,7 @@ }, { "path": "./dist/js/ouds-web.bundle.js", - "maxSize": "44.25 kB" + "maxSize": "46 kB" }, { "path": "./dist/js/ouds-web.bundle.min.js", @@ -50,19 +50,19 @@ }, { "path": "./dist/js/ouds-web.esm.js", - "maxSize": "29.25 kB" + "maxSize": "32.25 kB" }, { "path": "./dist/js/ouds-web.esm.min.js", - "maxSize": "19 kB" + "maxSize": "20 kB" }, { "path": "./dist/js/ouds-web.js", - "maxSize": "30 kB" + "maxSize": "32.75 kB" }, { "path": "./dist/js/ouds-web.min.js", - "maxSize": "17 kB" + "maxSize": "17.75 kB" } ], "ci": { diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dec90358e7..0dcd282ceb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,7 +37,6 @@ updates: - dependency-name: "@babel/core" - dependency-name: "@babel/preset-env" - dependency-name: "@docsearch/js" - - dependency-name: "@popperjs/core" - dependency-name: "@rollup/plugin-babel" - dependency-name: "@rollup/plugin-commonjs" - dependency-name: "@rollup/plugin-node-resolve" diff --git a/README.md b/README.md index 772e015141..73fcdb2ecb 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Within the download you’ll find the following directories and files, logically ``` -We provide compiled CSS and JS (`ouds-web.*`), as well as compiled and minified CSS and JS (`ouds-web.min.*`). [Source maps](https://web.dev/articles/source-maps) (`ouds-web.*.map`) are available for use with certain browsers’ developer tools. Bundled JS files (`ouds-web.bundle.js` and minified `ouds-web.bundle.min.js`) include [Popper](https://popper.js.org/docs/v2/). +We provide compiled CSS and JS (`ouds-web.*`), as well as compiled and minified CSS and JS (`ouds-web.min.*`). [Source maps](https://web.dev/articles/source-maps) (`ouds-web.*.map`) are available for use with certain browsers’ developer tools. Bundled JS files (`ouds-web.bundle.js` and minified `ouds-web.bundle.min.js`) include [Floating UI](https://floating-ui.com/). We also provide a compiled and minified CSS (`ouds-web-bootstrap.*` and `ouds-web-bootstrap.min.*`) enforcing Bootstrap compatibility. diff --git a/build/generate-sri.mjs b/build/generate-sri.mjs index 5e2253e7ec..0ba780d698 100644 --- a/build/generate-sri.mjs +++ b/build/generate-sri.mjs @@ -53,8 +53,8 @@ for (const brand of BRANDS) { 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 b1d381cf46..9be1681339 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 = `ouds-web${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/js/src/dropdown.js b/js/src/dropdown.js index ff4b02fe87..5623509f2a 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, 0], // OUDS mod - 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('OUDS Web\'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,15 @@ 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 +205,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 +221,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 +244,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 +343,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 0df36b04f3..a51100c157 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 03dcf85402..6aedde3a43 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, 10], // OUDS mod: instead of `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('OUDS Web\'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) { @@ -607,4 +725,27 @@ 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..17e9b282cb --- /dev/null +++ b/js/src/util/floating-ui.js @@ -0,0 +1,130 @@ +/** + * -------------------------------------------------------------------------- + * 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 = { + xs: 390, + sm: 480, + md: 736, + lg: 1024, + xl: 1320, + '2xl': 1640, + '3xl': 1880 +} + +/** + * 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 = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] + + 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 5de23e881a..4ffcf23d5d 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 = [ '