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; }> {} export class DialogProvider extends Provider { static readonly ctx = context('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(null); // ── Nesting ────────────────────────────────────────────────────────────── readonly parent: DialogProvider | undefined; readonly depth: number; nestedOpenCount = state(0); // ── Presence ───────────────────────────────────────────────────────────── readonly contentPresence: Presence; readonly overlayPresence: Presence; private contentRef = state(null); private overlayRef = state(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-` 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 { 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 ───────────��──────────────────────────────��────────────────────── interface DialogTriggerOpts extends WithRefOpts {} export class DialogTriggerProvider extends Provider { 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) => { // `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 { static create(opts: DialogContentOpts) { return new DialogContentProvider(opts); } readonly provider: DialogProvider; // ── Behavior layers ────────────────────────────────────────────────────── readonly focusScope: ReturnType; 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 { 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 ────���────────────────────────────────────────────────────────────── interface DialogTitleOpts extends WithRefOpts, ActiveProps<{ level: number }> {} export class DialogTitleProvider extends Provider { 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) ); } // ─�� Description ────────────────────────────────────────���──────────────────── interface DialogDescriptionOpts extends WithRefOpts {} export class DialogDescriptionProvider extends Provider { 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 ──────────���──────────────────────────────────────────────────────── interface DialogCloseOpts extends WithRefOpts {} export class DialogCloseProvider extends Provider { 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) => { void this.provider.runtime.trigger('close-cancel'); }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, ...this.resolveMorfoProps(), onclick: this.onclick } as const) ); }