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

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