@ -1,14 +1,19 @@
import { Provider , context , type ProviderOpts , type WithRefOpts } from '../../provider' ;
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 type { SomaMouseEvent } from '../../types' ;
import { Soma } from '../../core/soma.svelte' ;
import { Soma } from '../../core/soma.svelte' ;
import { isBrowser } from '../../dom' ;
import { isBrowser } from '../../dom' ;
import { createMorfoRuntime , type MorfoRuntime } from '../../morfo' ;
import { Presence } from '../../layers/presence.svelte' ;
import { Presence } from '../../layers/presence.svelte' ;
import type { Toaster , ToastData , SwipeDirection } from './toaster.svelte' ;
import type { Toaster , ToastData , SwipeDirection } from './toaster.svelte' ;
import { toastMorfo } from '../../../morfo/components/toast' ;
import { toastMorfo } from '../../../morfo/components/toast' ;
// ── Provider (root, no DOM) ─────────────────────────────────────────────────
createAttrs ( toastMorfo ) ;
registerContract ( toastMorfo ) ;
// ── Provider (root, virtual) ────────────────────────────────────────────────
interface ToastProviderOpts
interface ToastProviderOpts
extends
extends
@ -20,17 +25,33 @@ interface ToastProviderOpts
export class ToastProvider extends Provider < ToastProviderOpts > {
export class ToastProvider extends Provider < ToastProviderOpts > {
static readonly ctx = context < ToastProvider > ( 'Toast' ) ;
static readonly ctx = context < ToastProvider > ( 'Toast' ) ;
static get ( ) : ToastProvider | undefined { return this . ctx . getOr ( undefined ) as ToastProvider | undefined ; }
static get ( ) : ToastProvider | undefined {
static require ( ) : ToastProvider { return this . ctx . get ( ) ; }
return this . ctx . getOr ( undefined ) as ToastProvider | undefined ;
}
static require ( ) : ToastProvider {
return this . ctx . get ( ) ;
}
static create ( opts : ToastProviderOpts ) {
static create ( opts : ToastProviderOpts ) {
return new ToastProvider ( opts ) ;
return new ToastProvider ( opts ) ;
}
}
readonly soma = Soma . get ( ) ;
readonly soma = Soma . get ( ) ;
readonly runtime : MorfoRuntime ;
private constructor ( opts : ToastProviderOpts ) {
private constructor ( opts : ToastProviderOpts ) {
super ( opts , { morfo : toastMorfo , part : 'provider' } , ToastProvider . ctx ) ;
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 {
get toaster ( ) : Toaster {
@ -53,6 +74,10 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
private constructor ( opts : ToastViewportOpts ) {
private constructor ( opts : ToastViewportOpts ) {
super ( opts , { morfo : toastMorfo , part : 'viewport' } ) ;
super ( opts , { morfo : toastMorfo , part : 'viewport' } ) ;
this . provider = ToastProvider . require ( ) ;
this . provider = ToastProvider . require ( ) ;
this . provider . runtime . registerPart ( 'viewport' , {
id : opts.id ,
ref : opts.ref
} ) ;
// Register global hotkey to focus viewport, with cleanup. Supports
// Register global hotkey to focus viewport, with cleanup. Supports
// composite hotkeys like `['AltLeft', 'F8']` by tracking currently
// composite hotkeys like `['AltLeft', 'F8']` by tracking currently
@ -67,7 +92,6 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
return hotkey . every ( ( k ) = > {
return hotkey . every ( ( k ) = > {
if ( e . code === k || e . key === k ) return true ;
if ( e . code === k || e . key === k ) return true ;
if ( pressed . has ( 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 === 'Alt' || k === 'AltLeft' || k === 'AltRight' ) && e . altKey ) return true ;
if ( ( k === 'Control' || k === 'ControlLeft' || k === 'ControlRight' ) && e . ctrlKey )
if ( ( k === 'Control' || k === 'ControlLeft' || k === 'ControlRight' ) && e . ctrlKey )
return true ;
return true ;
@ -117,12 +141,7 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
readonly props = $derived . by ( ( ) = >
readonly props = $derived . by ( ( ) = >
this . assertProps ( {
this . assertProps ( {
. . . this . baseProps ,
. . . this . provider . runtime . partProps ( 'viewport' ) ,
. . . this . resolveMorfoProps ( {
props : {
label : this.provider.opts.label.current
}
} ) ,
tabindex : - 1
tabindex : - 1
} as const )
} as const )
) ;
) ;
@ -139,8 +158,12 @@ interface ToastItemOpts
export class ToastItemProvider extends Provider < ToastItemOpts > {
export class ToastItemProvider extends Provider < ToastItemOpts > {
static readonly ctx = context < ToastItemProvider > ( 'ToastItem' ) ;
static readonly ctx = context < ToastItemProvider > ( 'ToastItem' ) ;
static get ( ) : ToastItemProvider | undefined { return this . ctx . getOr ( undefined ) as ToastItemProvider | undefined ; }
static get ( ) : ToastItemProvider | undefined {
static require ( ) : ToastItemProvider { return this . ctx . get ( ) ; }
return this . ctx . getOr ( undefined ) as ToastItemProvider | undefined ;
}
static require ( ) : ToastItemProvider {
return this . ctx . get ( ) ;
}
static create ( opts : ToastItemOpts ) {
static create ( opts : ToastItemOpts ) {
return new ToastItemProvider ( opts ) ;
return new ToastItemProvider ( opts ) ;
@ -149,6 +172,13 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
readonly provider : ToastProvider ;
readonly provider : ToastProvider ;
readonly presence : Presence ;
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
// tracked IDs for ARIA
titleId = state ( '' ) ;
titleId = state ( '' ) ;
descriptionId = state ( '' ) ;
descriptionId = state ( '' ) ;
@ -174,6 +204,39 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
super ( opts , { morfo : toastMorfo , part : 'item' } , ToastItemProvider . ctx ) ;
super ( opts , { morfo : toastMorfo , part : 'item' } , ToastItemProvider . ctx ) ;
this . provider = ToastProvider . require ( ) ;
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
// Presence for exit animation
this . presence = new Presence ( {
this . presence = new Presence ( {
open : readableActive ( ( ) = > this . isOpen ) ,
open : readableActive ( ( ) = > this . isOpen ) ,
@ -216,7 +279,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
this . clearTimer ( ) ;
this . clearTimer ( ) ;
this . startTime = Date . now ( ) ;
this . startTime = Date . now ( ) ;
this . timerId = setTimeout ( ( ) = > {
this . timerId = setTimeout ( ( ) = > {
this. provider . toaster . dismiss ( this . opts . toast . current . id ) ;
void this . runtime . trigger ( 'dismiss' ) ;
} , this . remaining ) ;
} , this . remaining ) ;
}
}
@ -337,7 +400,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
if ( isCorrectDirection && Math . abs ( delta ) >= this . swipeThreshold ) {
if ( isCorrectDirection && Math . abs ( delta ) >= this . swipeThreshold ) {
this . swipeState = 'end' ;
this . swipeState = 'end' ;
this. provider . toaster . dismiss ( this . opts . toast . current . id ) ;
void this . runtime . trigger ( 'dismiss' ) ;
} else {
} else {
this . swipeState = 'cancel' ;
this . swipeState = 'cancel' ;
this . swipeDeltaX = 0 ;
this . swipeDeltaX = 0 ;
@ -351,22 +414,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
readonly props = $derived . by ( ( ) = >
readonly props = $derived . by ( ( ) = >
this . assertProps ( {
this . assertProps ( {
. . . this . baseProps ,
. . . this . runtime . partProps ( 'item' ) ,
. . . 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
}
} ) ,
tabindex : 0 ,
tabindex : 0 ,
style : {
style : {
'--soma-toast-swipe-move-x' : ` ${ this . swipeDeltaX } px ` ,
'--soma-toast-swipe-move-x' : ` ${ this . swipeDeltaX } px ` ,
@ -403,15 +451,21 @@ export class ToastTitleProvider extends Provider<ToastTitleOpts> {
return new ToastTitleProvider ( opts ) ;
return new ToastTitleProvider ( opts ) ;
}
}
readonly toastItem : ToastItemProvider ;
private constructor ( opts : ToastTitleOpts ) {
private constructor ( opts : ToastTitleOpts ) {
super ( opts , { morfo : toastMorfo , part : 'title' } ) ;
super ( opts , { morfo : toastMorfo , part : 'title' } ) ;
const toastItem = ToastItemProvider . require ( ) ;
this . toastItem = ToastItemProvider . require ( ) ;
toastItem . titleId . current = opts . id . current ;
this . toastItem . titleId . current = opts . id . current ;
this . toastItem . runtime . registerPart ( 'title' , {
id : opts.id ,
ref : opts.ref
} ) ;
}
}
readonly props = $derived . by ( ( ) = >
readonly props = $derived . by ( ( ) = >
this . assertProps ( {
this . assertProps ( {
. . . this . baseProps
. . . this . toastItem. runtime . partProps ( 'title' )
} as const )
} as const )
) ;
) ;
}
}
@ -425,15 +479,21 @@ export class ToastDescriptionProvider extends Provider<ToastDescriptionOpts> {
return new ToastDescriptionProvider ( opts ) ;
return new ToastDescriptionProvider ( opts ) ;
}
}
readonly toastItem : ToastItemProvider ;
private constructor ( opts : ToastDescriptionOpts ) {
private constructor ( opts : ToastDescriptionOpts ) {
super ( opts , { morfo : toastMorfo , part : 'description' } ) ;
super ( opts , { morfo : toastMorfo , part : 'description' } ) ;
const toastItem = ToastItemProvider . require ( ) ;
this . toastItem = ToastItemProvider . require ( ) ;
toastItem . descriptionId . current = opts . id . current ;
this . toastItem . descriptionId . current = opts . id . current ;
this . toastItem . runtime . registerPart ( 'description' , {
id : opts.id ,
ref : opts.ref
} ) ;
}
}
readonly props = $derived . by ( ( ) = >
readonly props = $derived . by ( ( ) = >
this . assertProps ( {
this . assertProps ( {
. . . this . baseProps
. . . this . toastItem. runtime . partProps ( 'description' )
} as const )
} as const )
) ;
) ;
}
}
@ -452,18 +512,26 @@ export class ToastActionProvider extends Provider<ToastActionOpts> {
return new ToastActionProvider ( opts ) ;
return new ToastActionProvider ( opts ) ;
}
}
readonly toastItem : ToastItemProvider ;
private constructor ( opts : ToastActionOpts ) {
private constructor ( opts : ToastActionOpts ) {
super ( opts , { morfo : toastMorfo , part : 'action' } ) ;
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 ( ( ) = >
readonly props = $derived . by ( ( ) = >
this . assertProps ( {
this . assertProps ( {
. . . this . baseProps ,
. . . this . toastItem . runtime . partProps ( 'action' )
. . . this . resolveMorfoProps ( {
props : {
altText : this.opts.altText.current
}
} )
} as const )
} as const )
) ;
) ;
}
}
@ -482,19 +550,32 @@ export class ToastCloseProvider extends Provider<ToastCloseOpts> {
private constructor ( opts : ToastCloseOpts ) {
private constructor ( opts : ToastCloseOpts ) {
super ( opts , { morfo : toastMorfo , part : 'close' } ) ;
super ( opts , { morfo : toastMorfo , part : 'close' } ) ;
this . toastItem = ToastItemProvider . require ( ) ;
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 > ) = > {
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 ( ( ) = >
readonly props = $derived . by ( ( ) = >
this . assertProps ( {
this . assertProps ( {
. . . this . baseProps ,
. . . this . toastItem . runtime . partProps ( 'close' ) ,
. . . this . resolveMorfoProps ( {
translations : ( key ) = > this . toastItem . provider . soma ? . langs . ts ( key )
} ) ,
onclick : this.onclick
onclick : this.onclick
} as const )
} as const )
) ;
) ;
}
}
// `SwipeDirection` is re-exported by toaster.svelte.ts; nothing else here uses it.
export type { SwipeDirection } ;