/**
* Value produced by parsing a data attribute
*/
export type AttributeValue = boolean | null | number | string;
/**
* Physical spring constants (produced by `springParameters`)
*/
export interface SpringParams {
stiffness: number;
damping: number;
mass: number;
}
/**
* Current spring state
*/
export interface SpringState {
position: number;
velocity: number;
}
/**
* Parse data attribute value to appropriate JavaScript type
* @param value - The attribute value to parse
* @returns The parsed value
* @example
* parseAttributeValue('true'); // returns true (boolean)
* parseAttributeValue('false'); // returns false (boolean)
* parseAttributeValue('null'); // returns null
* parseAttributeValue('123'); // returns 123 (number)
* parseAttributeValue('45.67'); // returns 45.67 (number)
* parseAttributeValue('some string'); // returns 'some string' (string)
*/
export const parseAttributeValue = (value: string): AttributeValue => {
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
if (value === 'null') {
return null;
}
if (/^-?\d+(\.\d+)?$/.test(value.trim())) {
return Number(value);
}
return value;
};
/**
* Extract data attributes from an element with a given prefix
* @param element - The DOM element to extract data attributes from
* @param prefix - The data attribute prefix (without 'data-')
* @returns Object with camelCase keys and parsed values
* @example
* // Given an element
* extractDataAttributes(element);
* // Returns: { backdrop: true }
*/
export const extractDataAttributes = (
element: unknown,
prefix = 'bs',
): Record => {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return {};
}
const { dataset } = element;
const attributes: Record = {};
const normalizedPrefix = prefix.toLowerCase();
const prefixLength = normalizedPrefix.length;
for (const [dataKey, dataValue] of Object.entries(dataset)) {
if (dataValue === undefined || !dataKey.toLowerCase().startsWith(normalizedPrefix)) {
continue;
}
const suffix = dataKey.slice(prefixLength);
if (!suffix) {
continue;
}
const propertyName = suffix.charAt(0).toLowerCase() + suffix.slice(1);
attributes[propertyName] = parseAttributeValue(dataValue);
}
return attributes;
};
/**
* Resolve an element from a selector or an element reference
* @param elementOrSelector - Element or CSS selector
* @param context - Context for querySelector
* @returns Resolved element or null
* @example
* resolveElement('#myElement'); // returns the element with ID 'myElement'
* resolveElement(someElement); // returns someElement if it's an Element
*/
export const resolveElement = (
elementOrSelector: Element | string | null | undefined,
context: Document | Element = document,
): Element | null => {
if (!elementOrSelector) {
return null;
}
if (elementOrSelector instanceof Element) {
return elementOrSelector;
}
if (typeof elementOrSelector === 'string') {
try {
return context.querySelector(elementOrSelector);
} catch {
return null;
}
}
return null;
};
/**
* Clamp a number between a minimum and maximum value
* @param value - The value to clamp
* @param min - The minimum value
* @param max - The maximum value
* @returns The clamped value
* @example
* clamp(5, 1, 10); // returns 5
* clamp(0, 1, 10); // returns 1
* clamp(15, 1, 10); // returns 10
*/
export const clamp = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(max, value));
};
/**
* Extract target selector from trigger element
* @param element - The trigger element
* @returns Target selector or null
* @example
* // Given an element
* extractTargetSelector(element); // returns '#mySheet'
* // Given an element
* extractTargetSelector(element); // returns '#mySheet'
* // Given an element
* extractTargetSelector(element); // returns '#mySheet'
* // Given an element
* extractTargetSelector(element); // returns null
*/
export const extractTargetSelector = (element: unknown): string | null => {
if (!(element instanceof Element)) {
return null;
}
// Prioritize data-bs-target over href
const targetValue = element.getAttribute('data-bs-target') || element.getAttribute('href');
if (!targetValue || targetValue === '#') {
return null;
}
if (targetValue.startsWith('#')) {
return targetValue.length > 1 ? targetValue : null;
}
try {
const url = new URL(targetValue, window.location.href);
const hash = url.hash;
return hash && hash.length > 1 ? hash : null;
} catch {
return null;
}
};
/**
* Calculate scrollbar width by creating a temporary element
* @returns Scrollbar width in pixels
* @example
* const scrollbarWidth = getScrollbarWidth(); // e.g., returns 15
*/
export const getScrollbarWidth = (): number => {
if (!document.body) {
return 0;
}
const scrollDiv = document.createElement('div');
scrollDiv.style.position = 'absolute';
scrollDiv.style.top = '-9999px';
scrollDiv.style.width = '50px';
scrollDiv.style.height = '50px';
scrollDiv.style.overflow = 'scroll';
document.body.appendChild(scrollDiv);
const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth;
document.body.removeChild(scrollDiv);
return scrollbarWidth;
};
/**
* Get the type of a value as a lowercase string
* @param value - Any value
* @returns Type name (e.g. 'string', 'number', 'array')
* @example
* getValueType(123); // returns 'number'
* getValueType('hello'); // returns 'string'
* getValueType([1, 2, 3]); // returns 'array'
* getValueType({ key: 'value' }); // returns 'object'
* getValueType(null); // returns 'null'
* getValueType(undefined); // returns 'undefined'
*/
export const getValueType = (value: unknown): string => {
return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
};
/**
* Parse a type string into an array of allowed types
* @param types - Type string like "(string|number)"
* @returns Array of allowed type names
* @example
* parseExpectedTypes('(string|number)'); // returns ['string', 'number']
* parseExpectedTypes('boolean'); // returns ['boolean']
* parseExpectedTypes('(array|object|null)'); // returns ['array', 'object', 'null']
* parseExpectedTypes(''); // returns []
*/
export const parseExpectedTypes = (types: string): string[] => {
return types
.replace(/\s+/g, '')
.replace(/^\(|\)$/g, '')
.split('|')
.filter(Boolean);
};
/**
* Validate configuration object against type definitions.
*
* Acts as an assertion function: when it returns without throwing, every
* property listed in `configTypes` is runtime-proven to match its declared
* type, so the config can be treated as `T` from that point on.
*
* @param componentName - Component name for error messages
* @param config - Configuration object to validate
* @param configTypes - Type definitions
* @throws {TypeError} If a config property has an invalid type
* @example
* const config = { backdrop: true };
* const configTypes = { backdrop: 'boolean' };
* validateConfigTypes('Sheet', config, configTypes); // No error
*
* const invalidConfig = { backdrop: 'yes' };
* validateConfigTypes('Sheet', invalidConfig, configTypes);
* // Throws TypeError: [Sheet] Option "backdrop" has invalid type: expected boolean, but received string.
*/
export function validateConfigTypes(
componentName: string,
config: Record = {},
configTypes: Record = {},
): asserts config is Record & T {
for (const propertyName of Object.keys(configTypes)) {
const expectedTypes = configTypes[propertyName];
const actualValue = config[propertyName];
if (actualValue === undefined) {
continue;
}
const actualType = getValueType(actualValue);
const allowedTypes = parseExpectedTypes(expectedTypes);
const isValid = allowedTypes.some((allowedType) => actualType === allowedType);
if (!isValid) {
throw new TypeError(
`[${componentName}] Option "${propertyName}" has invalid type: ` +
`expected ${allowedTypes.join(' | ')}, but received ${actualType}.`,
);
}
}
}
/**
* Get the vertical translation (Y-axis) of a DOM element
* @param element - The DOM element to measure
* @returns The vertical translation in pixels
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMMatrix}
* @example
* // Given an element with transform: translateY(100px);
* const translateY = getTranslateY(someElement); // returns 100
*
* // Given an element with no transform
* const translateY = getTranslateY(someElement); // returns 0
*/
export const getTranslateY = (element: unknown): number => {
if (!(element instanceof Element)) {
return 0;
}
const computedStyle = window.getComputedStyle(element);
const transform = computedStyle.transform;
if (!transform || transform === 'none') {
return 0;
}
try {
const matrix = new DOMMatrix(transform);
return matrix.m42;
} catch {
return 0;
}
};
/**
* Apple's rubber band formula (reverse-engineered from UIScrollView).
*
* Attempt to move past a boundary results in diminishing returns:
* the displayed offset asymptotically approaches `dimension` but never reaches it.
* Initial slope equals `coefficient`, so the first few pixels of overscroll
* move at (coefficient × 100)% of finger speed.
*
* Formula: b = (1 - 1 / (x * c / d + 1)) * d
* Equivalent: b = (x * d * c) / (d + c * x)
*
* @param offset - How far past the boundary (must be >= 0)
* @param dimension - Reference dimension (sheet height)
* @param coefficient - Resistance coefficient (Apple uses 0.55)
* @returns Displayed offset (always >= 0, always < dimension)
* @see {@link https://gist.github.com/originell/6961057} Analysis of Apple's rubber band scrolling
*/
export const rubberBand = (offset: number, dimension: number, coefficient: number): number => {
if (offset === 0 || dimension === 0) {
return 0;
}
return (1.0 - 1.0 / ((offset * coefficient) / dimension + 1.0)) * dimension;
};
/**
* Convert designer-friendly spring parameters to physical constants.
*
* Apple introduced this parameterization at WWDC 2018 ("Designing Fluid Interfaces"):
* - `dampingRatio` controls bounce (1.0 = no bounce, <1.0 = bouncy)
* - `response` controls speed (lower = faster, analogous to duration)
*
* Conversion assumes mass = 1:
* - `stiffness = (2π / response)²`
* - `damping = 4π · dampingRatio / response`
*
* @param dampingRatio - Damping ratio (1.0 = critically damped, 0.8 = slight bounce)
* @param response - Response time in seconds (0.4 is a good default)
* @returns Physical spring constants
*/
export const springParameters = (dampingRatio: number, response: number): SpringParams => {
return {
stiffness: Math.pow((2 * Math.PI) / response, 2),
damping: (4 * Math.PI * dampingRatio) / response,
mass: 1,
};
};
/**
* Check whether a spring animation has settled (converged to target).
*
* The animation is considered settled when both the distance from the target
* and the velocity are below the given threshold. Using 0.5px as the default
* threshold matches Apple's behavior - sub-pixel movements are imperceptible.
*
* @param state - Current spring state
* @param target - Target position
* @param positionThreshold - Maximum distance from target (px)
* @param velocityThreshold - Maximum velocity (px/s)
* @returns True if the spring has settled
*/
export const isSpringSettled = (
state: SpringState,
target: number,
positionThreshold = 0.5,
velocityThreshold = 0.5,
): boolean => {
return (
Math.abs(state.position - target) < positionThreshold &&
Math.abs(state.velocity) < velocityThreshold
);
};
/**
* Project how far a decelerating object will travel before stopping.
*
* This is Apple's formula from WWDC 2018 "Designing Fluid Interfaces".
* Given a release velocity and a deceleration rate, it computes the total
* displacement the object would cover if allowed to coast to a stop.
*
* UIScrollView uses two deceleration rates:
* - 0.998 (UIScrollView.DecelerationRate.normal) - default, ~499px per 1000px/s
* - 0.99 (UIScrollView.DecelerationRate.fast) - snappier, ~99px per 1000px/s
*
* The exact integral formula is `-v₀ / (1000 · ln(d))`, which differs from
* Apple's published approximation by less than 1%.
*
* @param velocity - Release velocity in px/s (positive = downward)
* @param decelerationRate - Deceleration rate (0–1, higher = more coasting)
* @returns Projected displacement in px (same sign as velocity)
*/
export const projectDisplacement = (velocity: number, decelerationRate: number): number => {
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
};
/**
* Advance spring state by dt using the exact analytical solution.
*
* Unconditionally stable for any dt, stiffness, or damping - no sub-stepping
* or numerical integration required. Solves the ODE exactly:
* m·x'' + c·x' + k·(x − target) = 0
*
* Three regimes, determined by the damping ratio ζ = c / (2√km):
* ζ < 1 underdamped - oscillatory exponential decay
* ζ ≈ 1 critically damped - fastest non-oscillatory convergence
* ζ > 1 overdamped - two real exponentials, slower than critical
*
* @param state - Current state
* @param target - Target position the spring pulls toward
* @param params - Spring constants
* @param dt - Time step in seconds (any positive value is stable)
* @returns Exact state at t + dt
*/
export const solveSpring = (
state: SpringState,
target: number,
params: SpringParams,
dt: number,
): SpringState => {
const { position, velocity } = state;
const { stiffness, damping, mass } = params;
// Initial conditions
const d0 = position - target;
const v0 = velocity;
// Canonical parameters
const omega0 = Math.sqrt(stiffness / mass);
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
// Common exponential decay
const expDecay = Math.exp(-zeta * omega0 * dt);
// Wider critical band for numerical stability
const EPS = 1e-4;
let d, v;
if (zeta > 1 - EPS && zeta < 1 + EPS) {
// Critically damped: d(t) = (A + B·t)·e^(−ω₀t)
const B = v0 + omega0 * d0;
d = (d0 + B * dt) * expDecay;
v = (B - omega0 * (d0 + B * dt)) * expDecay;
} else if (zeta < 1) {
// Underdamped: d(t) = e^(−ζω₀t)·[A·cos(ωd·t) + B·sin(ωd·t)]
const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);
const B = (v0 + zeta * omega0 * d0) / omegaD;
const cosT = Math.cos(omegaD * dt);
const sinT = Math.sin(omegaD * dt);
d = expDecay * (d0 * cosT + B * sinT);
v =
expDecay *
((-zeta * omega0 * d0 + omegaD * B) * cosT + (-zeta * omega0 * B - omegaD * d0) * sinT);
} else {
// Overdamped: d(t) = A·e^(r₁t) + B·e^(r₂t), r₁,r₂ = −ζω₀ ± γ
const gamma = omega0 * Math.sqrt(zeta * zeta - 1);
const r1 = -zeta * omega0 + gamma;
const r2 = -zeta * omega0 - gamma;
const A = (v0 - r2 * d0) / (2 * gamma);
const B = d0 - A;
const e1 = Math.exp(r1 * dt);
const e2 = Math.exp(r2 * dt);
d = A * e1 + B * e2;
v = r1 * A * e1 + r2 * B * e2;
}
return { position: target + d, velocity: v };
};
/**
* Sample recorded by the velocity tracker
*/
interface VelocitySample {
timestamp: number;
position: number;
}
/**
* Windowed velocity tracker for touch/pointer gestures.
*
* iOS's UIPanGestureRecognizer computes velocity from recent touch samples
* rather than the full gesture history. This class replicates that approach
* by maintaining a sliding window of {timestamp, position} samples and
* computing velocity from the oldest and newest entries within the window.
*
* Key behaviors:
* - Only samples within the last `windowMs` milliseconds are considered
* - If the gesture pauses (gap > windowMs before release), velocity is 0
* - Guards against division by zero from identical timestamps
* - Uses event.timeStamp (backed by performance.now) for microsecond precision
*/
export class VelocityTracker {
/** Recorded samples within the sliding window */
#samples: VelocitySample[] = [];
/** Window size in milliseconds */
#windowMs: number;
/**
* @param windowMs - Time window for velocity calculation (ms)
*/
constructor(windowMs = 100) {
this.#windowMs = windowMs;
}
/**
* Record a position sample at the given timestamp.
*
* Old samples (older than 2× the window) are pruned to prevent
* unbounded memory growth during long drag gestures.
*
* @param timestamp - Event timestamp in ms (use event.timeStamp)
* @param position - Current position in px
*/
addSample(timestamp: number, position: number): void {
this.#samples.push({ timestamp, position });
// Prune samples older than 2× window to bound memory
const cutoff = timestamp - this.#windowMs * 2;
this.#samples = this.#samples.filter((s) => s.timestamp > cutoff);
}
/**
* Compute velocity from samples within the time window.
*
* Returns 0 if:
* - Fewer than 2 samples exist within the window
* - Time delta between first and last sample is 0 (identical timestamps)
* - The gesture has paused (no recent samples)
*
* @param currentTimestamp - Current time in ms (from pointerup event)
* @returns Velocity in px/ms (positive = downward movement)
*/
getVelocity(currentTimestamp: number): number {
const cutoff = currentTimestamp - this.#windowMs;
const recent = this.#samples.filter((s) => s.timestamp >= cutoff);
if (recent.length < 2) {
return 0;
}
const first = recent[0];
const last = recent[recent.length - 1];
const dt = last.timestamp - first.timestamp;
if (dt === 0) {
return 0;
}
return (last.position - first.position) / dt;
}
/**
* Clear all recorded samples. Call when a new gesture begins.
*/
reset(): void {
this.#samples = [];
}
}