import type { Morfo } from '../types'; import { v } from '../types'; export const floatPanelMorfo = { name: 'FloatPanel', kebab: 'float-panel', scope: ['soma', 'sema', 'eidos'], expression: 'pack', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.float-panel.label|Floating panel', 'content.roledescription': '#?components.float-panel.content.roledescription|window' }, events: [ { // Verb per book cap. 26 §5: a floating panel opens "con marco // propio" — emerge.open (was `present`; C2 verbs pass, 2026-07-07). name: 'open', semantic: { family: 'emerge', verb: 'open', target: v.partRef('content'), sequence: 'pre' }, commits: { part: v.partRef('content'), attr: 'data-state', value: 'open' } }, { // Polymorphic close (book §5.3) — mirror of Popover / Dialog / Drawer. // Provider concretes the cause via `dismissWith(action, opts?)`: // sets `data-last-action` imperatively + passes `opts.semantic`. 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' } }, { // Pickup cue when a drag (move) gesture starts on the Header handle. name: 'drag-start', semantic: { family: 'handle', verb: 'pick', target: v.partRef('content'), sequence: 'pre', channels: ['sound', 'haptic'] } }, { // Drop cue when the move gesture ends. Continuous movement does NOT // emit per-frame (would buzz haptics nonstop — virtual-list doctrine). name: 'drag-end', semantic: { family: 'handle', verb: 'drop', target: v.partRef('content'), sequence: 'post', channels: ['haptic'] } }, { name: 'resize-start', semantic: { family: 'handle', verb: 'resize', target: v.partRef('content'), sequence: 'pre', channels: ['haptic'] } }, { name: 'resize-end', semantic: { family: 'handle', verb: 'resize', target: v.partRef('content'), sequence: 'post', channels: ['haptic'] } } ], focus: { initial: 'first-focusable', // Non-modal by default — the provider resolves the effective trap from // the `modal` prop (modal panels trap, free-floating panels don't). trap: false, return: 'trigger', restore: true }, parts: [ { name: 'Provider', kebab: 'provider', archetype: 'provider', kind: 'virtual', defaultElement: 'none', optional: false, data: [], aria: [] }, { name: 'Trigger', kebab: 'trigger', archetype: 'trigger', kind: 'public', defaultElement: 'button', role: 'button', optional: true, states: ['open', 'closed'], data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }], 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' } ], keyboard: [ { key: 'Enter', action: 'open' }, { key: ' ', action: 'open' } ] }, { 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-stage', values: ['default', 'minimized', 'maximized'], value: v.propRef('stage'), severity: 'optional' }, { attr: 'data-dragging', value: v.propRef('dragging'), severity: 'optional' }, { attr: 'data-resizing', value: v.propRef('resizing'), severity: 'optional' }, // CONSUMER-placed opt-out marker (never emitted by the runtime): // elements carrying it inside a `dragHandle="content"` panel do not // start a drag — the provider's NON_DRAG_SELECTOR consumes it // alongside the native interactive tags. { attr: 'data-float-panel-no-drag', severity: 'optional' }, // Which element grabs the drag — lets the recipe put the grab cursor // on the whole surface when `dragHandle="content"`. `values` makes the // runtime emit the STRING (header/content), not a boolean presence. { attr: 'data-drag-handle', values: ['header', 'content'], value: v.propRef('dragHandle'), severity: 'optional' }, { attr: 'data-topmost', value: v.propRef('topmost'), severity: 'optional' }, { attr: 'data-behind', value: v.propRef('behind'), severity: 'optional' }, { attr: 'data-last-action', values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'], severity: 'optional' }, { attr: 'data-starting-style', severity: 'optional', condition: { when: 'state-equals', state: 'open', value: 'starting' } }, { attr: 'data-ending-style', severity: 'optional', condition: { when: 'state-equals', state: 'closed', value: 'ending' } } ], aria: [ { attr: 'aria-labelledby', value: v.propRef('ariaLabelledby'), condition: { when: 'prop-truthy', prop: 'ariaLabelledby' }, severity: 'recommended' }, { attr: 'aria-describedby', value: v.propRef('ariaDescribedby'), condition: { when: 'prop-truthy', prop: 'ariaDescribedby' }, severity: 'optional' }, { attr: 'aria-modal', value: v.literal('true'), severity: 'optional', condition: { when: 'prop-truthy', prop: 'modal' } }, { // SR users hear "… window" rather than just "dialog". attr: 'aria-roledescription', value: v.translationRef('content.roledescription', 'window'), severity: 'recommended' } ], keyboard: [ { key: 'Escape', action: 'close' }, { key: 'Tab', action: 'focus-next' }, { key: 'Shift+Tab', action: 'focus-prev' } ] }, { // Drag handle. Grabbing it moves the panel. `data-dragging` lets the // recipe swap the cursor (grab → grabbing). name: 'Header', kebab: 'header', archetype: 'header', kind: 'public', defaultElement: 'div', optional: true, data: [ { attr: 'data-dragging', value: v.propRef('dragging'), severity: 'optional' }, // Keyboard "grab" state — eidos styles a grabbed-handle ring. { attr: 'data-grabbed', value: v.propRef('moveMode'), severity: 'optional' } ], aria: [ // Focusable drag-handle: a keyboard user tabs here and presses // Enter/Space to enter a move "grab mode", then the arrows move it. { attr: 'tabindex', value: v.literal('0') }, { attr: 'aria-keyshortcuts', value: v.literal('Enter Space') } ], // Move keys are gated on `moveMode` so out-of-mode arrows BUBBLE (the // runtime skips an entry whose condition is falsy → no preventDefault). // One `kb-move` handler reads the event (key + Shift) and applies. keyboard: [ { key: 'Enter', action: 'kb-move-toggle', condition: { when: 'prop-truthy', prop: 'draggable' } }, { key: ' ', action: 'kb-move-toggle', condition: { when: 'prop-truthy', prop: 'draggable' } }, { key: 'Escape', action: 'kb-move-cancel', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'ArrowUp', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'ArrowDown', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'ArrowLeft', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'ArrowRight', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'Shift+ArrowUp', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'Shift+ArrowDown', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'Shift+ArrowLeft', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'Shift+ArrowRight', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'PageUp', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'PageDown', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'Home', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }, { key: 'End', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } } ] }, { 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: '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' } ] }, { // Optional button cluster in the Header (right side) — holds Close / // Action and any custom buttons, grouped + right-aligned. Mirror of // Ark's `control` group. Purely structural (no state / events). name: 'Controls', kebab: 'controls', archetype: 'group', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] }, { // Generic Header action button (e.g. a delete icon in a properties // panel). The panel does NOT own its behaviour — the consumer wires // `onclick` + `aria-label` via passthrough. Repeatable. name: 'Action', kebab: 'action', archetype: 'action', kind: 'public', defaultElement: 'button', role: 'button', optional: true, data: [], aria: [{ attr: 'type', value: v.literal('button') }] }, { // Switches the panel's stage (minimize / maximize / restore). The // target stage is a soma-level prop; clicking it while already in that // stage returns to 'default' (toggle). Repeatable. name: 'StageTrigger', kebab: 'stage-trigger', archetype: 'trigger', kind: 'public', defaultElement: 'button', role: 'button', optional: true, data: [], aria: [{ attr: 'type', value: v.literal('button') }] }, { // Scrollable content region (below the Header). name: 'Body', kebab: 'body', archetype: 'viewport', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] }, { // Resize affordance. Repeatable; `data-resize-edge` names which // edge/corner this handle drives (n/s/e/w/ne/nw/se/sw). name: 'ResizeHandle', kebab: 'resize-handle', archetype: 'thumb', kind: 'public', defaultElement: 'div', role: 'presentation', optional: true, data: [ { attr: 'data-resize-edge', severity: 'optional' }, { attr: 'data-resizing', value: v.propRef('resizing'), severity: 'optional' } ], aria: [{ attr: 'aria-hidden', value: v.literal('true') }] }, { // Resize affordance for the se corner — a focusable button that owns // BOTH pointer resize (pointerdown → se) and keyboard resize (Enter/Space // enters a grab mode, arrows grow/shrink se-anchored). The keyboard path // makes resize fully keyboard-operable (WCAG 2.1.1), which the aria-hidden // pointer-only ResizeHandle divs do not. name: 'ResizeGrip', kebab: 'resize-grip', archetype: 'thumb', kind: 'public', defaultElement: 'button', role: 'button', optional: true, data: [{ attr: 'data-grabbed', value: v.propRef('resizeMode'), severity: 'optional' }], aria: [ { attr: 'type', value: v.literal('button') }, { attr: 'tabindex', value: v.literal('0') }, { attr: 'aria-keyshortcuts', value: v.literal('Enter Space') } ], keyboard: [ { key: 'Enter', action: 'kb-resize-toggle', condition: { when: 'prop-truthy', prop: 'resizable' } }, { key: ' ', action: 'kb-resize-toggle', condition: { when: 'prop-truthy', prop: 'resizable' } }, { key: 'Escape', action: 'kb-resize-cancel', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'ArrowUp', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'ArrowDown', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'ArrowLeft', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'ArrowRight', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'Shift+ArrowUp', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'Shift+ArrowDown', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'Shift+ArrowLeft', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'Shift+ArrowRight', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'Home', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }, { key: 'End', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } } ] } ] } as const satisfies Morfo;