diff --git a/src/uix/eidos/components/float-panel/README.md b/src/uix/eidos/components/float-panel/README.md new file mode 100644 index 000000000..ee97f0beb --- /dev/null +++ b/src/uix/eidos/components/float-panel/README.md @@ -0,0 +1,108 @@ +# FloatPanel (eidos) + +Visual wrapper over Soma's draggable/resizable panel. Adds only chrome β€” +`variant`, `color` (propagated to `Content` via context) β€” and the recipe that +styles the surface, the header drag-handle, the scrollable body and the resize +handles. Drag, resize, stacking, dismissal, focus and positioning are Soma's. + +```svelte + + + + + Inspector + + … + + + + + + + Properties + + πŸ—‘ + + + +``` + +`Controls` is the right-aligned button group (Ark's `control`); `Action` is a +generic header icon-button sharing `Close`'s chrome, for consumer-wired actions. +`closable` (default true) is the zero-config convenience; set it `false` when you +compose your own `Controls`. + +`StageTrigger` (min/max/restore) shares the same chrome and goes in `Controls` +too β€” it takes a `stage` prop and toggles to it: + +```svelte + + – + β–’ + + +``` + +`minimized` collapses to the header, `maximized` fills `bounds` (corners squared, +drag/resize off), `default` restores. See the soma README for the stage model. + +`` renders a focusable se-corner grip (the diagonal-line +mark) inside `Content` next to the `ResizeHandle` parts β€” it's the **keyboard +resize affordance** (Enter to grab, arrows to resize). The header is itself a +focusable keyboard move-handle. Full grab-mode model + live-region announcements +are in the soma README ("Keyboard move / resize"); the recipe only adds the +`:focus-visible` rings and the `data-grabbed` styling. + +## Props (visual) + +| Prop | Type | Default | +| --- | --- | --- | +| `variant` | `'surface' \| 'outline' \| 'ghost'` (canonical `ControlVariant`) | `'surface'` | +| `color` | `ColorRole` (8 roles) | `'neutral'` | + +All Soma `Provider` props pass through (`open`, `anchor`, `position`, `size`, `draggable`, `resizable`, `minWidth`, …). +There is intentionally **no visual `size` prop** β€” the panel's size is its `width`/`height` geometry. + +`` additionally takes `motion?: MotionPresetName` (default `'scale-fade'`, `'none'` +to disable) β€” the open/close presence preset, mirroring ``. + +## Recipe + +Tokens live under the `float-panel` key in `src/uix/eidos/lib/recipes/base.ts` (`--float-panel-*`). +The surface uses `--color-surface-overlay` + `--shadow-4` (β†’ `--shadow-5` when `data-topmost`); the +header is the grab handle (cursor `grab`β†’`grabbing` on `data-dragging`); the body scrolls; resize +handles position absolutely per `data-resize-edge` with edge-appropriate cursors. + +**Open/close motion comes from the motion system** (`uix.motion`), not hand-rolled CSS: the `Content` +wrapper writes `data-animation-style` (prop `motion`, default `'scale-fade'`) and the generated preset +animates `scale` + `opacity` keyed on `[data-state]`; Soma's `Presence` waits for that animation +before unmounting. `scale-fade` is chosen over the popover/menu default `slide-fade` precisely because +it never animates `translate` β€” the panel's drag position is also `translate`, and the two must not +fight. Timing is tuned per-component via `--motion-duration-{enter,exit}` (normal in / fast out). +Reduced-motion degrades to opacity-only automatically (the preset's `reduce` policy). Position +(`translate`) is never transitioned, so the drag stays 1:1. See `eidos-motion.md`. + +## Comparativa externa + +Closest reference is **Ark UI Floating Panel** (draggable+resizable, stages, persist, grid-snap). UIX +FloatPanel matches its core (drag, resize, bounds, min/max, stacking/topmost, anchoredβ†’free, **stages** +min/max/restore, **composable header buttons**) and adds the perceptual layer (sound/haptic on grab/ +release/resize) that no framework ships; persist/snap are deferred (see soma README Gaps). Radix/Chakra/ +MUI have no draggable panel; Mantine `FloatingWindow` is drag-only; `react-rnd` matches drag/resize but +lacks a11y/theming/perception. + +Sources: [Ark UI Floating Panel](https://ark-ui.com/docs/components/floating-panel) Β· +[react-rnd](https://github.com/bokuweb/react-rnd) Β· +[Mantine FloatingWindow](https://mantine.dev/core/floating-window/) Β· +[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). + +## Decisiones + +- **`variant`/`color` via context** (root is virtual, like toggle-group) β€” set on the root, applied on `Content`. +- **No visual `size`** β€” would clash with the panel's geometric size. +- Surface = `overlay` ladder + elevation shadow; `data-topmost` lifts it. + +## Gaps + +Same v2 roadmap as the soma layer (stages, persistence, snap-grid, keyboard move/resize). A docked / +fixed mode (answer to Figma's floating-panel-fatigue lesson) is a candidate v2 addition. diff --git a/src/uix/eidos/components/float-panel/context.ts b/src/uix/eidos/components/float-panel/context.ts new file mode 100644 index 000000000..6279190c1 --- /dev/null +++ b/src/uix/eidos/components/float-panel/context.ts @@ -0,0 +1,18 @@ +import { getContext, setContext } from 'svelte'; +import type { FloatPanelColor, FloatPanelVariant } from './types'; + +const KEY = Symbol('float-panel-eidos'); + +/** Visual chrome propagated from the (virtual) root to the Content surface. */ +export interface FloatPanelEidosCtx { + readonly variant: FloatPanelVariant; + readonly color: FloatPanelColor; +} + +export function setFloatPanelCtx(ctx: FloatPanelEidosCtx): void { + setContext(KEY, ctx); +} + +export function getFloatPanelCtx(): FloatPanelEidosCtx | undefined { + return getContext(KEY); +} diff --git a/src/uix/eidos/components/float-panel/float-panel-action.svelte b/src/uix/eidos/components/float-panel/float-panel-action.svelte new file mode 100644 index 000000000..35dfbbb46 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-action.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-body.svelte b/src/uix/eidos/components/float-panel/float-panel-body.svelte new file mode 100644 index 000000000..af35d6dd3 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-body.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-close.svelte b/src/uix/eidos/components/float-panel/float-panel-close.svelte new file mode 100644 index 000000000..2161b6ae1 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-close.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-content.svelte b/src/uix/eidos/components/float-panel/float-panel-content.svelte new file mode 100644 index 000000000..ada743f80 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-content.svelte @@ -0,0 +1,17 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-controls.svelte b/src/uix/eidos/components/float-panel/float-panel-controls.svelte new file mode 100644 index 000000000..c9bce1c54 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-controls.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-header.svelte b/src/uix/eidos/components/float-panel/float-panel-header.svelte new file mode 100644 index 000000000..a8af6e797 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-header.svelte @@ -0,0 +1,23 @@ + + + + + {@render children?.()} + {#if closable} + + Γ— + + {/if} + diff --git a/src/uix/eidos/components/float-panel/float-panel-resize-grip.svelte b/src/uix/eidos/components/float-panel/float-panel-resize-grip.svelte new file mode 100644 index 000000000..85dc40389 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-resize-grip.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-resize-handle.svelte b/src/uix/eidos/components/float-panel/float-panel-resize-handle.svelte new file mode 100644 index 000000000..5050672b1 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-resize-handle.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-stage-trigger.svelte b/src/uix/eidos/components/float-panel/float-panel-stage-trigger.svelte new file mode 100644 index 000000000..9536e89a7 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-stage-trigger.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-title.svelte b/src/uix/eidos/components/float-panel/float-panel-title.svelte new file mode 100644 index 000000000..df7f4d0a1 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-title.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel-trigger.svelte b/src/uix/eidos/components/float-panel/float-panel-trigger.svelte new file mode 100644 index 000000000..55c11b78a --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel-trigger.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/float-panel.css b/src/uix/eidos/components/float-panel/float-panel.css new file mode 100644 index 000000000..1c6b36011 --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel.css @@ -0,0 +1,326 @@ +/* FloatPanel β€” draggable/resizable floating surface. + Soma owns position/size/z-index (inline style) + data-state/dragging/resizing/ + topmost/behind. Eidos owns the surface chrome and the open/close animation. */ + +[data-float-panel-content] { + --_float-panel-accent: var(--float-panel-accent); + /* Open/close = motion-system preset (momento `--state`). The eidos wrapper + writes `data-animation-style` (default `scale-fade`); the generated motion + CSS animates `scale` + `opacity` keyed on `[data-state]`, and Soma's + Presence waits for that animation before unmounting. `scale-fade` is the + right default for a FREE panel: it never touches `translate`, so the drag + position (also `translate`) is left untouched. These per-component overrides + keep the prior recipe feel (normal in / fast out). See `eidos-motion.md`. */ + --motion-duration-enter: var(--duration-normal); + --motion-duration-exit: var(--duration-fast); + + display: flex; + flex-direction: column; + overflow: hidden; + box-sizing: border-box; + border: var(--float-panel-border-width) solid var(--float-panel-border); + border-radius: var(--float-panel-radius); + background: var(--float-panel-bg); + color: var(--float-panel-color); + font-family: var(--float-panel-font-family); + font-size: var(--float-panel-font-size); + line-height: var(--float-panel-line-height); + box-shadow: var(--float-panel-shadow); + /* Only the elevation lift (β†’ topmost) is eased here. Position (`translate`) + is never transitioned so the drag stays 1:1; presence is the preset's job. */ + transition: box-shadow var(--float-panel-transition-duration) + var(--float-panel-transition-ease); +} + +/* The focused/frontmost panel lifts. */ +[data-float-panel-content][data-topmost] { + box-shadow: var(--float-panel-shadow-topmost); +} + +/* Grab LIFTS the panel to the theme's highest elevation + (`--float-panel-shadow-active`). `data-dragging` / `data-resizing` are set on + POINTERDOWN, so the lift eases in (base `transition: box-shadow`) the instant + you press β€” before you move. During the move the shadow is then STATIC, so the + GPU-promoted layer (will-change set at pointerdown) just translates with no + per-frame shadow repaint β†’ smooth even though the lifted shadow is large. On + release it eases back down to the resting elevation. (The earlier stutter came + from the topmost shadow transitioning AS the move began; lifting on press + separates the shadow change from the motion, which is also better UX.) */ +[data-float-panel-content][data-dragging], +[data-float-panel-content][data-resizing] { + box-shadow: var(--float-panel-shadow-active); +} + +[data-float-panel-content]:focus-visible { + outline: none; + box-shadow: + var(--float-panel-shadow-topmost), + 0 0 0 2px var(--_float-panel-accent); +} + +/* Variants β€” canonical ControlVariant. The base above IS `surface` (the + elevated default); `outline` and `ghost` adjust from it. */ +[data-float-panel-content][data-variant='outline'] { + background: var(--color-surface-default); + border-color: var(--color-border-strong); + box-shadow: none; +} +[data-float-panel-content][data-variant='ghost'] { + background: var(--color-surface-default); + border-color: var(--color-border-subtle); + box-shadow: var(--shadow-2); +} + +/* Accent per color role (focus ring + resize-active + header rule emphasis). */ +[data-float-panel-content][data-color='primary'] { + --_float-panel-accent: var(--color-primary-border); +} +[data-float-panel-content][data-color='secondary'] { + --_float-panel-accent: var(--color-secondary-border); +} +[data-float-panel-content][data-color='neutral'] { + --_float-panel-accent: var(--color-neutral-border); +} +[data-float-panel-content][data-color='affirm'] { + --_float-panel-accent: var(--color-affirm-border); +} +[data-float-panel-content][data-color='fulfill'] { + --_float-panel-accent: var(--color-fulfill-border); +} +[data-float-panel-content][data-color='risk'] { + --_float-panel-accent: var(--color-risk-border); +} +[data-float-panel-content][data-color='threat'] { + --_float-panel-accent: var(--color-threat-border); +} +[data-float-panel-content][data-color='loss'] { + --_float-panel-accent: var(--color-loss-border); +} + +/* Header = drag handle. */ +[data-float-panel-header] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--float-panel-header-gap); + flex: 0 0 auto; + padding-block: var(--float-panel-header-py); + padding-inline: var(--float-panel-header-px); + background: var(--float-panel-header-bg); + /* Accent rule under the header β€” reflects `data-color` visibly. */ + border-block-end: 2px solid var(--_float-panel-accent); + cursor: grab; + touch-action: none; + user-select: none; +} +[data-float-panel-content][data-dragging] [data-float-panel-header], +[data-float-panel-header][data-dragging] { + cursor: grabbing; +} +/* The header is a focusable keyboard move-handle (tabindex 0) β€” show a focus + ring, and a steady ring while in keyboard "grab mode" (data-grabbed). */ +[data-float-panel-header]:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--_float-panel-accent); +} +[data-float-panel-header][data-grabbed] { + box-shadow: inset 0 0 0 2px var(--_float-panel-accent); + cursor: grabbing; +} + +/* When the WHOLE panel is the drag handle (dragHandle="content"), the surface + itself shows the grab cursor β€” not only the header. Interactive descendants + (buttons, inputs, the resize grip) keep their own cursor. */ +[data-float-panel-content][data-drag-handle='content'] { + cursor: grab; +} +[data-float-panel-content][data-drag-handle='content'][data-dragging] { + cursor: grabbing; +} + +[data-float-panel-title] { + font-size: var(--float-panel-title-font-size); + font-weight: var(--float-panel-title-font-weight); + color: var(--float-panel-title-color); + margin: 0; +} + +/* Header button group (Ark's `control`) β€” a right-aligned cluster holding the + Close / Action buttons (and any custom ones). The Header's `space-between` + pushes it to the trailing edge; this just spaces its buttons. */ +[data-float-panel-controls] { + display: inline-flex; + align-items: center; + gap: var(--float-panel-controls-gap); + flex: 0 0 auto; +} + +/* Close + Action + StageTrigger share ONE square icon-button chrome, so a custom + action (a delete icon, say) and the stage buttons sit flush next to Close with + identical sizing/hover/focus. */ +[data-float-panel-close], +[data-float-panel-action], +[data-float-panel-stage-trigger] { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + inline-size: var(--float-panel-button-size); + block-size: var(--float-panel-button-size); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-content-secondary); + font-size: var(--font-size-md); + line-height: 1; + cursor: pointer; +} +[data-float-panel-close]:hover, +[data-float-panel-action]:hover, +[data-float-panel-stage-trigger]:hover { + background: var(--color-surface-muted); + color: var(--color-content-primary); +} +[data-float-panel-close]:focus-visible, +[data-float-panel-action]:focus-visible, +[data-float-panel-stage-trigger]:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--_float-panel-accent); +} + +/* ── Stages ────────────────────────────────────────────────────────────────── + Soma writes `data-stage` on Content and computes the matching geometry + (minimized = header height, maximized = bounds). The recipe handles the + per-stage chrome. */ + +/* Minimized: collapse to the header. The body + resize affordances disappear; + the panel's inline height is cleared by Soma so it shrinks to the header. */ +[data-float-panel-content][data-stage='minimized'] [data-float-panel-body], +[data-float-panel-content][data-stage='minimized'] [data-float-panel-resize-handle], +[data-float-panel-content][data-stage='minimized'] [data-float-panel-resize-grip] { + display: none; +} + +/* Maximized: fills the bounds. No resize affordances; the header can't be grabbed + (the panel can't move); square off the corners since it spans edge-to-edge. */ +[data-float-panel-content][data-stage='maximized'] { + border-radius: 0; +} +[data-float-panel-content][data-stage='maximized'] [data-float-panel-resize-handle], +[data-float-panel-content][data-stage='maximized'] [data-float-panel-resize-grip] { + display: none; +} +[data-float-panel-content][data-stage='maximized'] [data-float-panel-header] { + cursor: default; +} + +/* Resize grip β€” a focusable se-corner button. It owns BOTH pointer resize + (pointerdown β†’ se resize) and keyboard resize (Enter/Space β†’ grab mode). The + diagonal corner lines read as "drag to resize". Use it instead of a bare se + ResizeHandle div so there's one unambiguous, keyboard-reachable se affordance. */ +[data-float-panel-resize-grip] { + position: absolute; + inset-inline-end: 0; + inset-block-end: 0; + inline-size: var(--float-panel-grip-size); + block-size: var(--float-panel-grip-size); + padding: 0; + margin: 0; + border: 0; + background-color: transparent; + color: var(--color-content-tertiary, var(--color-content-secondary)); + /* nwse-resize wins over the header's inherited grab cursor + the button UA. */ + cursor: nwse-resize !important; + touch-action: none; + /* Three nested corner chevrons β†’ an unmistakable resize grip. */ + background-image: repeating-linear-gradient( + -45deg, + transparent 0 3px, + currentColor 3px 4.5px + ); + background-size: 10px 10px; + background-position: bottom right; + background-repeat: no-repeat; + opacity: 0.65; +} +[data-float-panel-resize-grip]:hover, +[data-float-panel-resize-grip][data-grabbed] { + opacity: 1; + color: var(--_float-panel-accent); +} +[data-float-panel-resize-grip]:focus-visible { + outline: none; + opacity: 1; + box-shadow: inset 0 0 0 2px var(--_float-panel-accent); + border-radius: var(--radius-sm); +} + +/* Body = scrollable content. */ +[data-float-panel-body] { + flex: 1 1 auto; + min-block-size: 0; + overflow: auto; + padding-block: var(--float-panel-body-py); + padding-inline: var(--float-panel-body-px); +} + +/* Resize handles β€” absolutely positioned within the fixed Content. */ +[data-float-panel-resize-handle] { + position: absolute; + touch-action: none; +} +[data-float-panel-resize-handle][data-resize-edge='e'], +[data-float-panel-resize-handle][data-resize-edge='w'] { + inset-block: 0; + inline-size: var(--float-panel-resize-edge); + cursor: ew-resize; +} +[data-float-panel-resize-handle][data-resize-edge='n'], +[data-float-panel-resize-handle][data-resize-edge='s'] { + inset-inline: 0; + block-size: var(--float-panel-resize-edge); + cursor: ns-resize; +} +[data-float-panel-resize-handle][data-resize-edge='e'] { + inset-inline-end: 0; +} +[data-float-panel-resize-handle][data-resize-edge='w'] { + inset-inline-start: 0; +} +[data-float-panel-resize-handle][data-resize-edge='n'] { + inset-block-start: 0; +} +[data-float-panel-resize-handle][data-resize-edge='s'] { + inset-block-end: 0; +} +[data-float-panel-resize-handle][data-resize-edge='se'], +[data-float-panel-resize-handle][data-resize-edge='ne'], +[data-float-panel-resize-handle][data-resize-edge='sw'], +[data-float-panel-resize-handle][data-resize-edge='nw'] { + inline-size: var(--float-panel-resize-size); + block-size: var(--float-panel-resize-size); +} +[data-float-panel-resize-handle][data-resize-edge='se'] { + inset-inline-end: 0; + inset-block-end: 0; + cursor: nwse-resize; +} +[data-float-panel-resize-handle][data-resize-edge='nw'] { + inset-inline-start: 0; + inset-block-start: 0; + cursor: nwse-resize; +} +[data-float-panel-resize-handle][data-resize-edge='ne'] { + inset-inline-end: 0; + inset-block-start: 0; + cursor: nesw-resize; +} +[data-float-panel-resize-handle][data-resize-edge='sw'] { + inset-inline-start: 0; + inset-block-end: 0; + cursor: nesw-resize; +} + +/* Reduced-motion for the open/close is handled by the motion system itself + (the `scale-fade` preset degrades to opacity-only under `[data-motion='reduce']` + + `@media (prefers-reduced-motion)`), so no recipe-level override is needed. */ diff --git a/src/uix/eidos/components/float-panel/float-panel.svelte b/src/uix/eidos/components/float-panel/float-panel.svelte new file mode 100644 index 000000000..78e0885ce --- /dev/null +++ b/src/uix/eidos/components/float-panel/float-panel.svelte @@ -0,0 +1,33 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float-panel/index.ts b/src/uix/eidos/components/float-panel/index.ts new file mode 100644 index 000000000..3a23d8bac --- /dev/null +++ b/src/uix/eidos/components/float-panel/index.ts @@ -0,0 +1,74 @@ +// FloatPanel β€” eidos compound API, disciplined option C. +// +// import { FloatPanel } from '$uix/eidos/components/float-panel'; +// +// +// +// +// Inspector +// +// +// … +// +// +// +import FloatPanelComponent from './float-panel.svelte'; +import Trigger from './float-panel-trigger.svelte'; +import Content from './float-panel-content.svelte'; +import Header from './float-panel-header.svelte'; +import Title from './float-panel-title.svelte'; +import Close from './float-panel-close.svelte'; +import Controls from './float-panel-controls.svelte'; +import Action from './float-panel-action.svelte'; +import StageTrigger from './float-panel-stage-trigger.svelte'; +import Body from './float-panel-body.svelte'; +import ResizeHandle from './float-panel-resize-handle.svelte'; +import ResizeGrip from './float-panel-resize-grip.svelte'; + +type FloatPanelNamespace = typeof FloatPanelComponent & { + Trigger: typeof Trigger; + Content: typeof Content; + Header: typeof Header; + Title: typeof Title; + Close: typeof Close; + Controls: typeof Controls; + Action: typeof Action; + StageTrigger: typeof StageTrigger; + Body: typeof Body; + ResizeHandle: typeof ResizeHandle; + ResizeGrip: typeof ResizeGrip; +}; + +const FloatPanel = FloatPanelComponent as FloatPanelNamespace; +FloatPanel.Trigger = Trigger; +FloatPanel.Content = Content; +FloatPanel.Header = Header; +FloatPanel.Title = Title; +FloatPanel.Close = Close; +FloatPanel.Controls = Controls; +FloatPanel.Action = Action; +FloatPanel.StageTrigger = StageTrigger; +FloatPanel.Body = Body; +FloatPanel.ResizeHandle = ResizeHandle; +FloatPanel.ResizeGrip = ResizeGrip; + +export { FloatPanel }; + +export default FloatPanel; + +export type { + FloatPanelProps, + FloatPanelVariant, + FloatPanelColor, + FloatPanelTriggerProps as TriggerProps, + FloatPanelContentProps as ContentProps, + FloatPanelHeaderProps as HeaderProps, + FloatPanelTitleProps as TitleProps, + FloatPanelCloseProps as CloseProps, + FloatPanelControlsProps as ControlsProps, + FloatPanelActionProps as ActionProps, + FloatPanelStageTriggerProps as StageTriggerProps, + FloatPanelBodyProps as BodyProps, + FloatPanelResizeHandleProps as ResizeHandleProps, + FloatPanelResizeGripProps as ResizeGripProps +} from './types'; diff --git a/src/uix/eidos/components/float-panel/types.ts b/src/uix/eidos/components/float-panel/types.ts new file mode 100644 index 000000000..e68cb43a6 --- /dev/null +++ b/src/uix/eidos/components/float-panel/types.ts @@ -0,0 +1,65 @@ +import type { + ProviderProps, + TriggerProps, + ContentProps, + HeaderProps, + TitleProps, + CloseProps, + ControlsProps, + ActionProps, + StageTriggerProps, + BodyProps, + ResizeHandleProps, + ResizeGripProps +} from '$soma/components/float-panel'; +import type { ColorRole, ControlVariant } from '$uix/eidos/lib/types'; +import type { MotionPresetName } from '$uix/eidos/lib/motion/registry'; + +/** Surface treatment of the panel β€” canonical control variants (no invented values). */ +export type FloatPanelVariant = ControlVariant; +/** Accent palette (header rule, focus ring, resize-handle active). */ +export type FloatPanelColor = ColorRole; + +/** + * Props for the eidos `` root. + * + * Extends Soma's draggable/resizable panel with visual chrome only + * (`variant`, `color`). Drag, resize, stacking, dismissal, focus and + * positioning are owned by Soma. + */ +export type FloatPanelProps = ProviderProps & { + /** Surface treatment. @default 'surface' */ + variant?: FloatPanelVariant; + /** Accent palette. @default 'neutral' */ + color?: FloatPanelColor; +}; + +export type FloatPanelTriggerProps = TriggerProps; +export type FloatPanelContentProps = ContentProps & { + /** + * Presence animation preset (momento `--state`, drives `data-animation-style`). + * Defaults to `'scale-fade'` β€” scale + fade around the panel's own box, which + * composes cleanly with the drag `translate` (distinct CSS properties), so the + * open/close never fights the position. `'none'` disables. @default 'scale-fade' + */ + motion?: MotionPresetName; +}; +export type FloatPanelHeaderProps = HeaderProps & { + /** Render a built-in close button at the end of the header. @default true */ + closable?: boolean; +}; +export type FloatPanelTitleProps = TitleProps; +export type FloatPanelCloseProps = CloseProps; +/** Header button group (right-aligned cluster holding Close / Action / custom buttons). */ +export type FloatPanelControlsProps = ControlsProps; +/** + * Generic header action button (e.g. a delete icon). Same chrome as `Close`. + * Wire `onclick` and pass an `aria-label` for icon-only buttons. + */ +export type FloatPanelActionProps = ActionProps; +/** Minimize / maximize / restore button β€” takes a `stage` prop, toggles to it. */ +export type FloatPanelStageTriggerProps = StageTriggerProps; +/** Focusable se-corner resize grip β€” keyboard resize affordance (Enter to grab). */ +export type FloatPanelResizeGripProps = ResizeGripProps; +export type FloatPanelBodyProps = BodyProps; +export type FloatPanelResizeHandleProps = ResizeHandleProps; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index b83c12e8b..c5ed2b3b5 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1308,6 +1308,34 @@ --css-field-transition-duration: var(--duration-fast); --css-field-transition-ease: var(--ease-default); --css-field-disabled-opacity: 0.56; + --float-panel-bg: var(--color-surface-overlay); + --float-panel-border: var(--color-border-default); + --float-panel-border-width: var(--border-width); + --float-panel-radius: var(--radius-lg); + --float-panel-shadow: var(--shadow-4); + --float-panel-shadow-topmost: var(--shadow-5); + --float-panel-shadow-active: var(--shadow-6); + --float-panel-color: var(--color-content-primary); + --float-panel-font-family: var(--font-ui); + --float-panel-font-size: var(--font-size-sm); + --float-panel-line-height: var(--leading-ui); + --float-panel-header-bg: var(--color-surface-raised); + --float-panel-header-px: var(--space-3); + --float-panel-header-py: var(--space-2); + --float-panel-header-gap: var(--space-2); + --float-panel-controls-gap: var(--space-1); + --float-panel-title-font-size: var(--font-size-sm); + --float-panel-title-font-weight: var(--font-weight-semibold); + --float-panel-title-color: var(--color-content-primary); + --float-panel-body-px: var(--space-3); + --float-panel-body-py: var(--space-3); + --float-panel-resize-size: var(--space-3-5); + --float-panel-resize-edge: var(--space-1-5); + --float-panel-button-size: var(--space-7); + --float-panel-grip-size: var(--space-5); + --float-panel-accent: var(--color-neutral-border); + --float-panel-transition-duration: var(--duration-fast); + --float-panel-transition-ease: var(--ease-default); --date-field-stack-gap: var(--space-1-5); --date-field-height-xs: var(--control-height-xs); --date-field-height-sm: var(--control-height-sm); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index afe232b9a..11c51fcf2 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1142,6 +1142,42 @@ export const THEME_BASE_RECIPE_TOKENS = { 'transition-ease': 'var(--ease-default)', 'disabled-opacity': '0.56' }, + 'float-panel': { + bg: 'var(--color-surface-overlay)', + border: 'var(--color-border-default)', + 'border-width': 'var(--border-width)', + radius: 'var(--radius-lg)', + shadow: 'var(--shadow-4)', + 'shadow-topmost': 'var(--shadow-5)', + // Elevation worn WHILE grabbed (drag/resize): the panel LIFTS to the + // theme's highest elevation. Applied on pointerdown so the lift transition + // finishes before the move; the shadow is then static during the drag, so + // the GPU layer just moves (no per-frame shadow repaint). See float-panel.css. + 'shadow-active': 'var(--shadow-6)', + color: 'var(--color-content-primary)', + 'font-family': 'var(--font-ui)', + 'font-size': 'var(--font-size-sm)', + 'line-height': 'var(--leading-ui)', + 'header-bg': 'var(--color-surface-raised)', + 'header-px': 'var(--space-3)', + 'header-py': 'var(--space-2)', + 'header-gap': 'var(--space-2)', + 'controls-gap': 'var(--space-1)', + 'title-font-size': 'var(--font-size-sm)', + 'title-font-weight': 'var(--font-weight-semibold)', + 'title-color': 'var(--color-content-primary)', + 'body-px': 'var(--space-3)', + 'body-py': 'var(--space-3)', + 'resize-size': 'var(--space-3-5)', + 'resize-edge': 'var(--space-1-5)', + // Header icon-button (close / action / stage-trigger) hit target, and the + // se-corner resize grip β€” sized for comfort, not the tiny resize-handle. + 'button-size': 'var(--space-7)', + 'grip-size': 'var(--space-5)', + accent: 'var(--color-neutral-border)', + 'transition-duration': 'var(--duration-fast)', + 'transition-ease': 'var(--ease-default)' + }, 'date-field': { 'stack-gap': 'var(--space-1-5)', 'height-xs': 'var(--control-height-xs)', diff --git a/src/uix/langs/components/float-panel.ts b/src/uix/langs/components/float-panel.ts new file mode 100644 index 000000000..94e17e7ec --- /dev/null +++ b/src/uix/langs/components/float-panel.ts @@ -0,0 +1,67 @@ +import type { LangNode } from '$libs/langs'; + +/** + * Default strings for the FloatPanel component. Merged under + * `components.float-panel.*` by `ActiveUix` (via the `componentLangs` barrel). + * + * Morfo references entries by absolute idlangref + * (`'#?components.float-panel.{key}|fallback'`); the soma provider builds the + * keyboard announcements with `langs.ts(...)` + manual `{x}/{y}/{width}/{height}` + * interpolation, so the templates keep those placeholders. + */ +export const floatPanelLangs = { + label: { + es: 'Panel flotante', + en: 'Floating panel' + }, + content: { + roledescription: { + es: 'ventana', + en: 'window' + } + }, + 'move-handle-label': { + es: 'Mover panel', + en: 'Move panel' + }, + 'resize-handle-label': { + es: 'Redimensionar panel', + en: 'Resize panel' + }, + 'kb-move-enter': { + es: 'Moviendo el panel. Usa las flechas para mover, MayΓΊs mΓ‘s flecha para pasos mayores, Intro para soltar, Escape para cancelar.', + en: 'Moving panel. Use the arrow keys to move, Shift plus arrow for larger steps, Enter to drop, Escape to cancel.' + }, + 'kb-resize-enter': { + es: 'Redimensionando el panel. Usa las flechas para redimensionar, MayΓΊs mΓ‘s flecha para pasos mayores, Intro para confirmar, Escape para cancelar.', + en: 'Resizing panel. Use the arrow keys to resize, Shift plus arrow for larger steps, Enter to confirm, Escape to cancel.' + }, + 'kb-position': { + es: 'Panel en {x}, {y}.', + en: 'Panel at {x}, {y}.' + }, + 'kb-size': { + es: 'Panel de {width} por {height}.', + en: 'Panel {width} by {height}.' + }, + 'kb-bound': { + es: 'Has llegado al borde.', + en: 'Reached the edge.' + }, + 'kb-min-size': { + es: 'TamaΓ±o mΓ­nimo.', + en: 'Minimum size.' + }, + 'kb-max-size': { + es: 'TamaΓ±o mΓ‘ximo.', + en: 'Maximum size.' + }, + 'kb-cancelled': { + es: 'Cancelado. Panel restaurado.', + en: 'Cancelled. Panel restored.' + }, + 'kb-move-blocked': { + es: 'No se puede mover mientras estΓ‘ maximizado.', + en: 'Cannot move while maximized.' + } +} satisfies LangNode; diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index bb8348578..086011d55 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -28,6 +28,7 @@ import { editableLangs } from './editable'; import { feedLangs } from './feed'; import { fieldLangs } from './field'; import { fileUploadLangs } from './file-upload'; +import { floatPanelLangs } from './float-panel'; import { formLangs } from './form'; import { gridListLangs } from './grid-list'; import { imageLangs } from './image'; @@ -111,6 +112,7 @@ export const componentLangs = { feed: feedLangs, field: fieldLangs, 'file-upload': fileUploadLangs, + 'float-panel': floatPanelLangs, form: formLangs, 'grid-list': gridListLangs, image: imageLangs, diff --git a/src/uix/morfo/components/float-panel.ts b/src/uix/morfo/components/float-panel.ts new file mode 100644 index 000000000..60ee5d213 --- /dev/null +++ b/src/uix/morfo/components/float-panel.ts @@ -0,0 +1,384 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +export const floatPanelMorfo = { + name: 'FloatPanel', + kebab: 'float-panel', + scope: ['soma', 'sema'], + 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; diff --git a/src/uix/sema/components/float-panel.ts b/src/uix/sema/components/float-panel.ts new file mode 100644 index 000000000..88db02030 --- /dev/null +++ b/src/uix/sema/components/float-panel.ts @@ -0,0 +1,48 @@ +import { semaSelector } from '$uix/morfo'; +import { floatPanelMorfo } from '$uix/morfo/components/float-panel'; +import { sound, soundTuning } from '../sounds'; +import type { Sema } from '../sema-map'; + +/** + * FloatPanel perceptual defaults β€” SOUND + HAPTIC only (visual lives in eidos). + * + * Direct-manipulation family `handle`: a breathy pickup on grab, a soft landing + * on release, a brighter chime when a resize settles. Continuous move/resize do + * NOT emit per-frame (would buzz haptics β€” virtual-list doctrine), so only the + * start/end boundaries carry cues. Polymorphic `close` mirrors dialog/popover. + */ + +const onContent = (matchers?: Parameters>[2]) => + semaSelector(floatPanelMorfo, 'content', matchers); + +export const floatPanelSema: Sema = { + name: 'float-panel', + cascade: [ + { + selector: onContent({ eventName: 'drag-start' }), + channels: ['sound', 'haptic'], + sound: sound('handle.pickup.air') + }, + { + selector: onContent({ eventName: 'drag-end' }), + channels: ['sound', 'haptic'], + sound: sound('handle.release.soft'), + haptic: { kind: 'tap', intensity: 0.4, duration: 16 } + }, + { + selector: onContent({ eventName: 'resize-start' }), + channels: ['haptic'], + haptic: { kind: 'tick', intensity: 0.3, duration: 8 } + }, + { + selector: onContent({ eventName: 'resize-end' }), + channels: ['sound', 'haptic'], + sound: sound('handle.snap.chime'), + haptic: { kind: 'tap', intensity: 0.5, duration: 20 } + }, + { + selector: onContent({ eventName: 'close', eventFamily: 'emerge' }), + sound: soundTuning('emerge.exit.deep') + } + ] +}; diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts index ed3b6f693..3c95ea839 100644 --- a/src/uix/sema/components/index.ts +++ b/src/uix/sema/components/index.ts @@ -22,6 +22,7 @@ export { drawerSema } from './drawer'; export { dropdownMenuSema } from './dropdown-menu'; export { editableSema } from './editable'; export { fileUploadSema } from './file-upload'; +export { floatPanelSema } from './float-panel'; export { formSema } from './form'; export { menubarSema } from './menubar'; export { navigationMenuSema } from './navigation-menu'; diff --git a/src/uix/soma/components/float-panel/README.md b/src/uix/soma/components/float-panel/README.md new file mode 100644 index 000000000..0cb05b322 --- /dev/null +++ b/src/uix/soma/components/float-panel/README.md @@ -0,0 +1,190 @@ +# FloatPanel (soma) + +Headless **draggable + resizable floating panel** β€” a general-purpose, user-movable +surface (tool palette, inspector, mini-window). Composes the same layers as Popover +(Presence, Dismissal, FocusScope, ScrollLock, TextSelection) plus custom 2D drag / +edge resize and a shared stacking registry. Implements the WAI-ARIA +[Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) role; non-modal by default. + +## Anatomy + +```svelte + + + + Inspector + + + … + + + +``` + +`Content` is required; `Trigger`, `Header`, `Title`, `Close`, `Body`, `ResizeHandle` are optional. + +## Behavior + +- **Positioning**: anchored on open (to `anchor` via `side`/`align`/`offset`, viewport-clamped), + then **free** (`position: fixed` at `{x,y}`) β€” dragging detaches it. No `anchor`/`defaultPosition` + β†’ centered in the viewport. +- **Drag**: grab the `Header` (or the whole `Content` with `dragHandle="content"`) to move. Custom + 2D pointer drag (move-buffer + deferred pointer capture) clamped to `bounds` (viewport default). + Controls inside the header (`button, a, input, …, [data-float-panel-no-drag]`) don't start a drag. +- **Resize**: `ResizeHandle` parts (per edge/corner via `edge`) resize `Content` when `resizable`, + respecting `min/maxWidth/Height`; west/north edges keep the opposite side anchored. +- **Stacking**: a shared registry (`layers/stacking.svelte.ts`) raises the focused/clicked panel to + the front (`data-topmost` / `data-behind`, auto z-index). Multiple panels coexist. +- **Dismissal**: Escape closes when `closeOnEscape`; interact-outside closes only when + `closeOnInteractOutside` (off by default β€” a tool panel stays open while you work elsewhere). +- **Close** is polymorphic (book Β§5.3): `dismissWith('save'|'cancel'|'dismiss'|'dismiss-outside'|'fail')` + sets `data-last-action` + concretes the semantic. + +> **Why not `floating`/`gesture` layers?** Those serve trigger-anchored popovers (auto flip/shift, +> which would fight a user-placed panel) and axis-locked directional dismiss. A user-movable panel +> needs authoritative free 2D positioning, so FloatPanel uses `position: fixed` + custom pointer +> drag/resize (the scrubber pattern) instead. + +## Props (Provider) + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `open` | `boolean` (bindable) | `false` | Open state. | +| `anchor` | `HTMLElement \| null` | β€” | Element to place against on open. | +| `side` / `align` / `offset` | `Side` / `Align` / `number` | `'bottom'` / `'start'` / `8` | Anchored placement. | +| `position` | `{x,y}` (bindable) | β€” | Current top-left (free). | +| `defaultPosition` | `{x,y}` | β€” | Initial position when no `anchor`. | +| `size` | `{width,height}` (bindable) | β€” | Current size. | +| `defaultSize` | `{width,height}` | `{320,240}` | Initial size. | +| `draggable` | `boolean` | `true` | Allow drag-to-move. | +| `dragHandle` | `'header' \| 'content'` | `'header'` | What grabs the drag. | +| `resizable` | `boolean` | `false` | Enable `ResizeHandle` parts. | +| `minWidth`/`minHeight`/`maxWidth`/`maxHeight` | `number` | `160`/`120`/β€”/β€” | Size constraints. | +| `bounds` | `'viewport' \| HTMLElement` | `'viewport'` | Drag/resize containment. | +| `modal` | `boolean` | `false` | Focus trap + scroll lock (no visual backdrop yet β€” see Gaps). | +| `dismissible` / `closeOnEscape` / `closeOnInteractOutside` | `boolean` | `true`/`true`/`false` | Dismissal. | +| `zIndex` | `number` | auto | Override the stacking z-index. | +| `stage` | `'default' \| 'minimized' \| 'maximized'` (bindable) | `'default'` | Window stage. | +| `onOpenChange` / `onPositionChange` / `onSizeChange` / `onStageChange` / `onOpenChangeComplete` | callbacks | β€” | | + +`ResizeHandle`: `edge?: 'n'\|'s'\|'e'\|'w'\|'ne'\|'nw'\|'se'\|'sw'` (default `'se'`). `Title`: `level?` (2). `Close`: `action?` (`'dismiss'`). `StageTrigger`: `stage` (target stage; toggles to it). + +## Header buttons (composable) + +The header is composed, mirroring Ark's `control` group. `Controls` is the +right-aligned button cluster; `Close` is the built-in dismiss; `Action` is a +generic button the consumer wires (`onclick` + `aria-label`) β€” e.g. a delete +icon in a properties panel. Defaults and custom buttons compose freely: + +```svelte + + Properties + + πŸ—‘ + + + +``` + +`Action` owns no panel semantics (the panel doesn't know what it does); it's a +`type=button` with passthrough. Repeatable. + +## Stages (minimize / maximize / restore) + +`StageTrigger` switches the panel's `stage`. Each takes a target stage and +**toggles** (clicking it while already in that stage returns to `'default'`): + +```svelte + + – + β–’ + + +``` + +- **`minimized`** β€” collapses to the header (body + resize handles hidden); still draggable. +- **`maximized`** β€” fills `bounds` (the container for `absolute`, the viewport for `fixed`); drag + resize disabled, corners squared. +- **`default`** β€” restores. Because the maximized geometry is computed (never written to state), restore needs no saved-rect bookkeeping. + +`stage` is bindable + emits `onStageChange`; Content carries `data-stage`. + +## Keyboard move / resize (WCAG 2.1.1 + 2.5.7) + +Drag and resize are fully keyboard-operable via a **grab-mode transaction** (the +desktop-window model), so the panel isn't a pointer-only / dragging-movement trap: + +- The **Header** is a focusable move-handle (`tabindex=0`, `aria-label`, + `aria-keyshortcuts="Enter Space"`). Tab to it, **Enter/Space** to grab, then: + - **Arrows** move 1px Β· **Shift+Arrow** 10px Β· **PageUp/Down** 50px Β· **Home/End** jump to the bounds edge. + - **Enter/Space** drops (commit) Β· **Escape** cancels and reverts to the pre-grab position. +- `` is a focusable se-corner button that owns **both** + pointer resize (drag the grip) and keyboard resize β€” so resize is keyboard-operable + (WCAG 2.1.1), not pointer-only. Same grab model: **Enter** to grab, arrows + grow/shrink (se-anchored), **Home/End** = min/max size, **Enter** confirms, **Escape** reverts. +- A live region announces the grab instructions, the **debounced** position/size + (one readout per nudge burst), bound/min/max clamps (assertive), and cancel. + Out-of-mode arrows bubble normally (the grab gate means they don't hijack). +- Guards: no keyboard move while `maximized`; resize only in the `default` stage. + Content carries `aria-roledescription="window"`; the active handle gets `data-grabbed`. + +Geometry is committed to `$state` (the imperative geometry effect re-applies the +DOM) β€” the same source-of-truth path as pointer drag, so `onPositionChange` / +`onSizeChange` fire identically. + +## Data attributes + +- Content: `data-float-panel-content`, `data-state`, `data-stage`, `data-dragging`, `data-resizing`, `data-topmost`, `data-behind`, `data-last-action`, `data-starting-style`/`data-ending-style` +- Header: `data-float-panel-header`, `data-dragging`, `data-grabbed` (keyboard move mode) +- ResizeGrip: `data-float-panel-resize-grip`, `data-grabbed` (keyboard resize mode) +- ResizeHandle: `data-float-panel-resize-handle`, `data-resize-edge` +- Trigger/Title/Close/Controls/Action/StageTrigger/Body: `data-float-panel-{part}` + +## Sema events + +| Event | Family Β· Verb | Target | Sequence | When | +| --- | --- | --- | --- | --- | +| `present` | emerge Β· present | content | pre | Panel opens | +| `close` | emerge\|commit\|signal Β· (polymorphic) | content | pre | Panel closes | +| `drag-start` | handle Β· pick | content | pre | Move gesture begins (sound + haptic) | +| `drag-end` | handle Β· drop | content | post | Move gesture ends | +| `resize-start` / `resize-end` | handle Β· resize | content | pre / post | Resize begins / ends | + +Continuous move/resize do NOT emit per-frame (virtual-list doctrine β€” avoids haptic buzz). Pack: `src/uix/sema/components/float-panel.ts`. + +## Baseline + +No Air baseline β€” new component. Reference: Ark UI Floating Panel. Composition mirrors Popover; drag/resize use the custom-pointer pattern (number-field/css-field scrubber); the stacking registry is new. + +## Comparativa + +| Feature | Ark UI | react-rnd | Mantine | Radix/Chakra/MUI | FloatPanel | +| --- | --- | --- | --- | --- | --- | +| Draggable | βœ… | βœ… | βœ… | βœ— (lib) | βœ… | +| Resizable | βœ… | βœ… | βœ— | βœ— | βœ… (opt-in) | +| Viewport bounds | βœ… | βœ… | βœ… | β€” | βœ… | +| Bring-to-front / stacking | βœ… | βœ— | βœ— | β€” | βœ… | +| Anchoredβ†’free | ⚠️ | βœ— | βœ— | anchored only | βœ… | +| Stages (min/max/restore) | βœ… | βœ— | βœ— | βœ— | βœ… | +| Composable header buttons | βœ… | βœ— | βœ— | β€” | βœ… | +| **Keyboard move/resize + live-region** | βœ— | βœ— | βœ— | βœ— | βœ… | +| Perceptual (sound/haptic) | βœ— | βœ— | βœ— | βœ— | βœ… | +| ARIA dialog + focus | βœ… | βœ— | βœ— | βœ… | βœ… | + +## Decisiones + +- **General-purpose primitive**, not a palabras-specific panel. Any consumer can host arbitrary content. +- **Fixed positioning + custom drag/resize** over floating-ui/gesture (see "Why not" above). +- **Non-modal by default**; interact-outside does NOT close (tool-panel ergonomics). +- **Shared stacking singleton** as a lightweight window manager. + +## Gaps (v2 roadmap) + +| Gap | DisposiciΓ³n | Detalle | +| --- | --- | --- | +| Modal visual backdrop | **diferir** | `modal` traps focus + locks scroll today; a dimming overlay element (Γ  la Dialog) is the missing visual half. | +| Position/size persistence | **diferir** | `localStorage` by id. | +| Snap-to-grid Β· lock aspect ratio | **diferir** | Ark `gridSize` / `lockAspectRatio`; react-rnd grid. | +| Live anchor-follow until first drag | **diferir** | Re-track the anchor on scroll/resize before detach. | + +Keyboard move/resize + live-region β€” **shipped** (see "Keyboard move / resize" above); +this was the industry gap no competitor covers. diff --git a/src/uix/soma/components/float-panel/components/float-panel-action.svelte b/src/uix/soma/components/float-panel/components/float-panel-action.svelte new file mode 100644 index 000000000..6bb856124 --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-action.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-body.svelte b/src/uix/soma/components/float-panel/components/float-panel-body.svelte new file mode 100644 index 000000000..f0598e23a --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-body.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-close.svelte b/src/uix/soma/components/float-panel/components/float-panel-close.svelte new file mode 100644 index 000000000..fb24f107c --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-close.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-content.svelte b/src/uix/soma/components/float-panel/components/float-panel-content.svelte new file mode 100644 index 000000000..77604942e --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-content.svelte @@ -0,0 +1,35 @@ + + +{#if state.isPresent} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
{@render children?.()}
+ {/if} +{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-controls.svelte b/src/uix/soma/components/float-panel/components/float-panel-controls.svelte new file mode 100644 index 000000000..388333fca --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-controls.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-header.svelte b/src/uix/soma/components/float-panel/components/float-panel-header.svelte new file mode 100644 index 000000000..f19c451aa --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-header.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-resize-grip.svelte b/src/uix/soma/components/float-panel/components/float-panel-resize-grip.svelte new file mode 100644 index 000000000..fecb4dca4 --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-resize-grip.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-resize-handle.svelte b/src/uix/soma/components/float-panel/components/float-panel-resize-handle.svelte new file mode 100644 index 000000000..f28a1bdf0 --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-resize-handle.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-stage-trigger.svelte b/src/uix/soma/components/float-panel/components/float-panel-stage-trigger.svelte new file mode 100644 index 000000000..cc159197a --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-stage-trigger.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-title.svelte b/src/uix/soma/components/float-panel/components/float-panel-title.svelte new file mode 100644 index 000000000..f2ac49317 --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-title.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
{@render children?.()}
+{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel-trigger.svelte b/src/uix/soma/components/float-panel/components/float-panel-trigger.svelte new file mode 100644 index 000000000..79f24c334 --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel-trigger.svelte @@ -0,0 +1,33 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/float-panel/components/float-panel.svelte b/src/uix/soma/components/float-panel/components/float-panel.svelte new file mode 100644 index 000000000..3e72a4eed --- /dev/null +++ b/src/uix/soma/components/float-panel/components/float-panel.svelte @@ -0,0 +1,97 @@ + + +{@render children?.()} diff --git a/src/uix/soma/components/float-panel/exports.ts b/src/uix/soma/components/float-panel/exports.ts new file mode 100644 index 000000000..662e16876 --- /dev/null +++ b/src/uix/soma/components/float-panel/exports.ts @@ -0,0 +1,33 @@ +export { default as Provider } from './components/float-panel.svelte'; +export { default as Trigger } from './components/float-panel-trigger.svelte'; +export { default as Content } from './components/float-panel-content.svelte'; +export { default as Header } from './components/float-panel-header.svelte'; +export { default as Title } from './components/float-panel-title.svelte'; +export { default as Close } from './components/float-panel-close.svelte'; +export { default as Controls } from './components/float-panel-controls.svelte'; +export { default as Action } from './components/float-panel-action.svelte'; +export { default as StageTrigger } from './components/float-panel-stage-trigger.svelte'; +export { default as Body } from './components/float-panel-body.svelte'; +export { default as ResizeHandle } from './components/float-panel-resize-handle.svelte'; +export { default as ResizeGrip } from './components/float-panel-resize-grip.svelte'; + +export type { + FloatPanelSide, + FloatPanelAlign, + FloatPanelPosition, + FloatPanelSize, + FloatPanelResizeEdge, + FloatPanelStage, + FloatPanelProps as ProviderProps, + FloatPanelTriggerProps as TriggerProps, + FloatPanelContentProps as ContentProps, + FloatPanelHeaderProps as HeaderProps, + FloatPanelTitleProps as TitleProps, + FloatPanelCloseProps as CloseProps, + FloatPanelControlsProps as ControlsProps, + FloatPanelActionProps as ActionProps, + FloatPanelStageTriggerProps as StageTriggerProps, + FloatPanelBodyProps as BodyProps, + FloatPanelResizeHandleProps as ResizeHandleProps, + FloatPanelResizeGripProps as ResizeGripProps +} from './types'; diff --git a/src/uix/soma/components/float-panel/float-panel-provider.svelte.test.ts b/src/uix/soma/components/float-panel/float-panel-provider.svelte.test.ts new file mode 100644 index 000000000..927365cbc --- /dev/null +++ b/src/uix/soma/components/float-panel/float-panel-provider.svelte.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { createActiveTimers } from '$timer/active-timers.svelte'; +import { state } from '$libs/reactive'; +import type { Morfo } from '$uix/morfo'; +import { Soma } from '$soma/core/soma.svelte'; +import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; + +import { FloatPanelProvider } from './float-panel-provider.svelte'; +import { floatPanelStack } from '../../layers/stacking.svelte'; +import type { FloatPanelPosition, FloatPanelSize, FloatPanelStage } from './types'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +function installSomaHarness() { + const dom = createActiveDom(); + const timers = createActiveTimers(); + const disposeDom = dom.dispose.bind(dom); + dom.dispose = () => { + timers.dispose(); + disposeDom(); + }; + const soma = { + dom, + uix: { timers }, + langs: { getLocale: () => 'en-US', ts: (key: string) => key }, + prefs: { getDir: () => 'ltr' }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(FloatPanelProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function createOpts() { + return { + id: state('float-panel-root'), + ref: state(null), + open: state(false), + position: state(undefined), + size: state(undefined), + stage: state('default'), + modal: state(false), + anchor: state(null), + side: state<'top' | 'right' | 'bottom' | 'left'>('bottom'), + align: state<'start' | 'center' | 'end'>('start'), + offset: state(8), + defaultPosition: state(undefined), + defaultSize: state(undefined), + draggable: state(true), + dragHandle: state<'header' | 'content'>('header'), + resizable: state(true), + minWidth: state(160), + minHeight: state(120), + maxWidth: state(undefined), + maxHeight: state(undefined), + bounds: state<'viewport' | HTMLElement>('viewport'), + strategy: state<'fixed' | 'absolute'>('fixed'), + dismissible: state(true), + closeOnEscape: state(true), + closeOnInteractOutside: state(false), + zIndex: state(undefined), + onOpenChangeComplete: state<((open: boolean) => void) | undefined>(undefined) + }; +} + +describe('floatPanelStack', () => { + afterEach(() => { + ['a', 'b', 'c'].forEach((id) => floatPanelStack.unregister(id)); + }); + + it('orders panels, brings to front, and assigns ascending z-index', () => { + const s = floatPanelStack; + s.register('a'); + s.register('b'); + s.register('c'); + expect(s.isTopmost('c')).toBe(true); + expect(s.isBehind('a')).toBe(true); + expect(s.zIndexOf('a')).toBeLessThan(s.zIndexOf('c')); + + s.bringToFront('a'); + expect(s.isTopmost('a')).toBe(true); + expect(s.isTopmost('c')).toBe(false); + + s.unregister('a'); + // 'a' was frontmost; removing it leaves [b, c] β†’ 'c' is now top. + expect(s.isTopmost('c')).toBe(true); + }); +}); + +describe('FloatPanelProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('seeds position (viewport-centered) and size on open', () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + + provider.handleOpen(); + expect(opts.open.current).toBe(true); + // Default size 320Γ—240, centered in the live test-browser viewport. + const vw = window.innerWidth; + const vh = window.innerHeight; + expect(opts.size.current).toEqual({ width: 320, height: 240 }); + expect(opts.position.current).toEqual({ x: (vw - 320) / 2, y: (vh - 240) / 2 }); + + cleanup(); + dom.dispose(); + }); + + it('clamps position within the viewport bounds', () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + + const size = { width: 300, height: 200 }; + const vw = window.innerWidth; + const vh = window.innerHeight; + expect(provider.clampPosition(99999, 99999, size)).toEqual({ x: vw - 300, y: vh - 200 }); + expect(provider.clampPosition(-50, -50, size)).toEqual({ x: 0, y: 0 }); + + cleanup(); + dom.dispose(); + }); + + it('clamps size to min/max', () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + opts.minWidth.current = 200; + opts.minHeight.current = 150; + opts.maxWidth.current = 400; + opts.maxHeight.current = 300; + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + + expect(provider.clampSize(100, 100)).toEqual({ width: 200, height: 150 }); + expect(provider.clampSize(999, 999)).toEqual({ width: 400, height: 300 }); + expect(provider.clampSize(300, 250)).toEqual({ width: 300, height: 250 }); + + cleanup(); + dom.dispose(); + }); + + it('places against an anchor on open (side=bottom, align=start)', () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + const anchor = document.createElement('div'); + anchor.getBoundingClientRect = () => + ({ left: 100, top: 100, right: 220, bottom: 140, width: 120, height: 40 }) as DOMRect; + opts.anchor.current = anchor; + opts.defaultSize.current = { width: 200, height: 150 }; + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + + provider.handleOpen(); + // bottom + start: x = anchor.left, y = anchor.bottom + offset(8) + expect(opts.position.current).toEqual({ x: 100, y: 148 }); + + cleanup(); + dom.dispose(); + }); + + it('closes via dismissWith', async () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + + provider.handleOpen(); + expect(opts.open.current).toBe(true); + await provider.dismissWith('dismiss'); + expect(opts.open.current).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('toggles stage and blocks drag while maximized', () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + + provider.handleOpen(); + expect(provider.stage).toBe('default'); + + // toggleStage switches to the target... + provider.toggleStage('maximized'); + expect(opts.stage.current).toBe('maximized'); + // ...and toggles back to 'default' when already there. + provider.toggleStage('maximized'); + expect(opts.stage.current).toBe('default'); + + // While maximized, a drag gesture is a no-op (the panel fills the bounds). + provider.setStage('maximized'); + const down = new PointerEvent('pointerdown', { button: 0, clientX: 10, clientY: 10 }); + Object.defineProperty(down, 'currentTarget', { value: document.createElement('div') }); + provider.startDrag(down); + expect(provider.dragging).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('keyboard grab-mode moves, reverts on cancel, and guards by stage', () => { + const { dom } = installSomaHarness(); + const opts = createOpts(); + // Small size + position so the move stays well within ANY test viewport + // (this .svelte.test runs in a real browser whose width varies). + opts.size.current = { width: 40, height: 40 }; + opts.position.current = { x: 50, y: 50 }; + const { result: provider, cleanup } = withEffectRoot(() => FloatPanelProvider.create(opts)); + provider.handleOpen(); + + // Enter move mode (snapshots the pre-grab position). + provider.kbToggleMove(); + expect(provider.moveMode).toBe(true); + + // Arrow nudges write state (fine = 1px, coarse = 10px), within bounds. + provider.kbMove(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + provider.kbMove(new KeyboardEvent('keydown', { key: 'ArrowDown', shiftKey: true })); + expect(opts.position.current).toEqual({ x: 51, y: 60 }); + + // Escape reverts to the pre-grab snapshot and exits the mode. + provider.kbCancelMove(); + expect(provider.moveMode).toBe(false); + expect(opts.position.current).toEqual({ x: 50, y: 50 }); + + // Guard: keyboard move is refused while maximized. + provider.setStage('maximized'); + provider.kbToggleMove(); + expect(provider.moveMode).toBe(false); + + cleanup(); + dom.dispose(); + }); +}); diff --git a/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts b/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts new file mode 100644 index 000000000..a26c84eb3 --- /dev/null +++ b/src/uix/soma/components/float-panel/float-panel-provider.svelte.ts @@ -0,0 +1,1421 @@ +import { untrack } from 'svelte'; +import { context } from '../../provider'; +import { + state, + readableActive, + type Active, + type ActiveProps, + type State, + type StateProps +} from '$libs/reactive'; +import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent } from '../../types'; +import { KEYS } from '../../keyboard'; +import { Soma } from '../../core/soma.svelte'; +import { FLOAT_PANEL_LANGS } from './langs'; + +import { Presence } from '../../layers/presence.svelte'; +import { FocusScope } from '../../layers/focus-scope.svelte'; +import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; +import { ScrollLock } from '../../layers/scroll-lock.svelte'; +import { TextSelection } from '../../layers/text-selection.svelte'; +import { floatPanelStack } from '../../layers/stacking.svelte'; + +import { floatPanelMorfo } from '../../../morfo/components/float-panel'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import type { + FloatPanelPosition, + FloatPanelSize, + FloatPanelSide, + FloatPanelAlign, + FloatPanelResizeEdge, + FloatPanelStage +} from './types'; + +// ── Dismiss cause map (book Β§5.3 polymorphic close) β€” mirror of Popover ───── + +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' } } +}; + +const DEFAULT_SIZE: FloatPanelSize = { width: 320, height: 240 }; +const MOVE_BUFFER = 5; +const NON_DRAG_SELECTOR = 'button, a, input, select, textarea, [data-float-panel-no-drag]'; + +// Keyboard step ladder (CSS px) + live-region debounce. +const KB_STEP_FINE = 1; +const KB_STEP_COARSE = 10; +const KB_STEP_PAGE = 50; +const KB_ANNOUNCE_DEBOUNCE_MS = 300; + +// ── Provider (root β€” virtual) ─────────────────────────────────────────────── + +interface FloatPanelOpts + extends + StateProps<{ + open: boolean; + position: FloatPanelPosition | undefined; + size: FloatPanelSize | undefined; + stage: FloatPanelStage; + }>, + ActiveProps<{ + modal: boolean; + anchor: HTMLElement | null; + side: FloatPanelSide; + align: FloatPanelAlign; + offset: number; + defaultPosition: FloatPanelPosition | undefined; + defaultSize: FloatPanelSize | undefined; + draggable: boolean; + dragHandle: 'header' | 'content'; + resizable: boolean; + minWidth: number; + minHeight: number; + maxWidth: number | undefined; + maxHeight: number | undefined; + bounds: 'viewport' | HTMLElement; + strategy: 'fixed' | 'absolute'; + dismissible: boolean; + closeOnEscape: boolean; + closeOnInteractOutside: boolean; + zIndex: number | undefined; + onOpenChangeComplete: OnChangeFn | undefined; + }> { + id: Active; + ref?: State; +} + +export class FloatPanelProvider { + static readonly ctx = context('FloatPanel'); + static get(): FloatPanelProvider | undefined { + return this.ctx.getOr(undefined) as FloatPanelProvider | undefined; + } + static require(): FloatPanelProvider { + return this.ctx.get(); + } + static create(opts: FloatPanelOpts) { + return new FloatPanelProvider(opts); + } + + readonly opts: FloatPanelOpts; + readonly soma: Soma; + readonly runtime: SomaRuntime; + readonly runtimePart: SomaRuntimePart; + readonly contentPresence: Presence; + + dragging = $state(false); + resizing = $state(false); + // Keyboard "grab mode" β€” Enter/Space on the Header (move) or ResizeGrip + // (resize) toggles these; only IN a mode do the arrow keys act. They drive + // `data-grabbed` + gate the morfo keyboard entries (prop-truthy condition). + moveMode = $state(false); + resizeMode = $state(false); + + private contentRef = state(null); + private triggerRef = state(null); + + contentId = state(''); + triggerId = state(''); + titleId = state(''); + descriptionId = state(''); + + private constructor(opts: FloatPanelOpts) { + this.opts = opts; + FloatPanelProvider.ctx.set(this); + this.soma = Soma.require(); + + this.contentPresence = new Presence({ + dom: this.soma.dom, + open: opts.open, + ref: this.contentRef, + onComplete: (open) => opts.onOpenChangeComplete.current?.(open) + }); + + this.runtime = this.soma.runtime(floatPanelMorfo, { + states: { open: () => opts.open.current }, + props: { + modal: () => opts.modal.current, + stage: () => this.stage, + dragging: () => this.dragging, + resizing: () => this.resizing, + // Gate the keyboard "grab mode" entries + drive data-grabbed. + draggable: () => opts.draggable.current, + resizable: () => opts.resizable.current, + dragHandle: () => opts.dragHandle.current, + moveMode: () => this.moveMode, + resizeMode: () => this.resizeMode, + topmost: () => floatPanelStack.isTopmost(this.opts.id.current), + behind: () => floatPanelStack.isBehind(this.opts.id.current), + ariaLabelledby: () => this.titleId.current || this.triggerId.current || undefined, + ariaDescribedby: () => this.descriptionId.current || undefined + }, + parts: { + trigger: () => this.triggerId.current, + content: () => this.contentId.current, + title: () => this.titleId.current + }, + events: { + present: () => this.handleOpen(), + close: () => this.handleClose() + }, + // Keyboard actions dispatched by runtime.keydown() from the Header / + // ResizeGrip onkeydown. One handler per family reads the event. + actions: { + 'kb-move-toggle': () => this.kbToggleMove(), + 'kb-move': (e: KeyboardEvent) => this.kbMove(e), + 'kb-move-cancel': () => this.kbCancelMove(), + 'kb-resize-toggle': () => this.kbToggleResize(), + 'kb-resize': (e: KeyboardEvent) => this.kbResize(e), + 'kb-resize-cancel': () => this.kbCancelResize() + } + }); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: FloatPanelProvider.ctx, + syncAttrs: true + }); + + // Register in the shared stacking order while open; remove on close/unmount. + $effect(() => { + const id = this.opts.id.current; + if (!this.opts.open.current) return; + // Untrack the whole body β€” seedRect reads+writes opts.position/size and + // register reads+writes the shared `order`; tracking either would make + // this effect self-invalidate (A36 read-write loop). The effect tracks + // only open + id. `register` appends on top, so a freshly opened panel + // is already frontmost β€” no separate bringToFront needed here. + untrack(() => { + this.seedRect(); + floatPanelStack.register(id); + }); + return () => floatPanelStack.unregister(id); + }); + + // Clear any open keyboard grab-mode + its pending readout when the panel + // closes, so a stale mode can't persist across open/close. + $effect(() => { + if (this.opts.open.current) return; + this.moveMode = false; + this.resizeMode = false; + this.preGrabPos = null; + this.preGrabSize = null; + this.clearReadout(); + }); + + $effect(() => { + return () => { + this.dragCleanup?.(); + this.resizeCleanup?.(); + this.clearReadout(); + }; + }); + } + + // ── Open / close ──────────────────────────────────────────────────────── + + handleOpen() { + this.seedRect(); + this.opts.open.current = true; + } + + handleClose() { + this.opts.open.current = false; + } + + toggle() { + if (this.opts.open.current) void this.dismissWith('dismiss'); + else void this.runtime.trigger('present', { fallbackTarget: this.triggerRef.current ?? undefined }); + } + + 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') ?? undefined; + 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) { + this.contentRef.current = el; + } + setTriggerRef(el: HTMLElement | null) { + this.triggerRef.current = el; + } + + bringToFront() { + floatPanelStack.bringToFront(this.opts.id.current); + } + + // ── Geometry ────────────────────────────────────────────────────────────── + + get currentSize(): FloatPanelSize { + return this.opts.size.current ?? this.opts.defaultSize.current ?? DEFAULT_SIZE; + } + get currentPos(): FloatPanelPosition { + return this.opts.position.current ?? { x: 0, y: 0 }; + } + + // ── Stage (minimize / maximize / restore) ───────────────────────────────── + + get stage(): FloatPanelStage { + return this.opts.stage.current ?? 'default'; + } + setStage(stage: FloatPanelStage) { + // Leaving a movable/resizable stage cancels any open keyboard grab-mode + // (reverting the pre-grab geometry) so a stale data-grabbed ring + a ghost + // arrow interaction can't persist across a maximize / minimize. + if (this.moveMode && stage === 'maximized') this.kbCancelMove(); + if (this.resizeMode && stage !== 'default') this.kbCancelResize(); + this.opts.stage.current = stage; + } + /** Switch to `target`, or back to `'default'` if already there (toggle). */ + toggleStage(target: FloatPanelStage) { + this.setStage(this.stage === target ? 'default' : target); + } + + /** Geometry when maximized β€” fills the bounds (container box or viewport). */ + maximizedRect(): { x: number; y: number; width: number; height: number } { + const b = this.boundsRect(); + return { x: b.left, y: b.top, width: b.right - b.left, height: b.bottom - b.top }; + } + + // ── Keyboard move / resize (grab-mode transaction) ──────────────────────── + // Entering a mode snapshots the pre-grab geometry so Escape can revert. + // Steps write $state via setPosition/setSize (the geometry $effect re-applies + // the DOM); the action NEVER touches el.style directly. Per-keypress position/ + // size readouts are debounced; mode-enter / cancel / bound-hit announce now. + private preGrabPos: FloatPanelPosition | null = null; + private preGrabSize: FloatPanelSize | null = null; + private kbAxis: 'x' | 'y' = 'x'; + private readoutTimer: number | null = null; + private readoutKind: 'position' | 'size' | null = null; + + private announce( + lang: string, + params?: Record, + priority: 'polite' | 'assertive' = 'polite' + ): void { + let msg = this.soma.langs.ts(lang); + if (params) for (const [k, val] of Object.entries(params)) msg = msg.replaceAll(`{${k}}`, String(val)); + this.soma.uix?.announce?.(msg, priority); + } + private posParams(p: FloatPanelPosition) { + return { x: Math.round(p.x), y: Math.round(p.y) }; + } + private sizeParams(s: FloatPanelSize) { + return { width: Math.round(s.width), height: Math.round(s.height) }; + } + + private scheduleReadout(kind: 'position' | 'size'): void { + this.clearReadout(); + this.readoutKind = kind; + const win = this.soma.dom.getWindow(this.contentRef.current ?? undefined); + this.readoutTimer = win.setTimeout(() => { + this.readoutTimer = null; + if (this.readoutKind === 'position') + this.announce(FLOAT_PANEL_LANGS.KB_POSITION, this.posParams(this.currentPos)); + else if (this.readoutKind === 'size') + this.announce(FLOAT_PANEL_LANGS.KB_SIZE, this.sizeParams(this.currentSize)); + this.readoutKind = null; + }, KB_ANNOUNCE_DEBOUNCE_MS) as unknown as number; + } + private clearReadout(): void { + if (this.readoutTimer != null) { + this.soma.dom.getWindow(this.contentRef.current ?? undefined).clearTimeout(this.readoutTimer); + this.readoutTimer = null; + } + } + + kbToggleMove(): void { + if (!this.opts.draggable.current) return; + // The key is consumed by the morfo plan (gated on `draggable`) even when + // maximized β€” so explain why nothing happens rather than swallow it. + if (this.stage === 'maximized') { + this.announce(FLOAT_PANEL_LANGS.KB_MOVE_BLOCKED, undefined, 'assertive'); + return; + } + if (this.moveMode) { + this.clearReadout(); + this.moveMode = false; + this.preGrabPos = null; + this.announce(FLOAT_PANEL_LANGS.KB_POSITION, this.posParams(this.currentPos)); + return; + } + this.preGrabPos = { ...this.currentPos }; + this.moveMode = true; + this.announce(FLOAT_PANEL_LANGS.KB_MOVE_ENTER); + } + + kbMove(e: KeyboardEvent): void { + if (!this.moveMode) return; + const step = e.shiftKey ? KB_STEP_COARSE : KB_STEP_FINE; + const bounds = this.boundsRect(); + const size = this.currentSize; + const before = this.currentPos; + let { x, y } = before; + switch (e.key) { + case 'ArrowUp': y -= step; this.kbAxis = 'y'; break; + case 'ArrowDown': y += step; this.kbAxis = 'y'; break; + case 'ArrowLeft': x -= step; this.kbAxis = 'x'; break; + case 'ArrowRight': x += step; this.kbAxis = 'x'; break; + case 'PageUp': y -= KB_STEP_PAGE; this.kbAxis = 'y'; break; + case 'PageDown': y += KB_STEP_PAGE; this.kbAxis = 'y'; break; + case 'Home': if (this.kbAxis === 'x') x = bounds.left; else y = bounds.top; break; + case 'End': if (this.kbAxis === 'x') x = bounds.right - size.width; else y = bounds.bottom - size.height; break; + default: return; + } + const next = this.clampPosition(this.snapPx(x), this.snapPx(y), size, bounds); + if (next.x === before.x && next.y === before.y) { + this.announce(FLOAT_PANEL_LANGS.KB_BOUND, undefined, 'assertive'); + return; + } + this.setPosition(next); + this.scheduleReadout('position'); + } + + kbCancelMove(): void { + if (!this.moveMode) return; + this.clearReadout(); + if (this.preGrabPos) this.setPosition(this.preGrabPos); + this.moveMode = false; + this.preGrabPos = null; + this.announce(FLOAT_PANEL_LANGS.KB_CANCELLED); + } + + kbToggleResize(): void { + if (!this.opts.resizable.current || this.stage !== 'default') return; + if (this.resizeMode) { + this.clearReadout(); + this.resizeMode = false; + this.preGrabSize = null; + this.announce(FLOAT_PANEL_LANGS.KB_SIZE, this.sizeParams(this.currentSize)); + return; + } + this.preGrabSize = { ...this.currentSize }; + this.resizeMode = true; + this.announce(FLOAT_PANEL_LANGS.KB_RESIZE_ENTER); + } + + kbResize(e: KeyboardEvent): void { + if (!this.resizeMode) return; + const step = e.shiftKey ? KB_STEP_COARSE : KB_STEP_FINE; + const before = this.currentSize; + let { width, height } = before; + switch (e.key) { + case 'ArrowRight': width += step; break; + case 'ArrowLeft': width -= step; break; + case 'ArrowDown': height += step; break; + case 'ArrowUp': height -= step; break; + case 'Home': width = this.opts.minWidth.current; height = this.opts.minHeight.current; break; + case 'End': { + const b = this.maximizedRect(); + width = this.opts.maxWidth.current ?? b.width; + height = this.opts.maxHeight.current ?? b.height; + break; + } + default: return; + } + const growing = width > before.width || height > before.height; + const next = this.clampSize(this.snapPx(width), this.snapPx(height)); + if (next.width === before.width && next.height === before.height) { + this.announce( + growing ? FLOAT_PANEL_LANGS.KB_MAX_SIZE : FLOAT_PANEL_LANGS.KB_MIN_SIZE, + undefined, + 'assertive' + ); + return; + } + // se-anchored: top-left stays fixed β†’ only size changes, position untouched. + this.setSize(next); + this.scheduleReadout('size'); + } + + kbCancelResize(): void { + if (!this.resizeMode) return; + this.clearReadout(); + if (this.preGrabSize) this.setSize(this.preGrabSize); + this.resizeMode = false; + this.preGrabSize = null; + this.announce(FLOAT_PANEL_LANGS.KB_CANCELLED); + } + + readonly zIndex = $derived.by( + () => this.opts.zIndex.current ?? floatPanelStack.zIndexOf(this.opts.id.current) + ); + + private boundsRect(): { left: number; top: number; right: number; bottom: number } { + const b = this.opts.bounds.current; + // Absolute: coordinates are container-relative β†’ bound to the container's + // client box (0,0 .. clientWidth,clientHeight). + if (this.opts.strategy.current === 'absolute') { + const container = + b && b !== 'viewport' + ? b + : ((this.contentRef.current?.offsetParent as HTMLElement | null) ?? null); + if (container) { + return { left: 0, top: 0, right: container.clientWidth, bottom: container.clientHeight }; + } + return { left: 0, top: 0, right: Infinity, bottom: Infinity }; + } + // Fixed: viewport coordinates. + const win = this.soma.dom.getWindow(this.contentRef.current ?? undefined); + if (!b || b === 'viewport') { + return { left: 0, top: 0, right: win.innerWidth, bottom: win.innerHeight }; + } + const r = b.getBoundingClientRect(); + return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + } + + clampPosition( + x: number, + y: number, + size: FloatPanelSize, + bounds?: { left: number; top: number; right: number; bottom: number } + ): FloatPanelPosition { + const bd = bounds ?? this.boundsRect(); + const maxX = Math.max(bd.left, bd.right - size.width); + const maxY = Math.max(bd.top, bd.bottom - size.height); + return { + x: Math.min(Math.max(x, bd.left), maxX), + y: Math.min(Math.max(y, bd.top), maxY) + }; + } + + clampSize(width: number, height: number): FloatPanelSize { + const minW = this.opts.minWidth.current; + const minH = this.opts.minHeight.current; + const maxW = this.opts.maxWidth.current ?? Infinity; + const maxH = this.opts.maxHeight.current ?? Infinity; + return { + width: Math.min(Math.max(width, minW), maxW), + height: Math.min(Math.max(height, minH), maxH) + }; + } + + /** Seed position/size on open if not already controlled. */ + private seedRect(): void { + if (this.opts.size.current == null) { + this.opts.size.current = this.opts.defaultSize.current ?? DEFAULT_SIZE; + } + if (this.opts.position.current == null) { + this.opts.position.current = this.computeInitialPosition(); + } + } + + private absoluteContainer(): HTMLElement | null { + const b = this.opts.bounds.current; + if (b && b !== 'viewport') return b; + return (this.contentRef.current?.offsetParent as HTMLElement | null) ?? null; + } + + private computeInitialPosition(): FloatPanelPosition { + const size = this.currentSize; + const strategy = this.opts.strategy.current; + const dp = this.opts.defaultPosition.current; + if (dp) return this.clampPosition(dp.x, dp.y, size); + + const anchor = this.opts.anchor.current; + if (anchor) { + const r = anchor.getBoundingClientRect(); + const off = this.opts.offset.current; + const side = this.opts.side.current; + const align = this.opts.align.current; + let x = r.left; + let y = r.bottom + off; + if (side === 'top') y = r.top - size.height - off; + else if (side === 'bottom') y = r.bottom + off; + else if (side === 'left') { + x = r.left - size.width - off; + y = r.top; + } else if (side === 'right') { + x = r.right + off; + y = r.top; + } + if (side === 'top' || side === 'bottom') { + if (align === 'center') x = r.left + r.width / 2 - size.width / 2; + else if (align === 'end') x = r.right - size.width; + else x = r.left; + } else { + if (align === 'center') y = r.top + r.height / 2 - size.height / 2; + else if (align === 'end') y = r.bottom - size.height; + else y = r.top; + } + // Absolute: anchor rect is viewport-space; convert to container-relative. + if (strategy === 'absolute') { + const container = this.absoluteContainer(); + if (container) { + const cr = container.getBoundingClientRect(); + x = x - cr.left + container.scrollLeft; + y = y - cr.top + container.scrollTop; + } + } + return this.clampPosition(x, y, size); + } + + // Centered β€” within the container (absolute) or the viewport (fixed). + if (strategy === 'absolute') { + const bd = this.boundsRect(); + return this.clampPosition( + (bd.right - size.width) / 2, + (bd.bottom - size.height) / 2, + size + ); + } + const win = this.soma.dom.getWindow(); + return this.clampPosition( + (win.innerWidth - size.width) / 2, + (win.innerHeight - size.height) / 2, + size + ); + } + + private setPosition(pos: FloatPanelPosition): void { + // The bindable State setter (wrapper) fires onPositionChange. + this.opts.position.current = pos; + } + private setSize(size: FloatPanelSize): void { + // The bindable State setter (wrapper) fires onSizeChange. + this.opts.size.current = size; + } + + /** + * Snap a CSS-px value to the DEVICE pixel grid. The content rides a GPU layer + * during a gesture; a sub-pixel `translate` composites with interpolation and + * blurs the panel. Integer CSS px only lands on the device grid at integer DPR + * β€” on fractional-DPR displays (Windows 150% β†’ dpr 1.5) `round(v*dpr)/dpr` is + * what actually keeps it crisp. + */ + snapPx(value: number): number { + const dpr = this.soma.dom.getWindow(this.contentRef.current ?? undefined).devicePixelRatio || 1; + return Math.round(value * dpr) / dpr; + } + + // ── Drag (move) ───────────────────────────────────────────────────────── + + private dragCleanup: (() => void) | null = null; + private dragEl: HTMLElement | null = null; + private dragPointerId = -1; + private dragStartClient: FloatPanelPosition = { x: 0, y: 0 }; + private dragStartPos: FloatPanelPosition = { x: 0, y: 0 }; + private dragMoved = false; + // Bounds captured at drag start β€” re-reading clientWidth/getBoundingClientRect + // on every pointermove forces a synchronous reflow (layout thrash β†’ jank). + private dragBoundsCache: { left: number; top: number; right: number; bottom: number } | null = + null; + // Latest dragged position, applied directly to the DOM each pointermove and + // committed to `$state` once on release (so onPositionChange fires once, not + // per frame, and the reactive geometry effect resyncs at rest). + private dragLivePos: FloatPanelPosition | null = null; + + startDrag(e: PointerEvent): void { + if (!this.opts.draggable.current || e.button !== 0) return; + // A maximized panel fills the bounds β€” it can't be moved (mirrors OS windows). + if (this.stage === 'maximized') return; + const target = e.target as HTMLElement | null; + if (target?.closest(NON_DRAG_SELECTOR)) return; // don't drag from controls + this.dragEl = e.currentTarget as HTMLElement; + this.dragPointerId = e.pointerId; + this.dragStartClient = { x: e.clientX, y: e.clientY }; + this.dragStartPos = { ...this.currentPos }; + this.dragMoved = false; + this.dragBoundsCache = this.boundsRect(); + // Enter the grabbed state on POINTERDOWN (not on first move): the panel + // lifts to its drag elevation the instant you press, so the shadow's lift + // transition runs BEFORE you move β€” during the move the shadow is static + // and the layer just translates (smooth). `dragMoved` still gates the + // actual position update + the drag-start signal on real movement. + this.dragging = true; + // Promote the content to its own compositing layer NOW (on pointerdown), + // synchronously β€” not via the `active` effect, which lands a microtask + // after the first move and would let the first frame hitch on layer + // creation. `translate` matches the property we animate, so the browser + // keeps the (blurred) box-shadow baked in the layer instead of repainting + // it each frame. This also wins over the firma's `will-change` hint + // (events.css) that the drag-start signal would otherwise apply. + this.contentRef.current?.style.setProperty('will-change', 'translate'); + const doc = this.soma.dom.getDocument(this.dragEl); + const move = this.soma.dom.listen(doc, 'pointermove', (ev) => this.onDragMove(ev as PointerEvent)); + const up = this.soma.dom.listen(doc, 'pointerup', (ev) => this.endDrag(ev as PointerEvent)); + const cancel = this.soma.dom.listen(doc, 'pointercancel', (ev) => + this.endDrag(ev as PointerEvent) + ); + this.dragCleanup = () => { + move(); + up(); + cancel(); + }; + } + + private onDragMove(e: PointerEvent): void { + if (e.pointerId !== this.dragPointerId) return; + const dx = e.clientX - this.dragStartClient.x; + const dy = e.clientY - this.dragStartClient.y; + if (!this.dragMoved) { + if (Math.sqrt(dx * dx + dy * dy) < MOVE_BUFFER) return; + this.dragMoved = true; + // `dragging` (the grabbed/lifted state) was already set on pointerdown + // in startDrag β€” here we only mark that REAL movement has begun. + try { + this.dragEl?.setPointerCapture(this.dragPointerId); + } catch { + /* already released */ + } + this.bringToFront(); + void this.runtime.trigger('drag-start', { + fallbackTarget: this.contentRef.current ?? this.dragEl ?? undefined + }); + } + e.preventDefault(); + const clamped = this.clampPosition( + this.dragStartPos.x + dx, + this.dragStartPos.y + dy, + this.currentSize, + this.dragBoundsCache ?? undefined + ); + this.dragLivePos = clamped; + // Apply in the SAME task as the pointer event: no `$state` write, no + // re-render, no cssText rebuild β€” the panel stays glued to the cursor. + // Round to whole pixels: the content is on a GPU layer (will-change) during + // the drag, and a sub-pixel `translate` composites with interpolation, which + // blurs the panel's text/borders. Snapping to the pixel grid keeps it crisp + // (ark-ui stays sharp because it positions with integer left/top + no layer). + const el = this.contentRef.current; + if (el) el.style.translate = `${this.snapPx(clamped.x)}px ${this.snapPx(clamped.y)}px`; + } + + private endDrag(e: PointerEvent): void { + this.dragCleanup?.(); + this.dragCleanup = null; + try { + this.dragEl?.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + const moved = this.dragMoved; + this.dragMoved = false; + this.dragPointerId = -1; + this.dragBoundsCache = null; + if (moved) { + // Commit the dragged position to state once (fires onPositionChange); + // the geometry effect resyncs the DOM at rest. Commit BEFORE clearing + // `dragging` so the effect's first non-active run reads the final value. + if (this.dragLivePos) this.setPosition(this.dragLivePos); + void this.runtime.trigger('drag-end', { + fallbackTarget: this.contentRef.current ?? this.dragEl ?? undefined + }); + } + // Always settle the grabbed state (it was set on pointerdown, so a press + // WITHOUT movement β€” a plain click on the header β€” must lower it too). + this.dragging = false; + this.dragLivePos = null; + this.dragEl = null; + } + + // ── Resize ──────────────────────────────────────────────────────────────── + + private resizeCleanup: (() => void) | null = null; + private resizeEl: HTMLElement | null = null; + private resizePointerId = -1; + private resizeEdge: FloatPanelResizeEdge = 'se'; + private resizeStartClient: FloatPanelPosition = { x: 0, y: 0 }; + private resizeStartPos: FloatPanelPosition = { x: 0, y: 0 }; + private resizeStartSize: FloatPanelSize = { ...DEFAULT_SIZE }; + private resizeMoved = false; + // Latest resized size/pos, written directly to the DOM each move, committed + // to `$state` once on release (same imperative pattern as drag). + private resizeLiveSize: FloatPanelSize | null = null; + private resizeLivePos: FloatPanelPosition | null = null; + + startResize(e: PointerEvent, edge: FloatPanelResizeEdge): void { + if (!this.opts.resizable.current || e.button !== 0) return; + // Resize only in the normal stage (minimized fits the header; maximized + // fills the bounds β€” both manage their own size). + if (this.stage !== 'default') return; + e.stopPropagation(); // don't also start a drag from the header/content + this.resizeEl = e.currentTarget as HTMLElement; + this.resizePointerId = e.pointerId; + this.resizeEdge = edge; + this.resizeStartClient = { x: e.clientX, y: e.clientY }; + this.resizeStartPos = { ...this.currentPos }; + this.resizeStartSize = { ...this.currentSize }; + this.resizeMoved = false; + // Lift + promote on pointerdown (see startDrag). `resizeMoved` still gates + // the actual size update + resize-start signal on real movement. + this.resizing = true; + this.contentRef.current?.style.setProperty('will-change', 'translate'); + const doc = this.soma.dom.getDocument(this.resizeEl); + const move = this.soma.dom.listen(doc, 'pointermove', (ev) => + this.onResizeMove(ev as PointerEvent) + ); + const up = this.soma.dom.listen(doc, 'pointerup', (ev) => this.endResize(ev as PointerEvent)); + const cancel = this.soma.dom.listen(doc, 'pointercancel', (ev) => + this.endResize(ev as PointerEvent) + ); + this.resizeCleanup = () => { + move(); + up(); + cancel(); + }; + } + + private onResizeMove(e: PointerEvent): void { + if (e.pointerId !== this.resizePointerId) return; + const dx = e.clientX - this.resizeStartClient.x; + const dy = e.clientY - this.resizeStartClient.y; + if (!this.resizeMoved) { + if (Math.sqrt(dx * dx + dy * dy) < MOVE_BUFFER) return; + this.resizeMoved = true; + // `resizing` was already set on pointerdown in startResize. + try { + this.resizeEl?.setPointerCapture(this.resizePointerId); + } catch { + /* already released */ + } + this.bringToFront(); + void this.runtime.trigger('resize-start', { + fallbackTarget: this.contentRef.current ?? this.resizeEl ?? undefined + }); + } + e.preventDefault(); + + const edge = this.resizeEdge; + const start = this.resizeStartSize; + let width = start.width; + let height = start.height; + if (edge.includes('e')) width = start.width + dx; + if (edge.includes('w')) width = start.width - dx; + if (edge.includes('s')) height = start.height + dy; + if (edge.includes('n')) height = start.height - dy; + + const clamped = this.clampSize(width, height); + let { x, y } = this.resizeStartPos; + // Edges anchored at the opposite side keep that side fixed. + if (edge.includes('w')) x = this.resizeStartPos.x + (start.width - clamped.width); + if (edge.includes('n')) y = this.resizeStartPos.y + (start.height - clamped.height); + + this.resizeLiveSize = clamped; + this.resizeLivePos = { x, y }; + // Direct DOM write (synchronous, no re-render); state committed on release. + // Round to whole pixels β€” crisp on the GPU layer (see onDragMove). + const el = this.contentRef.current; + if (el) { + el.style.width = `${this.snapPx(clamped.width)}px`; + el.style.height = `${this.snapPx(clamped.height)}px`; + el.style.translate = `${this.snapPx(x)}px ${this.snapPx(y)}px`; + } + } + + private endResize(e: PointerEvent): void { + this.resizeCleanup?.(); + this.resizeCleanup = null; + try { + this.resizeEl?.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + const moved = this.resizeMoved; + this.resizeMoved = false; + this.resizePointerId = -1; + if (moved) { + // Commit final size + position once; geometry effect resyncs at rest. + if (this.resizeLiveSize) this.setSize(this.resizeLiveSize); + if (this.resizeLivePos) this.setPosition(this.resizeLivePos); + void this.runtime.trigger('resize-end', { + fallbackTarget: this.contentRef.current ?? this.resizeEl ?? undefined + }); + } + // Always settle (set on pointerdown, so a no-move press must lower it too). + this.resizing = false; + this.resizeLiveSize = null; + this.resizeLivePos = null; + this.resizeEl = null; + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Trigger ───────────────────────────────────────────────────────────────── + +interface FloatPanelTriggerOpts { + id: Active; + ref: State; +} + +export class FloatPanelTriggerProvider { + static create(opts: FloatPanelTriggerOpts) { + return new FloatPanelTriggerProvider(opts); + } + + readonly opts: FloatPanelTriggerOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelTriggerOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.provider.triggerId.current = opts.id.current; + this.runtimePart = this.provider.runtime.part('trigger', { + id: opts.id, + ref: opts.ref, + owner: this, + onRefChange: (el) => this.provider.setTriggerRef(el), + syncAttrs: true + }); + } + + readonly onclick = (_e: SomaMouseEvent) => { + this.provider.toggle(); + }; + + readonly onkeydown = (e: SomaKeyboardEvent) => { + if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { + e.preventDefault(); + this.provider.toggle(); + } + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + onclick: this.onclick, + onkeydown: this.onkeydown + })); +} + +// ── Content ─────────────────────────────────────────────────────────────────── + +interface FloatPanelContentOpts { + id: Active; + ref: State; +} + +export class FloatPanelContentProvider { + static create(opts: FloatPanelContentOpts) { + return new FloatPanelContentProvider(opts); + } + + readonly opts: FloatPanelContentOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + readonly focusScope: ReturnType; + readonly dismissal: Dismissal; + readonly scrollLock: ScrollLock; + readonly textSelection: TextSelection; + + private constructor(opts: FloatPanelContentOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.provider.contentId.current = opts.id.current; + this.runtimePart = this.provider.runtime.part('content', { + id: opts.id, + ref: opts.ref, + owner: this, + onRefChange: (el) => this.provider.setContentRef(el), + syncAttrs: true + }); + + // Focus: trap only when modal (free panels keep background interactive). + this.focusScope = FocusScope.use({ + dom: this.provider.soma.dom, + onOpenAutoFocus: readableActive(() => () => {}), + onCloseAutoFocus: readableActive(() => () => {}), + trap: readableActive(() => this.provider.opts.modal.current), + loop: readableActive(() => true), + ref: opts.ref, + enabled: readableActive(() => this.provider.contentPresence.isPresent) + }); + + this.dismissal = Dismissal.use({ + ref: readableActive(() => opts.ref.current), + dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, + enabled: readableActive(() => this.provider.opts.open.current), + onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss'); + }), + escapeKeydownBehavior: readableActive(() => + this.provider.opts.dismissible.current && this.provider.opts.closeOnEscape.current + ? 'close' + : 'ignore' + ), + onInteractOutside: readableActive(() => (e: PointerEvent) => { + if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside'); + }), + interactOutsideBehavior: readableActive(() => + this.provider.opts.dismissible.current && this.provider.opts.closeOnInteractOutside.current + ? 'close' + : 'ignore' + ), + onFocusOutside: readableActive(() => () => {}), + // Return false β†’ defer to the layer's default containment check + // (clicks inside the panel are NOT "outside"). Mirrors Popover. + isValidEvent: readableActive(() => () => false) + }); + + this.scrollLock = new ScrollLock(this.provider.soma.dom); + $effect(() => { + this.scrollLock.locked.current = + this.provider.opts.open.current && this.provider.opts.modal.current; + }); + + this.textSelection = TextSelection.use({ + ref: readableActive(() => opts.ref.current), + dom: this.provider.soma.dom, + enabled: readableActive(() => this.provider.dragging || this.provider.resizing), + onPointerDown: readableActive(() => () => {}), + onPointerUp: readableActive(() => () => {}) + }); + + // Imperative inline style β€” this effect (plus the drag/resize handlers in + // the parent provider) is the SOLE owner of the content element's geometry + // and stacking. Each property is set individually (never via cssText), so + // writes compose instead of clobbering, and the drag handler's direct + // `el.style.translate` survives the will-change / z-index re-applies that + // happen at gesture start. At rest this applies seeded / programmatic / + // post-gesture geometry; DURING a gesture it cedes translate/size to the + // handlers (guarded on `active`) so the hot pointer path owns the DOM alone. + $effect(() => { + const el = this.opts.ref.current; + if (!el) return; + const p = this.provider; + const active = p.dragging || p.resizing; + const stage = p.stage; + const pos = p.currentPos; + const size = p.currentSize; + // Chrome: cheap, changes only on strategy / bringToFront / gesture toggle. + el.style.position = p.opts.strategy.current; + el.style.left = '0px'; + el.style.top = '0px'; + el.style.pointerEvents = 'auto'; + el.style.zIndex = String(p.zIndex); + el.style.willChange = active ? 'translate' : ''; + el.style.userSelect = active ? 'none' : ''; + // Geometry: only when no gesture is driving the element directly. + // Round to whole pixels β€” `translate` is a transform and doesn't snap to + // the pixel grid, so a fractional value blurs the panel's text even at + // rest (and especially on the GPU layer during a gesture). + if (active) return; + if (stage === 'maximized') { + // Fill the bounds. The stored pos/size are left untouched so a + // restore to 'default' snaps straight back β€” no rect bookkeeping. + const m = p.maximizedRect(); + el.style.translate = `${p.snapPx(m.x)}px ${p.snapPx(m.y)}px`; + el.style.width = `${p.snapPx(m.width)}px`; + el.style.height = `${p.snapPx(m.height)}px`; + } else if (stage === 'minimized') { + // Collapse to the header: keep position + width, let height auto-fit + // (the recipe hides the body under `[data-stage='minimized']`). + el.style.translate = `${p.snapPx(pos.x)}px ${p.snapPx(pos.y)}px`; + el.style.width = `${p.snapPx(size.width)}px`; + el.style.height = ''; + } else { + el.style.translate = `${p.snapPx(pos.x)}px ${p.snapPx(pos.y)}px`; + el.style.width = `${p.snapPx(size.width)}px`; + el.style.height = `${p.snapPx(size.height)}px`; + } + }); + } + + readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); + + readonly onpointerdown = (e: PointerEvent) => { + this.provider.bringToFront(); + if (this.provider.opts.dragHandle.current === 'content') this.provider.startDrag(e); + }; + + readonly props = $derived.by(() => { + return { + ...this.runtimePart.props, + ...this.provider.contentPresence.transitionAttrs, + ...this.focusScope.props, + ...this.dismissal.props, + tabindex: -1, + // NOTE: geometry + stacking (`position`, `translate`, `width`, `height`, + // `z-index`, `will-change`) are deliberately NOT in this reactive style. + // Soma serializes `style` to a full string and reassigns the element's + // cssText on every render; routing the drag position through it added a + // microtask of latency per pointermove (the DOM updated a tick behind the + // cursor) AND rebuilt the whole declaration each frame (style recalc) β€” + // the drag felt stepped. They are now applied IMPERATIVELY via individual + // property writes (the geometry `$effect` below + the drag/resize + // handlers), which (a) update the DOM synchronously in the pointer + // handler β€” 1:1 with the cursor β€” and (b) touch only the one composited + // property, so a re-render can't clobber them with a stale cssText. + onpointerdown: this.onpointerdown + } as const; + }); +} + +// ── Header (drag handle) ────────────────────────────────────────────────────── + +interface FloatPanelHeaderOpts { + id: Active; + ref: State; +} + +export class FloatPanelHeaderProvider { + static create(opts: FloatPanelHeaderOpts) { + return new FloatPanelHeaderProvider(opts); + } + + readonly opts: FloatPanelHeaderOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelHeaderOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('header', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly onpointerdown = (e: PointerEvent) => { + if (this.provider.opts.dragHandle.current === 'header') this.provider.startDrag(e); + }; + + // Keyboard move: dispatch to the morfo keyboard plan ONLY when the header + // itself is focused (not a child button), so Enter on Close etc. is untouched. + // When a key is consumed (in grab mode), STOP propagation too β€” otherwise the + // cancel-Escape would also bubble to Content's Escape=close and shut the panel. + readonly onkeydown = (e: SomaKeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (this.provider.runtime.keydown('header', e)) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + 'aria-label': this.provider.soma.langs.ts(FLOAT_PANEL_LANGS.MOVE_HANDLE_LABEL), + onpointerdown: this.onpointerdown, + onkeydown: this.onkeydown + })); +} + +// ── Title ────────────────────────────────────────────────────────────────── + +interface FloatPanelTitleOpts extends ActiveProps<{ level: number }> { + id: Active; + ref: State; +} + +export class FloatPanelTitleProvider { + static create(opts: FloatPanelTitleOpts) { + return new FloatPanelTitleProvider(opts); + } + + readonly opts: FloatPanelTitleOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelTitleOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.provider.titleId.current = opts.id.current; + this.runtimePart = this.provider.runtime.part('title', { + id: opts.id, + ref: opts.ref, + owner: this, + props: { level: () => this.opts.level.current }, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Close ──────────────────────────────────────────────────────────────────── + +interface FloatPanelCloseOpts { + id: Active; + ref: State; + action: Active<'save' | 'cancel' | 'dismiss'>; +} + +export class FloatPanelCloseProvider { + static create(opts: FloatPanelCloseOpts) { + return new FloatPanelCloseProvider(opts); + } + + readonly opts: FloatPanelCloseOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelCloseOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('close', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly onclick = (_e: SomaMouseEvent) => { + void this.provider.dismissWith(this.opts.action.current); + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + type: 'button' as const, + 'aria-label': this.provider.soma.langs.ts(FLOAT_PANEL_LANGS.CLOSE), + onclick: this.onclick + })); +} + +// ── Body ─────────────────────────────────────────────────────────────────── + +interface FloatPanelBodyOpts { + id: Active; + ref: State; +} + +export class FloatPanelBodyProvider { + static create(opts: FloatPanelBodyOpts) { + return new FloatPanelBodyProvider(opts); + } + + readonly opts: FloatPanelBodyOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelBodyOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('body', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Controls (header button group) ────────────────────────────────────────── + +interface FloatPanelControlsOpts { + id: Active; + ref: State; +} + +export class FloatPanelControlsProvider { + static create(opts: FloatPanelControlsOpts) { + return new FloatPanelControlsProvider(opts); + } + + readonly opts: FloatPanelControlsOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelControlsOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('controls', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Action (generic header button) ────────────────────────────────────────── + +interface FloatPanelActionOpts { + id: Active; + ref: State; +} + +export class FloatPanelActionProvider { + static create(opts: FloatPanelActionOpts) { + return new FloatPanelActionProvider(opts); + } + + readonly opts: FloatPanelActionOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelActionOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('action', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + // `type=button` so it never submits a form; the consumer wires `onclick` + // (and an `aria-label` for icon-only buttons) via passthrough props. + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + type: 'button' as const + })); +} + +// ── StageTrigger (minimize / maximize / restore) ───────────────────────────── + +interface FloatPanelStageTriggerOpts extends ActiveProps<{ stage: FloatPanelStage }> { + id: Active; + ref: State; +} + +export class FloatPanelStageTriggerProvider { + static create(opts: FloatPanelStageTriggerOpts) { + return new FloatPanelStageTriggerProvider(opts); + } + + readonly opts: FloatPanelStageTriggerOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelStageTriggerOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('stage-trigger', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly onclick = (_e: SomaMouseEvent) => { + this.provider.toggleStage(this.opts.stage.current); + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + type: 'button' as const, + onclick: this.onclick + })); +} + +// ── ResizeHandle ────────────────────────────────────────────────────────────── + +interface FloatPanelResizeHandleOpts extends ActiveProps<{ edge: FloatPanelResizeEdge }> { + id: Active; + ref: State; +} + +export class FloatPanelResizeHandleProvider { + static create(opts: FloatPanelResizeHandleOpts) { + return new FloatPanelResizeHandleProvider(opts); + } + + readonly opts: FloatPanelResizeHandleOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelResizeHandleOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('resize-handle', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly onpointerdown = (e: PointerEvent) => { + this.provider.startResize(e, this.opts.edge.current); + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + 'data-resize-edge': this.opts.edge.current, + onpointerdown: this.onpointerdown + })); +} + +// ── ResizeGrip (keyboard resize affordance) ───────────────────────────────── + +interface FloatPanelResizeGripOpts { + id: Active; + ref: State; +} + +export class FloatPanelResizeGripProvider { + static create(opts: FloatPanelResizeGripOpts) { + return new FloatPanelResizeGripProvider(opts); + } + + readonly opts: FloatPanelResizeGripOpts; + readonly provider: FloatPanelProvider; + readonly runtimePart: SomaRuntimePart; + + private constructor(opts: FloatPanelResizeGripOpts) { + this.opts = opts; + this.provider = FloatPanelProvider.require(); + this.runtimePart = this.provider.runtime.part('resize-grip', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + // Pointer resize from the grip (se corner) β€” the grip is the unified se + // affordance for BOTH pointer and keyboard, so it isn't a dead button over + // the pointer ResizeHandle. + readonly onpointerdown = (e: PointerEvent) => { + this.provider.startResize(e, 'se'); + }; + + readonly onkeydown = (e: SomaKeyboardEvent) => { + if (this.provider.runtime.keydown('resize-grip', e)) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + 'aria-label': this.provider.soma.langs.ts(FLOAT_PANEL_LANGS.RESIZE_HANDLE_LABEL), + onpointerdown: this.onpointerdown, + onkeydown: this.onkeydown + })); +} diff --git a/src/uix/soma/components/float-panel/index.ts b/src/uix/soma/components/float-panel/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/float-panel/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/float-panel/langs.ts b/src/uix/soma/components/float-panel/langs.ts new file mode 100644 index 000000000..276f0a1d5 --- /dev/null +++ b/src/uix/soma/components/float-panel/langs.ts @@ -0,0 +1,20 @@ +/** Idlangref constants for the FloatPanel component. */ +export const FLOAT_PANEL_LANGS = { + CLOSE: '#?common.buttons.close|Close', + + // Keyboard move / resize (grab mode). `{x}/{y}/{width}/{height}` are + // interpolated by the provider (manual replace over the resolved template). + MOVE_HANDLE_LABEL: '#?components.float-panel.move-handle-label|Move panel', + RESIZE_HANDLE_LABEL: '#?components.float-panel.resize-handle-label|Resize panel', + KB_MOVE_ENTER: + '#?components.float-panel.kb-move-enter|Moving panel. Use the arrow keys to move, Shift plus arrow for larger steps, Enter to drop, Escape to cancel.', + KB_RESIZE_ENTER: + '#?components.float-panel.kb-resize-enter|Resizing panel. Use the arrow keys to resize, Shift plus arrow for larger steps, Enter to confirm, Escape to cancel.', + KB_POSITION: '#?components.float-panel.kb-position|Panel at {x}, {y}.', + KB_SIZE: '#?components.float-panel.kb-size|Panel {width} by {height}.', + KB_BOUND: '#?components.float-panel.kb-bound|Reached the edge.', + KB_MIN_SIZE: '#?components.float-panel.kb-min-size|Minimum size.', + KB_MAX_SIZE: '#?components.float-panel.kb-max-size|Maximum size.', + KB_CANCELLED: '#?components.float-panel.kb-cancelled|Cancelled. Panel restored.', + KB_MOVE_BLOCKED: '#?components.float-panel.kb-move-blocked|Cannot move while maximized.' +} as const; diff --git a/src/uix/soma/components/float-panel/types.ts b/src/uix/soma/components/float-panel/types.ts new file mode 100644 index 000000000..e9c4917e7 --- /dev/null +++ b/src/uix/soma/components/float-panel/types.ts @@ -0,0 +1,179 @@ +import type { WithChild, Without, OnChangeFn } from '../../types'; +import type { + PrimitiveDivAttributes, + PrimitiveButtonAttributes +} from '../../types'; + +export type FloatPanelSide = 'top' | 'right' | 'bottom' | 'left'; +export type FloatPanelAlign = 'start' | 'center' | 'end'; +export type FloatPanelPosition = { x: number; y: number }; +export type FloatPanelSize = { width: number; height: number }; +/** Resize edges/corners a `ResizeHandle` can drive. */ +export type FloatPanelResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; +/** Window stage β€” normal, collapsed-to-header, or filling the bounds. */ +export type FloatPanelStage = 'default' | 'minimized' | 'maximized'; + +export type FloatPanelProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Whether the panel is open. Bindable. @default false */ + open?: boolean; + /** Callback when open state changes. */ + onOpenChange?: OnChangeFn; + /** Callback when the open/close transition finishes. */ + onOpenChangeComplete?: OnChangeFn; + + /** + * Element the panel is placed against when it opens. The panel detaches to + * free positioning as soon as it is dragged. Omit for a free panel placed at + * `defaultPosition` (or viewport-centered). + */ + anchor?: HTMLElement | null; + /** Side of the anchor to place the panel on. @default 'bottom' */ + side?: FloatPanelSide; + /** Alignment along the anchor edge. @default 'start' */ + align?: FloatPanelAlign; + /** Gap (px) between anchor and panel. @default 8 */ + offset?: number; + + /** Current position (top-left, viewport px). Bindable. */ + position?: FloatPanelPosition; + /** Callback when the position changes (drag). */ + onPositionChange?: OnChangeFn; + /** Initial position when no `anchor` is given. */ + defaultPosition?: FloatPanelPosition; + + /** Current size (px). Bindable. */ + size?: FloatPanelSize; + /** Callback when the size changes (resize). */ + onSizeChange?: OnChangeFn; + /** Initial size. @default { width: 320, height: 240 } */ + defaultSize?: FloatPanelSize; + + /** Allow dragging the panel to move it. @default true */ + draggable?: boolean; + /** What grabs the drag: the header handle, or the whole panel. @default 'header' */ + dragHandle?: 'header' | 'content'; + /** Allow resizing via `ResizeHandle` parts. @default false */ + resizable?: boolean; + /** Minimum width (px). @default 160 */ + minWidth?: number; + /** Minimum height (px). @default 120 */ + minHeight?: number; + /** Maximum width (px). */ + maxWidth?: number; + /** Maximum height (px). */ + maxHeight?: number; + /** Containment area for drag/resize. @default 'viewport' */ + bounds?: 'viewport' | HTMLElement; + /** + * Positioning strategy. `'fixed'` floats over the whole viewport (free + * window). `'absolute'` positions within the nearest positioned ancestor + * (e.g. an editor region) β€” coordinates are container-relative and the panel + * scrolls with that container. Pair `'absolute'` with `bounds={containerEl}`. + * @default 'fixed' + */ + strategy?: 'fixed' | 'absolute'; + + /** + * Modal: trap focus + lock background scroll while open. A visual backdrop + * (dimming overlay) is a deferred v2 addition β€” today modal affects focus and + * scroll only, the page stays visible behind the panel. @default false + */ + modal?: boolean; + /** Whether Escape / interact-outside can close the panel. @default true */ + dismissible?: boolean; + /** Close on Escape. @default true */ + closeOnEscape?: boolean; + /** Close on interaction outside the panel. @default false (non-modal panels stay open) */ + closeOnInteractOutside?: boolean; + + /** Override the auto-managed stacking z-index. */ + zIndex?: number; + + /** + * Current stage. `'minimized'` collapses to the header (still draggable); + * `'maximized'` fills `bounds` (drag/resize off); `'default'` restores. + * Bindable. @default 'default' + */ + stage?: FloatPanelStage; + /** Callback when the stage changes. */ + onStageChange?: OnChangeFn; +}>; + +export type FloatPanelTriggerProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type FloatPanelContentProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type FloatPanelHeaderProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type FloatPanelTitleProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Heading level (1-6). @default 2 */ + level?: number; +}> & + Without; + +export type FloatPanelCloseProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Named dismiss cause. @default 'dismiss' */ + action?: 'save' | 'cancel' | 'dismiss'; +}> & + Without; + +export type FloatPanelControlsProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type FloatPanelActionProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type FloatPanelStageTriggerProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** + * Stage this trigger switches to. Clicking it while already in that stage + * returns to `'default'` (toggle). + */ + stage: FloatPanelStage; +}> & + Without; + +export type FloatPanelBodyProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; + +export type FloatPanelResizeHandleProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Which edge/corner this handle drives. @default 'se' */ + edge?: FloatPanelResizeEdge; +}> & + Without; + +export type FloatPanelResizeGripProps = WithChild<{ + /** Unique identifier. Auto-generated if omitted. */ + id?: string; +}> & + Without; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 797b29968..9ddc39f9b 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -26,6 +26,7 @@ export * as Editable from './editable'; export * as Feed from './feed'; export * as Field from './field'; export * as FileUpload from './file-upload'; +export * as FloatPanel from './float-panel'; export * as Form from './form'; export * as GridList from './grid-list'; export * as LinkPreview from './link-preview'; diff --git a/src/uix/soma/layers/stacking.svelte.ts b/src/uix/soma/layers/stacking.svelte.ts new file mode 100644 index 000000000..6ddcb8fcf --- /dev/null +++ b/src/uix/soma/layers/stacking.svelte.ts @@ -0,0 +1,59 @@ +// ── Stacking Layer ─────────────────────────────────────────────────────────── +// +// Shared z-order registry for floating, user-movable surfaces (FloatPanel). +// Multiple panels coexist; the one most recently focused/clicked rises to the +// front. Mirrors Ark UI FloatingPanel's `data-topmost` / `data-behind`. +// +// Backed by MODULE-LEVEL `$state` β€” the canonical Svelte 5 shared-state pattern, +// guaranteed reactive to every consumer's `$derived` / `$effect` across +// components (a class instance created at module load does NOT propagate +// reliably). Populated only client-side (panels register from `$effect`), so +// SSR leaves it empty β€” no cross-request leakage. + +/** Base z-index for the lowest panel. Each panel above adds 1. */ +export const FLOAT_PANEL_BASE_Z = 1000; + +/** Ids in stacking order; last entry is the topmost. */ +let order = $state([]); + +export const floatPanelStack = { + /** Append a panel on top of the stack (no-op if already present). */ + register(id: string): void { + if (!order.includes(id)) order = [...order, id]; + }, + + /** Remove a panel from the stack. */ + unregister(id: string): void { + if (order.includes(id)) order = order.filter((x) => x !== id); + }, + + /** Raise a panel to the top (no-op if already topmost or unknown). */ + bringToFront(id: string): void { + const n = order.length; + if (n === 0 || order[n - 1] === id || !order.includes(id)) return; + order = [...order.filter((x) => x !== id), id]; + }, + + /** True when `id` is the frontmost panel. */ + isTopmost(id: string): boolean { + const n = order.length; + return n > 0 && order[n - 1] === id; + }, + + /** True when `id` is registered but not the frontmost. */ + isBehind(id: string): boolean { + const n = order.length; + return order.includes(id) && order[n - 1] !== id; + }, + + /** Resolved z-index for `id` (base + stack position). */ + zIndexOf(id: string): number { + const i = order.indexOf(id); + return i < 0 ? FLOAT_PANEL_BASE_Z : FLOAT_PANEL_BASE_Z + i; + }, + + /** Number of registered panels. */ + get size(): number { + return order.length; + } +}; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index f12c527ab..388f8f956 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -11,6 +11,7 @@ import { drawerSema } from '$uix/sema/components/drawer'; import { editableSema } from '$uix/sema/components/editable'; import { fileUploadSema } from '$uix/sema/components/file-upload'; + import { floatPanelSema } from '$uix/sema/components/float-panel'; import { formSema } from '$uix/sema/components/form'; import { numberFieldSema } from '$uix/sema/components/number-field'; import { paginationSema } from '$uix/sema/components/pagination'; @@ -76,6 +77,7 @@ drawerSema, editableSema, fileUploadSema, + floatPanelSema, formSema, numberFieldSema, paginationSema, @@ -247,6 +249,7 @@ { slug: '/uix/components/alert-dialog', label: 'Alert dialog' }, { slug: '/uix/components/drawer', label: 'Drawer' }, { slug: '/uix/components/popover', label: 'Popover' }, + { slug: '/uix/components/float-panel', label: 'Float panel' }, { slug: '/uix/components/dropdown-menu', label: 'Dropdown menu' }, { slug: '/uix/components/context-menu', label: 'Context menu' }, { slug: '/uix/components/menubar', label: 'Menubar' }, diff --git a/web/routes/uix/components/float-panel/+page.svelte b/web/routes/uix/components/float-panel/+page.svelte new file mode 100644 index 000000000..c0f2273ac --- /dev/null +++ b/web/routes/uix/components/float-panel/+page.svelte @@ -0,0 +1,516 @@ + + +
+
+
Overlays Β· Floating panel
+

FloatPanel

+

+ A general-purpose draggable + resizable floating panel β€” a user-movable surface for tool + palettes, inspectors and mini-windows. Soma owns drag, resize, stacking, dismissal and focus; + Eidos adds the surface chrome. +

+
+ parts{compiled.parts.order.length} + events{events.length} + draggableyes + resizableyes +
+
+ +
+ +
+ + + + +
+ + +
+ + + + Inspector A + + +

Drag the header to move me.

+

+ {resizable ? 'Drag an edge or the SE corner to resize.' : 'Resize is disabled.'} +

+

+ Keyboard: focus the header (Tab), press Enter to grab, then arrows to + move; Enter drops, Esc cancels. Same on the corner grip to resize. +

+
+ {#if resizable} + + + + + {/if} +
+
+ + + + + Panel B + + + – + + + β–’ + + Γ— + + + Minimize, maximize, or bring to front. + + + + + + + + Propiedades + + (openC = false)}> + πŸ—‘ + + Γ— + + + Custom header: delete action + close. + + +
+ +
+ trace + {#if trace.length === 0} + drag the header, resize an edge, or close a panel + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} Β· {entry.family} + {fmtTime(entry.at)} + {/each} + {/if} + + A pos + {posLabel} + size + {sizeLabel} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ soma owns behavior Β· + eidos owns chrome. The panels are + strategy="absolute" bounded to the stage above. +

+ +
+ soma props Β· behavior +
+
+ {#each [['draggable', () => (draggable = !draggable), draggable], ['resizable', () => (resizable = !resizable), resizable], ['modal', () => (modal = !modal), modal], ['dismissible', () => (dismissible = !dismissible), dismissible], ['closeOnInteractOutside', () => (closeOnInteractOutside = !closeOnInteractOutside), closeOnInteractOutside], ['useAnchor', () => (useAnchor = !useAnchor), useAnchor]] as [label, toggle, val]} + + {/each} + + + +
+ +
+ eidos props Β· chrome +
+
+ + +
+ +
+
+ eidos + visual Β· draggable + resizable panel + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API + reference comparison

+
+ + + + + + + + + + + + +
FeatureArk UIreact-rndMantineRadix/Chakra/MUIUIX
Draggableβœ…βœ…βœ…βœ— (lib)βœ…
Resizableβœ…βœ…βœ—βœ—βœ… (opt-in)
Containment / boundsβœ…βœ…βœ…β€”βœ…
Bring-to-front / stackingβœ…βœ—βœ—β€”βœ…
fixed + absolute strategyβœ…β€”βœ—β€”βœ…
Anchored β†’ freeβš οΈβœ—βœ—anchoredβœ…
Perceptual (sound/haptic)βœ—βœ—βœ—βœ—βœ…
Stages min/maxβœ…βœ—βœ—β€”βΈ v2
+
+
+ + + + + + + + + + + + + + + +
PropOwnerDefaultNotes
opensomafalseBindable.
strategysomafixedfixed | absolute (container-relative).
anchor / side / align / offsetsomaβ€”/bottom/start/8Placement on open.
position / sizesomabindableFree x/y + w/h.
draggable / dragHandlesomatrue / headerMove gesture. content makes the whole surface a grab handle (grab cursor).
resizable / minΒ·maxWidth/HeightsomafalseEdge resize + the se ResizeGrip.
stagesomadefaultdefault | minimized | maximized (bindable). Via StageTrigger.
boundssomaviewportContainment.
modal / dismissible / closeOn*somafalse/true/…Dismissal.
varianteidossurfacesurface | outline | ghost.
coloreidosneutralAccent palette.
+

+ Compound parts: Header, Title, Controls, + Action (custom header button), Close, + StageTrigger (min/max/restore), Body, + ResizeHandle (edges), ResizeGrip (se, pointer + keyboard). + Keyboard: focus the header / grip β†’ Enter to grab β†’ arrows β†’ Enter/Esc. +

+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? 'β€”'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema Β· events +

+
+ + + + {#each events as action} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceTargetPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? 'β€”'}{action.semantic.sequence ?? 'pre'}{action.target} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors in src/uix/eidos/components/float-panel/float-panel.css. Soma sets + position/size/z-index inline (via the translate property for smooth drag); Eidos + styles the surface. +

+
+ + + + + + + + + +
SelectorOwnerPurpose
[data-float-panel-content]morfoSurface chrome (bg, border, shadow).
[data-float-panel-content][data-topmost]morfoFrontmost panel lifts (shadow).
[data-float-panel-content][data-variant][data-color]eidosVisual variant + accent.
[data-float-panel-header] (grab) / [data-dragging]morfoDrag handle cursor grab→grabbing.
[data-float-panel-resize-handle][data-resize-edge]morfoPer-edge resize affordance + cursor.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + +
ConcernContract
RoleContent uses role="dialog" (aria-modal when modal).
Labellingaria-labelledby β†’ Title.
FocusTrapped only when modal; returns to trigger on close.
KeyboardEscape closes (when dismissible). Keyboard move/resize is a documented v2 gap.
+
+
+ {/if} +