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/aspect-ratio/+page.svelte

484 lines
17 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 {
AspectRatio,
type AspectRatioProps,
type AspectRatioValue
} from '$uix/eidos/components/aspect-ratio';
import { Box } from '$uix/eidos/components/box';
import { compileMorfo } from '$uix/morfo';
import { aspectRatioMorfo } from '@/uix/morfo/components/aspect-ratio';
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 ───────────────────────────────────────────────────────
type RatioPreset = '1' | '4/3' | '16/9' | '21/9' | '3/4' | '2/3';
const ratioPresets: RatioPreset[] = ['1', '4/3', '16/9', '21/9', '3/4', '2/3'];
let ratio = $state<RatioPreset>('16/9');
let maxWidth = $state<number>(420);
type Demo = 'panel' | 'image' | 'iframe';
const demoOptions = ['panel', 'image', 'iframe'] as const;
let demo = $state<Demo>('panel');
const ratioValue = $derived<AspectRatioValue>(ratio);
const aspectProps = $derived<Partial<AspectRatioProps>>({
ratio: ratioValue,
maxWidth
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(aspectRatioMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$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();
});
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
'<!-- AspectRatio is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-box',
' data-aspect-ratio',
` style="aspect-ratio: ${ratio}; overflow: hidden; max-inline-size: ${maxWidth}px;"`,
'>',
' <img src="hero.jpg" alt="" />',
'</div>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { AspectRatio } from '$uix/eidos/components/aspect-ratio';",
'</' + 'script>',
'',
'<AspectRatio',
ratio !== '1' && ` ratio="${ratio}"`,
maxWidth !== 0 && ` maxWidth={${maxWidth}}`,
'>',
demo === 'image' && ' <img src="hero.jpg" alt="" />',
demo === 'iframe' && ' <iframe src="…" title="Embed" />',
demo === 'panel' && ' <div class="panel">…</div>',
'</AspectRatio>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · AspectRatio</div>
<h1 data-uix-page-title>AspectRatio</h1>
<p data-uix-page-lede>
Constrains its inner content to a fixed width/height ratio via the modern CSS
<code>aspect-ratio</code> property. Composes through <a href="/uix/components/box">&lt;Box&gt;</a>,
so every Box prop (<code>padding</code>, <code>maxWidth</code>, <code>gridColumn</code>, …) still
works on the wrapper. Useful for video / image / iframe containers that must hold their shape
regardless of inline size. Eidos-native: no soma backing, no semantic events.
</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>extends</span>Box
</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}>
<AspectRatio {...aspectProps} style="border: 1px dashed var(--color-border-default); border-radius: var(--radius-md);">
{#if demo === 'panel'}
<div
style="display: grid; place-items: center; background: linear-gradient(135deg, var(--color-primary-solid), var(--color-affirm-solid)); color: var(--color-content-on-solid); font-size: var(--font-size-xl); font-weight: var(--font-weight-bold);"
>
{ratio}
</div>
{:else if demo === 'image'}
<!-- svelte-ignore a11y_img_redundant_alt -->
<img
src="https://picsum.photos/seed/uix-aspect/800/600"
alt="Sample photo placeholder"
/>
{:else}
<iframe
src="about:blank"
title="Aspect ratio preview frame"
style="border: 0;"
></iframe>
{/if}
</AspectRatio>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>ratio</span>
<span>{ratio}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>maxWidth</span>
{maxWidth}px · <span data-uix-stage-trace-key>demo</span> {demo}
</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>2</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>
AspectRatio is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>ratio</code> prop maps to a single <code>--aspect-ratio</code> CSS variable; every
other prop (<code>maxWidth</code>, <code>padding</code>, …) inherits from Box.
</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>ratio</span>
<span data-uix-chips role="radiogroup">
{#each ratioPresets as opt}
<button data-uix-chip data-active={ratio === opt} onclick={() => (ratio = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>maxWidth <span data-uix-control-hint>pixels — inherited from Box</span></span
>
<input type="range" min="180" max="720" step="20" bind:value={maxWidth} />
<span data-uix-control-value>{maxWidth}px</span>
</label>
<label data-uix-control>
<span data-uix-control-label>demo content</span>
<span data-uix-chips role="radiogroup">
{#each demoOptions as opt}
<button data-uix-chip data-active={demo === opt} onclick={() => (demo = opt)}
>{opt}</button
>
{/each}
</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · aspect-ratio is eidos-native — equivalent markup shown</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</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 · ratio maps to --aspect-ratio</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>
The <code>ratio</code> prop accepts a number (e.g. <code>16/9</code> evaluated in JS) or a
string fraction (e.g. <code>"16/9"</code> emitted verbatim into CSS). Every other Box prop
(<code>padding</code>, <code>margin</code>, <code>maxWidth</code>, <code>gridColumn</code>,
…) passes through to the underlying <code>&lt;Box&gt;</code> shell.
</p>
<div data-uix-subsection-head>Aspect ratio</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">ratio</td>
<td class="type">number | string</td>
<td>
Number → CSS <code>aspect-ratio</code> as a decimal; string passes through
verbatim (<code>"16/9"</code>, <code>"4 / 3"</code>, <code>"1.5"</code>).
Default <code>1</code>.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Box</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">width / minWidth / maxWidth</td>
<td class="type">number | string</td>
<td>Constrains the box's inline size while keeping the ratio.</td>
</tr>
<tr>
<td class="name">padding / margin (and per-side)</td>
<td class="type">number | string</td>
<td>Numbers map to <code>var(--space-N)</code>.</td>
</tr>
<tr>
<td class="name">gridColumn / gridRow / placeSelf</td>
<td class="type">string</td>
<td>Use AspectRatio as a grid item — placement props live on Box.</td>
</tr>
<tr>
<td class="name">…</td>
<td class="type">BoxProps</td>
<td>See <a href="/uix/components/box">&lt;Box&gt;</a> for the full surface.</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</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;AspectRatio&gt;</code></td>
<td>
Radix exposes a numeric <code>ratio</code> only; UIX additionally accepts a
string fraction so the value reads literal in DevTools.
</td>
</tr>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;AspectRatio&gt;</code></td>
<td>
Chakra mixes the prop with the chained style-prop system; UIX keeps it pure
layout — every other concern (background, color) lives elsewhere.
</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;AspectRatio&gt;</code></td>
<td>
Mantine ships the same primitive; UIX adds the string-form ratio + composes
through Box for inherited sizing props.
</td>
</tr>
</tbody>
</table>
</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>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{aspectRatioMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{aspectRatioMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{aspectRatioMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>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>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</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.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
The Provider part emits only the <code>data-aspect-ratio</code> marker on top of the Box
shell — no states, no data properties, no aria attributes, no keyboard. The recipe consumes
the <code>--aspect-ratio</code> CSS variable the component writes inline.
</p>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
AspectRatio declares no semantic events. As a passive layout primitive, it does not commit,
emerge, or react to anything — it just constrains its child's geometry. Components that animate
or change state on appearance should compose AspectRatio inside a primitive (popover, drawer,
collapsible) that owns the relevant sema verbs.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/aspect-ratio/aspect-ratio.css</code>. The
Box shell carries the box-model surface; this recipe only adds the
<code>aspect-ratio</code> property and stretches the lone child to fill the box.
</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-aspect-ratio]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted by the component on top of the Box shell.</td>
</tr>
<tr>
<td class="name"
><code>[data-box][data-aspect-ratio] {`{ aspect-ratio: var(--aspect-ratio, 1); }`}</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Sets the CSS ratio from the variable the component writes inline.</td>
</tr>
<tr>
<td class="name"
><code>{`[data-box][data-aspect-ratio] > * { 100% × 100%, object-fit: cover }`}</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
Stretches the lone child (img / iframe / video / inner div) to fill the box.
</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>
None implicit. Default element is <code>&lt;div&gt;</code> — semantically
neutral. When wrapping media, supply the appropriate landmark / role on the
child (<code>&lt;img alt&gt;</code>, <code>&lt;iframe title&gt;</code>,
<code>&lt;video&gt;</code> with captions).
</td>
</tr>
<tr>
<td class="name">Label</td>
<td>
Not applicable — AspectRatio has no content semantics. Labels belong to the
child it constrains.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>AspectRatio is not focusable. Tab order follows the child.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>AspectRatio does not paint a focus ring.</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
No motion of its own. If the child is a video / animation, the consumer is
responsible for honouring <code>prefers-reduced-motion</code>.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.