blob: 4ebc44ce38dde042bae471d7c1a5c9d55d81b410 [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap util/floating-ui.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import { isRTL } from './index.js'
/**
* Types
*/
type ResponsivePlacements = Record<string, string>
interface BreakpointListener {
mql: MediaQueryList
handler: (event: MediaQueryListEvent) => void
}
/**
* Breakpoints for responsive placement (matches SCSS $breakpoints)
*/
export const BREAKPOINTS: Record<string, number> = {
sm: 576,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536
}
/**
* Default placement with RTL support
*/
export const getDefaultPlacement = (fallback = 'bottom'): string => {
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 placementString - The placement string to parse
* @param defaultPlacement - The default placement to use for xs/base
* @returns Object with breakpoint keys and placement values, or null if not responsive
*/
export const parseResponsivePlacement = (placementString: string | null | undefined, defaultPlacement = 'bottom'): ResponsivePlacements | null => {
// 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: ResponsivePlacements = { 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 responsivePlacements - Object with breakpoint keys and placement values
* @param defaultPlacement - Fallback placement
* @returns The active placement for current viewport
*/
export const getResponsivePlacement = (responsivePlacements: ResponsivePlacements | null, defaultPlacement = 'bottom'): string => {
if (!responsivePlacements) {
return defaultPlacement
}
// Get current viewport width
const viewportWidth = window.innerWidth
// Find the largest breakpoint that matches
let activePlacement = responsivePlacements.xs || defaultPlacement
// Check breakpoints in order (sm, md, lg, xl, 2xl)
const breakpointOrder = ['sm', 'md', 'lg', 'xl', '2xl']
for (const breakpoint of breakpointOrder) {
const minWidth = BREAKPOINTS[breakpoint]
if (viewportWidth >= minWidth && responsivePlacements[breakpoint]) {
activePlacement = responsivePlacements[breakpoint]
}
}
return activePlacement
}
/**
* Create media query listeners for responsive placement changes
*
* @param callback - Callback to run when breakpoint changes
* @returns Array of { mql, handler } objects for cleanup
*/
export const createBreakpointListeners = (callback: (event: MediaQueryListEvent) => void): BreakpointListener[] => {
const listeners: BreakpointListener[] = []
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 listeners - Array of { mql, handler } objects
*/
export const disposeBreakpointListeners = (listeners: BreakpointListener[]): void => {
for (const { mql, handler } of listeners) {
mql.removeEventListener('change', handler)
}
}
/**
* Public `offset` option shared by the positioned components (Tooltip, Menu, …):
* an `[skidding, distance]` array, a comma-separated string, or a callback
* returning that array.
*/
type FloatingOffsetOption = number[] | string | ((data: Record<string, any>, element: HTMLElement) => number[])
/**
* Public `floatingConfig` option: a Floating UI config object, a function that
* refines the default config, or null.
*/
type FloatingConfigOption = Record<string, any> | ((defaultConfig: Record<string, any>) => Record<string, any>) | null
/**
* Floating UI offset value: a number, an axis object, or our `[skidding, distance]` array
*/
type FloatingOffsetInput = number | number[] | { mainAxis?: number, crossAxis?: number, alignmentAxis?: number | null }
type FloatingOffsetValue = Exclude<FloatingOffsetInput, number[]>
/**
* Normalize an offset value into Floating UI's offset shape.
* A `[skidding, distance]` array becomes `{ mainAxis: distance, crossAxis: skidding }`;
* numbers and axis objects pass through unchanged.
*/
export const toFloatingOffset = (value: FloatingOffsetInput): FloatingOffsetValue => {
return Array.isArray(value) ? { mainAxis: value[1] || 0, crossAxis: value[0] || 0 } : value
}
export type {
BreakpointListener, ResponsivePlacements, FloatingOffsetValue, FloatingOffsetOption, FloatingConfigOption
}