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/components/popover/popover-provider.svelte.ts

836 lines
27 KiB

import { context } from '../../provider';
import {
readableActive,
state,
type Active,
type ActiveProps,
type State,
type StateProps
} from '$libs/reactive';
import type {
OnChangeFn,
SomaMouseEvent,
SomaKeyboardEvent,
Direction,
Arrayable,
StyleProperties
} from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { POPOVER_LANGS } from './langs';
import { contains } from '$adom';
import type { TimerHandle } from '$timer';
import { Presence } from '../../layers/presence.svelte';
import { FocusScope } from '../../layers/focus-scope.svelte';
import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte';
import { ScrollLock } from '../../layers/scroll-lock.svelte';
import { TextSelection } from '../../layers/text-selection.svelte';
import {
FloatingProvider,
FloatingContent,
FloatingArrow,
FloatingAnchor,
createFloatingShellRoot,
buildFloatingShellWrapperProps,
type Side,
type Align,
type Boundary
} from '../../layers/floating';
import { popoverMorfo } from '../../../morfo/components/popover';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
// ── Dismiss cause map (book §5.3 polymorphic close) ─────────────────────────
/**
* Concrete semantic shape for each named dismiss cause. Mirrors
* `DialogProvider` / `DrawerProvider` DISMISS_CAUSES. Used by
* `dismissWith(action, opts)` to translate a high-level action into the
* polymorphic `close` event's per-call concretion.
*/
interface DismissCause {
readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed';
readonly semantic: {
family: 'emerge' | 'commit' | 'signal';
verb: string;
intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss';
};
}
const DISMISS_CAUSES: Record<
'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
DismissCause
> = {
save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } },
cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } },
dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } },
'dismiss-outside': {
lastAction: 'dismissed-outside',
semantic: { family: 'emerge', verb: 'dismiss' }
},
fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } }
};
// ── Provider (root — no DOM) ────────────────────────────────────────────────
interface PopoverOpts
extends
StateProps<{ open: boolean }>,
ActiveProps<{
modal: boolean;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {
id: Active<string>;
ref?: State<HTMLElement | null>;
}
/**
* Popover root — runtime-direct (no `Provider` base inheritance).
*
* Same overlay shape as Dialog/Drawer. Adds floating positioning via the
* shared `FloatingProvider` layer + an Anchor part that can override the
* default trigger-as-anchor behaviour.
*/
export class PopoverProvider {
static readonly ctx = context<PopoverProvider>('Popover');
static get(): PopoverProvider | undefined {
return this.ctx.getOr(undefined) as PopoverProvider | undefined;
}
static require(): PopoverProvider {
return this.ctx.get();
}
static create(opts: PopoverOpts) {
return new PopoverProvider(opts);
}
readonly opts: PopoverOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
readonly floatingProvider: FloatingProvider;
// presence
readonly contentPresence: Presence;
readonly overlayPresence: Presence;
private contentRef = state<HTMLElement | null>(null);
private overlayRef = state<HTMLElement | null>(null);
private triggerRef = state<HTMLElement | null>(null);
// tracked IDs
contentId = state('');
triggerId = state('');
titleId = state('');
descriptionId = state('');
hoverOpenOnHover = state(false);
hoverCloseDelay = state(300);
private hoverTimer: TimerHandle | null = null;
private constructor(opts: PopoverOpts) {
this.opts = opts;
PopoverProvider.ctx.set(this);
this.soma = Soma.require();
const shell = createFloatingShellRoot({
dom: this.soma.dom,
open: opts.open,
contentRef: this.contentRef,
onOpenChangeComplete: opts.onOpenChangeComplete
});
this.floatingProvider = shell.floatingProvider;
this.contentPresence = shell.contentPresence;
// Overlay is popover-only — separate Presence not folded into the
// shared shell because the other 6 providers don't have an overlay.
this.overlayPresence = new Presence({
dom: this.soma.dom,
open: opts.open,
ref: this.overlayRef
});
const onPresent = () => this.handleOpen();
const onClose = () => this.handleClose();
this.runtime = this.soma.runtime(popoverMorfo, {
states: { open: () => opts.open.current },
props: {
modal: () => opts.modal.current,
ariaLabelledby: () => this.titleId.current || this.triggerId.current || undefined,
ariaDescribedby: () => this.descriptionId.current || undefined
},
parts: {
trigger: () => this.triggerId.current,
content: () => this.contentId.current,
title: () => this.titleId.current,
description: () => this.descriptionId.current
},
events: {
present: onPresent,
// Single polymorphic close (book §5.3) — mirror of Dialog
// / Drawer. Provider sets `data-last-action` imperatively
// + opts.semantic concrete the cause.
close: onClose
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: PopoverProvider.ctx,
syncAttrs: true
});
$effect(() => {
return () => this.clearHoverTimer();
});
}
handleOpen() {
this.opts.open.current = true;
}
handleClose() {
this.opts.open.current = false;
}
toggle() {
if (this.opts.open.current) this.handleClose();
else this.handleOpen();
}
setContentRef(el: HTMLElement | null) {
this.contentRef.current = el;
}
setTriggerRef(el: HTMLElement | null) {
this.triggerRef.current = el;
}
/**
* The registered trigger element (or null). Content reads it to exclude the
* trigger from the outside-dismiss: a re-click on an open trigger must close
* cleanly via the trigger's own toggle — without this, the pointerdown
* outside-dismiss closes and the click immediately re-opens (bounce).
*/
get triggerNode(): HTMLElement | null {
return this.triggerRef.current;
}
setOverlayRef(el: HTMLElement | null) {
this.overlayRef.current = el;
}
/**
* Programmatic dismiss with a named cause. Same shape as
* `DialogProvider.dismissWith`. Each cause maps to:
* - a `data-last-action` value, set imperatively on Content BEFORE
* the trigger fires so eidos can tint the exit animation;
* - a concrete semantic shape passed via `opts.semantic`, allowed by
* the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`.
*
* action='save' → commit.save + fulfill, data-last-action='saved'
* action='cancel' → emerge.close, data-last-action='cancelled'
* action='dismiss' → emerge.dismiss, data-last-action='dismissed'
* action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside'
* action='fail' → signal.alert + threat, data-last-action='failed'
*
* For 'fail', `opts.message` SHOULD be passed (live region announce).
*/
dismissWith(
action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
opts: { message?: string } = {}
): Promise<void> {
return this.triggerClose(DISMISS_CAUSES[action], opts);
}
private async triggerClose(
cause: DismissCause,
opts: { message?: string } = {}
): Promise<void> {
const target =
this.contentRef.current ??
this.runtime.partRef('content') ??
this.triggerRef.current ??
undefined;
// Imperative prewrite of `data-last-action` since the morfo's
// polymorphic `close` can't bind a single `prewrite` per call.
if (target) {
this.soma.dom.apply({
target,
attrs: { 'data-last-action': cause.lastAction }
});
}
await this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(opts.message !== undefined ? { message: opts.message } : {}),
semantic: cause.semantic
});
}
scheduleHoverOpen(delay: number, fallbackTarget: HTMLElement) {
this.clearHoverTimer();
this.hoverTimer = this.soma.uix.timers.schedule(
this.hoverTimerKey,
delay,
() => {
this.hoverTimer = null;
// Already open: ignore the hover-open. Re-firing `present` while open
// replays the presence (exit + enter) → the close-then-reopen bounce
// the user sees when hovering the trigger of an open popover. The
// clearHoverTimer above already cancelled any pending hover-close, so
// the popover simply stays open — the hover is a no-op, as intended.
if (this.opts.open.current) return;
void this.runtime.trigger('present', { fallbackTarget });
},
{ replace: true, meta: { component: 'popover', action: 'hover-open' } }
);
}
scheduleHoverClose(delay = this.hoverCloseDelay.current) {
this.clearHoverTimer();
this.hoverTimer = this.soma.uix.timers.schedule(
this.hoverTimerKey,
delay,
() => {
this.hoverTimer = null;
void this.dismissWith('dismiss');
},
{ replace: true, meta: { component: 'popover', action: 'hover-close' } }
);
}
handleHoverContentEnter() {
if (!this.hoverOpenOnHover.current) return;
this.clearHoverTimer();
}
handleHoverContentLeave() {
if (!this.hoverOpenOnHover.current) return;
if (this.opts.modal.current && this.opts.open.current) return;
this.scheduleHoverClose();
}
private get hoverTimerKey() {
return `soma:popover:${this.opts.id.current}:hover`;
}
private clearHoverTimer() {
if (this.hoverTimer !== null) {
this.hoverTimer.cancel();
this.hoverTimer = null;
}
}
}
// ── Trigger ─────────────────────────────────────────────────────────────────
interface PopoverTriggerOpts extends ActiveProps<{
openOnHover: boolean;
openDelay: number;
closeDelay: number;
}> {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverTriggerProvider {
static create(opts: PopoverTriggerOpts) {
return new PopoverTriggerProvider(opts);
}
readonly opts: PopoverTriggerOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: PopoverTriggerOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.provider.triggerId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => this.provider.setTriggerRef(el),
syncAttrs: true
});
// Register as floating anchor
FloatingAnchor.create({
id: opts.id,
ref: readableActive(() => opts.ref.current)
});
$effect(() => {
this.provider.hoverOpenOnHover.current = opts.openOnHover.current;
this.provider.hoverCloseDelay.current = opts.closeDelay.current;
});
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
// Toggle through the runtime so both directions emit a perceptual signal.
// `present`'s declared target (content) isn't in the DOM when opening —
// pass the trigger as fallbackTarget so the visual channel still has
// somewhere to write `data-event*`.
if (this.provider.opts.open.current) {
void this.provider.dismissWith('dismiss');
} else {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
if (this.provider.opts.open.current) {
void this.provider.dismissWith('dismiss');
} else {
void this.provider.runtime.trigger('present', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
}
};
readonly onpointerenter = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (!this.opts.openOnHover.current) return;
// Modal + already-open: ignore. The overlay covers the trigger so
// any pointerenter is the cursor passing over the trigger area
// while crossing the overlay — not a fresh hover gesture. Without
// this guard, modal+openOnHover loops (open → overlay → leave
// schedules close → close → re-enter → reopen).
if (this.provider.opts.modal.current && this.provider.opts.open.current) return;
const target = e.currentTarget as HTMLElement;
// Go through the runtime so Sema fires (prewrite + data-event*)
// the same as click/keyboard. Bypassing it (raw `handleOpen()`)
// skipped sound/haptic + perceptual signature on hover-opened
// popovers.
this.provider.scheduleHoverOpen(this.opts.openDelay.current, target);
};
readonly onpointerleave = () => {
if (!this.opts.openOnHover.current) return;
// Same reason: when modal is open, leaving the trigger means the
// cursor crossed onto the overlay — don't schedule a close. The
// user must close deliberately (Escape, Close button).
if (this.provider.opts.modal.current && this.provider.opts.open.current) return;
this.provider.scheduleHoverClose(this.opts.closeDelay.current);
};
// `aria-haspopup`, `aria-expanded`, `aria-controls`, `type`, `data-state`
// are all declared on the morfo's Trigger part — the SomaRuntime emits
// them through `dom.apply`. Spreading them manually here would duplicate
// (and potentially desync) the runtime's authority.
readonly props = $derived.by(() => ({
...this.runtimePart.props,
onclick: this.onclick,
onkeydown: this.onkeydown,
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave
}));
}
// ── Content ─────────────────────────────────────────────────────────────────
interface PopoverContentOpts extends ActiveProps<{
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';
customAnchor: string | HTMLElement | null;
// `trapFocus` / `preventScroll` default to `modal` when omitted —
// see PopoverContentProvider constructor for the resolution.
trapFocus: boolean | undefined;
loop: boolean;
preventScroll: boolean | undefined;
restoreScrollDelay: number;
onOpenAutoFocus: (e: Event) => void;
onCloseAutoFocus: (e: Event) => void;
onEscapeKeydown: (e: KeyboardEvent) => void;
escapeKeydownBehavior: DismissalBehavior | undefined;
onInteractOutside: (e: PointerEvent) => void;
interactOutsideBehavior: DismissalBehavior | undefined;
onFocusOutside: (e: FocusEvent) => void;
onPlaced: () => void;
dir: Direction;
style: StyleProperties | null | undefined | string;
}> {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverContentProvider {
static create(opts: PopoverContentOpts) {
return new PopoverContentProvider(opts);
}
readonly opts: PopoverContentOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
readonly floating: FloatingContent;
// Behavior layers
readonly focusScope: ReturnType<typeof FocusScope.use>;
readonly dismissal: Dismissal;
readonly scrollLock: ScrollLock;
readonly textSelection: TextSelection;
private constructor(opts: PopoverContentOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.provider.contentId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('content', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => this.provider.setContentRef(el),
syncAttrs: true
});
// Floating positioning
this.floating = FloatingContent.create({
id: opts.id,
wrapperId: readableActive(() => `${opts.id.current}-wrapper`),
side: opts.side,
sideOffset: opts.sideOffset,
align: opts.align,
alignOffset: opts.alignOffset,
arrowPadding: opts.arrowPadding,
avoidCollisions: opts.avoidCollisions,
collisionBoundary: opts.collisionBoundary,
collisionPadding: opts.collisionPadding,
sticky: opts.sticky,
hideWhenDetached: opts.hideWhenDetached,
updatePositionStrategy: opts.updatePositionStrategy,
strategy: opts.strategy,
onPlaced: opts.onPlaced,
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: opts.customAnchor
});
// Focus scope. trapFocus defaults to modal — non-modal popovers
// should leave focus free (they don't intercept page interaction);
// modal popovers must trap (they own the screen until dismissed).
// An explicit boolean from the consumer overrides the derivation.
this.focusScope = FocusScope.use({
dom: this.provider.soma.dom,
onOpenAutoFocus: opts.onOpenAutoFocus,
onCloseAutoFocus: opts.onCloseAutoFocus,
trap: readableActive(() => opts.trapFocus.current ?? this.provider.opts.modal.current),
loop: opts.loop,
ref: opts.ref,
enabled: readableActive(() => this.provider.contentPresence.isPresent)
});
// Dismissal — resolve behaviors from modal
const resolvedInteractBehavior = readableActive(
() =>
opts.interactOutsideBehavior.current ??
(this.provider.opts.modal.current ? 'ignore' : 'close')
);
const resolvedEscapeBehavior = readableActive(
() => opts.escapeKeydownBehavior.current ?? 'close'
);
this.dismissal = Dismissal.use({
ref: readableActive(() => opts.ref.current),
dom: this.provider.soma.dom,
timers: this.provider.soma.uix?.timers,
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss');
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
// The trigger sits outside Content, so a re-click on an open trigger
// reads as an "outside" interaction — dismissing on pointerdown, then
// the trigger's click re-opens (bounce). Let the trigger own its
// toggle: ignore an outside interaction that lands on it.
const triggerNode = this.provider.triggerNode;
const target = e.target as Node | null;
if (triggerNode && target && contains(triggerNode, target)) return;
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside');
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
isValidEvent: readableActive(() => () => false)
});
// Scroll lock. Same modal-derived default as trapFocus — modal
// popovers should freeze the page while open; non-modal popovers
// shouldn't restrict the user from scrolling around them.
this.scrollLock = new ScrollLock(
this.provider.soma.dom,
undefined,
() => opts.restoreScrollDelay.current
);
$effect(() => {
const effectivePreventScroll = opts.preventScroll.current ?? this.provider.opts.modal.current;
this.scrollLock.locked.current = this.provider.opts.open.current && effectivePreventScroll;
});
// Text selection
this.textSelection = TextSelection.use({
ref: readableActive(() => opts.ref.current),
dom: this.provider.soma.dom,
enabled: readableActive(() => this.provider.opts.open.current),
onPointerDown: readableActive(() => () => {}),
onPointerUp: readableActive(() => () => {})
});
}
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating));
// `role`, `aria-labelledby`, `aria-modal`, `data-state` come from the
// morfo's Content part via the SomaRuntime; the layered attrs (floating
// position, presence transition markers, focus scope, dismissal) keep
// flowing from their owning layers.
readonly props = $derived.by(() => ({
...this.runtimePart.props,
...this.floating.props,
...this.provider.contentPresence.transitionAttrs,
...this.focusScope.props,
...this.dismissal.props,
onpointerenter: () => this.provider.handleHoverContentEnter(),
onpointerleave: () => this.provider.handleHoverContentLeave()
}));
}
// ── Title ──────────────────────────────────────────────────────────────────
interface PopoverTitleOpts extends ActiveProps<{ level: number }> {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverTitleProvider {
static create(opts: PopoverTitleOpts) {
return new PopoverTitleProvider(opts);
}
readonly opts: PopoverTitleOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: PopoverTitleOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.provider.titleId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('title', {
id: opts.id,
ref: opts.ref,
owner: this,
props: { level: () => this.opts.level.current },
syncAttrs: true
});
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Description ────────────────────────────────────────────────────────────
interface PopoverDescriptionOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverDescriptionProvider {
static create(opts: PopoverDescriptionOpts) {
return new PopoverDescriptionProvider(opts);
}
readonly opts: PopoverDescriptionOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: PopoverDescriptionOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.provider.descriptionId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('description', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Arrow ────────────────────────────────────────────────────────────────────
interface PopoverArrowOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverArrowProvider {
static create(opts: PopoverArrowOpts) {
return new PopoverArrowProvider(opts);
}
readonly opts: PopoverArrowOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
readonly floatingArrow: FloatingArrow;
private constructor(opts: PopoverArrowOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.runtimePart = this.provider.runtime.part('arrow', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
this.floatingArrow = FloatingArrow.create(opts);
}
readonly props = $derived.by(() => ({
...this.runtimePart.props,
...this.floatingArrow.props
}));
}
// ── Close ────────────────────────────────────────────────────────────────────
interface PopoverCloseOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
action: Active<'save' | 'cancel' | 'dismiss'>;
}
export class PopoverCloseProvider {
static create(opts: PopoverCloseOpts) {
return new PopoverCloseProvider(opts);
}
readonly opts: PopoverCloseOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: PopoverCloseOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.runtimePart = this.provider.runtime.part('close', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
// Convert the button's `action` prop to the polymorphic
// dismissWith. 'dismiss' → 'dismiss', 'save' → 'save', etc.
void this.provider.dismissWith(this.opts.action.current);
};
readonly props = $derived.by(() => ({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.provider.soma.langs.ts(POPOVER_LANGS.CLOSE),
onclick: this.onclick
}));
}
// ── Anchor ───────────────────────────────────────────────────────────────────
interface PopoverAnchorOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverAnchorProvider {
static create(opts: PopoverAnchorOpts) {
return new PopoverAnchorProvider(opts);
}
readonly opts: PopoverAnchorOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: PopoverAnchorOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.runtimePart = this.provider.runtime.part('anchor', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
// Override the floating anchor to use this element instead of trigger
FloatingAnchor.create({
id: opts.id,
ref: readableActive(() => opts.ref.current)
});
}
readonly props = $derived.by(() => ({
...this.runtimePart.props
}));
}
// ── Overlay ──────────────────────────────────────────────────────────────────
interface PopoverOverlayOpts {
id: Active<string>;
ref: State<HTMLElement | null>;
}
export class PopoverOverlayProvider {
static create(opts: PopoverOverlayOpts) {
return new PopoverOverlayProvider(opts);
}
readonly opts: PopoverOverlayOpts;
readonly provider: PopoverProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: PopoverOverlayOpts) {
this.opts = opts;
this.provider = PopoverProvider.require();
this.runtimePart = this.provider.runtime.part('overlay', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => this.provider.setOverlayRef(el),
syncAttrs: true
});
}
readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent);
// `data-state` and `aria-hidden` come from the morfo via the runtime.
readonly props = $derived.by(() => ({
...this.runtimePart.props,
style: { 'pointer-events': 'auto' },
...this.provider.overlayPresence.transitionAttrs
}));
}

Powered by TurnKey Linux.