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

480 lines
16 KiB

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

Powered by TurnKey Linux.