diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json
index 21dd4812fc..cb7004da41 100644
--- a/.bundlewatch.config.json
+++ b/.bundlewatch.config.json
@@ -10,11 +10,11 @@
},
{
"path": "./packages/orange/dist/css/ouds-web-grid.css",
- "maxSize": "9.5 kB"
+ "maxSize": "7.5 kB"
},
{
"path": "./packages/orange/dist/css/ouds-web-grid.min.css",
- "maxSize": "8.5 kB"
+ "maxSize": "7 kB"
},
{
"path": "./packages/orange/dist/css/ouds-web-reboot.css",
@@ -26,43 +26,43 @@
},
{
"path": "./packages/orange/dist/css/ouds-web-utilities.css",
- "maxSize": "24.0 kB"
+ "maxSize": "23.75 kB"
},
{
"path": "./packages/orange/dist/css/ouds-web-utilities.min.css",
- "maxSize": "23.25 kB"
+ "maxSize": "22.75 kB"
},
{
"path": "./packages/orange/dist/css/ouds-web.css",
- "maxSize": "65.5 kB"
+ "maxSize": "65.25 kB"
},
{
"path": "./packages/orange/dist/css/ouds-web.min.css",
- "maxSize": "61.75 kB"
+ "maxSize": "61.5 kB"
},
{
"path": "./dist/js/ouds-web.bundle.js",
- "maxSize": "48.5 kB"
+ "maxSize": "46 kB"
},
{
"path": "./dist/js/ouds-web.bundle.min.js",
- "maxSize": "25.5 kB"
+ "maxSize": "24 kB"
},
{
"path": "./dist/js/ouds-web.esm.js",
- "maxSize": "33.25 kB"
+ "maxSize": "32.25 kB"
},
{
"path": "./dist/js/ouds-web.esm.min.js",
- "maxSize": "20.5 kB"
+ "maxSize": "20 kB"
},
{
"path": "./dist/js/ouds-web.js",
- "maxSize": "34.0 kB"
+ "maxSize": "32.75 kB"
},
{
"path": "./dist/js/ouds-web.min.js",
- "maxSize": "18.25 kB"
+ "maxSize": "17.75 kB"
}
],
"ci": {
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index ea24bf4033..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"
@@ -67,7 +66,6 @@ updates:
- dependency-name: "image-size"
- dependency-name: "ip"
- dependency-name: "jasmine"
- - dependency-name: "jquery"
- dependency-name: "js-yaml"
- dependency-name: "karma"
- dependency-name: "karma-browserstack-launcher"
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/alert.js b/js/src/alert.js
index 88232bceba..a0e6cab970 100644
--- a/js/src/alert.js
+++ b/js/src/alert.js
@@ -8,7 +8,6 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import { enableDismissTrigger } from './util/component-functions.js'
-import { defineJQueryPlugin } from './util/index.js'
/**
* Constants
@@ -53,23 +52,6 @@ class Alert extends BaseComponent {
EventHandler.trigger(this._element, EVENT_CLOSED)
this.dispose()
}
-
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Alert.getOrCreateInstance(this)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config](this)
- })
- }
}
/**
@@ -78,10 +60,4 @@ class Alert extends BaseComponent {
enableDismissTrigger(Alert, 'close')
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Alert)
-
export default Alert
diff --git a/js/src/button.js b/js/src/button.js
index a797f5050d..7697edecf2 100644
--- a/js/src/button.js
+++ b/js/src/button.js
@@ -7,7 +7,6 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
-import { defineJQueryPlugin } from './util/index.js'
/**
* Constants
@@ -37,17 +36,6 @@ class Button extends BaseComponent {
// Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method
this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE))
}
-
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Button.getOrCreateInstance(this)
-
- if (config === 'toggle') {
- data[config]()
- }
- })
- }
}
/**
@@ -63,10 +51,4 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => {
data.toggle()
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Button)
-
export default Button
diff --git a/js/src/carousel.js b/js/src/carousel.js
index 7f9dc8c32c..80f96019ef 100644
--- a/js/src/carousel.js
+++ b/js/src/carousel.js
@@ -10,7 +10,6 @@ import EventHandler from './dom/event-handler.js'
import Manipulator from './dom/manipulator.js'
import SelectorEngine from './dom/selector-engine.js'
import {
- defineJQueryPlugin,
getNextActiveElement,
isRTL,
isVisible,
@@ -557,25 +556,6 @@ class Carousel extends BaseComponent {
}
}
// End mod
-
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Carousel.getOrCreateInstance(this, config)
-
- if (typeof config === 'number') {
- data.to(config)
- return
- }
-
- if (typeof config === 'string') {
- if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- }
- })
- }
}
/**
@@ -620,10 +600,4 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
}
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Carousel)
-
export default Carousel
diff --git a/js/src/collapse.js b/js/src/collapse.js
index 5be0f368ae..40879e4667 100644
--- a/js/src/collapse.js
+++ b/js/src/collapse.js
@@ -9,7 +9,6 @@ import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import {
- defineJQueryPlugin,
getElement,
reflow
} from './util/index.js'
@@ -254,26 +253,6 @@ class Collapse extends BaseComponent {
element.setAttribute('aria-expanded', isOpen)
}
}
-
- // Static
- static jQueryInterface(config) {
- const _config = {}
- if (typeof config === 'string' && /show|hide/.test(config)) {
- _config.toggle = false
- }
-
- return this.each(function () {
- const data = Collapse.getOrCreateInstance(this, _config)
-
- if (typeof config === 'string') {
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- }
- })
- }
}
/**
@@ -291,10 +270,4 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (
}
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Collapse)
-
export default Collapse
diff --git a/js/src/dom/event-handler.js b/js/src/dom/event-handler.js
index 561d8751d7..a61ec5db0c 100644
--- a/js/src/dom/event-handler.js
+++ b/js/src/dom/event-handler.js
@@ -5,8 +5,6 @@
* --------------------------------------------------------------------------
*/
-import { getjQuery } from '../util/index.js'
-
/**
* Constants
*/
@@ -261,37 +259,8 @@ const EventHandler = {
return null
}
- const $ = getjQuery()
- const typeEvent = getTypeEvent(event)
- const inNamespace = event !== typeEvent
-
- let jQueryEvent = null
- let bubbles = true
- let nativeDispatch = true
- let defaultPrevented = false
-
- if (inNamespace && $) {
- jQueryEvent = $.Event(event, args)
-
- $(element).trigger(jQueryEvent)
- bubbles = !jQueryEvent.isPropagationStopped()
- nativeDispatch = !jQueryEvent.isImmediatePropagationStopped()
- defaultPrevented = jQueryEvent.isDefaultPrevented()
- }
-
- const evt = hydrateObj(new Event(event, { bubbles, cancelable: true }), args)
-
- if (defaultPrevented) {
- evt.preventDefault()
- }
-
- if (nativeDispatch) {
- element.dispatchEvent(evt)
- }
-
- if (evt.defaultPrevented && jQueryEvent) {
- jQueryEvent.preventDefault()
- }
+ const evt = hydrateObj(new Event(event, { bubbles: true, cancelable: true }), args)
+ element.dispatchEvent(evt)
return evt
}
diff --git a/js/src/dropdown.js b/js/src/dropdown.js
index 073e370af7..5623509f2a 100644
--- a/js/src/dropdown.js
+++ b/js/src/dropdown.js
@@ -5,13 +5,18 @@
* --------------------------------------------------------------------------
*/
-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'
import SelectorEngine from './dom/selector-engine.js'
import {
- defineJQueryPlugin,
execute,
getElement,
getNextActiveElement,
@@ -21,6 +26,12 @@ import {
isVisible,
noop
} from './util/index.js'
+import {
+ parseResponsivePlacement,
+ getResponsivePlacement,
+ createBreakpointListeners,
+ disposeBreakpointListeners
+} from './util/floating-ui.js'
/**
* Constants
@@ -46,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'
}
@@ -82,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)'
}
@@ -92,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
@@ -136,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;
@@ -169,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()
}
}
@@ -198,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)
}
@@ -215,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
@@ -237,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
+ }
+
+ _parseResponsivePlacements() {
+ this._responsivePlacements = parseResponsivePlacement(this._config.placement, DEFAULT_PLACEMENT)
- if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) {
- return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP
+ 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() {
@@ -286,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
}
}
@@ -337,22 +432,6 @@ class Dropdown extends BaseComponent {
}
// Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Dropdown.getOrCreateInstance(this, config)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- })
- }
-
static clearMenus(event) {
if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) {
return
@@ -446,10 +525,4 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (
Dropdown.getOrCreateInstance(this).toggle()
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Dropdown)
-
export default Dropdown
diff --git a/js/src/modal.js b/js/src/modal.js
index dd61649ecc..538d218653 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -12,7 +12,7 @@ import Backdrop from './util/backdrop.js'
import { enableDismissTrigger } from './util/component-functions.js'
import FocusTrap from './util/focustrap.js'
import {
- defineJQueryPlugin, isRTL, isVisible, reflow
+ isRTL, isVisible, reflow
} from './util/index.js'
import ScrollBarHelper from './util/scrollbar.js'
@@ -313,23 +313,6 @@ class Modal extends BaseComponent {
this._element.style.paddingLeft = ''
this._element.style.paddingRight = ''
}
-
- // Static
- static jQueryInterface(config, relatedTarget) {
- return this.each(function () {
- const data = Modal.getOrCreateInstance(this, config)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config](relatedTarget)
- })
- }
}
/**
@@ -369,10 +352,4 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (
enableDismissTrigger(Modal)
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Modal)
-
export default Modal
diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js
index 8d1feb13bb..b25a7f3b9f 100644
--- a/js/src/offcanvas.js
+++ b/js/src/offcanvas.js
@@ -12,7 +12,6 @@ import Backdrop from './util/backdrop.js'
import { enableDismissTrigger } from './util/component-functions.js'
import FocusTrap from './util/focustrap.js'
import {
- defineJQueryPlugin,
isDisabled,
isVisible
} from './util/index.js'
@@ -206,23 +205,6 @@ class Offcanvas extends BaseComponent {
EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
})
}
-
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Offcanvas.getOrCreateInstance(this, config)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config](this)
- })
- }
}
/**
@@ -273,10 +255,4 @@ EventHandler.on(window, EVENT_RESIZE, () => {
enableDismissTrigger(Offcanvas)
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Offcanvas)
-
export default Offcanvas
diff --git a/js/src/orange-navbar.js b/js/src/orange-navbar.js
index e9c899206d..8e1cc2c9da 100644
--- a/js/src/orange-navbar.js
+++ b/js/src/orange-navbar.js
@@ -8,7 +8,6 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
-import { defineJQueryPlugin } from './util/index.js'
/**
* Constants
@@ -41,22 +40,6 @@ class OrangeNavbar extends BaseComponent {
el.classList.remove('header-minimized')
}
}
-
- static jQueryInterface(config) {
- return this.each(function () {
- const data = OrangeNavbar.getOrCreateInstance(this, config)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- })
- }
}
/**
@@ -75,10 +58,4 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
}
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(OrangeNavbar)
-
export default OrangeNavbar
diff --git a/js/src/popover.js b/js/src/popover.js
index 1aeeac12bd..a51100c157 100644
--- a/js/src/popover.js
+++ b/js/src/popover.js
@@ -6,7 +6,7 @@
*/
import Tooltip from './tooltip.js'
-import { defineJQueryPlugin } from './util/index.js'
+import EventHandler from './dom/event-handler.js'
/**
* Constants
@@ -16,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,
@@ -69,29 +74,38 @@ class Popover extends Tooltip {
_getContent() {
return this._resolvePossibleFunction(this._config.content)
}
+}
+
+/**
+ * Data API implementation - auto-initialize popovers
+ */
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Popover.getOrCreateInstance(this, config)
+const initPopover = event => {
+ const target = event.target.closest(SELECTOR_DATA_TOGGLE)
+ if (!target) {
+ return
+ }
- if (typeof config !== 'string') {
- return
- }
+ // Prevent default for click events to avoid navigation
+ if (event.type === 'click') {
+ event.preventDefault()
+ }
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
+ // Get or create instance
+ const popover = Popover.getOrCreateInstance(target)
- data[config]()
- })
+ // 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()
}
}
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Popover)
+// 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/quantity-selector.js b/js/src/quantity-selector.js
index 21a374e5f2..7efbe77851 100644
--- a/js/src/quantity-selector.js
+++ b/js/src/quantity-selector.js
@@ -8,7 +8,6 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
-import { defineJQueryPlugin } from './util/index.js'
/**
* Constants
@@ -113,22 +112,6 @@ class QuantitySelector extends BaseComponent {
btnUp.setAttribute('disabled', '')
}
}
-
- static jQueryInterface(config) {
- return this.each(function () {
- const data = QuantitySelector.getOrCreateInstance(this, config)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- })
- }
}
/**
@@ -145,10 +128,4 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
}
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(QuantitySelector)
-
export default QuantitySelector
diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js
index 94fddb4bad..0378d3e3d0 100644
--- a/js/src/scrollspy.js
+++ b/js/src/scrollspy.js
@@ -9,7 +9,7 @@ import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import {
- defineJQueryPlugin, getElement, isDisabled, isVisible
+ getElement, isDisabled, isVisible
} from './util/index.js'
/**
@@ -258,23 +258,6 @@ class ScrollSpy extends BaseComponent {
node.classList.remove(CLASS_NAME_ACTIVE)
}
}
-
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = ScrollSpy.getOrCreateInstance(this, config)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- })
- }
}
/**
@@ -287,10 +270,4 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
}
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(ScrollSpy)
-
export default ScrollSpy
diff --git a/js/src/tab.js b/js/src/tab.js
index dfaef0ffa2..989a37a657 100644
--- a/js/src/tab.js
+++ b/js/src/tab.js
@@ -8,7 +8,7 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
-import { defineJQueryPlugin, getNextActiveElement, isDisabled } from './util/index.js'
+import { getNextActiveElement, isDisabled } from './util/index.js'
/**
* Constants
@@ -263,23 +263,6 @@ class Tab extends BaseComponent {
_getOuterElement(elem) {
return elem.closest(SELECTOR_OUTER) || elem
}
-
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Tab.getOrCreateInstance(this)
-
- if (typeof config !== 'string') {
- return
- }
-
- if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config]()
- })
- }
}
/**
@@ -306,10 +289,5 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
Tab.getOrCreateInstance(element)
}
})
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Tab)
export default Tab
diff --git a/js/src/toast.js b/js/src/toast.js
index 87b8823fe4..6c9396cde6 100644
--- a/js/src/toast.js
+++ b/js/src/toast.js
@@ -8,7 +8,7 @@
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import { enableDismissTrigger } from './util/component-functions.js'
-import { defineJQueryPlugin, reflow } from './util/index.js'
+import { reflow } from './util/index.js'
/**
* Constants
@@ -192,21 +192,6 @@ class Toast extends BaseComponent {
clearTimeout(this._timeout)
this._timeout = null
}
-
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Toast.getOrCreateInstance(this, config)
-
- if (typeof config === 'string') {
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
-
- data[config](this)
- }
- })
- }
}
/**
@@ -215,10 +200,4 @@ class Toast extends BaseComponent {
enableDismissTrigger(Toast)
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Toast)
-
export default Toast
diff --git a/js/src/tooltip.js b/js/src/tooltip.js
index fa65a77524..6aedde3a43 100644
--- a/js/src/tooltip.js
+++ b/js/src/tooltip.js
@@ -5,15 +5,28 @@
* --------------------------------------------------------------------------
*/
-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'
import {
- defineJQueryPlugin, execute, findShadowRoot, getElement, getUID, isRTL, noop
+ execute, findShadowRoot, getElement, getUID, isRTL, noop
} 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) {
@@ -605,29 +723,29 @@ class Tooltip extends BaseComponent {
this.tip = null
}
}
+}
- // Static
- static jQueryInterface(config) {
- return this.each(function () {
- const data = Tooltip.getOrCreateInstance(this, config)
+/**
+ * Data API implementation - auto-initialize tooltips
+ */
- if (typeof config !== 'string') {
- return
- }
+const initTooltip = event => {
+ const target = event.target.closest(SELECTOR_DATA_TOGGLE)
+ if (!target) {
+ return
+ }
- if (typeof data[config] === 'undefined') {
- throw new TypeError(`No method named "${config}"`)
- }
+ // Get or create instance and trigger the appropriate action
+ const tooltip = Tooltip.getOrCreateInstance(target)
- data[config]()
- })
+ // For focus events, manually trigger enter to show
+ if (event.type === 'focusin') {
+ tooltip._activeTrigger.focus = true
+ tooltip._enter()
}
}
-/**
- * jQuery
- */
-
-defineJQueryPlugin(Tooltip)
+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/src/util/index.js b/js/src/util/index.js
index e9df90f77b..626385e329 100644
--- a/js/src/util/index.js
+++ b/js/src/util/index.js
@@ -76,17 +76,12 @@ const isElement = object => {
return false
}
- if (typeof object.jquery !== 'undefined') {
- object = object[0]
- }
-
return typeof object.nodeType !== 'undefined'
}
const getElement = object => {
- // it's a jQuery object or a node element
if (isElement(object)) {
- return object.jquery ? object[0] : object
+ return object
}
if (typeof object === 'string' && object.length > 0) {
@@ -176,14 +171,6 @@ const reflow = element => {
element.offsetHeight // eslint-disable-line no-unused-expressions
}
-const getjQuery = () => {
- if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) {
- return window.jQuery
- }
-
- return null
-}
-
const DOMContentLoadedCallbacks = []
const onDOMContentLoaded = callback => {
@@ -205,23 +192,6 @@ const onDOMContentLoaded = callback => {
const isRTL = () => document.documentElement.dir === 'rtl'
-const defineJQueryPlugin = plugin => {
- onDOMContentLoaded(() => {
- const $ = getjQuery()
- /* istanbul ignore if */
- if ($) {
- const name = plugin.NAME
- const JQUERY_NO_CONFLICT = $.fn[name]
- $.fn[name] = plugin.jQueryInterface
- $.fn[name].Constructor = plugin
- $.fn[name].noConflict = () => {
- $.fn[name] = JQUERY_NO_CONFLICT
- return plugin.jQueryInterface
- }
- }
- })
-}
-
const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => {
return typeof possibleCallback === 'function' ? possibleCallback.call(...args) : defaultValue
}
@@ -284,12 +254,10 @@ const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed
}
export {
- defineJQueryPlugin,
execute,
executeAfterTransition,
findShadowRoot,
getElement,
- getjQuery,
getNextActiveElement,
getTransitionDurationFromElement,
getUID,
diff --git a/js/tests/helpers/fixture.js b/js/tests/helpers/fixture.js
index 86e93116a0..acf5827fd5 100644
--- a/js/tests/helpers/fixture.js
+++ b/js/tests/helpers/fixture.js
@@ -27,16 +27,6 @@ export const createEvent = (eventName, parameters = {}) => {
return new Event(eventName, parameters)
}
-export const jQueryMock = {
- elements: undefined,
- fn: {},
- each(fn) {
- for (const element of this.elements) {
- fn.call(element)
- }
- }
-}
-
export const clearBodyAndDocument = () => {
const attributes = ['data-bs-padding-right', 'style']
diff --git a/js/tests/karma.conf.js b/js/tests/karma.conf.js
index c8866dd808..7fe3c29714 100644
--- a/js/tests/karma.conf.js
+++ b/js/tests/karma.conf.js
@@ -11,7 +11,6 @@ const { browsers } = require('./browsers.js')
const ENV = process.env
const BROWSERSTACK = Boolean(ENV.BROWSERSTACK)
const DEBUG = Boolean(ENV.DEBUG)
-const JQUERY_TEST = Boolean(ENV.JQUERY)
const frameworks = [
'jasmine'
@@ -61,7 +60,7 @@ const config = {
files: [
'node_modules/hammer-simulator/index.js',
{
- pattern: 'js/tests/unit/**/!(jquery).spec.js',
+ pattern: 'js/tests/unit/**/*.spec.js',
watched: !BROWSERSTACK
}
],
@@ -111,21 +110,6 @@ if (BROWSERSTACK) {
config.customLaunchers = browsers
config.browsers = Object.keys(browsers)
reporters.push('BrowserStack', 'kjhtml')
-} else if (JQUERY_TEST) {
- frameworks.push('detectBrowsers')
- plugins.push(
- 'karma-chrome-launcher',
- 'karma-firefox-launcher',
- 'karma-detect-browsers'
- )
- config.detectBrowsers = detectBrowsers
- config.files = [
- 'node_modules/jquery/dist/jquery.slim.min.js',
- {
- pattern: 'js/tests/unit/jquery.spec.js',
- watched: false
- }
- ]
} else {
frameworks.push('detectBrowsers')
plugins.push(
diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js
index 97cc3cc532..e6c153d1b1 100644
--- a/js/tests/unit/alert.spec.js
+++ b/js/tests/unit/alert.spec.js
@@ -1,6 +1,6 @@
import Alert from '../../src/alert.js'
import { getTransitionDurationFromElement } from '../../src/util/index.js'
-import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js'
+import { clearFixture, getFixture } from '../helpers/fixture.js'
describe('Alert', () => {
let fixtureEl
@@ -141,80 +141,6 @@ describe('Alert', () => {
})
})
- describe('jQueryInterface', () => {
- it('should handle config passed and toggle existing alert', () => {
- fixtureEl.innerHTML = '
'
-
- const alertEl = fixtureEl.querySelector('.alert')
- const alert = new Alert(alertEl)
-
- const spy = spyOn(alert, 'close')
-
- jQueryMock.fn.alert = Alert.jQueryInterface
- jQueryMock.elements = [alertEl]
-
- jQueryMock.fn.alert.call(jQueryMock, 'close')
-
- expect(spy).toHaveBeenCalled()
- })
-
- it('should create new alert instance and call close', () => {
- fixtureEl.innerHTML = ''
-
- const alertEl = fixtureEl.querySelector('.alert')
-
- jQueryMock.fn.alert = Alert.jQueryInterface
- jQueryMock.elements = [alertEl]
-
- expect(Alert.getInstance(alertEl)).toBeNull()
- jQueryMock.fn.alert.call(jQueryMock, 'close')
-
- expect(fixtureEl.querySelector('.alert')).toBeNull()
- })
-
- it('should just create an alert instance without calling close', () => {
- fixtureEl.innerHTML = ''
-
- const alertEl = fixtureEl.querySelector('.alert')
-
- jQueryMock.fn.alert = Alert.jQueryInterface
- jQueryMock.elements = [alertEl]
-
- jQueryMock.fn.alert.call(jQueryMock)
-
- expect(Alert.getInstance(alertEl)).not.toBeNull()
- expect(fixtureEl.querySelector('.alert')).not.toBeNull()
- })
-
- it('should throw an error on undefined method', () => {
- fixtureEl.innerHTML = ''
-
- const div = fixtureEl.querySelector('div')
- const action = 'undefinedMethod'
-
- jQueryMock.fn.alert = Alert.jQueryInterface
- jQueryMock.elements = [div]
-
- expect(() => {
- jQueryMock.fn.alert.call(jQueryMock, action)
- }).toThrowError(TypeError, `No method named "${action}"`)
- })
-
- it('should throw an error on protected method', () => {
- fixtureEl.innerHTML = ''
-
- const div = fixtureEl.querySelector('div')
- const action = '_getConfig'
-
- jQueryMock.fn.alert = Alert.jQueryInterface
- jQueryMock.elements = [div]
-
- expect(() => {
- jQueryMock.fn.alert.call(jQueryMock, action)
- }).toThrowError(TypeError, `No method named "${action}"`)
- })
- })
-
describe('getInstance', () => {
it('should return alert instance', () => {
fixtureEl.innerHTML = ''
diff --git a/js/tests/unit/base-component.spec.js b/js/tests/unit/base-component.spec.js
index 5b7d52e233..6c69567ed8 100644
--- a/js/tests/unit/base-component.spec.js
+++ b/js/tests/unit/base-component.spec.js
@@ -124,18 +124,11 @@ describe('Base Component', () => {
expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass)
})
- it('should accept element, either passed as a CSS selector, jQuery element, or DOM element', () => {
+ it('should accept element, either passed as a CSS selector, or DOM element', () => {
createInstance()
expect(DummyClass.getInstance('#foo')).toEqual(instance)
expect(DummyClass.getInstance(element)).toEqual(instance)
-
- const fakejQueryObject = {
- 0: element,
- jquery: 'foo'
- }
-
- expect(DummyClass.getInstance(fakejQueryObject)).toEqual(instance)
})
it('should return null when there is no instance', () => {
diff --git a/js/tests/unit/button.spec.js b/js/tests/unit/button.spec.js
index 6624fee7c9..ed8bb1c84d 100644
--- a/js/tests/unit/button.spec.js
+++ b/js/tests/unit/button.spec.js
@@ -1,5 +1,5 @@
import Button from '../../src/button.js'
-import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js'
+import { clearFixture, getFixture } from '../helpers/fixture.js'
describe('Button', () => {
let fixtureEl
@@ -93,52 +93,6 @@ describe('Button', () => {
})
})
- describe('jQueryInterface', () => {
- it('should handle config passed and toggle existing button', () => {
- fixtureEl.innerHTML = ''
-
- const btnEl = fixtureEl.querySelector('.btn')
- const button = new Button(btnEl)
-
- const spy = spyOn(button, 'toggle')
-
- jQueryMock.fn.button = Button.jQueryInterface
- jQueryMock.elements = [btnEl]
-
- jQueryMock.fn.button.call(jQueryMock, 'toggle')
-
- expect(spy).toHaveBeenCalled()
- })
-
- it('should create new button instance and call toggle', () => {
- fixtureEl.innerHTML = ''
-
- const btnEl = fixtureEl.querySelector('.btn')
-
- jQueryMock.fn.button = Button.jQueryInterface
- jQueryMock.elements = [btnEl]
-
- jQueryMock.fn.button.call(jQueryMock, 'toggle')
-
- expect(Button.getInstance(btnEl)).not.toBeNull()
- expect(btnEl).toHaveClass('active')
- })
-
- it('should just create a button instance without calling toggle', () => {
- fixtureEl.innerHTML = ''
-
- const btnEl = fixtureEl.querySelector('.btn')
-
- jQueryMock.fn.button = Button.jQueryInterface
- jQueryMock.elements = [btnEl]
-
- jQueryMock.fn.button.call(jQueryMock)
-
- expect(Button.getInstance(btnEl)).not.toBeNull()
- expect(btnEl).not.toHaveClass('active')
- })
- })
-
describe('getInstance', () => {
it('should return button instance', () => {
fixtureEl.innerHTML = ''
diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js
index 6c2fc2cf5c..4a5e3ac27b 100644
--- a/js/tests/unit/carousel.spec.js
+++ b/js/tests/unit/carousel.spec.js
@@ -3,7 +3,7 @@ import EventHandler from '../../src/dom/event-handler.js'
import { isRTL, noop } from '../../src/util/index.js'
import Swipe from '../../src/util/swipe.js'
import {
- clearFixture, createEvent, getFixture, jQueryMock
+ clearFixture, createEvent, getFixture
} from '../helpers/fixture.js'
describe('Carousel', () => {
@@ -1988,66 +1988,6 @@ describe('Carousel', () => {
})
})
- describe('jQueryInterface', () => {
- it('should create a carousel', () => {
- fixtureEl.innerHTML = ''
-
- const div = fixtureEl.querySelector('div')
-
- jQueryMock.fn.carousel = Carousel.jQueryInterface
- jQueryMock.elements = [div]
-
- jQueryMock.fn.carousel.call(jQueryMock)
-
- expect(Carousel.getInstance(div)).not.toBeNull()
- })
-
- it('should not re create a carousel', () => {
- fixtureEl.innerHTML = ''
-
- const div = fixtureEl.querySelector('div')
- const carousel = new Carousel(div)
-
- jQueryMock.fn.carousel = Carousel.jQueryInterface
- jQueryMock.elements = [div]
-
- jQueryMock.fn.carousel.call(jQueryMock)
-
- expect(Carousel.getInstance(div)).toEqual(carousel)
- })
-
- it('should call to if the config is a number', () => {
- fixtureEl.innerHTML = ''
-
- const div = fixtureEl.querySelector('div')
- const carousel = new Carousel(div)
- const slideTo = 2
-
- const spy = spyOn(carousel, 'to')
-
- jQueryMock.fn.carousel = Carousel.jQueryInterface
- jQueryMock.elements = [div]
-
- jQueryMock.fn.carousel.call(jQueryMock, slideTo)
-
- expect(spy).toHaveBeenCalledWith(slideTo)
- })
-
- it('should throw error on undefined method', () => {
- fixtureEl.innerHTML = ''
-
- const div = fixtureEl.querySelector('div')
- const action = 'undefinedMethod'
-
- jQueryMock.fn.carousel = Carousel.jQueryInterface
- jQueryMock.elements = [div]
-
- expect(() => {
- jQueryMock.fn.carousel.call(jQueryMock, action)
- }).toThrowError(TypeError, `No method named "${action}"`)
- })
- })
-
describe('data-api', () => {
it('should init carousels with data-bs-ride="carousel" on load', () => {
fixtureEl.innerHTML = ''
diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js
index dca2671c91..3dd1a68b98 100644
--- a/js/tests/unit/collapse.spec.js
+++ b/js/tests/unit/collapse.spec.js
@@ -1,6 +1,6 @@
import Collapse from '../../src/collapse.js'
import EventHandler from '../../src/dom/event-handler.js'
-import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js'
+import { clearFixture, getFixture } from '../helpers/fixture.js'
describe('Collapse', () => {
let fixtureEl
@@ -43,30 +43,7 @@ describe('Collapse', () => {
expect(collapseByElement._element).toEqual(collapseEl)
})
- it('should allow jquery object in parent config', () => {
- fixtureEl.innerHTML = [
- ''
- ].join('')
-
- const collapseEl = fixtureEl.querySelector('div.collapse')
- const myCollapseEl = fixtureEl.querySelector('.my-collapse')
- const fakejQueryObject = {
- 0: myCollapseEl,
- jquery: 'foo'
- }
- const collapse = new Collapse(collapseEl, {
- parent: fakejQueryObject
- })
-
- expect(collapse._config.parent).toEqual(myCollapseEl)
- })
-
- it('should allow non jquery object in parent config', () => {
+ it('should allow object in parent config', () => {
fixtureEl.innerHTML = [
'',
'
',
@@ -943,49 +920,6 @@ describe('Collapse', () => {
})
})
- describe('jQueryInterface', () => {
- it('should create a collapse', () => {
- fixtureEl.innerHTML = '
'
-
- const div = fixtureEl.querySelector('div')
-
- jQueryMock.fn.collapse = Collapse.jQueryInterface
- jQueryMock.elements = [div]
-
- jQueryMock.fn.collapse.call(jQueryMock)
-
- expect(Collapse.getInstance(div)).not.toBeNull()
- })
-
- it('should not re create a collapse', () => {
- fixtureEl.innerHTML = '
'
-
- const div = fixtureEl.querySelector('div')
- const collapse = new Collapse(div)
-
- jQueryMock.fn.collapse = Collapse.jQueryInterface
- jQueryMock.elements = [div]
-
- jQueryMock.fn.collapse.call(jQueryMock)
-
- expect(Collapse.getInstance(div)).toEqual(collapse)
- })
-
- it('should throw error on undefined method', () => {
- fixtureEl.innerHTML = '
'
-
- const div = fixtureEl.querySelector('div')
- const action = 'undefinedMethod'
-
- jQueryMock.fn.collapse = Collapse.jQueryInterface
- jQueryMock.elements = [div]
-
- expect(() => {
- jQueryMock.fn.collapse.call(jQueryMock, action)
- }).toThrowError(TypeError, `No method named "${action}"`)
- })
- })
-
describe('getInstance', () => {
it('should return collapse instance', () => {
fixtureEl.innerHTML = '
'
diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js
index 37f84e7ca4..4ffcf23d5d 100644
--- a/js/tests/unit/dropdown.spec.js
+++ b/js/tests/unit/dropdown.spec.js
@@ -2,7 +2,7 @@ import EventHandler from '../../src/dom/event-handler.js'
import Dropdown from '../../src/dropdown.js'
import { noop } from '../../src/util/index.js'
import {
- clearFixture, createEvent, getFixture, jQueryMock
+ clearFixture, createEvent, getFixture
} from '../helpers/fixture.js'
describe('Dropdown', () => {
@@ -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 = [
'
',
'
',
@@ -144,17 +143,17 @@ describe('Dropdown', () => {
const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
const dropdown = new Dropdown(btnDropdown, {
- popperConfig: {
+ floatingConfig: {
placement: 'left'
}
})
- const popperConfig = dropdown._getPopperConfig()
+ const floatingConfig = dropdown._getFloatingConfig('bottom-start', [])
- expect(popperConfig.placement).toEqual('left')
+ expect(floatingConfig.placement).toEqual('left')
})
- it('should allow to pass config to Popper with `popperConfig` as a function', () => {
+ it('should allow to pass config to Floating UI with `floatingConfig` as a function', () => {
fixtureEl.innerHTML = [
'
',
'
',
@@ -165,18 +164,18 @@ describe('Dropdown', () => {
].join('')
const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
- const getPopperConfig = jasmine.createSpy('getPopperConfig').and.returnValue({ placement: 'left' })
+ const getFloatingConfig = jasmine.createSpy('getFloatingConfig').and.returnValue({ placement: 'left' })
const dropdown = new Dropdown(btnDropdown, {
- popperConfig: getPopperConfig
+ floatingConfig: getFloatingConfig
})
- const popperConfig = dropdown._getPopperConfig()
+ const floatingConfig = dropdown._getFloatingConfig('bottom-start', [])
// Ensure that the function was called with the default config.
- expect(getPopperConfig).toHaveBeenCalledWith(jasmine.objectContaining({
+ expect(getFloatingConfig).toHaveBeenCalledWith(jasmine.objectContaining({
placement: jasmine.any(String)
}))
- expect(popperConfig.placement).toEqual('left')
+ expect(floatingConfig.placement).toEqual('left')
})
})
@@ -205,7 +204,7 @@ describe('Dropdown', () => {
})
})
- it('should destroy old popper references on toggle', () => {
+ it('should destroy old Floating UI references on toggle', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'
',
@@ -230,12 +229,12 @@ describe('Dropdown', () => {
firstDropdownEl.addEventListener('shown.bs.dropdown', () => {
expect(btnDropdown1).toHaveClass('show')
- spyOn(dropdown1._popper, 'destroy')
+ expect(dropdown1._floatingCleanup).not.toBeNull()
btnDropdown2.click()
})
secondDropdownEl.addEventListener('shown.bs.dropdown', () => setTimeout(() => {
- expect(dropdown1._popper.destroy).toHaveBeenCalled()
+ expect(dropdown1._floatingCleanup).toBeNull()
resolve()
}))
@@ -508,32 +507,6 @@ describe('Dropdown', () => {
})
})
- it('should toggle a dropdown with a jquery object reference', () => {
- return new Promise(resolve => {
- fixtureEl.innerHTML = [
- '
',
- ' ',
- ' ',
- '
'
- ].join('')
-
- const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
- const dropdown = new Dropdown(btnDropdown, {
- reference: { 0: fixtureEl, jquery: 'jQuery' }
- })
-
- btnDropdown.addEventListener('shown.bs.dropdown', () => {
- expect(btnDropdown).toHaveClass('show')
- expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
- resolve()
- })
-
- dropdown.toggle()
- })
- })
-
it('should toggle a dropdown with a valid virtual element reference', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
@@ -570,21 +543,22 @@ describe('Dropdown', () => {
}
})).toThrowError(TypeError, 'DROPDOWN: Option "reference" provided type "object" without a required "getBoundingClientRect" method.')
- // use onFirstUpdate as Poppers internal update is executed async
const dropdown = new Dropdown(btnDropdown, {
- reference: virtualElement,
- popperConfig: {
- onFirstUpdate() {
- expect(spy).toHaveBeenCalled()
- expect(btnDropdown).toHaveClass('show')
- expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
- resolve()
- }
- }
+ reference: virtualElement
})
const spy = spyOn(virtualElement, 'getBoundingClientRect').and.callThrough()
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ // Floating UI calls getBoundingClientRect asynchronously
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ }, 20)
+ })
+
dropdown.toggle()
})
})
@@ -865,7 +839,7 @@ describe('Dropdown', () => {
})
})
- it('should hide a dropdown and destroy popper', () => {
+ it('should hide a dropdown and cleanup Floating UI', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'
',
@@ -880,12 +854,12 @@ describe('Dropdown', () => {
const dropdown = new Dropdown(btnDropdown)
btnDropdown.addEventListener('shown.bs.dropdown', () => {
- spyOn(dropdown._popper, 'destroy')
+ expect(dropdown._floatingCleanup).not.toBeNull()
dropdown.hide()
})
btnDropdown.addEventListener('hidden.bs.dropdown', () => {
- expect(dropdown._popper.destroy).toHaveBeenCalled()
+ expect(dropdown._floatingCleanup).toBeNull()
resolve()
})
@@ -1059,7 +1033,7 @@ describe('Dropdown', () => {
const dropdown = new Dropdown(btnDropdown)
- expect(dropdown._popper).toBeNull()
+ expect(dropdown._floatingCleanup).toBeNull()
expect(dropdown._menu).not.toBeNull()
expect(dropdown._element).not.toBeNull()
const spy = spyOn(EventHandler, 'off')
@@ -1071,7 +1045,7 @@ describe('Dropdown', () => {
expect(spy).toHaveBeenCalledWith(btnDropdown, Dropdown.EVENT_KEY)
})
- it('should dispose dropdown with Popper', () => {
+ it('should dispose dropdown with Floating UI', () => {
fixtureEl.innerHTML = [
'
',
'
',
@@ -1086,46 +1060,49 @@ describe('Dropdown', () => {
dropdown.toggle()
- expect(dropdown._popper).not.toBeNull()
+ expect(dropdown._floatingCleanup).not.toBeNull()
expect(dropdown._menu).not.toBeNull()
expect(dropdown._element).not.toBeNull()
dropdown.dispose()
- expect(dropdown._popper).toBeNull()
+ expect(dropdown._floatingCleanup).toBeNull()
expect(dropdown._menu).toBeNull()
expect(dropdown._element).toBeNull()
})
})
describe('update', () => {
- it('should call Popper and detect navbar on update', () => {
- fixtureEl.innerHTML = [
- '
',
- ' ',
- ' ',
- '
'
- ].join('')
+ it('should call Floating UI update on update', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
- const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
- const dropdown = new Dropdown(btnDropdown)
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
- dropdown.toggle()
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdown._floatingCleanup).not.toBeNull()
- expect(dropdown._popper).not.toBeNull()
+ const spyUpdate = spyOn(dropdown, '_updateFloatingPosition')
- const spyUpdate = spyOn(dropdown._popper, 'update')
- const spyDetect = spyOn(dropdown, '_detectNavbar')
+ dropdown.update()
- dropdown.update()
+ expect(spyUpdate).toHaveBeenCalled()
+ resolve()
+ })
- expect(spyUpdate).toHaveBeenCalled()
- expect(spyDetect).toHaveBeenCalled()
+ dropdown.toggle()
+ })
})
- it('should just detect navbar on update', () => {
+ it('should do nothing on update if not shown', () => {
fixtureEl.innerHTML = [
'
',
'
',
@@ -1138,12 +1115,12 @@ describe('Dropdown', () => {
const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
const dropdown = new Dropdown(btnDropdown)
- const spy = spyOn(dropdown, '_detectNavbar')
+ const spy = spyOn(dropdown, '_updateFloatingPosition')
dropdown.update()
- expect(dropdown._popper).toBeNull()
- expect(spy).toHaveBeenCalled()
+ expect(dropdown._floatingCleanup).toBeNull()
+ expect(spy).not.toHaveBeenCalled()
})
})
@@ -1191,7 +1168,7 @@ describe('Dropdown', () => {
})
})
- it('should not use "static" Popper in navbar', () => {
+ it('should use Floating UI positioning in navbar', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'