soma: Toast pilot — multi-instance runtime + trigger() integration

Commit B of the Toast pilot. The runtime.trigger() contract from commit A
now has its first real consumer: all three dismiss paths route through
`runtime.trigger('dismiss')`, which emits the perceptual signal one frame
before the toaster mutates state.

Multi-instance architecture validated
Toast is the first component with multiple per-instance subtrees: each
toast in the queue is its own scope with its own children. Two runtime
instances coexist per Toast tree:

  ToastProvider (root)
    └── runtime instance A — registers `viewport` only
  ToastItemProvider (one per active toast in queue)
    └── runtime instance B — registers `item` + its children
                              (`title`, `description`, `action`, `close`)

`partRef('item')` resolves to THIS toast's element because the lookup
happens on THIS Item's runtime, not a shared one. `runtime.trigger('dismiss')`
on Item-B targets Item-B's DOM element. Per-toast isolation by construction.

dismiss wired through runtime.trigger
Three call sites now go through the same path instead of calling
`toaster.dismiss(id)` directly:
- ToastCloseProvider.onclick (user clicks the × button)
- ToastItemProvider startTimer setTimeout callback (auto-dismiss)
- ToastItemProvider onpointerup (swipe gesture meets threshold)

The runtime fires `data-event="dismiss"` on the item, awaits one rAF for
CSS to observe it, then calls the dismiss handler that mutates
`toast.dismissing = true`. The item's effect re-derives `data-state="closed"`
from `isOpen = !dismissing` and writes it via dom.apply on the next tick.

intent-driven role / aria-live still works
The morfo's `mapRef(propRef('intent'), { neutral: 'status', risk: 'alert', … })`
for `role` and `aria-live` is resolved by the runtime per-Item: each toast
reads its own `intent` from `opts.toast.current.intent` via the per-Item
runtime sources. Risk/threat toasts get `role=alert` + `aria-live=assertive`;
the others get `role=status` + `aria-live=polite`. No code in the provider
selects this — the morfo declares it, the resolver computes it.

Per-part props
Action's `altText` is per-instance per-action — passed to the runtime via
`registerPart('action', { props: { altText: () => opts.altText.current } })`.
Validates that the runtime's per-part source override (introduced in
commit 8ede5353 but never exercised) works as designed.

Translation lookup
Close's `aria-label` resolves through `v.translationRef` against the Item
runtime's `translate` source (`soma.langs.ts`). Confirms translationRef
ergonomics through the runtime path.

What this commit does NOT do (deferred)
- `present` and `announce` events: declared in the morfo but not wired.
  Would fire on Item mount; needs a small effect at the right phase.
- `runtime.keydown` for Item's `Escape` keyboard declaration: requires
  the V2 runtime API.
- presence layer (data-starting-style/data-ending-style) still flows
  through Svelte render — not migrated to dom.apply. Mixed model is OK
  while the boundary is clearly between morfo-declared attrs (runtime)
  and layer-declared transitions (Svelte).

Verification
- 84/84 focused unit tests green.
- 66/66 morfo-check (toast included).
- 68/68 smoke routes 200 OK.
- svelte-check unchanged at 155.
morfo-runtime
dev 6 months ago
parent 2b39a2423d
commit 14aa218658

