import type { Morfo } from '../types'; import { v } from '../types'; export const drawerMorfo = { name: 'Drawer', kebab: 'drawer', scope: ['soma', 'sema'], apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.drawer.label|Drawer', trigger: '#?components.drawer.trigger|Open drawer' }, events: [ { name: 'present', semantic: { family: 'emerge', verb: 'present', target: v.partRef('content'), sequence: 'pre' }, commits: { part: v.partRef('content'), attr: 'data-state', value: 'open' } }, { // 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', persistence: 'transient', allowedFamilies: ['emerge', 'commit', 'signal'] }, regime: 'lock', commits: { part: v.partRef('content'), attr: 'data-state', value: 'closed' } }, // ── Direct manipulation (handle family, canon §1.3) ────────────────── // The drawer is a manipulable object; its drag interaction is a // first-class semantic surface. Channels per-event let the consumer // silence the perceptual signal where it would be noisy (e.g. drop // haptic from drag-progress) without the morfo deciding for them. { name: 'drag-start', semantic: { family: 'handle', verb: 'pick', target: v.partRef('content'), sequence: 'pre' } }, { name: 'drag-progress', semantic: { family: 'handle', verb: 'drag', target: v.partRef('content'), // The signal is concurrent with the visible motion of the // finger. Soma throttles the trigger to rAF so we don't queue // hundreds of holds per second. sequence: 'coincident' } }, { name: 'drag-end', semantic: { family: 'handle', verb: 'drop', target: v.partRef('content'), sequence: 'post' } }, { name: 'resize', semantic: { family: 'handle', verb: 'resize', target: v.partRef('content'), sequence: 'post' } } ], focus: { initial: 'first-focusable', trap: true, return: 'trigger', restore: true }, parts: [ { name: 'Provider', kebab: 'provider', archetype: 'provider', kind: 'virtual', defaultElement: 'none', optional: false, states: ['open', 'closed'], data: [ { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { /** * Drawer presentation mode. Three variants: * 'overlay' — modal: backdrop + focus trap + dismissible (default) * 'inline' — non-modal: no backdrop, no focus trap, dismissible * via Escape; sits over content without blocking it * 'persistent' — always-visible: no Trigger, no close-* events, * part of the layout (sidebar pattern) * * Eidos consumes `data-variant` to pick positioning (fixed vs * static) and overlay rendering. Soma derives `modal: boolean` * internally as `variant === 'overlay'` for the aria-modal * condition + focus/scroll-lock behavior. */ attr: 'data-variant', values: ['overlay', 'inline', 'persistent'], value: v.propRef('variant') } ], aria: [] }, { name: 'Trigger', kebab: 'trigger', archetype: 'trigger', kind: 'public', defaultElement: 'button', role: 'button', optional: false, states: ['open', 'closed'], data: [ { attr: 'data-state', values: ['open', 'closed'] }, { attr: 'data-disabled', severity: 'optional' } ], aria: [ { attr: 'type', value: v.literal('button') }, { attr: 'aria-haspopup', value: v.literal('dialog') }, { attr: 'aria-expanded', value: v.stateRef('open') }, { attr: 'aria-controls', value: v.partRef('content'), condition: { when: 'part-present', part: 'content' }, severity: 'recommended' }, { attr: 'aria-label', value: v.translationRef('#?components.drawer.trigger|Open drawer'), severity: 'recommended' } ] }, { name: 'Content', kebab: 'content', archetype: 'content', kind: 'public', defaultElement: 'div', role: 'dialog', optional: false, states: ['open', 'closed'], data: [ { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-side', values: ['top', 'bottom', 'left', 'right'] }, { attr: 'data-dragging', severity: 'optional' }, { attr: 'data-nested', severity: 'optional' }, { attr: 'data-nested-open', severity: 'optional' }, { // Mirrors `data-variant` on the provider so recipe selectors // can scope by variant directly on Content (`[data-drawer-content] // [data-variant='inline']`) without a descendant combinator. attr: 'data-variant', values: ['overlay', 'inline', 'persistent'], value: v.propRef('variant') }, { /** * Sema alignment: causal exit reason. Updated BEFORE * `data-state` flips to `'closed'` so eidos can tint the * exit animation per-action. Same pattern as Dialog. */ attr: 'data-last-action', values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'], severity: 'optional' } ], aria: [ { attr: 'aria-modal', value: v.literal('true'), severity: 'optional', condition: { when: 'prop-truthy', prop: 'modal' } }, { attr: 'aria-labelledby', value: v.partRef('title'), condition: { when: 'part-present', part: 'title' }, severity: 'recommended' }, { attr: 'aria-describedby', value: v.partRef('description'), condition: { when: 'part-present', part: 'description' }, severity: 'optional' } ], keyboard: [ { key: 'Escape', action: 'close' }, { key: 'Tab', action: 'focus-next' }, { key: 'Shift+Tab', action: 'focus-prev' } ] }, { name: 'Overlay', kebab: 'overlay', archetype: 'overlay', kind: 'public', defaultElement: 'div', optional: true, states: ['open', 'closed'], data: [ { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-nested', severity: 'optional' }, { attr: 'data-nested-open', severity: 'optional' }, { // Overlay mirrors the provider's variant so the recipe can // hide the backdrop in `'inline'` and `'persistent'` modes // directly via attribute selector — even when a consumer // renders `` explicitly in a non-overlay // variant. The wrapper's flat form skips Overlay anyway. attr: 'data-variant', values: ['overlay', 'inline', 'persistent'], value: v.propRef('variant') } ], aria: [{ attr: 'aria-hidden', value: v.literal('true') }] }, { name: 'Handle', kebab: 'handle', archetype: 'thumb', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [{ attr: 'aria-hidden', value: v.literal('true') }] }, { name: 'Title', kebab: 'title', archetype: 'title', kind: 'public', defaultElement: 'div', role: 'heading', optional: true, data: [], aria: [{ attr: 'aria-level', value: v.propRef('level'), severity: 'recommended' }] }, { name: 'Description', kebab: 'description', archetype: 'description', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] }, { name: 'Close', kebab: 'close', archetype: 'close', kind: 'public', defaultElement: 'button', role: 'button', optional: true, data: [], aria: [ { attr: 'type', value: v.literal('button') }, { attr: 'aria-label', value: v.commonRef('buttons.close', 'Close'), severity: 'recommended' } ] } ] } as const satisfies Morfo;