|
|
|
|
import type { Morfo } from '../types';
|
|
|
|
|
import { v } from '../types';
|
|
|
|
|
|
|
|
|
|
export const floatPanelMorfo = {
|
|
|
|
|
name: 'FloatPanel',
|
|
|
|
|
kebab: 'float-panel',
|
fix(uix): audit Phase 3 — SYS-1 scope-drift, declare 'eidos' where a recipe exists
Decision (the one definitional call SYS-1 required): morfo `scope` enumerates
the layers that IMPLEMENT the component (per the glossary), so a component with
an `eidos/components/{c}/` recipe + wrapper MUST list `'eidos'`. This matches the
62 components that already declared it correctly (dropdown-menu, accordion, tabs,
checkbox, radio-group, splitter, …) and makes "which components have eidos"
answerable from the morfo. Chosen over the implicit-eidos alternative for
explicitness.
Added `'eidos'` to the morfo-level `scope` of every drifted component — the 62
with a real eidos recipe (.svelte/.css, not a README-only dir) whose scope
omitted it. After this, all 124 audited morfos declare scope consistently;
scope-drift = 0.
`scope` is purely documentary (the only readers are the schema validator —
`layerSchema` already accepts `'eidos'` — and the 2-of-3 doctrine; nothing keys
behavior off it), so this is a behavior-safe documentation alignment: every diff
is exactly one `scope:` line, `npm run check` adds zero errors.
words/palabras/chronos excluded. calendar included (its morfo is clean; the
view-switch refactor lives in soma/eidos, untouched here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 months ago
|
|
|
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: [
|
|
|
|
|
{
|
|
|
|
|
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) — 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' },
|
|
|
|
|
// 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;
|