@ -1,14 +1,19 @@
import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { readableActive, state, type Active, type ActiveProps } from '../../reactive';
import { createAttrs, registerContract } from '../../attrs';
import { readableActive, state, type ActiveProps } from '../../reactive';
import type { SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { isBrowser } from '../../dom';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo';
import { Presence } from '../../layers/presence.svelte';
import type { Toaster, ToastData, SwipeDirection } from './toaster.svelte';
import { toastMorfo } from '../../../morfo/components/toast';
// ── Provider (root, no DOM) ─────────────────────────────────────────────────
createAttrs(toastMorfo);
registerContract(toastMorfo);
// ── Provider (root, virtual) ────────────────────────────────────────────────
interface ToastProviderOpts
extends
@ -20,17 +25,33 @@ interface ToastProviderOpts
export class ToastProvider extends Provider<ToastProviderOpts> {
static readonly ctx = context<ToastProvider>('Toast');
static get(): ToastProvider | undefined { return this.ctx.getOr(undefined) as ToastProvider | undefined; }
static require(): ToastProvider { return this.ctx.get(); }
static get(): ToastProvider | undefined {
return this.ctx.getOr(undefined) as ToastProvider | undefined;
}
static require(): ToastProvider {
return this.ctx.get();
}
static create(opts: ToastProviderOpts) {
return new ToastProvider(opts);
}
readonly soma = Soma.get();
readonly runtime: MorfoRuntime;
private constructor(opts: ToastProviderOpts) {
super(opts, { morfo: toastMorfo, part: 'provider' }, ToastProvider.ctx);
// Root-scope runtime — only the Viewport part registers here. Item and
// its children get their own runtime per-instance (see ToastItemProvider).
const soma = Soma.require();
this.runtime = createMorfoRuntime(toastMorfo, {
dom: soma.dom,
semantic: soma.semantic,
props: {
label: () => opts.label.current
}
});
}
get toaster(): Toaster {
@ -53,6 +74,10 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
private constructor(opts: ToastViewportOpts) {
super(opts, { morfo: toastMorfo, part: 'viewport' });
this.provider = ToastProvider.require();
this.provider.runtime.registerPart('viewport', {
id: opts.id,
ref: opts.ref
});
// Register global hotkey to focus viewport, with cleanup. Supports
// composite hotkeys like `['AltLeft', 'F8']` by tracking currently
@ -67,7 +92,6 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
return hotkey.every((k) => {
if (e.code === k || e.key === k) return true;
if (pressed.has(k)) return true;
// Treat modifier aliases as currently active.
if ((k === 'Alt' || k === 'AltLeft' || k === 'AltRight') && e.altKey) return true;
if ((k === 'Control' || k === 'ControlLeft' || k === 'ControlRight') && e.ctrlKey)
return true;
@ -117,12 +141,7 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
...this.resolveMorfoProps({
props: {
label: this.provider.opts.label.current
}
}),
...this.provider.runtime.partProps('viewport'),
tabindex: -1
} as const)
);
@ -139,8 +158,12 @@ interface ToastItemOpts
export class ToastItemProvider extends Provider<ToastItemOpts> {
static readonly ctx = context<ToastItemProvider>('ToastItem');
static get(): ToastItemProvider | undefined { return this.ctx.getOr(undefined) as ToastItemProvider | undefined; }
static require(): ToastItemProvider { return this.ctx.get(); }
static get(): ToastItemProvider | undefined {
return this.ctx.getOr(undefined) as ToastItemProvider | undefined;
}
static require(): ToastItemProvider {
return this.ctx.get();
}
static create(opts: ToastItemOpts) {
return new ToastItemProvider(opts);
@ -149,6 +172,13 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
readonly provider: ToastProvider;
readonly presence: Presence;
/**
* Per-instance runtime. Every toast in the queue is its own scope:
* `partRef('item')` must resolve to THIS toast's element, not any other.
* Title / Description / Action / Close inside this Item register here.
*/
readonly runtime: MorfoRuntime;
// tracked IDs for ARIA
titleId = state('');
descriptionId = state('');
@ -174,6 +204,39 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
super(opts, { morfo: toastMorfo, part: 'item' }, ToastItemProvider.ctx);
this.provider = ToastProvider.require();
// Per-instance runtime for { item, title, description, action, close }.
// `events.dismiss` is the WHEN — runtime.trigger('dismiss') runs the
// perceptual signal first, then this callback mutates state, then the
// item's effect re-derives data-state etc. via dom.apply.
const soma = Soma.require();
this.runtime = createMorfoRuntime(toastMorfo, {
dom: soma.dom,
semantic: soma.semantic,
states: {
open: () => this.isOpen
},
props: {
intent: () => opts.toast.current.intent,
loading: () => opts.toast.current.loading,
swipeState: () => (this.swipeState === 'idle' ? undefined : this.swipeState),
swipeDirection: () => this.provider.toaster.swipeDirection
},
parts: {
title: () => this.titleId.current || undefined,
description: () => this.descriptionId.current || undefined
},
translate: (key) => soma.langs.ts(key),
events: {
dismiss: () => {
this.provider.toaster.dismiss(opts.toast.current.id);
}
}
});
this.runtime.registerPart('item', {
id: opts.id,
ref: opts.ref
});
// Presence for exit animation
this.presence = new Presence({
open: readableActive(() => this.isOpen),
@ -216,7 +279,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
this.clearTimer();
this.startTime = Date.now();
this.timerId = setTimeout(() => {
this.provider.toaster.dismiss(this.opts.toast.current.id);
void this.runtime.trigger('dismiss');
}, this.remaining);
}
@ -337,7 +400,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
if (isCorrectDirection && Math.abs(delta) >= this.swipeThreshold) {
this.swipeState = 'end';
this.provider.toaster.dismiss(this.opts.toast.current.id);
void this.runtime.trigger('dismiss');
} else {
this.swipeState = 'cancel';
this.swipeDeltaX = 0;
@ -351,22 +414,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
...this.resolveMorfoProps({
states: {
open: this.isOpen
},
props: {
intent: this.opts.toast.current.intent,
loading: this.opts.toast.current.loading,
swipeState: this.swipeState === 'idle' ? undefined : this.swipeState,
swipeDirection: this.provider.toaster.swipeDirection
},
parts: {
title: this.titleId.current || undefined,
description: this.descriptionId.current || undefined
}
}),
...this.runtime.partProps('item'),
tabindex: 0,
style: {
'--soma-toast-swipe-move-x': `${this.swipeDeltaX}px`,
@ -403,15 +451,21 @@ export class ToastTitleProvider extends Provider<ToastTitleOpts> {
return new ToastTitleProvider(opts);
}
readonly toastItem: ToastItemProvider;
private constructor(opts: ToastTitleOpts) {
super(opts, { morfo: toastMorfo, part: 'title' });
const toastItem = ToastItemProvider.require();
toastItem.titleId.current = opts.id.current;
this.toastItem = ToastItemProvider.require();
this.toastItem.titleId.current = opts.id.current;
this.toastItem.runtime.registerPart('title', {
id: opts.id,
ref: opts.ref
});
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps
...this.toastItem.runtime.partProps('title')
} as const)
);
}
@ -425,15 +479,21 @@ export class ToastDescriptionProvider extends Provider<ToastDescriptionOpts> {
return new ToastDescriptionProvider(opts);
}
readonly toastItem: ToastItemProvider;
private constructor(opts: ToastDescriptionOpts) {
super(opts, { morfo: toastMorfo, part: 'description' });
const toastItem = ToastItemProvider.require();
toastItem.descriptionId.current = opts.id.current;
this.toastItem = ToastItemProvider.require();
this.toastItem.descriptionId.current = opts.id.current;
this.toastItem.runtime.registerPart('description', {
id: opts.id,
ref: opts.ref
});
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps
...this.toastItem.runtime.partProps('description')
} as const)
);
}
@ -452,18 +512,26 @@ export class ToastActionProvider extends Provider<ToastActionOpts> {
return new ToastActionProvider(opts);
}
readonly toastItem: ToastItemProvider;
private constructor(opts: ToastActionOpts) {
super(opts, { morfo: toastMorfo, part: 'action' });
this.toastItem = ToastItemProvider.require();
// Per-part props: `altText` lives on Action's own opts, not on the Item.
// Pass it via registerPart so the Action's effect resolves it locally.
this.toastItem.runtime.registerPart('action', {
id: opts.id,
ref: opts.ref,
props: {
altText: () => opts.altText.current
}
});
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
...this.resolveMorfoProps({
props: {
altText: this.opts.altText.current
}
})
...this.toastItem.runtime.partProps('action')
} as const)
);
}
@ -482,19 +550,32 @@ export class ToastCloseProvider extends Provider<ToastCloseOpts> {
private constructor(opts: ToastCloseOpts) {
super(opts, { morfo: toastMorfo, part: 'close' });
this.toastItem = ToastItemProvider.require();
this.toastItem.runtime.registerPart('close', {
id: opts.id,
ref: opts.ref
});
}
/**
* Routes through `runtime.trigger('dismiss')` rather than calling
* `toaster.dismiss(...)` directly. The runtime emits the perceptual signal
* (`data-event="dismiss"` on the item) one frame before the structural
* change, then invokes the dismiss handler that mutates the toaster state.
*
* `void` is intentional — DOM event handlers must be sync; the trigger's
* Promise chain runs in the background.
*/
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.toastItem.provider.toaster.dismiss(this.toastItem.opts.toast.current.id);
void this.toastItem.runtime.trigger('dismiss');
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
...this.resolveMorfoProps({
translations: (key) => this.toastItem.provider.soma?.langs.ts(key)
}),
...this.toastItem.runtime.partProps('close'),
onclick: this.onclick
} as const)
);
}
// `SwipeDirection` is re-exported by toaster.svelte.ts; nothing else here uses it.
export type { SwipeDirection };

Loading…
Cancel
Save

Powered by TurnKey Linux.