|
|
import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider';
|
|
|
import {
|
|
|
boolToEmptyStrOrUndef,
|
|
|
getDataOpenClosed
|
|
|
} from '$libs/dom';
|
|
|
import {
|
|
|
readableActive,
|
|
|
state,
|
|
|
type Active,
|
|
|
type ActiveProps,
|
|
|
type StateProps
|
|
|
} from '$libs/reactive';
|
|
|
import type { OnChangeFn, SomaMouseEvent } from '../../types';
|
|
|
import { Soma } from '../../core/soma.svelte';
|
|
|
import { watch } from 'runed';
|
|
|
import { untrack } from 'svelte';
|
|
|
|
|
|
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 { dialogMorfo } from '../../../morfo/components/dialog';
|
|
|
import { type MorfoRuntime } from '$uix/morfo';
|
|
|
|
|
|
// ── Provider (root) ─────────────────────────────────────────────────────────
|
|
|
|
|
|
export type DialogVariant = 'dialog' | 'alertdialog';
|
|
|
|
|
|
interface DialogOpts
|
|
|
extends
|
|
|
ProviderOpts,
|
|
|
StateProps<{ open: boolean }>,
|
|
|
ActiveProps<{
|
|
|
disabled: boolean;
|
|
|
modal: boolean;
|
|
|
variant: DialogVariant;
|
|
|
onOpenChangeComplete: OnChangeFn<boolean>;
|
|
|
}> {}
|
|
|
|
|
|
export class DialogProvider extends Provider<DialogOpts> {
|
|
|
static readonly ctx = context<DialogProvider>('Dialog');
|
|
|
static get(): DialogProvider | undefined { return this.ctx.getOr(undefined) as DialogProvider | undefined; }
|
|
|
static require(): DialogProvider { return this.ctx.get(); }
|
|
|
|
|
|
static create(opts: DialogOpts) {
|
|
|
return new DialogProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly soma = Soma.get();
|
|
|
|
|
|
// tracked IDs for ARIA
|
|
|
contentId = state('');
|
|
|
titleId = state('');
|
|
|
descriptionId = state('');
|
|
|
triggerId = state('');
|
|
|
|
|
|
// tracked nodes
|
|
|
triggerNode = state<HTMLElement | null>(null);
|
|
|
|
|
|
// ── Nesting ──────────────────────────────────────────────────────────────
|
|
|
readonly parent: DialogProvider | undefined;
|
|
|
readonly depth: number;
|
|
|
nestedOpenCount = state(0);
|
|
|
|
|
|
// ── Presence ─────────────────────────────────────────────────────────────
|
|
|
readonly contentPresence: Presence;
|
|
|
readonly overlayPresence: Presence;
|
|
|
|
|
|
private contentRef = state<HTMLElement | null>(null);
|
|
|
private overlayRef = state<HTMLElement | null>(null);
|
|
|
|
|
|
// ── Morfo runtime (side-channel for trigger() cadence) ───────────────────
|
|
|
// The runtime fires the prewrite → semantic.emit → handler sequence so
|
|
|
// eidos can react to data-event-* during the configured hold and tint the
|
|
|
// exit border by `data-last-action`. The legacy provider machinery
|
|
|
// (assertProps + resolveMorfoProps) stays — runtime is alongside, not
|
|
|
// replacing.
|
|
|
readonly runtime: MorfoRuntime;
|
|
|
|
|
|
private constructor(opts: DialogOpts) {
|
|
|
// Read parent BEFORE registering in context (ctx.set happens in super)
|
|
|
const parent = DialogProvider.get() as DialogProvider | undefined;
|
|
|
super(opts, { morfo: dialogMorfo, part: 'provider' }, DialogProvider.ctx);
|
|
|
|
|
|
this.parent = parent;
|
|
|
this.depth = parent ? parent.depth + 1 : 0;
|
|
|
|
|
|
// Track nested open count in parent — untrack to avoid circular reactivity
|
|
|
if (parent) {
|
|
|
watch(
|
|
|
() => opts.open.current,
|
|
|
(isOpen, prevOpen) => {
|
|
|
untrack(() => {
|
|
|
if (isOpen && !prevOpen) parent.nestedOpenCount.current++;
|
|
|
else if (!isOpen && prevOpen)
|
|
|
parent.nestedOpenCount.current = Math.max(0, parent.nestedOpenCount.current - 1);
|
|
|
});
|
|
|
}
|
|
|
);
|
|
|
}
|
|
|
|
|
|
this.contentPresence = new Presence({
|
|
|
open: opts.open,
|
|
|
ref: this.contentRef,
|
|
|
onComplete: (open) => opts.onOpenChangeComplete.current(open)
|
|
|
});
|
|
|
|
|
|
this.overlayPresence = new Presence({
|
|
|
open: opts.open,
|
|
|
ref: this.overlayRef
|
|
|
});
|
|
|
|
|
|
this.runtime = this.soma.uix.runtime(dialogMorfo, {
|
|
|
states: { open: () => opts.open.current },
|
|
|
props: {
|
|
|
disabled: () => opts.disabled.current,
|
|
|
modal: () => opts.modal.current
|
|
|
},
|
|
|
events: {
|
|
|
open: () => this.handleOpen(),
|
|
|
'close-save': () => this.handleClose(),
|
|
|
'close-cancel': () => this.handleClose(),
|
|
|
'close-dismiss': () => this.handleClose(),
|
|
|
'close-dismiss-outside': () => this.handleClose(),
|
|
|
'close-after-fail': () => this.handleClose()
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
readonly isNested = $derived.by(() => this.depth > 0);
|
|
|
readonly hasNestedOpen = $derived.by(() => this.nestedOpenCount.current > 0);
|
|
|
|
|
|
handleOpen() {
|
|
|
if (this.opts.disabled.current || this.opts.open.current) return;
|
|
|
this.opts.open.current = true;
|
|
|
}
|
|
|
|
|
|
handleClose() {
|
|
|
if (this.opts.disabled.current || !this.opts.open.current) return;
|
|
|
this.opts.open.current = false;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Programmatic dismiss with a named cause. Drives the cadence through the
|
|
|
* morfo runtime so the matching `close-<action>` event fires its prewrite
|
|
|
* (writes `data-last-action`) BEFORE flipping `data-state` to `'closed'`.
|
|
|
* Eidos tints the exit border by causa during the close animation.
|
|
|
*
|
|
|
* action='save' → close-save (border tint = success)
|
|
|
* action='cancel' → close-cancel (border tint = neutral)
|
|
|
* action='dismiss' → close-dismiss (border tint = neutral)
|
|
|
* action='dismiss-outside' → close-dismiss-outside (border tint = neutral)
|
|
|
* action='fail' → close-after-fail (border tint = danger)
|
|
|
*/
|
|
|
dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise<void> {
|
|
|
const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`;
|
|
|
return this.runtime.trigger(eventName);
|
|
|
}
|
|
|
|
|
|
setContentRef(el: HTMLElement | null) {
|
|
|
this.contentRef.current = el;
|
|
|
}
|
|
|
|
|
|
setOverlayRef(el: HTMLElement | null) {
|
|
|
this.overlayRef.current = el;
|
|
|
}
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps,
|
|
|
...this.resolveMorfoProps({
|
|
|
states: {
|
|
|
open: this.opts.open.current
|
|
|
},
|
|
|
props: {
|
|
|
disabled: this.opts.disabled.current
|
|
|
}
|
|
|
})
|
|
|
} as const)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── Trigger ───────────<E29480><E29480>──────────────────────────────<E29480><E29480>──────────────────────
|
|
|
|
|
|
interface DialogTriggerOpts extends WithRefOpts {}
|
|
|
|
|
|
export class DialogTriggerProvider extends Provider<DialogTriggerOpts> {
|
|
|
static create(opts: DialogTriggerOpts) {
|
|
|
return new DialogTriggerProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly provider: DialogProvider;
|
|
|
|
|
|
private constructor(opts: DialogTriggerOpts) {
|
|
|
super(opts, { morfo: dialogMorfo, part: 'trigger' });
|
|
|
this.provider = DialogProvider.require();
|
|
|
this.provider.triggerId.current = opts.id.current;
|
|
|
}
|
|
|
|
|
|
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
|
// `open`'s declared target (partRef('content')) is NOT in the DOM
|
|
|
// until handleOpen flips state. Pass the trigger as `fallbackTarget`
|
|
|
// so the runtime can still emit the perceptual signal — the visual
|
|
|
// channel writes its `data-event*` to the trigger briefly, sound /
|
|
|
// vibra channels don't care about the target.
|
|
|
void this.provider.runtime.trigger('open', {
|
|
|
fallbackTarget: e.currentTarget as HTMLElement
|
|
|
});
|
|
|
};
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps,
|
|
|
...this.resolveMorfoProps({
|
|
|
states: {
|
|
|
open: this.provider.opts.open.current
|
|
|
},
|
|
|
parts: {
|
|
|
content: this.provider.contentId.current
|
|
|
}
|
|
|
}),
|
|
|
disabled: this.provider.opts.disabled.current || undefined,
|
|
|
onclick: this.onclick,
|
|
|
...this.attachment
|
|
|
} as const)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── Content ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
interface DialogContentOpts
|
|
|
extends
|
|
|
WithRefOpts,
|
|
|
ActiveProps<{
|
|
|
trapFocus: boolean;
|
|
|
loop: boolean;
|
|
|
preventScroll: boolean;
|
|
|
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;
|
|
|
}> {}
|
|
|
|
|
|
export class DialogContentProvider extends Provider<DialogContentOpts> {
|
|
|
static create(opts: DialogContentOpts) {
|
|
|
return new DialogContentProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly provider: DialogProvider;
|
|
|
|
|
|
// ── Behavior layers ──────────────────────────────────────────────────────
|
|
|
readonly focusScope: ReturnType<typeof FocusScope.use>;
|
|
|
readonly dismissal: Dismissal;
|
|
|
readonly scrollLock: ScrollLock;
|
|
|
readonly textSelection: TextSelection;
|
|
|
|
|
|
private constructor(opts: DialogContentOpts) {
|
|
|
super(opts, { morfo: dialogMorfo, part: 'content' }, undefined, (el) => {
|
|
|
this.provider.setContentRef(el);
|
|
|
});
|
|
|
this.provider = DialogProvider.require();
|
|
|
this.provider.contentId.current = opts.id.current;
|
|
|
|
|
|
// Register the content part with the morfo runtime so close-* triggers
|
|
|
// can resolve `event.target = partRef('content')` to this DOM node.
|
|
|
this.provider.runtime.registerPart('content', { id: opts.id, ref: opts.ref });
|
|
|
|
|
|
// Layers — all integrated as behaviors
|
|
|
this.focusScope = FocusScope.use({
|
|
|
onOpenAutoFocus: opts.onOpenAutoFocus,
|
|
|
onCloseAutoFocus: opts.onCloseAutoFocus,
|
|
|
trap: opts.trapFocus,
|
|
|
loop: opts.loop,
|
|
|
ref: opts.ref,
|
|
|
enabled: readableActive(() => this.provider.contentPresence.isPresent)
|
|
|
});
|
|
|
|
|
|
// Behavior resolution: explicit prop wins → variant/modal defaults → 'close'
|
|
|
const isAlert = () => this.provider.opts.variant.current === 'alertdialog';
|
|
|
const resolvedInteractBehavior = readableActive(
|
|
|
() =>
|
|
|
opts.interactOutsideBehavior.current ??
|
|
|
(this.provider.opts.modal.current || isAlert() ? 'ignore' : 'close')
|
|
|
);
|
|
|
const resolvedEscapeBehavior = readableActive(
|
|
|
() => opts.escapeKeydownBehavior.current ?? (isAlert() ? 'ignore' : 'close')
|
|
|
);
|
|
|
|
|
|
this.dismissal = Dismissal.use({
|
|
|
ref: readableActive(() => opts.ref.current),
|
|
|
enabled: readableActive(() => this.provider.opts.open.current),
|
|
|
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
|
|
|
opts.onEscapeKeydown.current(e);
|
|
|
if (!e.defaultPrevented) void this.provider.runtime.trigger('close-dismiss');
|
|
|
}),
|
|
|
escapeKeydownBehavior: resolvedEscapeBehavior,
|
|
|
onInteractOutside: readableActive(() => (e: PointerEvent) => {
|
|
|
opts.onInteractOutside.current(e);
|
|
|
if (!e.defaultPrevented) void this.provider.runtime.trigger('close-dismiss-outside');
|
|
|
}),
|
|
|
interactOutsideBehavior: resolvedInteractBehavior,
|
|
|
onFocusOutside: opts.onFocusOutside,
|
|
|
isValidEvent: readableActive(() => () => false)
|
|
|
});
|
|
|
|
|
|
this.scrollLock = new ScrollLock(undefined, () => opts.restoreScrollDelay.current);
|
|
|
$effect(() => {
|
|
|
this.scrollLock.locked.current =
|
|
|
this.provider.opts.open.current && opts.preventScroll.current;
|
|
|
});
|
|
|
|
|
|
this.textSelection = TextSelection.use({
|
|
|
ref: readableActive(() => opts.ref.current),
|
|
|
enabled: readableActive(() => this.provider.opts.open.current),
|
|
|
onPointerDown: readableActive(() => () => {}),
|
|
|
onPointerUp: readableActive(() => () => {})
|
|
|
});
|
|
|
}
|
|
|
|
|
|
readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent);
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps,
|
|
|
...this.resolveMorfoProps({
|
|
|
states: {
|
|
|
open: this.provider.opts.open.current
|
|
|
},
|
|
|
props: {
|
|
|
modal: this.provider.opts.modal.current
|
|
|
},
|
|
|
parts: {
|
|
|
title: this.provider.titleId.current,
|
|
|
description: this.provider.descriptionId.current
|
|
|
}
|
|
|
}),
|
|
|
role: this.provider.opts.variant.current,
|
|
|
'aria-roledescription': undefined,
|
|
|
'data-nested': this.provider.isNested ? '' : undefined,
|
|
|
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
|
|
|
style: {
|
|
|
'pointer-events': 'auto',
|
|
|
'--soma-dialog-depth': `${this.provider.depth}`,
|
|
|
'--soma-dialog-nested-count': `${this.provider.nestedOpenCount.current}`
|
|
|
},
|
|
|
...this.provider.contentPresence.transitionAttrs,
|
|
|
...this.focusScope.props,
|
|
|
...this.dismissal.props
|
|
|
} as const)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── Overlay ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
interface DialogOverlayOpts extends WithRefOpts {}
|
|
|
|
|
|
export class DialogOverlayProvider extends Provider<DialogOverlayOpts> {
|
|
|
static create(opts: DialogOverlayOpts) {
|
|
|
return new DialogOverlayProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly provider: DialogProvider;
|
|
|
|
|
|
private constructor(opts: DialogOverlayOpts) {
|
|
|
super(opts, { morfo: dialogMorfo, part: 'overlay' }, undefined, (el) => {
|
|
|
this.provider.setOverlayRef(el);
|
|
|
});
|
|
|
this.provider = DialogProvider.require();
|
|
|
}
|
|
|
|
|
|
readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent);
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps,
|
|
|
...this.resolveMorfoProps({
|
|
|
states: {
|
|
|
open: this.provider.opts.open.current
|
|
|
}
|
|
|
}),
|
|
|
'data-nested': this.provider.isNested ? '' : undefined,
|
|
|
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
|
|
|
style: {
|
|
|
'pointer-events': 'auto',
|
|
|
'--soma-dialog-depth': `${this.provider.depth}`,
|
|
|
'--soma-dialog-nested-count': `${this.provider.nestedOpenCount.current}`
|
|
|
},
|
|
|
...this.provider.overlayPresence.transitionAttrs
|
|
|
} as const)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── Title ────<E29480><E29480><EFBFBD>──────────────────────────────────────────────────────────────
|
|
|
|
|
|
interface DialogTitleOpts extends WithRefOpts, ActiveProps<{ level: number }> {}
|
|
|
|
|
|
export class DialogTitleProvider extends Provider<DialogTitleOpts> {
|
|
|
static create(opts: DialogTitleOpts) {
|
|
|
return new DialogTitleProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly provider: DialogProvider;
|
|
|
|
|
|
private constructor(opts: DialogTitleOpts) {
|
|
|
super(opts, { morfo: dialogMorfo, part: 'title' });
|
|
|
this.provider = DialogProvider.require();
|
|
|
this.provider.titleId.current = opts.id.current;
|
|
|
}
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps,
|
|
|
...this.resolveMorfoProps({
|
|
|
props: {
|
|
|
level: this.opts.level.current
|
|
|
}
|
|
|
})
|
|
|
} as const)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ─<><E29480> Description ────────────────────────────────────────<E29480><E29480><EFBFBD>────────────────────
|
|
|
|
|
|
interface DialogDescriptionOpts extends WithRefOpts {}
|
|
|
|
|
|
export class DialogDescriptionProvider extends Provider<DialogDescriptionOpts> {
|
|
|
static create(opts: DialogDescriptionOpts) {
|
|
|
return new DialogDescriptionProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly provider: DialogProvider;
|
|
|
|
|
|
private constructor(opts: DialogDescriptionOpts) {
|
|
|
super(opts, { morfo: dialogMorfo, part: 'description' });
|
|
|
this.provider = DialogProvider.require();
|
|
|
this.provider.descriptionId.current = opts.id.current;
|
|
|
}
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps
|
|
|
} as const)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── Close ──────────<E29480><E29480><EFBFBD>────────────────────────────────────────────────────────
|
|
|
|
|
|
interface DialogCloseOpts extends WithRefOpts {}
|
|
|
|
|
|
export class DialogCloseProvider extends Provider<DialogCloseOpts> {
|
|
|
static create(opts: DialogCloseOpts) {
|
|
|
return new DialogCloseProvider(opts);
|
|
|
}
|
|
|
|
|
|
readonly provider: DialogProvider;
|
|
|
|
|
|
private constructor(opts: DialogCloseOpts) {
|
|
|
super(opts, { morfo: dialogMorfo, part: 'close' });
|
|
|
this.provider = DialogProvider.require();
|
|
|
}
|
|
|
|
|
|
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
|
void this.provider.runtime.trigger('close-cancel');
|
|
|
};
|
|
|
|
|
|
readonly props = $derived.by(() =>
|
|
|
this.assertProps({
|
|
|
...this.baseProps,
|
|
|
...this.resolveMorfoProps(),
|
|
|
onclick: this.onclick
|
|
|
} as const)
|
|
|
);
|
|
|
}
|