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.
836 lines
27 KiB
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
|
|
}));
|
|
}
|