|
|
4 months ago | |
|---|---|---|
| .. | ||
| components | 4 months ago | |
| README.md | 4 months ago | |
| exports.ts | 4 months ago | |
| float-panel-provider.svelte.test.ts | 4 months ago | |
| float-panel-provider.svelte.ts | 4 months ago | |
| index.ts | 4 months ago | |
| langs.ts | 4 months ago | |
| types.ts | 4 months ago | |
README.md
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 role; non-modal by default.
Anatomy
<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
anchorviaside/align/offset, viewport-clamped), then free (position: fixedat{x,y}) — dragging detaches it. Noanchor/defaultPosition→ centered in the viewport. - Drag: grab the
Header(or the wholeContentwithdragHandle="content") to move. Custom 2D pointer drag (move-buffer + deferred pointer capture) clamped tobounds(viewport default). Controls inside the header (button, a, input, …, [data-float-panel-no-drag]) don't start a drag. - Resize:
ResizeHandleparts (per edge/corner viaedge) resizeContentwhenresizable, respectingmin/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 whencloseOnInteractOutside(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')setsdata-last-action+ concretes the semantic.
Why not
floating/gesturelayers? 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 usesposition: 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:
<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'):
<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— fillsbounds(the container forabsolute, the viewport forfixed); 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 thedefaultstage. Content carriesaria-roledescription="window"; the active handle getsdata-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.