soma test: split forms demo into dedicated switch/checkbox/radio-group pages

The combined `/test/soma/forms` page covered three separate components
(Switch, Checkbox, RadioGroup) in one route. Each deserves its own demo
page — matching the convention of every other component having a
dedicated `/test/soma/{kebab}` route, and letting the permutation runner
actually exercise them (the runner navigates per morfo-kebab, not per
merged page, so the combined demo left the 3 morfos SKIPPED).

## New demos

- `/test/soma/switch` — basic + disabled variants. `data-perm-step="0"`
  toggles the basic switch. Axes: `dir, disabled`.
- `/test/soma/checkbox` — basic + indeterminate + group variants. Steps
  0-2 toggle the first checkbox + first two group items. Seeds
  `?perm.checked=true` / `?perm.value=a,b`. Axes: `dir`.
- `/test/soma/radio-group` — active + disabled variants. Steps 0-2 select
  each option. Seeds `?perm.value=option-2` / `?perm.orientation`.
  Axes: `dir, orientation`.

Each demo reuses the CSS from the original combined page (via `:global`
selectors for `data-switch`, `data-checkbox`, `data-radio-group-item`).
All three have `aria-label` on the Provider — the DEV-mode A4 warning
added in batch 10 of audit 2026-04-21 requires it.

The combined `/test/soma/forms` page is kept in place (pre-existing
integration demo showing composition); future batches may decide
whether to drop it or repurpose it.

## Coverage delta

- Before: 63/66 demos · 131 variants · 202 steps.
- After:  66/66 demos · 139 variants · 228 steps. (smoke: 68 routes)

Coverage complete — every morfo now has at least one instrumented demo
under the permutation runner.

## Verification

- `npm run check`: 0 errors in new demo files.
- `npm run smoke`: 68/68 routes (was 65).
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 66/66 demos · 228 steps · 139 variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
semantuix
dev 6 months ago
parent db3ad3497f
commit 0d8de2869f

