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/split-button/+page.svelte

520 lines
23 KiB

<script lang="ts">
import {
SplitButton,
type SplitButtonVariant,
type SplitButtonSize,
type SplitButtonColor
} from '$uix/eidos/components/split-button';
import { compileMorfo } from '$uix/morfo';
import { splitButtonMorfo } from '@/uix/morfo/components/split-button';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state — every public prop is a control ────────────────────────
let variant = $state<SplitButtonVariant>('solid');
let size = $state<SplitButtonSize>('md');
let color = $state<SplitButtonColor>('primary');
let disabled = $state(false);
let open = $state(false);
let ariaLabel = $state('Save options');
let primaryLabel = $state('Save');
let lastAction = $state<string>('—');
// Menu (float) props
let matchAnchorWidth = $state(true);
let menuOffsetAuto = $state(true);
let menuSideOffset = $state(8);
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const;
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
const colors = ['primary', 'secondary', 'neutral'] as const;
// ── Compiled morfo ─────────────────────────────────────────────────────
const compiled = compileMorfo(splitButtonMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$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') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Code snippet (eidos-native — no soma layer) ────────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { SplitButton } from '$uix/eidos/components/split-button';",
' let open = $state(false);',
'</' + 'script>',
'',
'<SplitButton',
' bind:open',
variant !== 'solid' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
disabled && ' disabled',
` aria-label="${ariaLabel || 'Actions'}"`,
'>',
` <SplitButton.Primary onclick={save}>${primaryLabel}</SplitButton.Primary>`,
' <SplitButton.Menu' +
(!matchAnchorWidth ? ' matchAnchorWidth={false}' : '') +
(!menuOffsetAuto ? ` sideOffset={${menuSideOffset}}` : '') +
'>',
' <SplitButton.Item onSelect={saveAs}>Save as…</SplitButton.Item>',
' <SplitButton.Item onSelect={saveCopy}>Save a copy</SplitButton.Item>',
' <SplitButton.Separator />',
' <SplitButton.Item data-color="threat" onSelect={discard}>Discard</SplitButton.Item>',
' </SplitButton.Menu>',
'</SplitButton>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · SplitButton</div>
<h1 data-uix-page-title>SplitButton</h1>
<p data-uix-page-lede>
A primary action joined to a ▾ menu trigger — <code>Save | ▾</code> → secondary actions.
Pure composition: <a href="/uix/components/button-group">&lt;ButtonGroup attached&gt;</a> +
<a href="/uix/components/button">&lt;Button&gt;</a> +
<a href="/uix/components/dropdown-menu">&lt;DropdownMenu&gt;</a>. The ▾ trigger is
auto-rendered and inherits the group's variant / size / color; only the ▾ opens the menu.
0 own events — interactive by composition.
</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>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>composes</span>ButtonGroup · Button · DropdownMenu
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<SplitButton
{variant}
{size}
{color}
{disabled}
bind:open
aria-label={ariaLabel || undefined}
>
<SplitButton.Primary onclick={() => (lastAction = 'primary: Save')}>
{primaryLabel}
</SplitButton.Primary>
<SplitButton.Menu
{matchAnchorWidth}
sideOffset={menuOffsetAuto ? undefined : menuSideOffset}
>
<SplitButton.Item onSelect={() => (lastAction = 'Save as…')}>Save as…</SplitButton.Item>
<SplitButton.Item onSelect={() => (lastAction = 'Save a copy')}
>Save a copy</SplitButton.Item
>
<SplitButton.Separator />
<SplitButton.Item data-color="threat" onSelect={() => (lastAction = 'Discard')}
>Discard</SplitButton.Item
>
</SplitButton.Menu>
</SplitButton>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click Save, open the ▾ menu, or pick an item</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</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>menu</span>
{open ? 'open' : 'closed'} · <span data-uix-stage-trace-key>last</span>
{lastAction}
</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 <span data-uix-tab-count>7</span>
</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 · 0e</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>
SplitButton is eidos-only — no <span data-uix-layer-badge="soma">soma</span> split. The
visual props propagate to BOTH the primary and the ▾ trigger via the ButtonGroup context.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>both buttons</span></span>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>aria-label <span data-uix-control-hint>group name</span></span>
<input type="text" bind:value={ariaLabel} />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · menu (float) ·
<code>&lt;SplitButton.Menu&gt;</code>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>
matchAnchorWidth <span data-uix-control-hint>≥ surface width</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={matchAnchorWidth} />
<span data-uix-switch-label>{matchAnchorWidth ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>
sideOffset <span data-uix-control-hint>
{menuOffsetAuto ? 'canonical (--space-* per size)' : menuSideOffset + 'px gap'}
</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={menuOffsetAuto} />
<span data-uix-switch-label>{menuOffsetAuto ? 'auto' : 'manual'}</span>
</span>
</label>
{#if !menuOffsetAuto}
<label data-uix-control>
<span data-uix-control-label>
sideOffset px <span data-uix-control-hint>override</span>
</span>
<input type="range" min="0" max="24" step="1" bind:value={menuSideOffset} />
</label>
{/if}
</div>
<div data-uix-subsection-head>Demo content <span style="font-weight: normal; color: var(--uix-text-muted);">· primary label</span></div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>primary label</span>
<input type="text" bind:value={primaryLabel} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · composes ButtonGroup + Button + DropdownMenu</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</h2>
<p data-uix-section-desc>
Root props propagate to both buttons; DropdownMenu provider props (<code>open</code>,
<code>modal</code>, <code>onOpenChange</code>…) pass through. The menu surface
(<code>Menu</code> / <code>Item</code> / <code>Separator</code> / <code>Group</code> /
<code>CheckboxItem</code> / <code>RadioGroup</code> / <code>Sub</code>…) is re-exported from
<a href="/uix/components/dropdown-menu">DropdownMenu</a>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Menu open state.</td></tr>
<tr><td class="name">variant</td><td class="type">'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain'</td><td class="default">'solid'</td><td>Propagated to both buttons.</td></tr>
<tr><td class="name">size</td><td class="type">ResponsiveProp&lt;'xs' | 'sm' | 'md' | 'lg' | 'xl'&gt;</td><td class="default">'md'</td><td>Propagated to both buttons.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral'</td><td class="default">'primary'</td><td>Propagated to both buttons.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disables both buttons.</td></tr>
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">— (chevron)</td><td>Override the ▾ glyph. The default chevron rotates while open.</td></tr>
<tr><td class="name">menuLabel</td><td class="type">string</td><td class="default">'More actions'</td><td>Accessible name for the ▾ trigger.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name for the split group.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>
<code>&lt;SplitButton.Menu&gt;</code> props <span style="font-weight: normal; color: var(--uix-text-muted);">· the float</span>
</div>
<p data-uix-section-desc>
The menu IS a <a href="/uix/components/dropdown-menu">DropdownMenu.Content</a> with
split-button-aware defaults: it anchors to the whole group, matches its width, and sits a
gap below it. Every Content positioning prop (<code>side</code>, <code>align</code>,
<code>collisionPadding</code>…) still passes through.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">matchAnchorWidth</td><td class="type">ResponsiveProp&lt;boolean&gt;</td><td class="default">true</td><td>Min width tracks the split surface (the anchor width); content can grow it.</td></tr>
<tr><td class="name">sideOffset</td><td class="type">number</td><td class="default">canonical</td><td>Gap below the button. Unset → resolved from the size's <code>--space-*</code> step (density × scaling aware); pass a number to override.</td></tr>
<tr><td class="name">size</td><td class="type">ResponsiveProp&lt;'xs'…'xl'&gt;</td><td class="default">split's size</td><td>Forwarded to <a href="/uix/components/dropdown-menu">DropdownMenu.Content's <code>size</code></a> (1:1 scale); defaults to the split's size, override to decouple.</td></tr>
<tr><td class="name">align</td><td class="type">'start' | 'center' | 'end'</td><td class="default">'start'</td><td>Edge of the surface the menu aligns to.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference vs UIX</th></tr></thead>
<tbody>
<tr><td class="name">fluent-ui</td><td><code>&lt;SplitButton&gt;</code></td><td>Same model (primary + menu). UIX shares variant/size/color to both via the ButtonGroup context automatically.</td></tr>
<tr><td class="name">ant-design</td><td><code>&lt;Dropdown.Button&gt;</code></td><td>Same split. UIX's menu is the full DropdownMenu (groups/checkbox/radio/submenus), not a flat list.</td></tr>
<tr><td class="name">mui</td><td>ButtonGroup + Menu recipe</td><td>MUI documents it as a recipe; UIX ships it as a component with the chevron-rotates affordance.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
scope-eidos — no soma runtime. The morfo declares the split-specific contract: the
<code>role="group"</code> provider, the <code>data-state</code> that rotates the chevron,
and the Primary / Trigger parts (structural identities over the composed Button /
DropdownMenu.Trigger). Source: <code>src/uix/morfo/components/split-button.ts</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{splitButtonMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{splitButtonMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{splitButtonMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={splitButtonMorfo.apg} target="_blank" rel="noreferrer">menu-button ↗</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{splitButtonMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</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.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each splitButtonMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: string };
severity?: string;
}>;
}}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source</th><th>Condition</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
SplitButton declares no events of its own — it is interactive <strong>by composition</strong>.
The primary fires <code>contact-activate</code> (Button), the ▾ fires
<code>emerge.open</code>/<code>close</code> (DropdownMenu), and each item fires
<code>commit-select</code> (DropdownMenu). Exercise the stage and watch the trace strip.
</p>
<div data-uix-empty-state>No Sema events declared.</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Layout + the segmented seam come from <a href="/uix/components/button-group">&lt;ButtonGroup attached&gt;</a>;
Button + DropdownMenu paint the buttons. SplitButton's own CSS at
<code>src/uix/eidos/components/split-button/split-button.css</code> owns the
split-specific affordances: the ▾ rotates open and stays a slim tail, and the menu
tracks the surface width, scales with <code>size</code>, and sits a canonical gap below.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-split-button][data-state='open']</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Group open state (stamped from `open`).</td></tr>
<tr><td class="name"><code>… [data-split-button-trigger] [data-svg='chevron']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Rotate the ▾ chevron 180° while the menu is open.</td></tr>
<tr><td class="name"><code>[data-split-button-trigger][data-icon-only]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Slim the ▾ to glyph + snug padding (not a square).</td></tr>
<tr><td class="name"><code>[data-dropdown-menu-content][data-split-button-menu]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td><code>min-inline-size</code> = the anchor (surface) width; content grows it.</td></tr>
<tr><td class="name"><code>[data-split-button-menu][data-size='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Gap step per size (font / height / padding scale come from <a href="/uix/components/dropdown-menu">DropdownMenu's own size axis</a>).</td></tr>
<tr><td class="name"><code>[data-split-button-menu][data-canonical-gap][data-side='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Canonical separation: a <code>--space-*</code> margin on the placed side.</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">Group</td><td><code>role="group"</code> (from ButtonGroup) + <code>aria-label</code> — names the split as a unit.</td></tr>
<tr><td class="name">Primary</td><td>A native <code>&lt;button&gt;</code> (from Button) — its own action, independently Tab-focusable.</td></tr>
<tr><td class="name">Menu trigger</td><td>Follows the APG <a href={splitButtonMorfo.apg} target="_blank" rel="noreferrer">Menu Button</a> (from DropdownMenu): <code>aria-haspopup="menu"</code> + <code>aria-expanded</code>, opens with Enter/Space/ArrowDown, names itself via <code>menuLabel</code>.</td></tr>
<tr><td class="name">Menu</td><td><code>role="menu"</code> with arrow-key navigation, typeahead, Escape-to-close, focus return to the ▾ (all from DropdownMenu).</td></tr>
<tr><td class="name">Keyboard</td><td><span data-uix-kbd>Tab</span> moves between the primary and the ▾ (no roving); each opens / activates with its native keys.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.