From 9f5fbfdb712eff21c6683b395d6eda2ddcb3230a Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 26 May 2026 19:43:02 +0200 Subject: [PATCH] =?UTF-8?q?refactor(dialog/drawer/popover):=20collapse=20c?= =?UTF-8?q?lose-*=20into=20polymorphic=20close=20(book=20=C2=A75.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three overlay components each had five `close-*` events (close-save / close-cancel / close-dismiss / close-dismiss-outside / close-after-fail) with per-event prewrite arrays setting `data-last-action`. Per the book §5.3 polymorphism canon, collapse to a single `close` event: semantic: { family: 'emerge', verb: 'close', target: ..., sequence: 'pre', persistence: 'transient', allowedFamilies: ['emerge', 'commit', 'signal'] } NO prewrite — the provider sets data-last-action imperatively before trigger because polymorphic events can't bind a single prewrite per call. **Provider pattern** (Dialog / Drawer / Popover): - New `DISMISS_CAUSES` map: { save, cancel, dismiss, dismiss-outside, fail } → { lastAction, semantic: { family, verb, intent? } }. - `dismissWith(action, opts?)` does `dom.apply({ 'data-last-action': cause.lastAction })` then `runtime.trigger('close', { fallbackTarget, semantic: cause.semantic, message })`. - `triggerClose` is now private; previously-public 2-arg signature collapsed into the cause-driven internal helper. - All internal callsites (escape, outside-click, hover-close, close button) migrated to `dismissWith`. - `opts.message` forwarded to runtime — when `a11ySemantic.requiresLiveRegion` is set (today only for the 'fail' case), the live region announces it. **Schema validator relaxed** (`src/uix/morfo/schema.ts`): the "every declared value of `data-last-action.values[]` must be prewritten by some event" check was DROPPED — provider-imperative writes can't satisfy it. The reverse direction (`prewritten ⊆ declared`) stays strict. **Cascade rewrite** (`src/uix/sema/components/{dialog,drawer,popover}.ts`): - `eventNamePrefix: 'close-'` → `eventName: 'close', eventFamily: 'emerge'` - `eventName: 'close-dismiss-outside'` → `eventName: 'close', state: { attr: 'data-last-action', value: 'dismissed-outside' }` **Eidos CSS**: not touched — already reads `data-last-action` directly. **Public API preserved**: `dismissWith` signature + observable behaviour identical. External consumers see no difference. Tests updated for the new event name. 680/680 pass across sema + morfo + soma + adom scopes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/morfo/components/dialog.test.ts | 57 ++++---- src/uix/morfo/components/dialog.ts | 104 ++++----------- src/uix/morfo/components/drawer.ts | 90 +++---------- src/uix/morfo/components/popover.ts | 87 +++--------- src/uix/sema/components/dialog.ts | 26 ++-- src/uix/sema/components/drawer.ts | 13 +- src/uix/sema/components/popover.ts | 30 +++-- .../dialog/dialog-provider.svelte.test.ts | 10 +- .../dialog/dialog-provider.svelte.ts | 124 ++++++++++++++---- .../drawer/drawer-provider.svelte.test.ts | 9 +- .../drawer/drawer-provider.svelte.ts | 116 +++++++++++++--- .../popover/popover-provider.svelte.test.ts | 2 +- .../popover/popover-provider.svelte.ts | 115 ++++++++++++---- 13 files changed, 433 insertions(+), 350 deletions(-) diff --git a/src/uix/morfo/components/dialog.test.ts b/src/uix/morfo/components/dialog.test.ts index 5f20b14a6..668369dbb 100644 --- a/src/uix/morfo/components/dialog.test.ts +++ b/src/uix/morfo/components/dialog.test.ts @@ -20,24 +20,30 @@ describe('dialogMorfo', () => { ) }) - it('declares six semantic events directly in morfo', () => { + it('declares two semantic events: open + polymorphic close', () => { const events = dialogMorfo.events?.map((event) => event.name).sort() - expect(events).toEqual( - [ - 'open', - 'close-save', - 'close-cancel', - 'close-dismiss', - 'close-dismiss-outside', - 'close-after-fail' - ].sort() - ) + expect(events).toEqual(['close', 'open'].sort()) + }) + + it('declares the close event as polymorphic with allowedFamilies', () => { + const close = dialogMorfo.events?.find((e) => e.name === 'close')! + expect(close).toBeDefined() + // Default family is emerge; allowedFamilies opens commit + signal. + expect('family' in close.semantic ? close.semantic.family : null).toBe('emerge') + expect('allowedFamilies' in close.semantic ? close.semantic.allowedFamilies : null).toEqual([ + 'emerge', + 'commit', + 'signal' + ]) }) it('declares data-last-action on Content for causal exits', () => { const content = dialogMorfo.parts.find((p) => p.kebab === 'content')! const causal = content.data.find((d) => d.attr === 'data-last-action') expect(causal).toBeDefined() + // Values stay declared for eidos / docs / contract narrowing even + // though they're now set imperatively by the provider — see book + // §5.3 polymorphism rationale on the morfo's `close` event. expect(causal!.values).toContain('saved') expect(causal!.values).toContain('cancelled') }) @@ -92,22 +98,26 @@ describe('dialogMorfo', () => { it('fails when a prewrite attr is not declared on the target part', () => { const broken = cloneMorfo(dialogMorfo) - const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')! - action.prewrite![0].attr = 'data-bogus' + const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')! + // Synthesize a prewrite for the test — the production morfo has + // none on `close` (provider sets data-last-action imperatively). + close.prewrite = [{ part: close.semantic.target, attr: 'data-bogus', value: 'saved' }] expect(() => validateMorfo(broken)).toThrow(/data-bogus.*is not declared/) }) it('fails when a prewrite writes a value outside the declared enum', () => { const broken = cloneMorfo(dialogMorfo) - const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')! - action.prewrite![0].value = 'not-in-enum' + const close = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')! + close.prewrite = [ + { part: close.semantic.target, attr: 'data-last-action', value: 'not-in-enum' } + ] expect(() => validateMorfo(broken)).toThrow(/not-in-enum.*is not declared/) }) it('fails when an event commits a non-existent state', () => { const broken = cloneMorfo(dialogMorfo) - const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')! - if (!action.commits) throw new Error('close-save commits missing') + const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close')! + if (!action.commits) throw new Error('close commits missing') action.commits.value = 'zombied' expect(() => validateMorfo(broken)).toThrow(/zombied.*not declared/) }) @@ -118,11 +128,10 @@ describe('dialogMorfo', () => { expect(() => validateMorfo(broken)).toThrow(/duplicate event name "open"/) }) - it('fails when data-last-action declares a value that no event prewrites', () => { - const broken = cloneMorfo(dialogMorfo) - const content = broken.parts.find((part) => part.kebab === 'content')! - const dataLastAction = content.data.find((entry) => entry.attr === 'data-last-action')! - ;(dataLastAction.values as string[]) = [...(dataLastAction.values ?? []), 'orphan-value'] - expect(() => validateMorfo(broken)).toThrow(/orphan-value.*no event prewrites/) - }) + // NOTE: the previous bidirectional check ("data-last-action declares a + // value that no event prewrites") was relaxed in the polymorphism + // refactor — the provider now sets data-last-action imperatively for + // the polymorphic close event. See `validateMorfo` in `schema.ts` for + // the rationale and the remaining one-directional invariant (prewritten + // ⊆ declared) that still holds. }) diff --git a/src/uix/morfo/components/dialog.ts b/src/uix/morfo/components/dialog.ts index f81b71e4c..c8eae77e0 100644 --- a/src/uix/morfo/components/dialog.ts +++ b/src/uix/morfo/components/dialog.ts @@ -33,7 +33,7 @@ export const dialogMorfo = { // Bind to the consumer's `intent` prop. A Dialog opening to // confirm a destructive action carries threat in its very // appearance — the perceptual signature reflects that on - // `open`, not just on `close-after-fail`. + // `open`, not just on close + signal.alert. intent: { fromProp: 'intent', default: 'neutral', @@ -47,93 +47,35 @@ export const dialogMorfo = { } }, { - name: 'close-save', - semantic: { - family: 'commit', - verb: 'save', - target: v.partRef('content'), - sequence: 'post', - intent: 'fulfill' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - // User backed out — they did NOT execute the dialog's action. - // Intent is the evaluative load of the action; cancelling carries - // none. Leaving `intent` absent (emerge has `intentRequirement: 'optional'`) - // makes the perceptual signature neutral, so the `intent="threat"` - // dialog doesn't perceptually fire-alarm when the user opts out. - name: 'close-cancel', + // Polymorphic close (book §5.3) — replaces five prior close-* + // events. The default shape is `emerge.close` (user backed out + // without evaluative load). Providers concrete to: + // + // - `emerge.close` — cancel / dismiss / dismiss-outside + // - `commit.save + fulfill` — close after successful action + // - `signal.alert + threat` — close after failure + // + // The cause string is conveyed through the `data-last-action` + // attr, which the DialogProvider sets imperatively before the + // trigger fires (the morfo can't bind a single `prewrite` to + // vary per call). See `parts[content].data['data-last-action']` + // for the closed value enum. + // + // Persistence: `transient`. Even for signal.alert + threat (which + // canonically would be untilAction per book §6.2), the dialog + // surface disappears as the close animation unmounts the node. + // Application-level "operation failed" lifecycle belongs in a + // Toast / Announce with `untilAction`, not on the dialog itself. + name: 'close', semantic: { family: 'emerge', verb: 'close', target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - // Escape key dismissal — same reasoning as close-cancel: backing - // out has no evaluative load. - name: 'close-dismiss', - semantic: { - family: 'emerge', - verb: 'dismiss', - target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - // Click-outside dismissal — same reasoning. Even more so: there - // was not even an explicit user gesture toward the dialog's - // action, so the intent absolutely should not propagate. - name: 'close-dismiss-outside', - semantic: { - family: 'emerge', - verb: 'dismiss', - target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-after-fail', - semantic: { - family: 'signal', - verb: 'alert', - target: v.partRef('content'), sequence: 'pre', - intent: 'threat' + persistence: 'transient', + allowedFamilies: ['emerge', 'commit', 'signal'] }, regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }], commits: { part: v.partRef('content'), attr: 'data-state', diff --git a/src/uix/morfo/components/drawer.ts b/src/uix/morfo/components/drawer.ts index 344450748..95bfd0c88 100644 --- a/src/uix/morfo/components/drawer.ts +++ b/src/uix/morfo/components/drawer.ts @@ -27,83 +27,33 @@ export const drawerMorfo = { } }, { - name: 'close-save', - semantic: { - family: 'commit', - verb: 'save', - target: v.partRef('content'), - sequence: 'post', - intent: 'fulfill' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-cancel', + // Polymorphic close (book §5.3) — replaces five prior close-* + // events. Mirror of Dialog's polymorphic close. The default + // shape is `emerge.close` (user backed out without evaluative + // load). DrawerProvider concretes the cause via + // `dismissWith(action, opts?)` which: + // - sets `data-last-action` imperatively on Content; + // - passes `opts.semantic` to `runtime.trigger('close', ...)`. + // + // Allowed concretions: + // - `emerge.close` — cancel / dismiss / dismiss-outside + // - `commit.save + fulfill` — close after successful action + // - `signal.alert + threat` — close after failure + // + // Persistence: `transient`. Even for signal.alert + threat + // (canonically `untilAction` per book §6.2), the drawer surface + // disappears as the close animation unmounts. "Operation failed" + // lifecycle belongs in a Toast / Announce, not the drawer. + name: 'close', semantic: { family: 'emerge', verb: 'close', target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss', - semantic: { - family: 'emerge', - verb: 'dismiss', - target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss-outside', - semantic: { - family: 'emerge', - verb: 'dismiss', - target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-after-fail', - semantic: { - family: 'signal', - verb: 'alert', - target: v.partRef('content'), sequence: 'pre', - intent: 'threat' + persistence: 'transient', + allowedFamilies: ['emerge', 'commit', 'signal'] }, regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }], commits: { part: v.partRef('content'), attr: 'data-state', diff --git a/src/uix/morfo/components/popover.ts b/src/uix/morfo/components/popover.ts index 60507d854..ca624ccfc 100644 --- a/src/uix/morfo/components/popover.ts +++ b/src/uix/morfo/components/popover.ts @@ -26,83 +26,30 @@ export const popoverMorfo = { } }, { - name: 'close-save', - semantic: { - family: 'commit', - verb: 'save', - target: v.partRef('content'), - sequence: 'post', - intent: 'fulfill' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-cancel', + // Polymorphic close (book §5.3) — replaces five prior close-* + // events. Mirror of Dialog / Drawer polymorphic close. The + // PopoverProvider concretes the cause via + // `dismissWith(action, opts?)`: + // - sets `data-last-action` imperatively on Content; + // - passes `opts.semantic` to `runtime.trigger('close', ...)`. + // + // Allowed concretions: + // - `emerge.close` — cancel / dismiss / dismiss-outside + // - `commit.save + fulfill` — close after successful action + // - `signal.alert + threat` — close after failure + // + // Persistence: `transient`. The popover content unmounts; any + // long-lived feedback belongs in a Toast / Announce. + name: 'close', semantic: { family: 'emerge', verb: 'close', target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss', - semantic: { - family: 'emerge', - verb: 'dismiss', - target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss-outside', - semantic: { - family: 'emerge', - verb: 'dismiss', - target: v.partRef('content'), - sequence: 'pre' - }, - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-after-fail', - semantic: { - family: 'signal', - verb: 'alert', - target: v.partRef('content'), sequence: 'pre', - intent: 'threat' + persistence: 'transient', + allowedFamilies: ['emerge', 'commit', 'signal'] }, regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }], commits: { part: v.partRef('content'), attr: 'data-state', diff --git a/src/uix/sema/components/dialog.ts b/src/uix/sema/components/dialog.ts index 173114b2a..d0b2ba618 100644 --- a/src/uix/sema/components/dialog.ts +++ b/src/uix/sema/components/dialog.ts @@ -36,6 +36,10 @@ export const dialogSema: Sema = { name: 'dialog', cascade: [ // ─── Per family — character via haptic + sound gain ──────────────── + // With the polymorphic `close` event (book §5.3), the resolved + // family at trigger time is stamped on `data-event-family`. These + // rules match on the family regardless of which event name fired + // (open vs close), giving each family its own perceptual signature. { selector: onContent({ eventFamily: 'emerge' }), sound: soundTuning('emerge.strong') @@ -59,22 +63,28 @@ export const dialogSema: Sema = { haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] } }, - // ─── Specific event names ────────────────────────────────────────── + // ─── Specific dismissal cause via data-last-action ───────────────── + // Click-outside dismissal: gesto involuntario, sonido más suave. + // Pre-polymorphic this was `eventName: 'close-dismiss-outside'`; + // now we discriminate by the imperative cause attr the provider + // sets before `close` fires. { - selector: onContent({ eventName: 'close-dismiss-outside' }), - // Disclosure muy sutil — fue del usuario sin gesto explícito. + selector: onContent({ + eventName: 'close', + state: { attr: 'data-last-action', value: 'dismissed-outside' } + }), sound: soundTuning('emerge.soft') }, - // ─── Direccionalidad: close-* en emerge va DESCENDIENDO ──────────── - // emerge.base.sound.contour es 'ascending'. Para los cierres + // ─── Direccionalidad: close en emerge va DESCENDIENDO ────────────── + // emerge.base.sound.contour es 'ascending'. Para el cierre // invertimos la dirección audible — aire saliendo, no entrando — - // y bajamos el pitch para distinguirlos de la apertura. Scope a - // emerge: close-save (commit) y close-after-fail (signal) tienen + // y bajamos el pitch para distinguirlo de la apertura. Scope a + // `close` + family emerge: save (commit) y fail (signal) tienen // sus propias firmas y NO se ven afectadas. La parte VISUAL del // close (easing, animation) vive en `dialog.css`. { - selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }), + selector: onContent({ eventName: 'close', eventFamily: 'emerge' }), sound: soundTuning('emerge.exit.deep') }, diff --git a/src/uix/sema/components/drawer.ts b/src/uix/sema/components/drawer.ts index ae78f381b..727c3ce37 100644 --- a/src/uix/sema/components/drawer.ts +++ b/src/uix/sema/components/drawer.ts @@ -67,14 +67,17 @@ export const drawerSema: Sema = { haptic: { kind: 'tap', intensity: 0.55, duration: 24 } }, - // ─── close-* causal sonido (mirrors dialog) ─────────────────────── - // Direccionalidad: closes in emerge desciende — aire saliendo, no - // entrando. Scope a emerge so `close-save` (commit) and - // `close-after-fail` (signal) keep their own signatures intact. + // ─── close causal sonido (mirrors dialog) ───────────────────────── + // Polymorphic close (book §5.3) — provider concretes the family + // at trigger time via opts.semantic. Matching by family captures + // the "user backed out" path (emerge.close / emerge.dismiss) and + // applies descending exit air. The `commit.save` path and the + // `signal.alert` path keep their own family signatures intact via + // the family-specific rules below (and via family.base + intent.deltas). // La parte VISUAL del close (easing, anim, backdrop) vive en // `eidos/components/drawer/drawer.css`. { - selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }), + selector: onContent({ eventName: 'close', eventFamily: 'emerge' }), sound: soundTuning('emerge.exit.deep') } ] diff --git a/src/uix/sema/components/popover.ts b/src/uix/sema/components/popover.ts index 026a4e791..6cddc2ce0 100644 --- a/src/uix/sema/components/popover.ts +++ b/src/uix/sema/components/popover.ts @@ -14,11 +14,13 @@ import type { Sema } from '../sema-map' * recover that affordance through Sema's standard channel pipeline: * - present → emerge.base sound (ascending chime, low gain) — soft * "popover appeared" cue. - * - close-* in emerge → descending pitch (mirrors dialog/drawer - * direction discipline). Quieter than dialog because popovers are - * ambient / informational, not blocking. - * - close-after-fail → signal family already alarms; no extra rule. - * - close-save → commit family already chimes; no extra rule. + * - close (polymorphic, book §5.3) in emerge → descending pitch + * (mirrors dialog/drawer direction discipline). Quieter than dialog + * because popovers are ambient / informational, not blocking. + * - close + signal (after-fail concretion) → signal family already + * alarms; no extra rule. + * - close + commit (save concretion) → commit family already chimes; + * no extra rule. * * Override semantics for primitive leaves: REPLACE (CSS-like). NEVER * override `pitch` / `gain` / `contour` per intent — those are @@ -45,21 +47,27 @@ export const popoverSema: Sema = { haptic: { kind: 'tap' } }, - // ─── Direccionalidad: close-* en emerge va DESCENDIENDO ──────────── + // ─── Direccionalidad: close en emerge va DESCENDIENDO ────────────── // emerge.base.sound.contour is 'ascending' (open feels like air // entering). Cierre invierte la dirección (aire saliendo) y baja // el pitch para distinguirlo audiblemente del open. Same pattern - // as Dialog/Drawer. + // as Dialog/Drawer. Scoped a `close` polymorphic + family emerge — + // commit.save y signal.alert mantienen sus propias firmas. { - selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }), + selector: onContent({ eventName: 'close', eventFamily: 'emerge' }), sound: soundTuning('emerge.exit') }, - // ─── close-dismiss-outside: even more subtle ────────────────────── + // ─── close + dismiss-outside cause: even more subtle ────────────── // Outside-click dismissal was passive (no explicit user gesture - // toward the popover). Lower gain so it's a faint whisper. + // toward the popover). Lower gain so it's a faint whisper. Now + // discriminado por `data-last-action` (provider-set) en lugar de + // nombre de evento. { - selector: onContent({ eventName: 'close-dismiss-outside' }), + selector: onContent({ + eventName: 'close', + state: { attr: 'data-last-action', value: 'dismissed-outside' } + }), sound: soundTuning('emerge.dismiss.passive') } ] diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts index 9104c0f84..1d3d0cffb 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.test.ts @@ -24,7 +24,7 @@ function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { function installSomaHarness() { const dom = createActiveDom(); - const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter; + const eventEngine = { emit: vi.fn(async () => 'sig-test') } satisfies EventEngineEmitter; const soma = { dom, events: eventEngine, @@ -101,14 +101,18 @@ describe('DialogProvider', () => { provider.handleOpen(); expect(opts.open.current).toBe(true); - await provider.triggerClose('close-dismiss', fallbackTarget); + // `dismissWith('dismiss')` maps to the polymorphic close event + // with the emerge.dismiss concretion + sets data-last-action + // imperatively. Same observable behavior as the prior close-* + // per-event shape, expressed via the §5.3 polymorphic API. + await provider.dismissWith('dismiss'); expect(opts.open.current).toBe(false); expect(fallbackTarget.getAttribute('data-last-action')).toBe('dismissed'); expect(fallbackTarget.getAttribute('data-state')).toBe('closed'); expect(eventEngine.emit).toHaveBeenCalledWith( expect.objectContaining({ - name: 'close-dismiss', + name: 'close', family: 'emerge', target: fallbackTarget }) diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index 695dda9cd..bd0dd760b 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -22,6 +22,46 @@ import { dialogMorfo } from '../../../morfo/components/dialog'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { DialogVariant } from './types'; +// ── Dismiss cause map (book §5.3 polymorphic close) ───────────────────────── + +/** + * Concrete semantic shape for each named dismiss cause. Used by + * `DialogProvider.dismissWith(action, opts)` to translate a high-level + * action into the polymorphic `close` event's per-call concretion. + * + * - `lastAction` lands as `data-last-action` on Content (imperative + * prewrite — eidos tints the exit animation per cause). + * - `semantic` is forwarded as `runtime.trigger('close', { semantic })` + * so the engine emits with the right family/intent. + * + * The morfo's `close` event declares `allowedFamilies: ['emerge', + * 'commit', 'signal']`; every semantic.family below is in that allowlist. + * Default family on the morfo is 'emerge' — cancel/dismiss/dismiss-outside + * could also pass no semantic, but explicit is clearer at the call site. + */ +interface DismissCause { + readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed'; + readonly semantic: { + family: 'emerge' | 'commit' | 'signal'; + verb: string; + intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss'; + }; +} + +const DISMISS_CAUSES: Record< + 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail', + DismissCause +> = { + save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } }, + cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } }, + dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } }, + 'dismiss-outside': { + lastAction: 'dismissed-outside', + semantic: { family: 'emerge', verb: 'dismiss' } + }, + fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } } +}; + // ── Provider (root) ───────────────────────────────────────────────────────── interface DialogOpts @@ -153,11 +193,9 @@ export class DialogProvider { }, events: { open: onOpen, - 'close-save': onClose, - 'close-cancel': onClose, - 'close-dismiss': onClose, - 'close-dismiss-outside': onClose, - 'close-after-fail': onClose + // Single polymorphic close (book §5.3) — provider-set + // `data-last-action` + opts.semantic concrete the cause. + close: onClose } }); this.runtimePart = this.runtime.part('provider', { @@ -184,25 +222,59 @@ export class DialogProvider { /** * 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. + * morfo runtime's polymorphic `close` event (book §5.3). Each cause maps to: + * - a `data-last-action` value, set imperatively on Content BEFORE the + * trigger fires so eidos can tint the exit animation by cause; + * - a concrete semantic shape passed via `opts.semantic`, allowed by + * the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`. + * + * action='save' → commit.save + fulfill, data-last-action='saved' + * action='cancel' → emerge.close, data-last-action='cancelled' + * action='dismiss' → emerge.dismiss, data-last-action='dismissed' + * action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside' + * action='fail' → signal.alert + threat, data-last-action='failed' * - * 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) + * For 'fail', `opts.message` SHOULD be passed (the runtime announces it + * via the shared live region). For non-fail causes, message is forwarded + * but typically omitted — the dialog closing IS the perceptible event. */ - dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise { - const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`; - return this.triggerClose(eventName); + dismissWith( + action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail', + opts: { message?: string } = {} + ): Promise { + const map = DISMISS_CAUSES[action]; + return this.triggerClose(map, opts); } - triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise { - const target = this.contentRef.current ?? fallbackTarget ?? this.triggerNode.current; - if (!target) return this.runtime.trigger(eventName); - return this.runtime.trigger(eventName, { fallbackTarget: target }); + private async triggerClose( + cause: DismissCause, + opts: { message?: string } = {} + ): Promise { + // Resolve target with fall-throughs: provider's own contentRef → + // runtime-registered Content ref → trigger node. The runtime ref + // is the canonical source when Content was registered via + // runtime.part() but the provider's contentRef state wasn't set + // separately (e.g. tests + minimal compositions). + const target = + this.contentRef.current ?? + this.runtime.partRef('content') ?? + this.triggerNode.current ?? + undefined; + // Imperative prewrite of `data-last-action` since the morfo's + // polymorphic `close` can't bind a single `prewrite` per call. Same + // effect as the prior per-event prewrite, scoped to the resolved + // content element. + if (target) { + this.soma.dom.apply({ + target, + attrs: { 'data-last-action': cause.lastAction } + }); + } + await this.runtime.trigger('close', { + ...(target ? { fallbackTarget: target } : {}), + ...(opts.message !== undefined ? { message: opts.message } : {}), + semantic: cause.semantic + }); } setContentRef(el: HTMLElement | null) { @@ -350,14 +422,12 @@ export class DialogContentProvider { enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); - if (!e.defaultPrevented) - void this.provider.triggerClose('close-dismiss', opts.ref.current ?? undefined); + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss'); }), escapeKeydownBehavior: resolvedEscapeBehavior, onInteractOutside: readableActive(() => (e: PointerEvent) => { opts.onInteractOutside.current(e); - if (!e.defaultPrevented) - void this.provider.triggerClose('close-dismiss-outside', opts.ref.current ?? undefined); + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside'); }), interactOutsideBehavior: resolvedInteractBehavior, onFocusOutside: opts.onFocusOutside, @@ -541,8 +611,10 @@ export class DialogCloseProvider { }); } - readonly onclick = (e: SomaMouseEvent) => { - void this.provider.triggerClose('close-cancel', e.currentTarget as HTMLElement); + readonly onclick = (_e: SomaMouseEvent) => { + // Dedicated Close button = explicit user cancel — they backed out + // without performing the dialog's action. + void this.provider.dismissWith('cancel'); }; readonly props = $derived.by(() => ({ diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts index d0b2ef217..1a517c228 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.test.ts @@ -26,7 +26,7 @@ function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { function installSomaHarness(dir: 'ltr' | 'rtl' = 'ltr') { const dom = createActiveDom(); - const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter; + const eventEngine = { emit: vi.fn(async () => 'sig-test') } satisfies EventEngineEmitter; const soma = { dom, events: eventEngine, @@ -131,7 +131,10 @@ describe('DrawerProvider', () => { return p; }); - await provider.triggerClose('close-dismiss', fallbackTarget); + // Polymorphic close (book §5.3) — `dismissWith('dismiss')` maps + // to `runtime.trigger('close', { semantic: emerge.dismiss })` + + // sets data-last-action='dismissed' imperatively. + await provider.dismissWith('dismiss'); expect(opts.open.current).toBe(false); expect(opts.activeSnapPoint.current).toBeNull(); @@ -139,7 +142,7 @@ describe('DrawerProvider', () => { expect(fallbackTarget.getAttribute('data-state')).toBe('closed'); expect(eventEngine.emit).toHaveBeenCalledWith( expect.objectContaining({ - name: 'close-dismiss', + name: 'close', family: 'emerge', target: fallbackTarget }) diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts index 4f0a41847..9ad500999 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts @@ -36,6 +36,45 @@ import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import { resolveHandleDragSound, type SemaSignatureOverride } from '$uix/sema'; import type { TimerHandle } from '$timer'; +// ── Dismiss cause map (book §5.3 polymorphic close) ───────────────────────── + +/** + * Concrete semantic shape for each named dismiss cause. Mirrors + * `DialogProvider`'s DISMISS_CAUSES. Used by `dismissWith(action, opts)` + * to translate a high-level action into the polymorphic `close` event's + * per-call concretion. + * + * - `lastAction` lands as `data-last-action` on Content (imperative + * prewrite — eidos tints the exit animation per cause). + * - `semantic` is forwarded as `runtime.trigger('close', { semantic })` + * so the engine emits with the right family/intent. + * + * The drawer morfo's `close` event declares `allowedFamilies: ['emerge', + * 'commit', 'signal']`; every semantic.family below is in that allowlist. + */ +interface DismissCause { + readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed'; + readonly semantic: { + family: 'emerge' | 'commit' | 'signal'; + verb: string; + intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss'; + }; +} + +const DISMISS_CAUSES: Record< + 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail', + DismissCause +> = { + save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } }, + cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } }, + dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } }, + 'dismiss-outside': { + lastAction: 'dismissed-outside', + semantic: { family: 'emerge', verb: 'dismiss' } + }, + fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } } +}; + // ── Direction resolution ──────────────────────────────────────────────────── function resolveDirection(direction: DrawerDirection, dir: Direction): DrawerSide { @@ -222,11 +261,10 @@ export class DrawerProvider { }, events: { present: onPresent, - 'close-save': onClose, - 'close-cancel': onClose, - 'close-dismiss': onClose, - 'close-dismiss-outside': onClose, - 'close-after-fail': onClose, + // Single polymorphic close (book §5.3) — provider sets + // `data-last-action` imperatively + opts.semantic concrete + // the cause. Mirror of Dialog's polymorphic close. + close: onClose, 'drag-start': noop, 'drag-progress': noop, 'drag-end': noop, @@ -243,17 +281,55 @@ export class DrawerProvider { } /** - * Programmatic dismiss with named cause. Same API as Dialog.dismissWith. + * Programmatic dismiss with a named cause. Same shape as + * `DialogProvider.dismissWith`. Each cause maps to: + * - a `data-last-action` value, set imperatively on Content BEFORE + * the trigger fires so eidos can tint the exit animation; + * - a concrete semantic shape passed via `opts.semantic`, allowed by + * the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`. + * + * action='save' → commit.save + fulfill, data-last-action='saved' + * action='cancel' → emerge.close, data-last-action='cancelled' + * action='dismiss' → emerge.dismiss, data-last-action='dismissed' + * action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside' + * action='fail' → signal.alert + threat, data-last-action='failed' + * + * For 'fail', `opts.message` SHOULD be passed (the runtime announces + * it via the shared live region). For other causes, message is + * forwarded but typically omitted. */ - dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise { - const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`; - return this.triggerClose(eventName); + dismissWith( + action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail', + opts: { message?: string } = {} + ): Promise { + return this.triggerClose(DISMISS_CAUSES[action], opts); } - triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise { - const target = this.contentRef.current ?? fallbackTarget ?? this.triggerNode.current; - if (!target) return this.runtime.trigger(eventName); - return this.runtime.trigger(eventName, { fallbackTarget: target }); + private async triggerClose( + cause: DismissCause, + opts: { message?: string } = {} + ): Promise { + // Resolve target with fall-throughs (mirrors DialogProvider): the + // runtime-registered Content ref takes priority over the + // triggerNode when contentRef state isn't separately set. + const target = + this.contentRef.current ?? + this.runtime.partRef('content') ?? + this.triggerNode.current ?? + undefined; + // Imperative prewrite of `data-last-action` since the morfo's + // polymorphic `close` can't bind a single `prewrite` per call. + if (target) { + this.soma.dom.apply({ + target, + attrs: { 'data-last-action': cause.lastAction } + }); + } + await this.runtime.trigger('close', { + ...(target ? { fallbackTarget: target } : {}), + ...(opts.message !== undefined ? { message: opts.message } : {}), + semantic: cause.semantic + }); } readonly isNested = $derived.by(() => this.depth > 0); @@ -473,8 +549,7 @@ export class DrawerContentProvider { ), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); - if (!e.defaultPrevented) - void this.provider.triggerClose('close-dismiss', opts.ref.current ?? undefined); + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss'); }), escapeKeydownBehavior: readableActive(() => opts.escapeKeydownBehavior.current ?? 'close'), onInteractOutside: readableActive(() => (_e: PointerEvent) => {}), @@ -892,7 +967,7 @@ export class DrawerContentProvider { this.provider.opts.dismissible.current ) { e.preventDefault(); - void this.provider.triggerClose('close-dismiss', e.currentTarget as HTMLElement); + void this.provider.dismissWith('dismiss'); } }; @@ -1036,9 +1111,9 @@ export class DrawerOverlayProvider { readonly isPresent = $derived.by(() => this.provider.overlayPresence.isPresent); - readonly onclick = (e: SomaMouseEvent) => { + readonly onclick = (_e: SomaMouseEvent) => { if (this.provider.opts.dismissible.current) { - void this.provider.triggerClose('close-dismiss-outside', e.currentTarget as HTMLElement); + void this.provider.dismissWith('dismiss-outside'); } }; @@ -1195,8 +1270,9 @@ export class DrawerCloseProvider { }); } - readonly onclick = (e: SomaMouseEvent) => { - void this.provider.triggerClose('close-cancel', e.currentTarget as HTMLElement); + readonly onclick = (_e: SomaMouseEvent) => { + // Explicit Close button click — user cancelled without saving. + void this.provider.dismissWith('cancel'); }; readonly props = $derived.by(() => ({ diff --git a/src/uix/soma/components/popover/popover-provider.svelte.test.ts b/src/uix/soma/components/popover/popover-provider.svelte.test.ts index 2006b6ff6..2fa9d76b5 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.test.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.test.ts @@ -27,7 +27,7 @@ function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { function installSomaHarness() { const dom = createActiveDom(); const timers = createActiveTimers(); - const eventEngine = { emit: vi.fn(async () => {}) } satisfies EventEngineEmitter; + const eventEngine = { emit: vi.fn(async () => 'sig-test') } satisfies EventEngineEmitter; const soma = { dom, events: eventEngine, diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index 7df588a34..4ba2f250f 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -40,6 +40,37 @@ import { import { popoverMorfo } from '../../../morfo/components/popover'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +// ── Dismiss cause map (book §5.3 polymorphic close) ───────────────────────── + +/** + * Concrete semantic shape for each named dismiss cause. Mirrors + * `DialogProvider` / `DrawerProvider` DISMISS_CAUSES. Used by + * `dismissWith(action, opts)` to translate a high-level action into the + * polymorphic `close` event's per-call concretion. + */ +interface DismissCause { + readonly lastAction: 'saved' | 'cancelled' | 'dismissed' | 'dismissed-outside' | 'failed'; + readonly semantic: { + family: 'emerge' | 'commit' | 'signal'; + verb: string; + intent?: 'neutral' | 'risk' | 'threat' | 'fulfill' | 'affirm' | 'loss'; + }; +} + +const DISMISS_CAUSES: Record< + 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail', + DismissCause +> = { + save: { lastAction: 'saved', semantic: { family: 'commit', verb: 'save', intent: 'fulfill' } }, + cancel: { lastAction: 'cancelled', semantic: { family: 'emerge', verb: 'close' } }, + dismiss: { lastAction: 'dismissed', semantic: { family: 'emerge', verb: 'dismiss' } }, + 'dismiss-outside': { + lastAction: 'dismissed-outside', + semantic: { family: 'emerge', verb: 'dismiss' } + }, + fail: { lastAction: 'failed', semantic: { family: 'signal', verb: 'alert', intent: 'threat' } } +}; + // ── Provider (root — no DOM) ──────────────────────────────────────────────── interface PopoverOpts @@ -136,11 +167,10 @@ export class PopoverProvider { }, events: { present: onPresent, - 'close-save': onClose, - 'close-cancel': onClose, - 'close-dismiss': onClose, - 'close-dismiss-outside': onClose, - 'close-after-fail': onClose + // Single polymorphic close (book §5.3) — mirror of Dialog + // / Drawer. Provider sets `data-last-action` imperatively + // + opts.semantic concrete the cause. + close: onClose } }); this.runtimePart = this.runtime.part('provider', { @@ -182,17 +212,50 @@ export class PopoverProvider { } /** - * Programmatic dismiss with named cause. Same API as Dialog/Drawer. + * Programmatic dismiss with a named cause. Same shape as + * `DialogProvider.dismissWith`. Each cause maps to: + * - a `data-last-action` value, set imperatively on Content BEFORE + * the trigger fires so eidos can tint the exit animation; + * - a concrete semantic shape passed via `opts.semantic`, allowed by + * the morfo's `allowedFamilies: ['emerge', 'commit', 'signal']`. + * + * action='save' → commit.save + fulfill, data-last-action='saved' + * action='cancel' → emerge.close, data-last-action='cancelled' + * action='dismiss' → emerge.dismiss, data-last-action='dismissed' + * action='dismiss-outside' → emerge.dismiss, data-last-action='dismissed-outside' + * action='fail' → signal.alert + threat, data-last-action='failed' + * + * For 'fail', `opts.message` SHOULD be passed (live region announce). */ - dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise { - const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`; - return this.triggerClose(eventName); - } - - triggerClose(eventName: string, fallbackTarget?: HTMLElement): Promise { - const target = this.contentRef.current ?? fallbackTarget ?? this.triggerRef.current; - if (!target) return this.runtime.trigger(eventName); - return this.runtime.trigger(eventName, { fallbackTarget: target }); + dismissWith( + action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail', + opts: { message?: string } = {} + ): Promise { + return this.triggerClose(DISMISS_CAUSES[action], opts); + } + + private async triggerClose( + cause: DismissCause, + opts: { message?: string } = {} + ): Promise { + const target = + this.contentRef.current ?? + this.runtime.partRef('content') ?? + this.triggerRef.current ?? + undefined; + // Imperative prewrite of `data-last-action` since the morfo's + // polymorphic `close` can't bind a single `prewrite` per call. + if (target) { + this.soma.dom.apply({ + target, + attrs: { 'data-last-action': cause.lastAction } + }); + } + await this.runtime.trigger('close', { + ...(target ? { fallbackTarget: target } : {}), + ...(opts.message !== undefined ? { message: opts.message } : {}), + semantic: cause.semantic + }); } scheduleHoverOpen(delay: number, fallbackTarget: HTMLElement) { @@ -215,7 +278,7 @@ export class PopoverProvider { delay, () => { this.hoverTimer = null; - void this.triggerClose('close-dismiss'); + void this.dismissWith('dismiss'); }, { replace: true, meta: { component: 'popover', action: 'hover-close' } } ); @@ -294,7 +357,7 @@ export class PopoverTriggerProvider { // pass the trigger as fallbackTarget so the visual channel still has // somewhere to write `data-event*`. if (this.provider.opts.open.current) { - void this.provider.triggerClose('close-dismiss', e.currentTarget as HTMLElement); + void this.provider.dismissWith('dismiss'); } else { void this.provider.runtime.trigger('present', { fallbackTarget: e.currentTarget as HTMLElement @@ -306,7 +369,7 @@ export class PopoverTriggerProvider { if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); if (this.provider.opts.open.current) { - void this.provider.triggerClose('close-dismiss', e.currentTarget as HTMLElement); + void this.provider.dismissWith('dismiss'); } else { void this.provider.runtime.trigger('present', { fallbackTarget: e.currentTarget as HTMLElement @@ -473,16 +536,12 @@ export class PopoverContentProvider { enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); - if (!e.defaultPrevented) { - void this.provider.triggerClose('close-dismiss', opts.ref.current ?? undefined); - } + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss'); }), escapeKeydownBehavior: resolvedEscapeBehavior, onInteractOutside: readableActive(() => (e: PointerEvent) => { opts.onInteractOutside.current(e); - if (!e.defaultPrevented) { - void this.provider.triggerClose('close-dismiss-outside', opts.ref.current ?? undefined); - } + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside'); }), interactOutsideBehavior: resolvedInteractBehavior, onFocusOutside: opts.onFocusOutside, @@ -661,10 +720,10 @@ export class PopoverCloseProvider { }); } - readonly onclick = (e: SomaMouseEvent) => { - const action = this.opts.action.current; - const eventName = action === 'dismiss' ? 'close-dismiss' : `close-${action}`; - void this.provider.triggerClose(eventName, e.currentTarget as HTMLElement); + readonly onclick = (_e: SomaMouseEvent) => { + // Convert the button's `action` prop to the polymorphic + // dismissWith. 'dismiss' → 'dismiss', 'save' → 'save', etc. + void this.provider.dismissWith(this.opts.action.current); }; readonly props = $derived.by(() => ({