feat(uix): FloatPanel — draggable/resizable floating panel

New general-purpose overlay component across all 4 layers (morfo/soma/sema/eidos)
plus a shared stacking layer and i18n catalog.

- Drag (2D free, anchored→free) + resize with device-pixel-snapped, GPU-composited
  1:1 movement; elevation lifts on pointerdown for the pickup metaphor.
- Stages: minimize / maximize / restore (StageTrigger, computed maximized geometry
  so restore needs no saved-rect bookkeeping).
- Composable header buttons: Controls + Action (custom) + Close + StageTrigger.
- Keyboard a11y (the industry gap): grab-mode move/resize on the focusable header
  and a se-corner ResizeGrip (pointer + keyboard), with debounced live-region
  announcements, bounds/min-max cues, aria-roledescription/keyshortcuts.
- Stacking registry (bring-to-front/topmost), Presence motion preset (scale-fade),
  es/en langs catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 9452bc2545
commit b3ef470646

@ -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
<!-- Zero-config: <FloatPanel.Header> auto-renders a Close (closable, default true). -->
<FloatPanel bind:open anchor={el} resizable variant="surface" color="primary">
<FloatPanel.Content>
<FloatPanel.Header>
<FloatPanel.Title>Inspector</FloatPanel.Title>
</FloatPanel.Header>
<FloatPanel.Body>…</FloatPanel.Body>
<FloatPanel.ResizeHandle edge="se" />
</FloatPanel.Content>
</FloatPanel>
<!-- Custom header buttons: set closable={false} and compose Controls. -->
<FloatPanel.Header closable={false}>
<FloatPanel.Title>Properties</FloatPanel.Title>
<FloatPanel.Controls>
<FloatPanel.Action aria-label="Delete object" onclick={remove}>🗑</FloatPanel.Action>
<FloatPanel.Close />
</FloatPanel.Controls>
</FloatPanel.Header>
```
`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
<FloatPanel.Controls>
<FloatPanel.StageTrigger stage="minimized" aria-label="Minimize">–</FloatPanel.StageTrigger>
<FloatPanel.StageTrigger stage="maximized" aria-label="Maximize">▢</FloatPanel.StageTrigger>
<FloatPanel.Close />
</FloatPanel.Controls>
```
`minimized` collapses to the header, `maximized` fills `bounds` (corners squared,
drag/resize off), `default` restores. See the soma README for the stage model.
`<FloatPanel.ResizeGrip />` 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.
`<FloatPanel.Content>` additionally takes `motion?: MotionPresetName` (default `'scale-fade'`, `'none'`
to disable) — the open/close presence preset, mirroring `<Popover.Content motion>`.
## 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.

@ -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<FloatPanelEidosCtx | undefined>(KEY);
}

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelActionProps } from './types';
let { children, ...rest }: FloatPanelActionProps = $props();
</script>
<FloatPanel.Action {...rest}>
{@render children?.()}
</FloatPanel.Action>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelBodyProps } from './types';
let { children, ...rest }: FloatPanelBodyProps = $props();
</script>
<FloatPanel.Body {...rest}>
{@render children?.()}
</FloatPanel.Body>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelCloseProps } from './types';
let { children, ...rest }: FloatPanelCloseProps = $props();
</script>
<FloatPanel.Close {...rest}>
{@render children?.()}
</FloatPanel.Close>

@ -0,0 +1,17 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import { getFloatPanelCtx } from './context';
import type { FloatPanelContentProps } from './types';
let { motion = 'scale-fade', children, ...rest }: FloatPanelContentProps = $props();
const ctx = getFloatPanelCtx();
</script>
<FloatPanel.Content
{...rest}
data-variant={ctx?.variant}
data-color={ctx?.color}
data-animation-style={motion === 'none' ? undefined : motion}
>
{@render children?.()}
</FloatPanel.Content>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelControlsProps } from './types';
let { children, ...rest }: FloatPanelControlsProps = $props();
</script>
<FloatPanel.Controls {...rest}>
{@render children?.()}
</FloatPanel.Controls>

@ -0,0 +1,23 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import Close from './float-panel-close.svelte';
import Controls from './float-panel-controls.svelte';
import type { FloatPanelHeaderProps } from './types';
let { closable = true, children, ...rest }: FloatPanelHeaderProps = $props();
</script>
<!--
`closable` (default true) is the zero-config convenience: it appends a
right-aligned Controls group with a Close button. To compose your OWN header
buttons (custom actions + close, in any order), set `closable={false}` and
render a `<FloatPanel.Controls>` with `<FloatPanel.Action>` / `<FloatPanel.Close>`.
-->
<FloatPanel.Header {...rest}>
{@render children?.()}
{#if closable}
<Controls>
<Close>×</Close>
</Controls>
{/if}
</FloatPanel.Header>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelResizeGripProps } from './types';
let { children, ...rest }: FloatPanelResizeGripProps = $props();
</script>
<FloatPanel.ResizeGrip {...rest}>
{@render children?.()}
</FloatPanel.ResizeGrip>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelResizeHandleProps } from './types';
let { children, ...rest }: FloatPanelResizeHandleProps = $props();
</script>
<FloatPanel.ResizeHandle {...rest}>
{@render children?.()}
</FloatPanel.ResizeHandle>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelStageTriggerProps } from './types';
let { children, ...rest }: FloatPanelStageTriggerProps = $props();
</script>
<FloatPanel.StageTrigger {...rest}>
{@render children?.()}
</FloatPanel.StageTrigger>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelTitleProps } from './types';
let { children, ...rest }: FloatPanelTitleProps = $props();
</script>
<FloatPanel.Title {...rest}>
{@render children?.()}
</FloatPanel.Title>

@ -0,0 +1,10 @@
<script lang="ts">
import * as FloatPanel from '$soma/components/float-panel';
import type { FloatPanelTriggerProps } from './types';
let { children, ...rest }: FloatPanelTriggerProps = $props();
</script>
<FloatPanel.Trigger {...rest}>
{@render children?.()}
</FloatPanel.Trigger>

@ -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. */

@ -0,0 +1,33 @@
<script lang="ts">
import './float-panel.css';
/**
* Eidos `<FloatPanel>` — visual chrome over Soma's draggable/resizable panel.
* Soma owns drag, resize, stacking, dismissal, focus and positioning.
*/
import * as FloatPanel from '$soma/components/float-panel';
import { setFloatPanelCtx } from './context';
import type { FloatPanelProps } from './types';
let {
open = $bindable(false),
position = $bindable(),
size = $bindable(),
variant = 'surface',
color = 'neutral',
children,
...rest
}: FloatPanelProps = $props();
setFloatPanelCtx({
get variant() {
return variant;
},
get color() {
return color;
}
});
</script>
<FloatPanel.Provider {...rest} bind:open bind:position bind:size>
{@render children?.()}
</FloatPanel.Provider>

@ -0,0 +1,74 @@
// FloatPanel — eidos compound API, disciplined option C.
//
// import { FloatPanel } from '$uix/eidos/components/float-panel';
//
// <FloatPanel bind:open>
// <FloatPanel.Content>
// <FloatPanel.Header>
// <FloatPanel.Title>Inspector</FloatPanel.Title>
// <FloatPanel.Close />
// </FloatPanel.Header>
// <FloatPanel.Body>…</FloatPanel.Body>
// <FloatPanel.ResizeHandle edge="se" />
// </FloatPanel.Content>
// </FloatPanel>
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';

@ -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 `<FloatPanel>` 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;

@ -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);

@ -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)',

@ -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;

@ -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,

@ -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;

@ -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<typeof semaSelector<typeof floatPanelMorfo>>[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')
}
]
};

@ -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';

@ -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
<FloatPanel.Provider bind:open anchor={el} resizable>
<FloatPanel.Content>
<FloatPanel.Header>
<FloatPanel.Title>Inspector</FloatPanel.Title>
<FloatPanel.Close />
</FloatPanel.Header>
<FloatPanel.Body>…</FloatPanel.Body>
<FloatPanel.ResizeHandle edge="se" />
</FloatPanel.Content>
</FloatPanel.Provider>
```
`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
<FloatPanel.Header>
<FloatPanel.Title>Properties</FloatPanel.Title>
<FloatPanel.Controls>
<FloatPanel.Action aria-label="Delete object" onclick={remove}>🗑</FloatPanel.Action>
<FloatPanel.Close />
</FloatPanel.Controls>
</FloatPanel.Header>
```
`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
<FloatPanel.Controls>
<FloatPanel.StageTrigger stage="minimized" aria-label="Minimize">–</FloatPanel.StageTrigger>
<FloatPanel.StageTrigger stage="maximized" aria-label="Maximize">▢</FloatPanel.StageTrigger>
<FloatPanel.Close />
</FloatPanel.Controls>
```
- **`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.
- `<FloatPanel.ResizeGrip />` 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.

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelActionProvider } from '../float-panel-provider.svelte';
import type { FloatPanelActionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-action'),
children,
child,
...restProps
}: FloatPanelActionProps = $props();
const state = FloatPanelActionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelBodyProvider } from '../float-panel-provider.svelte';
import type { FloatPanelBodyProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-body'),
children,
child,
...restProps
}: FloatPanelBodyProps = $props();
const state = FloatPanelBodyProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelCloseProvider } from '../float-panel-provider.svelte';
import type { FloatPanelCloseProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-close'),
action = 'dismiss',
children,
child,
...restProps
}: FloatPanelCloseProps = $props();
const state = FloatPanelCloseProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
action: readableActive(() => action)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelContentProvider } from '../float-panel-provider.svelte';
import type { FloatPanelContentProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-content'),
children,
child,
...restProps
}: FloatPanelContentProps = $props();
const state = FloatPanelContentProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelControlsProvider } from '../float-panel-provider.svelte';
import type { FloatPanelControlsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-controls'),
children,
child,
...restProps
}: FloatPanelControlsProps = $props();
const state = FloatPanelControlsProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelHeaderProvider } from '../float-panel-provider.svelte';
import type { FloatPanelHeaderProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-header'),
children,
child,
...restProps
}: FloatPanelHeaderProps = $props();
const state = FloatPanelHeaderProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelResizeGripProvider } from '../float-panel-provider.svelte';
import type { FloatPanelResizeGripProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-resize-grip'),
children,
child,
...restProps
}: FloatPanelResizeGripProps = $props();
const state = FloatPanelResizeGripProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelResizeHandleProvider } from '../float-panel-provider.svelte';
import type { FloatPanelResizeHandleProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-resize-handle'),
edge = 'se',
children,
child,
...restProps
}: FloatPanelResizeHandleProps = $props();
const state = FloatPanelResizeHandleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
edge: readableActive(() => edge)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelStageTriggerProvider } from '../float-panel-provider.svelte';
import type { FloatPanelStageTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-stage-trigger'),
stage,
children,
child,
...restProps
}: FloatPanelStageTriggerProps = $props();
const state = FloatPanelStageTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
stage: readableActive(() => stage)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelTitleProvider } from '../float-panel-provider.svelte';
import type { FloatPanelTitleProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-title'),
level = 2,
children,
child,
...restProps
}: FloatPanelTitleProps = $props();
const state = FloatPanelTitleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
level: readableActive(() => level)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FloatPanelTriggerProvider } from '../float-panel-provider.svelte';
import type { FloatPanelTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'float-panel-trigger'),
children,
child,
...restProps
}: FloatPanelTriggerProps = $props();
const state = FloatPanelTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,97 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { createId } from '$active-uix/id';
import { FloatPanelProvider } from '../float-panel-provider.svelte';
import type { FloatPanelProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'float-panel'),
open = $bindable(false),
position = $bindable(),
size = $bindable(),
stage = $bindable('default'),
anchor = null,
side = 'bottom',
align = 'start',
offset = 8,
defaultPosition,
defaultSize,
draggable = true,
dragHandle = 'header',
resizable = false,
minWidth = 160,
minHeight = 120,
maxWidth,
maxHeight,
bounds = 'viewport',
strategy = 'fixed',
modal = false,
dismissible = true,
closeOnEscape = true,
closeOnInteractOutside = false,
zIndex,
onOpenChange,
onOpenChangeComplete,
onPositionChange,
onSizeChange,
onStageChange,
children
}: FloatPanelProps = $props();
FloatPanelProvider.create({
id: readableActive(() => id),
open: writableActive(
() => open,
(v) => {
open = v;
onOpenChange?.(v);
}
),
position: writableActive(
() => position,
(v) => {
position = v;
if (v) onPositionChange?.(v);
}
),
size: writableActive(
() => size,
(v) => {
size = v;
if (v) onSizeChange?.(v);
}
),
stage: writableActive(
() => stage,
(v) => {
stage = v;
onStageChange?.(v);
}
),
modal: readableActive(() => modal),
anchor: readableActive(() => anchor),
side: readableActive(() => side),
align: readableActive(() => align),
offset: readableActive(() => offset),
defaultPosition: readableActive(() => defaultPosition),
defaultSize: readableActive(() => defaultSize),
draggable: readableActive(() => draggable),
dragHandle: readableActive(() => dragHandle),
resizable: readableActive(() => resizable),
minWidth: readableActive(() => minWidth),
minHeight: readableActive(() => minHeight),
maxWidth: readableActive(() => maxWidth),
maxHeight: readableActive(() => maxHeight),
bounds: readableActive(() => bounds),
strategy: readableActive(() => strategy),
dismissible: readableActive(() => dismissible),
closeOnEscape: readableActive(() => closeOnEscape),
closeOnInteractOutside: readableActive(() => closeOnInteractOutside),
zIndex: readableActive(() => zIndex),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});
</script>
{@render children?.()}

@ -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';

@ -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<T>(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<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
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<HTMLElement | null>(null),
open: state(false),
position: state<FloatPanelPosition | undefined>(undefined),
size: state<FloatPanelSize | undefined>(undefined),
stage: state<FloatPanelStage>('default'),
modal: state(false),
anchor: state<HTMLElement | null>(null),
side: state<'top' | 'right' | 'bottom' | 'left'>('bottom'),
align: state<'start' | 'center' | 'end'>('start'),
offset: state(8),
defaultPosition: state<FloatPanelPosition | undefined>(undefined),
defaultSize: state<FloatPanelSize | undefined>(undefined),
draggable: state(true),
dragHandle: state<'header' | 'content'>('header'),
resizable: state(true),
minWidth: state(160),
minHeight: state(120),
maxWidth: state<number | undefined>(undefined),
maxHeight: state<number | undefined>(undefined),
bounds: state<'viewport' | HTMLElement>('viewport'),
strategy: state<'fixed' | 'absolute'>('fixed'),
dismissible: state(true),
closeOnEscape: state(true),
closeOnInteractOutside: state(false),
zIndex: state<number | undefined>(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();
});
});

@ -0,0 +1 @@
export * from './exports';

@ -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;

@ -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<boolean>;
/** Callback when the open/close transition finishes. */
onOpenChangeComplete?: OnChangeFn<boolean>;
/**
* 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<FloatPanelPosition>;
/** Initial position when no `anchor` is given. */
defaultPosition?: FloatPanelPosition;
/** Current size (px). Bindable. */
size?: FloatPanelSize;
/** Callback when the size changes (resize). */
onSizeChange?: OnChangeFn<FloatPanelSize>;
/** 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<FloatPanelStage>;
}>;
export type FloatPanelTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type FloatPanelContentProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type FloatPanelHeaderProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type FloatPanelTitleProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Heading level (1-6). @default 2 */
level?: number;
}> &
Without<PrimitiveDivAttributes, {}>;
export type FloatPanelCloseProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Named dismiss cause. @default 'dismiss' */
action?: 'save' | 'cancel' | 'dismiss';
}> &
Without<PrimitiveButtonAttributes, {}>;
export type FloatPanelControlsProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type FloatPanelActionProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
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<PrimitiveButtonAttributes, {}>;
export type FloatPanelBodyProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type FloatPanelResizeHandleProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Which edge/corner this handle drives. @default 'se' */
edge?: FloatPanelResizeEdge;
}> &
Without<PrimitiveDivAttributes, {}>;
export type FloatPanelResizeGripProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;

@ -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';

@ -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<string[]>([]);
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;
}
};

@ -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' },

@ -0,0 +1,516 @@
<script lang="ts">
import {
FloatPanel,
type FloatPanelColor,
type FloatPanelVariant
} from '$uix/eidos/components/float-panel';
import { compileMorfo } from '$uix/morfo';
import { floatPanelMorfo } from '@/uix/morfo/components/float-panel';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Side = 'top' | 'right' | 'bottom' | 'left';
type Align = 'start' | 'center' | 'end';
type TraceEntry = { event: string; family: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live');
// open states
let openA = $state(true);
let openB = $state(false);
let openC = $state(false);
// behavior props
let draggable = $state(true);
let dragHandle = $state<'header' | 'content'>('header');
let resizable = $state(true);
let modal = $state(false);
let dismissible = $state(true);
let closeOnInteractOutside = $state(false);
let useAnchor = $state(false);
let side = $state<Side>('bottom');
let align = $state<Align>('start');
// visual props
let variant = $state<FloatPanelVariant>('surface');
let color = $state<FloatPanelColor>('neutral');
// bound geometry (panel A) for the readout
let posA = $state<{ x: number; y: number } | undefined>({ x: 24, y: 24 });
let sizeA = $state<{ width: number; height: number } | undefined>({ width: 300, height: 210 });
let stageRef = $state<HTMLElement | null>(null);
let triggerA = $state<HTMLElement | null>(null);
let trace = $state<TraceEntry[]>([]);
const compiled = compileMorfo(floatPanelMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const variants: FloatPanelVariant[] = ['surface', 'outline', 'ghost'];
const colors: FloatPanelColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
function resetPositions() {
posA = { x: 24, y: 24 };
sizeA = { width: 300, height: 210 };
}
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{ event: ev, family: target.getAttribute('data-event-family') ?? '-', at: Date.now() },
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const posLabel = $derived(posA ? `${Math.round(posA.x)}, ${Math.round(posA.y)}` : '—');
const sizeLabel = $derived(sizeA ? `${Math.round(sizeA.width)}×${Math.round(sizeA.height)}` : '—');
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { FloatPanel } from '$uix/eidos/components/float-panel';",
' let open = $state(true);',
'</' + 'script>',
'',
'<FloatPanel',
' bind:open',
' strategy="absolute"',
' bounds={containerEl}',
!draggable && ' draggable={false}',
dragHandle !== 'header' && ` dragHandle="${dragHandle}"`,
resizable && ' resizable',
modal && ' modal',
useAnchor && ` anchor={triggerEl} side="${side}" align="${align}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'neutral' && ` color="${color}"`,
'>',
' <FloatPanel.Content>',
' <FloatPanel.Header>',
' <FloatPanel.Title>Inspector</FloatPanel.Title>',
' </FloatPanel.Header>',
' <FloatPanel.Body>…</FloatPanel.Body>',
resizable && ' <FloatPanel.ResizeHandle edge="se" />',
' </FloatPanel.Content>',
'</FloatPanel>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Overlays · Floating panel</div>
<h1 data-uix-page-title>FloatPanel</h1>
<p data-uix-page-lede>
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.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>draggable</span>yes</span>
<span data-uix-meta-pill><span data-uix-meta-key>resizable</span>yes</span>
</div>
</header>
<div data-uix-stage>
<!-- Triggers live OUTSIDE the floating area so panels never cover them. -->
<div
style="display: flex; flex-wrap: wrap; gap: var(--uix-space-1); margin-block-end: var(--uix-space-2);"
>
<button bind:this={triggerA} data-uix-chip data-active={openA} onclick={() => (openA = !openA)}>
{openA ? 'Close' : 'Open'} Inspector
</button>
<button data-uix-chip data-active={openB} onclick={() => (openB = !openB)}>Toggle Panel B</button>
<button data-uix-chip data-active={openC} onclick={() => (openC = !openC)}>Toggle Panel C</button>
<button data-uix-chip onclick={resetPositions}>Reset A</button>
</div>
<!-- Bounded, positioned container: absolute panels stay inside it. -->
<div
bind:this={stageRef}
data-uix-stage-area
style="position: relative; min-block-size: 420px; overflow: hidden;"
>
<FloatPanel
bind:open={openA}
bind:position={posA}
bind:size={sizeA}
strategy="absolute"
bounds={stageRef ?? 'viewport'}
anchor={useAnchor ? triggerA : null}
{side}
{align}
{draggable}
{dragHandle}
{resizable}
{modal}
{dismissible}
{closeOnInteractOutside}
{variant}
{color}
minWidth={200}
minHeight={140}
>
<FloatPanel.Content>
<FloatPanel.Header>
<FloatPanel.Title>Inspector A</FloatPanel.Title>
</FloatPanel.Header>
<FloatPanel.Body>
<p style="margin: 0 0 var(--uix-space-2);">Drag the header to move me.</p>
<p style="margin: 0 0 var(--uix-space-2); color: var(--color-content-secondary);">
{resizable ? 'Drag an edge or the SE corner to resize.' : 'Resize is disabled.'}
</p>
<p style="margin: 0; color: var(--color-content-secondary);">
Keyboard: focus the header (Tab), press <kbd>Enter</kbd> to grab, then arrows to
move; <kbd>Enter</kbd> drops, <kbd>Esc</kbd> cancels. Same on the corner grip to resize.
</p>
</FloatPanel.Body>
{#if resizable}
<FloatPanel.ResizeHandle edge="e" />
<FloatPanel.ResizeHandle edge="s" />
<!-- The grip owns the se corner (pointer + keyboard resize). -->
<FloatPanel.ResizeGrip />
{/if}
</FloatPanel.Content>
</FloatPanel>
<FloatPanel
bind:open={openB}
strategy="absolute"
bounds={stageRef ?? 'viewport'}
defaultPosition={{ x: 150, y: 90 }}
color="primary"
draggable
>
<FloatPanel.Content>
<FloatPanel.Header closable={false}>
<FloatPanel.Title>Panel B</FloatPanel.Title>
<FloatPanel.Controls>
<FloatPanel.StageTrigger stage="minimized" aria-label="Minimizar">
–
</FloatPanel.StageTrigger>
<FloatPanel.StageTrigger stage="maximized" aria-label="Maximizar">
▢
</FloatPanel.StageTrigger>
<FloatPanel.Close>×</FloatPanel.Close>
</FloatPanel.Controls>
</FloatPanel.Header>
<FloatPanel.Body>Minimize, maximize, or bring to front.</FloatPanel.Body>
</FloatPanel.Content>
</FloatPanel>
<!-- Panel C — a "properties" panel showing COMPOSABLE header buttons:
a custom delete Action next to the default Close, grouped in
Controls. `closable={false}` suppresses the auto-close so the
header is composed explicitly. -->
<FloatPanel
bind:open={openC}
strategy="absolute"
bounds={stageRef ?? 'viewport'}
defaultPosition={{ x: 260, y: 150 }}
color="affirm"
draggable
>
<FloatPanel.Content>
<FloatPanel.Header closable={false}>
<FloatPanel.Title>Propiedades</FloatPanel.Title>
<FloatPanel.Controls>
<FloatPanel.Action aria-label="Borrar objeto" onclick={() => (openC = false)}>
🗑
</FloatPanel.Action>
<FloatPanel.Close>×</FloatPanel.Close>
</FloatPanel.Controls>
</FloatPanel.Header>
<FloatPanel.Body>Custom header: delete action + close.</FloatPanel.Body>
</FloatPanel.Content>
</FloatPanel>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>drag the header, resize an edge, or close a panel</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>A pos</span>
<span data-uix-stage-trace-event>{posLabel}</span>
<span data-uix-stage-trace-key style="margin-inline-start: var(--uix-space-2);">size</span>
<span data-uix-stage-trace-event>{sizeLabel}</span>
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns behavior ·
<span data-uix-layer-badge="eidos">eidos</span> owns chrome. The panels are
<code>strategy="absolute"</code> bounded to the stage above.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · behavior
</div>
<div data-uix-controls>
{#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]}
<label data-uix-control>
<span data-uix-control-label>{label}</span>
<span data-uix-switch>
<input type="checkbox" checked={val as boolean} onchange={toggle as () => void} />
<span data-uix-switch-label>{(val as boolean) ? 'on' : 'off'}</span>
</span>
</label>
{/each}
<label data-uix-control>
<span data-uix-control-label>dragHandle</span>
<span data-uix-chips role="radiogroup">
{#each ['header', 'content'] as h}
<button data-uix-chip data-active={dragHandle === h} onclick={() => (dragHandle = h as 'header' | 'content')}>{h}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>side <span data-uix-control-hint>(re-open with useAnchor)</span></span>
<span data-uix-chips role="radiogroup">
{#each ['top', 'right', 'bottom', 'left'] as s}
<button data-uix-chip data-active={side === s} onclick={() => (side = s as Side)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each ['start', 'center', 'end'] as a}
<button data-uix-chip data-active={align === a} onclick={() => (align = a as Align)}>{a}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · chrome
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as item}
<button data-uix-chip data-active={variant === item} onclick={() => (variant = item)}>{item}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as item}
<button data-uix-chip data-active={color === item} onclick={() => (color = item)}>{item}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · draggable + resizable panel</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API + reference comparison</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Feature</th><th>Ark UI</th><th>react-rnd</th><th>Mantine</th><th>Radix/Chakra/MUI</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Draggable</td><td>✅</td><td>✅</td><td>✅</td><td>✗ (lib)</td><td>✅</td></tr>
<tr><td class="name">Resizable</td><td>✅</td><td>✅</td><td>✗</td><td>✗</td><td>✅ (opt-in)</td></tr>
<tr><td class="name">Containment / bounds</td><td>✅</td><td>✅</td><td>✅</td><td>—</td><td>✅</td></tr>
<tr><td class="name">Bring-to-front / stacking</td><td>✅</td><td>✗</td><td>✗</td><td>—</td><td>✅</td></tr>
<tr><td class="name">fixed + absolute strategy</td><td>✅</td><td>—</td><td>✗</td><td>—</td><td>✅</td></tr>
<tr><td class="name">Anchored → free</td><td>⚠️</td><td>✗</td><td>✗</td><td>anchored</td><td>✅</td></tr>
<tr><td class="name">Perceptual (sound/haptic)</td><td>✗</td><td>✗</td><td>✗</td><td>✗</td><td>✅</td></tr>
<tr><td class="name">Stages min/max</td><td>✅</td><td>✗</td><td>✗</td><td>—</td><td>⏸ v2</td></tr>
</tbody>
</table>
</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">open</td><td>soma</td><td class="default">false</td><td>Bindable.</td></tr>
<tr><td class="name">strategy</td><td>soma</td><td class="default">fixed</td><td>fixed | absolute (container-relative).</td></tr>
<tr><td class="name">anchor / side / align / offset</td><td>soma</td><td class="default">—/bottom/start/8</td><td>Placement on open.</td></tr>
<tr><td class="name">position / size</td><td>soma</td><td class="default">bindable</td><td>Free x/y + w/h.</td></tr>
<tr><td class="name">draggable / dragHandle</td><td>soma</td><td class="default">true / header</td><td>Move gesture. <code>content</code> makes the whole surface a grab handle (grab cursor).</td></tr>
<tr><td class="name">resizable / min·maxWidth/Height</td><td>soma</td><td class="default">false</td><td>Edge resize + the se <code>ResizeGrip</code>.</td></tr>
<tr><td class="name">stage</td><td>soma</td><td class="default">default</td><td>default | minimized | maximized (bindable). Via <code>StageTrigger</code>.</td></tr>
<tr><td class="name">bounds</td><td>soma</td><td class="default">viewport</td><td>Containment.</td></tr>
<tr><td class="name">modal / dismissible / closeOn*</td><td>soma</td><td class="default">false/true/…</td><td>Dismissal.</td></tr>
<tr><td class="name">variant</td><td>eidos</td><td class="default">surface</td><td>surface | outline | ghost.</td></tr>
<tr><td class="name">color</td><td>eidos</td><td class="default">neutral</td><td>Accent palette.</td></tr>
</tbody>
</table>
<p style="margin: var(--uix-space-3) 0 0; color: var(--uix-text-muted); font-size: var(--uix-font-size-sm);">
Compound parts: <code>Header</code>, <code>Title</code>, <code>Controls</code>,
<code>Action</code> (custom header button), <code>Close</code>,
<code>StageTrigger</code> (min/max/restore), <code>Body</code>,
<code>ResizeHandle</code> (edges), <code>ResizeGrip</code> (se, pointer + keyboard).
Keyboard: focus the header / grip → <kbd>Enter</kbd> to grab → arrows → <kbd>Enter</kbd>/<kbd>Esc</kbd>.
</p>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each events as action}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td>{action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const t = stageRef?.querySelector('[data-float-panel-content]') as HTMLElement | null;
if (!t) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
intent: action.semantic.intent as never,
target: t
});
}}>▶ play</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors in <code>src/uix/eidos/components/float-panel/float-panel.css</code>. Soma sets
position/size/z-index inline (via the <code>translate</code> property for smooth drag); Eidos
styles the surface.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-float-panel-content]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Surface chrome (bg, border, shadow).</td></tr>
<tr><td class="name"><code>[data-float-panel-content][data-topmost]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Frontmost panel lifts (shadow).</td></tr>
<tr><td class="name"><code>[data-float-panel-content][data-variant][data-color]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Visual variant + accent.</td></tr>
<tr><td class="name"><code>[data-float-panel-header]</code> (grab) / <code>[data-dragging]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Drag handle cursor grab→grabbing.</td></tr>
<tr><td class="name"><code>[data-float-panel-resize-handle][data-resize-edge]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Per-edge resize affordance + cursor.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Role</td><td>Content uses <code>role="dialog"</code> (<code>aria-modal</code> when modal).</td></tr>
<tr><td class="name">Labelling</td><td><code>aria-labelledby</code> → Title.</td></tr>
<tr><td class="name">Focus</td><td>Trapped only when modal; returns to trigger on close.</td></tr>
<tr><td class="name">Keyboard</td><td>Escape closes (when dismissible). Keyboard move/resize is a documented v2 gap.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.