You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/float-panel
dev b3ef470646
feat(uix): FloatPanel — draggable/resizable floating panel
4 months ago
..
components feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
README.md feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
exports.ts feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
float-panel-provider.svelte.test.ts feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
float-panel-provider.svelte.ts feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
index.ts feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
langs.ts feat(uix): FloatPanel — draggable/resizable floating panel 4 months ago
types.ts feat(uix): FloatPanel — draggable/resizable floating panel 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 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:

<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 — 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.

Powered by TurnKey Linux.