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.
svelte-kit-vice/src/uix/soma/layers/floating/floating.svelte.ts

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];
}

Powered by TurnKey Linux.