306 lines
12 KiB
JavaScript
306 lines
12 KiB
JavaScript
import { arrow, autoUpdate, flip, hide, limitShift, offset, shift, size, } from "@floating-ui/dom";
|
|
import { attachRef, cssToStyleObj, getWindow, styleToString, simpleBox, boxFrom, } 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 = simpleBox(null);
|
|
customAnchorNode = simpleBox(null);
|
|
triggerNode = simpleBox(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 = simpleBox(null);
|
|
wrapperRef = simpleBox(null);
|
|
arrowRef = simpleBox(null);
|
|
contentAttachment = attachRef(this.contentRef);
|
|
wrapperAttachment = attachRef(this.wrapperRef);
|
|
arrowAttachment = attachRef(this.arrowRef);
|
|
// ids
|
|
arrowId = simpleBox(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 = boxFrom(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];
|
|
}
|