bits-ui
Version:
The headless components for Svelte.
306 lines (305 loc) • 12.4 kB
JavaScript
import { arrow, autoUpdate, flip, hide, limitShift, offset, shift, size, } from "@floating-ui/dom";
import { attachRef, box, cssToStyleObj, getWindow, styleToString, } from "svelte-toolbelt";
import { Context, ElementSize, watch } from "runed";
import { isNotNull } from "../../../internal/is.js";
import { useId } from "../../../internal/use-id.js";
import { useFloating } from "../../../internal/floating-svelte/use-floating.svelte.js";
export const SIDE_OPTIONS = ["top", "right", "bottom", "left"];
export const ALIGN_OPTIONS = ["start", "center", "end"];
const OPPOSITE_SIDE = {
top: "bottom",
right: "left",
bottom: "top",
left: "right",
};
const FloatingRootContext = new Context("Floating.Root");
const FloatingContentContext = new Context("Floating.Content");
const FloatingTooltipRootContext = new Context("Floating.Root");
export class FloatingRootState {
static create(tooltip = false) {
return tooltip
? FloatingTooltipRootContext.set(new FloatingRootState())
: FloatingRootContext.set(new FloatingRootState());
}
anchorNode = box(null);
customAnchorNode = box(null);
triggerNode = box(null);
constructor() {
$effect(() => {
if (this.customAnchorNode.current) {
if (typeof this.customAnchorNode.current === "string") {
this.anchorNode.current = document.querySelector(this.customAnchorNode.current);
}
else {
this.anchorNode.current = this.customAnchorNode.current;
}
}
else {
this.anchorNode.current = this.triggerNode.current;
}
});
}
}
export class FloatingContentState {
static create(opts, tooltip = false) {
return tooltip
? FloatingContentContext.set(new FloatingContentState(opts, FloatingTooltipRootContext.get()))
: FloatingContentContext.set(new FloatingContentState(opts, FloatingRootContext.get()));
}
opts;
root;
// nodes
contentRef = box(null);
wrapperRef = box(null);
arrowRef = box(null);
contentAttachment = attachRef(this.contentRef);
wrapperAttachment = attachRef(this.wrapperRef);
arrowAttachment = attachRef(this.arrowRef);
// ids
arrowId = box(useId());
#transformedStyle = $derived.by(() => {
if (typeof this.opts.style === "string")
return cssToStyleObj(this.opts.style);
if (!this.opts.style)
return {};
});
#updatePositionStrategy = undefined;
#arrowSize = new ElementSize(() => this.arrowRef.current ?? undefined);
#arrowWidth = $derived(this.#arrowSize?.width ?? 0);
#arrowHeight = $derived(this.#arrowSize?.height ?? 0);
#desiredPlacement = $derived.by(() => (this.opts.side?.current +
(this.opts.align.current !== "center"
? `-${this.opts.align.current}`
: "")));
#boundary = $derived.by(() => Array.isArray(this.opts.collisionBoundary.current)
? this.opts.collisionBoundary.current
: [this.opts.collisionBoundary.current]);
hasExplicitBoundaries = $derived(this.#boundary.length > 0);
detectOverflowOptions = $derived.by(() => ({
padding: this.opts.collisionPadding.current,
boundary: this.#boundary.filter(isNotNull),
altBoundary: this.hasExplicitBoundaries,
}));
#availableWidth = $state(undefined);
#availableHeight = $state(undefined);
#anchorWidth = $state(undefined);
#anchorHeight = $state(undefined);
middleware = $derived.by(() => [
offset({
mainAxis: this.opts.sideOffset.current + this.#arrowHeight,
alignmentAxis: this.opts.alignOffset.current,
}),
this.opts.avoidCollisions.current &&
shift({
mainAxis: true,
crossAxis: false,
limiter: this.opts.sticky.current === "partial" ? limitShift() : undefined,
...this.detectOverflowOptions,
}),
this.opts.avoidCollisions.current && flip({ ...this.detectOverflowOptions }),
size({
...this.detectOverflowOptions,
apply: ({ rects, availableWidth, availableHeight }) => {
const { width: anchorWidth, height: anchorHeight } = rects.reference;
this.#availableWidth = availableWidth;
this.#availableHeight = availableHeight;
this.#anchorWidth = anchorWidth;
this.#anchorHeight = anchorHeight;
},
}),
this.arrowRef.current &&
arrow({
element: this.arrowRef.current,
padding: this.opts.arrowPadding.current,
}),
transformOrigin({ arrowWidth: this.#arrowWidth, arrowHeight: this.#arrowHeight }),
this.opts.hideWhenDetached.current &&
hide({ strategy: "referenceHidden", ...this.detectOverflowOptions }),
].filter(Boolean));
floating;
placedSide = $derived.by(() => getSideFromPlacement(this.floating.placement));
placedAlign = $derived.by(() => getAlignFromPlacement(this.floating.placement));
arrowX = $derived.by(() => this.floating.middlewareData.arrow?.x ?? 0);
arrowY = $derived.by(() => this.floating.middlewareData.arrow?.y ?? 0);
cannotCenterArrow = $derived.by(() => this.floating.middlewareData.arrow?.centerOffset !== 0);
contentZIndex = $state();
arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]);
wrapperProps = $derived.by(() => ({
id: this.opts.wrapperId.current,
"data-bits-floating-content-wrapper": "",
style: {
...this.floating.floatingStyles,
// keep off page when measuring
transform: this.floating.isPositioned
? this.floating.floatingStyles.transform
: "translate(0, -200%)",
minWidth: "max-content",
zIndex: this.contentZIndex,
"--bits-floating-transform-origin": `${this.floating.middlewareData.transformOrigin?.x} ${this.floating.middlewareData.transformOrigin?.y}`,
"--bits-floating-available-width": `${this.#availableWidth}px`,
"--bits-floating-available-height": `${this.#availableHeight}px`,
"--bits-floating-anchor-width": `${this.#anchorWidth}px`,
"--bits-floating-anchor-height": `${this.#anchorHeight}px`,
// hide the content if using the hide middleware and should be hidden
...(this.floating.middlewareData.hide?.referenceHidden && {
visibility: "hidden",
"pointer-events": "none",
}),
...this.#transformedStyle,
},
// Floating UI calculates logical alignment based the `dir` attribute
dir: this.opts.dir.current,
...this.wrapperAttachment,
}));
props = $derived.by(() => ({
"data-side": this.placedSide,
"data-align": this.placedAlign,
style: styleToString({
...this.#transformedStyle,
}),
...this.contentAttachment,
}));
arrowStyle = $derived({
position: "absolute",
left: this.arrowX ? `${this.arrowX}px` : undefined,
top: this.arrowY ? `${this.arrowY}px` : undefined,
[this.arrowBaseSide]: 0,
"transform-origin": {
top: "",
right: "0 0",
bottom: "center 0",
left: "100% 0",
}[this.placedSide],
transform: {
top: "translateY(100%)",
right: "translateY(50%) rotate(90deg) translateX(-50%)",
bottom: "rotate(180deg)",
left: "translateY(50%) rotate(-90deg) translateX(50%)",
}[this.placedSide],
visibility: this.cannotCenterArrow ? "hidden" : undefined,
});
constructor(opts, root) {
this.opts = opts;
this.root = root;
if (opts.customAnchor) {
this.root.customAnchorNode.current = opts.customAnchor.current;
}
watch(() => opts.customAnchor.current, (customAnchor) => {
this.root.customAnchorNode.current = customAnchor;
});
this.floating = useFloating({
strategy: () => this.opts.strategy.current,
placement: () => this.#desiredPlacement,
middleware: () => this.middleware,
reference: this.root.anchorNode,
whileElementsMounted: (...args) => {
const cleanup = autoUpdate(...args, {
animationFrame: this.#updatePositionStrategy?.current === "always",
});
return cleanup;
},
open: () => this.opts.enabled.current,
sideOffset: () => this.opts.sideOffset.current,
alignOffset: () => this.opts.alignOffset.current,
});
$effect(() => {
if (!this.floating.isPositioned)
return;
this.opts.onPlaced?.current();
});
watch(() => this.contentRef.current, (contentNode) => {
if (!contentNode)
return;
const win = getWindow(contentNode);
this.contentZIndex = win.getComputedStyle(contentNode).zIndex;
});
$effect(() => {
this.floating.floating.current = this.wrapperRef.current;
});
}
}
export class FloatingArrowState {
static create(opts) {
return new FloatingArrowState(opts, FloatingContentContext.get());
}
opts;
content;
constructor(opts, content) {
this.opts = opts;
this.content = content;
}
props = $derived.by(() => ({
id: this.opts.id.current,
style: this.content.arrowStyle,
"data-side": this.content.placedSide,
...this.content.arrowAttachment,
}));
}
export class FloatingAnchorState {
static create(opts, tooltip = false) {
return tooltip
? new FloatingAnchorState(opts, FloatingTooltipRootContext.get())
: new FloatingAnchorState(opts, FloatingRootContext.get());
}
opts;
root;
constructor(opts, root) {
this.opts = opts;
this.root = root;
if (opts.virtualEl && opts.virtualEl.current) {
root.triggerNode = box.from(opts.virtualEl.current);
}
else {
root.triggerNode = opts.ref;
}
}
}
//
// HELPERS
//
function transformOrigin(options) {
return {
name: "transformOrigin",
options,
fn(data) {
const { placement, rects, middlewareData } = data;
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
const isArrowHidden = cannotCenterArrow;
const arrowWidth = isArrowHidden ? 0 : options.arrowWidth;
const arrowHeight = isArrowHidden ? 0 : options.arrowHeight;
const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement);
const noArrowAlign = { start: "0%", center: "50%", end: "100%" }[placedAlign];
const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2;
const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2;
let x = "";
let y = "";
if (placedSide === "bottom") {
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
y = `${-arrowHeight}px`;
}
else if (placedSide === "top") {
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
y = `${rects.floating.height + arrowHeight}px`;
}
else if (placedSide === "right") {
x = `${-arrowHeight}px`;
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
}
else if (placedSide === "left") {
x = `${rects.floating.width + arrowHeight}px`;
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
}
return { data: { x, y } };
},
};
}
function getSideAndAlignFromPlacement(placement) {
const [side, align = "center"] = placement.split("-");
return [side, align];
}
export function getSideFromPlacement(placement) {
return getSideAndAlignFromPlacement(placement)[0];
}
export function getAlignFromPlacement(placement) {
return getSideAndAlignFromPlacement(placement)[1];
}