@ -0,0 +1,184 @@
<script lang="ts">
import * as Checkbox from '$soma/components/checkbox';
import { Perms } from '../_perms.svelte';
const perms = Perms.getOr({} as Record<string, string | number | boolean>);
function parseList(v: unknown, fallback: string[]): string[] {
if (typeof v !== 'string' || v.length === 0) return fallback;
return v.split(',').map((s) => s.trim()).filter(Boolean);
}
let check1 = $state((perms.checked as boolean) ?? false);
let check2 = $state(true);
let checkIndeterminate = $state(true);
let groupValues = $state<string[]>(parseList(perms.value, ['b']));
</script>
<svelte:head>
<title>Checkbox · Soma</title>
<meta name="perm-axes" content="dir" />
</svelte:head>
<div class="page">
<h1>Soma Checkbox</h1>
<p>Checkable input with `checked` / `unchecked` / `indeterminate` states and a group variant.</p>
<section>
<h2>1. Basic</h2>
<div class="form-row">
<Checkbox.Provider
bind:checked={check1}
aria-label="Accept terms"
data-perm-step="0"
data-perm-label="toggle accept"
>
{#snippet children({ checked })}
<span class="check-indicator">{checked ? '✓' : ''}</span>
{/snippet}
</Checkbox.Provider>
<span>Accept terms ({check1 ? 'checked' : 'unchecked'})</span>
</div>
<div class="form-row">
<Checkbox.Provider bind:checked={check2} aria-label="Newsletter">
{#snippet children({ checked })}
<span class="check-indicator">{checked ? '✓' : ''}</span>
{/snippet}
</Checkbox.Provider>
<span>Newsletter ({check2 ? 'checked' : 'unchecked'})</span>
</div>
</section>
<section>
<h2>2. Indeterminate</h2>
<div class="form-row">
<Checkbox.Provider
checked={false}
bind:indeterminate={checkIndeterminate}
aria-label="Partial selection"
>
{#snippet children({ checked, indeterminate })}
<span class="check-indicator">{indeterminate ? '−' : checked ? '✓' : ''}</span>
{/snippet}
</Checkbox.Provider>
<span>Partial ({checkIndeterminate ? 'indeterminate' : 'resolved'})</span>
</div>
</section>
<section>
<h2>3. Checkbox Group</h2>
<p class="state">selected: {JSON.stringify(groupValues)}</p>
<Checkbox.Group bind:value={groupValues}>
<Checkbox.GroupLabel>Notifications</Checkbox.GroupLabel>
{#each ['a', 'b', 'c'] as item, i}
<div class="form-row">
<Checkbox.Provider
value={item}
checked={groupValues.includes(item)}
aria-label="Option {item.toUpperCase()}"
data-perm-step={i === 0 ? '1' : i === 1 ? '2' : undefined}
data-perm-label={i === 0 ? 'toggle group A' : i === 1 ? 'toggle group B' : undefined}
>
{#snippet children({ checked })}
<span class="check-indicator">{checked ? '✓' : ''}</span>
{/snippet}
</Checkbox.Provider>
<span>Option {item.toUpperCase()}</span>
</div>
{/each}
</Checkbox.Group>
</section>
<section>
<h2>Data attributes</h2>
<pre>Root: [data-checkbox] [role=checkbox] [data-state=checked|unchecked|indeterminate] [aria-checked]
Group: [data-checkbox-group] [role=group]</pre>
</section>
</div>
<style>
.page {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
h1 {
margin-bottom: 0.5rem;
}
h2 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
p {
color: #555;
line-height: 1.6;
}
section {
padding: 1.25rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 1rem;
}
.state {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #64748b;
}
pre {
font-size: 0.75rem;
background: #f8fafc;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0;
}
.form-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0;
}
:global([data-checkbox]) {
width: 20px;
height: 20px;
border: 2px solid #cbd5e1;
border-radius: 4px;
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
color: white;
transition:
background 150ms,
border-color 150ms;
padding: 0;
}
:global([data-checkbox][data-state='checked']) {
background: #3b82f6;
border-color: #3b82f6;
}
:global([data-checkbox][data-state='indeterminate']) {
background: #94a3b8;
border-color: #94a3b8;
}
:global([data-checkbox]:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.check-indicator {
font-size: 12px;
line-height: 1;
}
:global([data-checkbox-group-label]) {
font-weight: 600;
font-size: 0.9rem;
display: block;
margin-bottom: 0.5rem;
}
</style>

@ -0,0 +1,146 @@
<script lang="ts">
import * as RadioGroup from '$soma/components/radio-group';
import { Perms } from '../_perms.svelte';
const perms = Perms.getOr({} as Record<string, string | number | boolean>);
let radioValue = $state((perms.value as string) ?? 'option-1');
let orientation = $state<'horizontal' | 'vertical'>(
(perms.orientation as 'horizontal' | 'vertical') ?? 'vertical'
);
</script>
<svelte:head>
<title>RadioGroup · Soma</title>
<meta name="perm-axes" content="dir,orientation" />
</svelte:head>
<div class="page">
<h1>Soma RadioGroup</h1>
<p>Single-select group of radio items with roving tabindex and WAI-ARIA support.</p>
<section>
<h2>1. Basic</h2>
<p class="state">value: {radioValue}</p>
<RadioGroup.Provider bind:value={radioValue} {orientation} aria-label="Subscription plan">
{#each ['option-1', 'option-2', 'option-3'] as opt, i}
<div class="form-row">
<RadioGroup.Item
value={opt}
data-perm-step={i < 3 ? String(i) : undefined}
data-perm-label={i === 0
? 'select option-1'
: i === 1
? 'select option-2'
: i === 2
? 'select option-3'
: undefined}
>
{#snippet children({ checked })}
<span class="radio-indicator">{checked ? '●' : '○'}</span>
{/snippet}
</RadioGroup.Item>
<span>{opt}</span>
</div>
{/each}
</RadioGroup.Provider>
</section>
<section>
<h2>2. Disabled + loop</h2>
<RadioGroup.Provider value="b" disabled loop aria-label="Read-only selection">
{#each ['a', 'b', 'c'] as opt}
<div class="form-row">
<RadioGroup.Item value={opt}>
{#snippet children({ checked })}
<span class="radio-indicator">{checked ? '●' : '○'}</span>
{/snippet}
</RadioGroup.Item>
<span>Item {opt}</span>
</div>
{/each}
</RadioGroup.Provider>
</section>
<section>
<h2>Data attributes</h2>
<pre>Root: [data-radio-group] [role=radiogroup] [data-orientation]
Item: [data-radio-group-item] [role=radio] [data-state=checked|unchecked] [data-value]</pre>
</section>
</div>
<style>
.page {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
h1 {
margin-bottom: 0.5rem;
}
h2 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
p {
color: #555;
line-height: 1.6;
}
section {
padding: 1.25rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 1rem;
}
.state {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #64748b;
}
pre {
font-size: 0.75rem;
background: #f8fafc;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0;
}
.form-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0;
}
:global([data-radio-group-item]) {
width: 20px;
height: 20px;
border: 2px solid #cbd5e1;
border-radius: 50%;
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 10px;
transition: border-color 150ms;
}
:global([data-radio-group-item][data-state='checked']) {
border-color: #3b82f6;
}
:global([data-radio-group-item]:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
:global([data-radio-group-item]:focus-visible) {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.radio-indicator {
color: #3b82f6;
font-size: 12px;
line-height: 1;
}
</style>

@ -0,0 +1,117 @@
<script lang="ts">
import * as Switch from '$soma/components/switch';
import { Perms } from '../_perms.svelte';
const perms = Perms.getOr({} as Record<string, string | number | boolean>);
let checked = $state((perms.checked as boolean) ?? false);
let disabled = $state((perms.disabled as boolean) ?? false);
</script>
<svelte:head>
<title>Switch · Soma</title>
<meta name="perm-axes" content="dir,disabled" />
</svelte:head>
<div class="page">
<h1>Soma Switch</h1>
<p>Binary on/off control with keyboard toggle and `role="switch"`.</p>
<section>
<h2>1. Basic</h2>
<p class="state">checked: {checked ? 'on' : 'off'}</p>
<Switch.Provider
bind:checked
{disabled}
aria-label="Notifications"
data-perm-step="0"
data-perm-label="toggle switch"
>
<Switch.Thumb />
</Switch.Provider>
</section>
<section>
<h2>2. Disabled (forced checked)</h2>
<Switch.Provider disabled checked={true} aria-label="Permanently on">
<Switch.Thumb />
</Switch.Provider>
</section>
<section>
<h2>Data attributes</h2>
<pre>Root: [data-switch] [role=switch] [data-state=checked|unchecked] [aria-checked]
Thumb: [data-switch-thumb] [data-state]</pre>
</section>
</div>
<style>
.page {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
h1 {
margin-bottom: 0.5rem;
}
h2 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
p {
color: #555;
line-height: 1.6;
}
section {
padding: 1.25rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 1rem;
}
.state {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #64748b;
}
pre {
font-size: 0.75rem;
background: #f8fafc;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0;
}
:global([data-switch]) {
width: 44px;
height: 24px;
border-radius: 12px;
border: 1px solid #cbd5e1;
background: #e2e8f0;
padding: 2px;
cursor: pointer;
position: relative;
transition: background 150ms;
}
:global([data-switch][data-state='checked']) {
background: #3b82f6;
border-color: #3b82f6;
}
:global([data-switch]:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
:global([data-switch-thumb]) {
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
transition: transform 150ms;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
:global([data-switch][data-state='checked'] [data-switch-thumb]) {
transform: translateX(20px);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.