You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
416 lines
14 KiB
416 lines
14 KiB
import {
|
|
type Middleware,
|
|
type Placement,
|
|
arrow,
|
|
autoUpdate,
|
|
flip,
|
|
hide,
|
|
limitShift,
|
|
offset,
|
|
shift,
|
|
size
|
|
} from '@floating-ui/dom';
|
|
import { attachRef, type RefAttachment } from '$libs/reactive';
|
|
import { cssToStyleObj, styleToString } from '$soma/css';
|
|
import type { ActiveDom } from '$adom';
|
|
import { state, readableActive, type Active, type ActiveProps } from '$libs/reactive';
|
|
import { isNotNull } from '$soma/types';
|
|
import { useId } from '$soma/id';
|
|
import { context, type SomaContext } from '$soma/provider';
|
|
import type { Arrayable, Direction, StyleProperties } from '$soma/types';
|
|
import type { WithRefOpts } from '$soma/provider';
|
|
import { ElementSize, watch } from 'runed';
|
|
|
|
import { useFloating } from './use-floating.svelte';
|
|
import type { Measurable, UseFloatingReturn } from './types';
|
|
import { OPPOSITE_SIDE, type Align, type Boundary, type Side } from './placement';
|
|
|
|
// ── FloatingProvider ─────────────────────────────────────────────────────────
|
|
|
|
export interface FloatingProviderOpts {
|
|
dom: ActiveDom;
|
|
}
|
|
|
|
export class FloatingProvider {
|
|
static readonly ctx = context<FloatingProvider>('FloatingProvider');
|
|
|
|
static create(opts: FloatingProviderOpts) {
|
|
return FloatingProvider.ctx.set(new FloatingProvider(opts));
|
|
}
|
|
|
|
readonly opts: FloatingProviderOpts;
|
|
anchorNode = state<Measurable | HTMLElement | null>(null);
|
|
customAnchorNode = state<Measurable | HTMLElement | null | string>(null);
|
|
triggerNode: Active<Measurable | HTMLElement | null> = state(null);
|
|
|
|
private constructor(opts: FloatingProviderOpts) {
|
|
this.opts = opts;
|
|
$effect(() => {
|
|
if (this.customAnchorNode.current) {
|
|
if (typeof this.customAnchorNode.current === 'string') {
|
|
const el = this.opts.dom.query(this.customAnchorNode.current);
|
|
this.anchorNode.current = el;
|
|
} else {
|
|
this.anchorNode.current = this.customAnchorNode.current;
|
|
}
|
|
} else {
|
|
this.anchorNode.current = this.triggerNode.current;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── FloatingContent ──────────────────────────────────────────────────────────
|
|
|
|
export interface FloatingContentOpts extends ActiveProps<{
|
|
id: string;
|
|
wrapperId: string;
|
|
side: Side;
|
|
sideOffset: number;
|
|
align: Align;
|
|
alignOffset: number;
|
|
arrowPadding: number;
|
|
avoidCollisions: boolean;
|
|
collisionBoundary: Arrayable<Boundary>;
|
|
collisionPadding: number | Partial<Record<Side, number>>;
|
|
sticky: 'partial' | 'always';
|
|
hideWhenDetached: boolean;
|
|
updatePositionStrategy: 'optimized' | 'always';
|
|
strategy: 'fixed' | 'absolute';
|
|
onPlaced: () => void;
|
|
dir: Direction;
|
|
style: StyleProperties | null | undefined | string;
|
|
enabled: boolean;
|
|
customAnchor: string | HTMLElement | null | Measurable;
|
|
}> {}
|
|
|
|
export class FloatingContent {
|
|
static readonly ctx = context<FloatingContent>('FloatingContent');
|
|
|
|
static create(opts: FloatingContentOpts) {
|
|
return FloatingContent.ctx.set(new FloatingContent(opts, FloatingProvider.ctx.get()));
|
|
}
|
|
|
|
readonly opts: FloatingContentOpts;
|
|
readonly provider: FloatingProvider;
|
|
|
|
// nodes
|
|
contentRef = state<HTMLElement | null>(null);
|
|
wrapperRef = state<HTMLElement | null>(null);
|
|
arrowRef = state<HTMLElement | null>(null);
|
|
readonly contentAttachment: RefAttachment = attachRef(this.contentRef);
|
|
readonly wrapperAttachment: RefAttachment = attachRef(this.wrapperRef);
|
|
readonly arrowAttachment: RefAttachment = attachRef(this.arrowRef);
|
|
|
|
// ids
|
|
arrowId = state(useId());
|
|
|
|
transformedStyle = $derived.by(() => {
|
|
if (typeof this.opts.style === 'string') return cssToStyleObj(this.opts.style);
|
|
if (!this.opts.style) return {};
|
|
});
|
|
|
|
updatePositionStrategy = undefined as unknown as FloatingContentOpts['updatePositionStrategy'];
|
|
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}` : '')) as Placement
|
|
);
|
|
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<number | undefined>(undefined);
|
|
availableHeight = $state<number | undefined>(undefined);
|
|
anchorWidth = $state<number | undefined>(undefined);
|
|
anchorHeight = $state<number | undefined>(undefined);
|
|
middleware: 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) as Middleware[]
|
|
);
|
|
floating: UseFloatingReturn;
|
|
placedSide = $derived.by(() => getSideFromPlacement(this.floating.placement.current));
|
|
placedAlign = $derived.by(() => getAlignFromPlacement(this.floating.placement.current));
|
|
arrowX = $derived.by(() => this.floating.middlewareData.current.arrow?.x);
|
|
arrowY = $derived.by(() => this.floating.middlewareData.current.arrow?.y);
|
|
cannotCenterArrow = $derived.by(() => {
|
|
const centerOffset = this.floating.middlewareData.current.arrow?.centerOffset;
|
|
return centerOffset !== undefined && centerOffset !== 0;
|
|
});
|
|
contentZIndex = $state<string>();
|
|
arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]);
|
|
wrapperProps = $derived.by(
|
|
() =>
|
|
({
|
|
id: this.opts.wrapperId.current,
|
|
'data-soma-floating-wrapper': '',
|
|
style: {
|
|
...this.floating.floatingStyles.current,
|
|
transform: this.floating.isPositioned.current
|
|
? this.floating.floatingStyles.current.transform
|
|
: 'translate(0, -200%)',
|
|
minWidth: 'max-content',
|
|
zIndex:
|
|
this.contentZIndex && this.contentZIndex !== 'auto' ? this.contentZIndex : undefined,
|
|
'--soma-floating-transform-origin': `${this.floating.middlewareData.current.transformOrigin?.x} ${this.floating.middlewareData.current.transformOrigin?.y}`,
|
|
'--soma-floating-available-width': `${this.availableWidth}px`,
|
|
'--soma-floating-available-height': `${this.availableHeight}px`,
|
|
'--soma-floating-anchor-width': `${this.anchorWidth}px`,
|
|
'--soma-floating-anchor-height': `${this.anchorHeight}px`,
|
|
...(this.floating.middlewareData.current.hide?.referenceHidden && {
|
|
visibility: 'hidden',
|
|
'pointer-events': 'none'
|
|
}),
|
|
...this.transformedStyle
|
|
},
|
|
dir: this.opts.dir.current,
|
|
...this.wrapperAttachment
|
|
}) as const
|
|
);
|
|
props = $derived.by(
|
|
() =>
|
|
({
|
|
'data-side': this.placedSide,
|
|
'data-align': this.placedAlign,
|
|
style: styleToString({
|
|
...this.transformedStyle
|
|
}),
|
|
...this.contentAttachment
|
|
}) as const
|
|
);
|
|
|
|
arrowStyle = $derived({
|
|
position: 'absolute',
|
|
left: this.arrowX !== undefined ? `${this.arrowX}px` : undefined,
|
|
top: this.arrowY !== undefined ? `${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' as const) : undefined
|
|
});
|
|
|
|
private constructor(opts: FloatingContentOpts, provider: FloatingProvider) {
|
|
this.opts = opts;
|
|
this.provider = provider;
|
|
this.updatePositionStrategy = opts.updatePositionStrategy;
|
|
|
|
if (opts.customAnchor) {
|
|
this.provider.customAnchorNode.current = opts.customAnchor.current;
|
|
}
|
|
|
|
watch(
|
|
() => opts.customAnchor.current,
|
|
(customAnchor) => {
|
|
this.provider.customAnchorNode.current = customAnchor;
|
|
}
|
|
);
|
|
|
|
this.floating = useFloating({
|
|
strategy: () => this.opts.strategy.current,
|
|
placement: () => this.desiredPlacement,
|
|
middleware: () => this.middleware,
|
|
reference: this.provider.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.current) return;
|
|
this.opts.onPlaced?.current();
|
|
});
|
|
|
|
watch(
|
|
() => this.contentRef.current,
|
|
(contentNode) => {
|
|
if (!contentNode || !this.opts.enabled.current) return;
|
|
const rafId = this.provider.opts.dom.requestFrame(() => {
|
|
if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return;
|
|
const zIndex = this.provider.opts.dom
|
|
.getWindow(contentNode)
|
|
.getComputedStyle(contentNode).zIndex;
|
|
if (zIndex !== this.contentZIndex) {
|
|
this.contentZIndex = zIndex;
|
|
}
|
|
}, contentNode);
|
|
|
|
return () => {
|
|
this.provider.opts.dom.cancelFrame(rafId, contentNode);
|
|
};
|
|
}
|
|
);
|
|
|
|
$effect(() => {
|
|
this.floating.floating.current = this.wrapperRef.current;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── FloatingArrow ────────────────────────────────────────────────────────────
|
|
|
|
interface FloatingArrowOpts extends WithRefOpts {}
|
|
|
|
export class FloatingArrow {
|
|
static create(opts: FloatingArrowOpts) {
|
|
return new FloatingArrow(opts, FloatingContent.ctx.get());
|
|
}
|
|
|
|
readonly opts: FloatingArrowOpts;
|
|
readonly content: FloatingContent;
|
|
|
|
constructor(opts: FloatingArrowOpts, content: FloatingContent) {
|
|
this.opts = opts;
|
|
this.content = content;
|
|
}
|
|
|
|
readonly props = $derived.by(
|
|
() =>
|
|
({
|
|
id: this.opts.id.current,
|
|
style: this.content.arrowStyle,
|
|
'data-side': this.content.placedSide,
|
|
...this.content.arrowAttachment
|
|
}) as const
|
|
);
|
|
}
|
|
|
|
// ── FloatingAnchor ───────────────────────────────────────────────────────────
|
|
|
|
interface FloatingAnchorOpts extends ActiveProps<{
|
|
id: string;
|
|
virtualEl?: Measurable | null;
|
|
ref: Measurable | HTMLElement | null;
|
|
}> {}
|
|
|
|
export class FloatingAnchor {
|
|
static create(opts: FloatingAnchorOpts) {
|
|
return new FloatingAnchor(opts, FloatingProvider.ctx.get());
|
|
}
|
|
|
|
readonly opts: FloatingAnchorOpts;
|
|
readonly provider: FloatingProvider;
|
|
|
|
constructor(opts: FloatingAnchorOpts, provider: FloatingProvider) {
|
|
this.opts = opts;
|
|
this.provider = provider;
|
|
|
|
if (opts.virtualEl && opts.virtualEl.current) {
|
|
provider.triggerNode = readableActive(() => opts.virtualEl!.current ?? null);
|
|
} else {
|
|
provider.triggerNode = opts.ref;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function transformOrigin(options: { arrowWidth: number; arrowHeight: number }): Middleware {
|
|
return {
|
|
name: 'transformOrigin',
|
|
options,
|
|
fn(data) {
|
|
const { placement, rects, middlewareData } = data;
|
|
|
|
const centerOffset = middlewareData.arrow?.centerOffset;
|
|
const cannotCenterArrow = centerOffset !== undefined && 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: Placement) {
|
|
const [side, align = 'center'] = placement.split('-');
|
|
return [side as Side, align as Align] as const;
|
|
}
|
|
|
|
export function getSideFromPlacement(placement: Placement) {
|
|
return getSideAndAlignFromPlacement(placement)[0];
|
|
}
|
|
|
|
export function getAlignFromPlacement(placement: Placement) {
|
|
return getSideAndAlignFromPlacement(placement)[1];
|
|
}
|