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 = [ - '
', - '
', - ' Toggle item', - '
Lorem ipsum
', - '
', - '
' - ].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 = [ '