popover: extend morfo with events + soft-migrate provider + exit tinting

Same harmonization pattern as Drawer (which mirrored Dialog):

Morfo (`morfo/components/popover.ts`):
  - Added 6 events: present + 5 close-* (save/cancel/dismiss/
    dismiss-outside/after-fail). present + close-cancel/dismiss/
    dismiss-outside use family='emerge'; close-save uses commit/fulfill;
    close-after-fail uses alert/threat.
  - Added data-last-action to Content's data array.
  - scope: ['soma', 'sema'].

Eidos (`eidos/components/popover.css`):
  - Tint rules: combine [data-state='closed'] + [data-side] +
    [data-last-action] to color the trigger-facing edge (opposite to
    the popover side) during exit. saved → success, failed → danger,
    cancelled/dismissed/dismissed-outside → neutral.

Soma (`soma/components/popover/popover-provider.svelte.ts`):
  - PopoverProvider injects MorfoRuntime + dismissWith(action).
  - Registers content part on mount.
  - Wires close paths through runtime.trigger():
      Close.onclick      → 'close-cancel'
      Escape (Dismissal) → 'close-dismiss'
      InteractOutside    → 'close-dismiss-outside'
  - PopoverProvider class re-exported from $soma/components/popover.

Demo (`test/eidos/popover/+page.svelte`):
  - Tinted-exit section with Save/Cancel/Fail buttons via local
    _dismiss-button.svelte (mirror of Dialog/Drawer pattern).

Verification: smoke 68/68, morfo-check 66/66.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
morfo-runtime
dev 5 months ago
parent 106b972075
commit b5453af9c4

@ -1,6 +1,7 @@
<script lang="ts">
import * as Popover from '$soma/components/popover';
import Portal from '$soma/components/internal/portal.svelte';
import DismissButton from './_dismiss-button.svelte';
const sides = ['top', 'right', 'bottom', 'left'] as const;
</script>
@ -52,6 +53,30 @@
{/each}
</div>
</section>
<section>
<h2>Exit tinting by causa</h2>
<p>
El morfo del Popover ahora declara <code>events</code> con <code>prewrite</code>. Cada
botón llama a <code>dismissWith(action)</code> que escribe el
<code>data-last-action</code> antes de cerrar; eidos tiñe el borde apuntando hacia el
trigger durante la animación de salida.
</p>
<Popover.Provider>
<Popover.Trigger>Open tinted popover</Popover.Trigger>
<Portal>
<Popover.Content sideOffset={8}>
<p>How are you closing me?</p>
<div class="tint-actions">
<DismissButton action="save">Save</DismissButton>
<DismissButton action="cancel">Cancel</DismissButton>
<DismissButton action="fail">Fail</DismissButton>
</div>
<Popover.Arrow />
</Popover.Content>
</Portal>
</Popover.Provider>
</section>
</main>
<style>
@ -92,4 +117,10 @@
color: #64748b;
font-size: 0.85rem;
}
.tint-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
}
</style>

