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/web/routes/uix/components/float-panel/+page.svelte

517 lines
21 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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

Powered by TurnKey Linux.