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
parent
9452bc2545
commit
b3ef470646
@ -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,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;
|
||||
@ -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;
|
||||
@ -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')
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -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';
|
||||
File diff suppressed because it is too large
Load Diff
@ -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, {}>;
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
Loading…
Reference in new issue