@ -0,0 +1,35 @@
<script lang="ts">
import { PopoverProvider } from '$soma/components/popover';
import type { Snippet } from 'svelte';
type Action = 'save' | 'cancel' | 'fail' | 'dismiss' | 'dismiss-outside';
let { action, children }: { action: Action; children: Snippet } = $props();
const provider = PopoverProvider.get();
</script>
<button class="tint-btn {action}" type="button" onclick={() => provider?.dismissWith(action)}>
{@render children()}
</button>
<style>
.tint-btn {
padding: 0.3rem 0.6rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: white;
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.tint-btn.save {
border-color: #22c55e;
}
.tint-btn.cancel {
border-color: #94a3b8;
}
.tint-btn.fail {
border-color: #ef4444;
}
</style>

@ -191,3 +191,31 @@
[data-popover-arrow] {
fill: var(--eidos-popover-bg);
}
/* ── data-last-action exit tinting (mirrors Dialog/Drawer) ──────────────
* Prewrite writes `data-last-action` BEFORE state flips to 'closed'.
* Tint the trigger-facing edge (opposite to data-side) during the exit
* animation so the user gets a brief tinted flash by causa of the close.
*/
[data-popover-content][data-state='closed'][data-side='top'][data-last-action='saved'] { border-bottom: 2px solid var(--eidos-color-success-element); }
[data-popover-content][data-state='closed'][data-side='bottom'][data-last-action='saved'] { border-top: 2px solid var(--eidos-color-success-element); }
[data-popover-content][data-state='closed'][data-side='right'][data-last-action='saved'] { border-left: 2px solid var(--eidos-color-success-element); }
[data-popover-content][data-state='closed'][data-side='left'][data-last-action='saved'] { border-right: 2px solid var(--eidos-color-success-element); }
[data-popover-content][data-state='closed'][data-side='top'][data-last-action='failed'] { border-bottom: 2px solid var(--eidos-color-danger-element); }
[data-popover-content][data-state='closed'][data-side='bottom'][data-last-action='failed'] { border-top: 2px solid var(--eidos-color-danger-element); }
[data-popover-content][data-state='closed'][data-side='right'][data-last-action='failed'] { border-left: 2px solid var(--eidos-color-danger-element); }
[data-popover-content][data-state='closed'][data-side='left'][data-last-action='failed'] { border-right: 2px solid var(--eidos-color-danger-element); }
[data-popover-content][data-state='closed'][data-side='top'][data-last-action='cancelled'],
[data-popover-content][data-state='closed'][data-side='top'][data-last-action='dismissed'],
[data-popover-content][data-state='closed'][data-side='top'][data-last-action='dismissed-outside'] { border-bottom: 2px solid var(--eidos-color-neutral-element); }
[data-popover-content][data-state='closed'][data-side='bottom'][data-last-action='cancelled'],
[data-popover-content][data-state='closed'][data-side='bottom'][data-last-action='dismissed'],
[data-popover-content][data-state='closed'][data-side='bottom'][data-last-action='dismissed-outside'] { border-top: 2px solid var(--eidos-color-neutral-element); }
[data-popover-content][data-state='closed'][data-side='right'][data-last-action='cancelled'],
[data-popover-content][data-state='closed'][data-side='right'][data-last-action='dismissed'],
[data-popover-content][data-state='closed'][data-side='right'][data-last-action='dismissed-outside'] { border-left: 2px solid var(--eidos-color-neutral-element); }
[data-popover-content][data-state='closed'][data-side='left'][data-last-action='cancelled'],
[data-popover-content][data-state='closed'][data-side='left'][data-last-action='dismissed'],
[data-popover-content][data-state='closed'][data-side='left'][data-last-action='dismissed-outside'] { border-right: 2px solid var(--eidos-color-neutral-element); }

@ -4,9 +4,84 @@ import { v } from '../types';
export const popoverMorfo = {
name: 'Popover',
kebab: 'popover',
scope: ['soma'],
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
events: [
{
name: 'present',
target: v.partRef('content'),
semantic: { family: 'emerge' },
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'open'
}
},
{
name: 'close-save',
target: v.partRef('content'),
semantic: { family: 'commit', 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',
target: v.partRef('content'),
semantic: { family: 'emerge' },
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',
target: v.partRef('content'),
semantic: { family: 'emerge' },
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',
target: v.partRef('content'),
semantic: { family: 'emerge' },
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',
target: v.partRef('content'),
semantic: { family: 'alert', intent: 'threat' },
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
}
],
focus: {
initial: 'first-focusable',
trap: true,
@ -60,7 +135,14 @@ export const popoverMorfo = {
role: 'dialog',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{
attr: 'data-last-action',
values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'],
severity: 'optional'
}
],
aria: [
{
attr: 'aria-labelledby',

@ -6,6 +6,8 @@ export { default as Close } from './components/popover-close.svelte';
export { default as Anchor } from './components/popover-anchor.svelte';
export { default as Overlay } from './components/popover-overlay.svelte';
export { PopoverProvider } from './popover-provider.svelte';
export type {
PopoverProps as ProviderProps,
PopoverTriggerProps as TriggerProps,

@ -36,6 +36,7 @@ import {
} from '../../layers/floating';
import { popoverMorfo } from '../../../morfo/components/popover';
import { createMorfoRuntime, type MorfoRuntime } from '../../morfo/runtime.svelte';
const attrs = createAttrs(popoverMorfo);
registerContract(popoverMorfo);
@ -73,6 +74,9 @@ export class PopoverProvider extends Provider<PopoverOpts> {
contentId = state('');
triggerId = state('');
// ── Morfo runtime (side-channel for close-* event cadence) ───────────────
readonly runtime: MorfoRuntime;
private constructor(opts: PopoverOpts) {
super(opts, 'Popover', 'provider', attrs.provider, PopoverProvider.ctx);
@ -88,6 +92,20 @@ export class PopoverProvider extends Provider<PopoverOpts> {
open: opts.open,
ref: this.overlayRef
});
this.runtime = createMorfoRuntime(popoverMorfo, {
dom: this.soma!.dom,
semantic: this.soma!.semantic,
states: { open: () => opts.open.current },
props: { modal: () => opts.modal.current },
events: {
'close-save': () => this.handleClose(),
'close-cancel': () => this.handleClose(),
'close-dismiss': () => this.handleClose(),
'close-dismiss-outside': () => this.handleClose(),
'close-after-fail': () => this.handleClose()
}
});
}
handleOpen() {
@ -111,6 +129,14 @@ export class PopoverProvider extends Provider<PopoverOpts> {
this.overlayRef.current = el;
}
/**
* Programmatic dismiss with named cause. Same API as Dialog/Drawer.
* Drives the cadence through the morfo runtime.
*/
dismissWith(action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail'): Promise<void> {
const eventName = action === 'fail' ? 'close-after-fail' : `close-${action}`;
return this.runtime.trigger(eventName);
}
}
// ── Trigger ─────────────────────────────────────────────────────────────────
@ -254,6 +280,10 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
this.provider = PopoverProvider.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 });
// Floating positioning
this.floating = FloatingContent.create({
id: opts.id,
@ -302,12 +332,12 @@ export class PopoverContentProvider extends Provider<PopoverContentOpts> {
enabled: readableActive(() => this.provider.opts.open.current),
onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => {
opts.onEscapeKeydown.current(e);
if (!e.defaultPrevented) this.provider.handleClose();
if (!e.defaultPrevented) void this.provider.runtime.trigger('close-dismiss');
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) this.provider.handleClose();
if (!e.defaultPrevented) void this.provider.runtime.trigger('close-dismiss-outside');
}),
interactOutsideBehavior: resolvedInteractBehavior,
onFocusOutside: opts.onFocusOutside,
@ -398,7 +428,7 @@ export class PopoverCloseProvider extends Provider<PopoverCloseOpts> {
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.provider.handleClose();
void this.provider.runtime.trigger('close-cancel');
};
readonly props = $derived.by(() =>

Loading…
Cancel
Save

Powered by TurnKey Linux.