feat(field-langs): localized field wrapping Field with an in-control language switcher

FieldLangs holds one value per language and switches the active editing language
through an adaptive in-control switcher, composing the shipped Field / Popover /
TextArea instead of reinventing them (picker composition pattern, A27).

- Coordinator (soma): values map + activeLang + required-per-language validity;
  exposes the value/selection proxies the composed controls bind to; fires
  shift-navigate (context change) as its only semantic event.
- Switcher (eidos, FieldLangs' own -- deliberately NOT a composed ToggleGroup,
  whose commit-toggle is the wrong semantic for a language navigation and would
  leak into the field): a segmented radiogroup for 3 or fewer languages, an own
  Popover dropdown for more; per-language set/required dot; roving keyboard, RTL.
- Single-line Input and multiline Textarea (corner switcher, the field's default
  floating label untouched); floatingLabel passthrough; switcher font one
  typographic step below the control (label doctrine).
- morfo (provider/switcher/lang-option/menu/textarea parts + shift-navigate
  event), i18n catalog, and an interactive demo.

Guards: svelte-check clean, morfo test, eidos-lint 0 invalid, component:audit PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 1878633f77
commit 0522efe04a

@ -0,0 +1,160 @@
# FieldLangs
A localized-input wrapper around `Field`: it holds **one value per language**
and switches which language is being edited through a compact switcher docked
**inside the control**. The first supported language is the default; a subset
can be marked required, and the field is invalid while any required language is
empty.
```svelte
<script lang="ts">
import { FieldLangs } from '$uix/eidos/components/field-langs';
let values = $state<Record<string, string>>({});
</script>
<FieldLangs languages={['en', 'es', 'fr']} required={['en']} bind:values>
<FieldLangs.Label>Product name <FieldLangs.RequiredIndicator /></FieldLangs.Label>
<FieldLangs.Control>
<FieldLangs.Input placeholder="Name…" />
</FieldLangs.Control>
<FieldLangs.HelperText>Provide the name for each language.</FieldLangs.HelperText>
<FieldLangs.ErrorText>Required in every marked language.</FieldLangs.ErrorText>
</FieldLangs>
```
The switcher is docked **inside the field control** (like a trailing unit
selector), at the logical `switcherSide` (`'end'` default · `'start'`, flips in
RTL) — the consumer does **not** compose it. Its form **adapts to the language
count**: a compact segmented toggle for ≤3 languages, an own `Popover` dropdown
for more (or forced via `switcher`). Each switch shows the compact code (or an
explicit label) and exposes the full localized language name as its accessible
name.
## Architecture (composition, not a fork)
FieldLangs follows the picker composition pattern (A27): a headless
`FieldLangsProvider` (soma) owns the shared `values` map, the active language
and the per-language completion / validity state, and the eidos wrapper
composes the visual `Field` with the switcher injected inside the control.
- `Field.Input`'s value is bound, via the coordinator, to `values[activeLang]`;
switching the language swaps the shown value (one input reused).
- **Segmented mode** (≤3 langs) is **FieldLangs' own radiogroup** of
`<button role="radio">`s — NOT a composed ToggleGroup. Switching a language is
a `shift-navigate` (context change), so the switch fires only that; it does
**not** leak a `commit-toggle` (nor ToggleGroup's chrome) into the control.
Borderless, flush; the active language is a solid-primary pill; each language
carries a set/unset dot. Roving keyboard (arrow / Home / End), RTL-aware.
- **Select mode** (more langs) is **also FieldLangs' own** — a ghost `Popover`
trigger + a vertical `radiogroup` of the same buttons. Selecting a language
fires only `shift-navigate` (no `Select` commit leaking into the field), and
the dropdown shows each language's set/unset dot. The overlay behavior is the
composed `Popover` (a frame — `emerge` — which is semantically fine).
- `FieldLangs.Control` injects the switcher inside `Field.Control` at the logical
side; the control's padding on that edge is zeroed so the switcher docks flush
against the border, nothing between.
- Only three parts are FieldLangs' own (`provider`, `switcher`, `lang-option`);
the composed `Field` and `Popover` surfaces keep their own morfo contracts.
## Baseline
**No Air baseline** — FieldLangs is a new component; the legacy `air/` layer had
no localized / per-language field. It composes the shipped `Field` (+ `Popover`
for the many-languages dropdown) and adds its own switcher; not migrated.
## Comparativa
A localized / per-language field is a CMS pattern, **not** an APG one: none of
**Ark UI, Bits UI, Radix or React Aria** ships it. The relevant references are
the localization UIs of headless CMSs.
| Feature | Strapi | Payload | Directus | Sanity | Ark/Bits/Radix/RAC | FieldLangs |
| --- | --- | --- | --- | --- | --- | --- |
| Per-language value map | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Language switcher | entry | field | tabs | tabs | ❌ | ✅ segmented |
| First language = default | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Required per language (subset) | ⚠️ global | ✅ | ✅ | ⚠️ | ❌ | ✅ |
| Completion cue on switches | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ (`*` risk→affirm) |
| Switcher docked inside the control | — | — | — | — | ❌ | ✅ (`switcherSide`) |
| Adaptive switcher (segmented / select by count) | — | — | — | — | ❌ | ✅ (`switcher`) |
| Fallback value as placeholder | — | ✅ | — | ✅ | ❌ | ❌ (v2) |
| Copy from another language | — | ✅ | ✅ | — | ❌ | ❌ (v2) |
| Deep Form integration (per-locale names) | ✅ | ✅ | ✅ | ✅ | n/a | ❌ (v2) |
## Decisiones
- **Segmented switch is FieldLangs' own, NOT a composed ToggleGroup**
(compose-first exception, justified). A ToggleGroup fires `commit-toggle` —
the wrong semantic for a language-context *navigation* — and its chrome +
events would leak into the field control. FieldLangs renders its own
`radiogroup` (roving keyboard, ARIA, RTL) that fires **only** `shift-navigate`.
The composed pieces that DO fit are still composed: `Field` (the control) and,
for the many-languages dropdown, `Popover` (a frame overlay — no commit
semantics of its own).
- **One input reused, value swaps.** Switching the language rebinds a single
`Field.Input` to `values[activeLang]` rather than mounting one input per
language. Simpler; per-language IME/undo state is not preserved (see Gaps).
- **`shift.navigate` for the language switch.** Changing the editing language is
a context shift (book cap. 27), not a state fixation — `expression:
'family-default'`, no per-component pack. It is the switch's *only* semantic
event (no `commit-toggle`).
- **Required cue is per-switch + field-level.** Each required switch carries a
`*` (risk while empty, affirm once filled); the field turns invalid while any
required language is empty, surfacing `Field.ErrorText`.
- **`value`/`onValueChange` over a nested `bind:`.** The input is wired one-way
value in + `onValueChange` out (a function binding through the eidos→soma
`$bindable` chain did not propagate).
- **Name kept as `FieldLangs`** (user's choice) despite the near-collision with
the `langs` runtime service; alternatives (`LocalizedField`, `LangField`) were
offered and declined.
## Subset (colored)
`color` accepts the full `ColorRole` set (`primary · secondary · tertiary ·
neutral · affirm · fulfill · risk · threat · loss`) — forwarded verbatim to the
composed `Field` for its focus ring / required accent. No narrowing.
## Sema events
| Name | Family | Verb | Sequence | Intent | Target | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| `shift-navigate` | `shift` | `navigate` | `post` | — (frame) | `provider` | Active editing language changed. Family-default signature. The **only** semantic event of the switch — the own radiogroup fires no `commit-toggle`. |
## Gaps
Presented for scope sign-off — each row below carries an explicit disposition.
### Out of scope (v2 roadmap) — disposición: **diferir**
- **Fallback value as placeholder** (Payload, Sanity ship it). Show the default
language's value greyed as the placeholder of an empty secondary language.
Disposición: **diferir** (v2). Est. ~30 lines (provider `fallbackValue`
derived + input placeholder wiring).
- **Copy from another language** (Directus, Payload). A per-switch "copy from …"
action to seed one language from another. Disposición: **diferir** (v2). Est.
~60 lines (a menu per switch + `copyLang(from, to)` on the provider).
- **Deep Form integration** (per-locale submission names, e.g. `title.en`).
Today FieldLangs is `bind:values`-driven; inside a `Form` it does not register
a per-locale field set. Disposición: **diferir** (v2). Est. ~80 lines (register
N Form fields or a composite value serializer).
- **Per-language input state** (N inputs, only active shown) to preserve
IME/undo/scroll per language. Disposición: **diferir** — the one-input-reused
model covers the common case. Est. ~40 lines.
### Dropped — disposición: **descartar**
- **RTL per language** — writing direction is a `Field` / document concern.
Disposición: **descartar** for FieldLangs core; a `dir` field on `LangSpec` is
accepted but wiring `dir` onto the input is left to the consumer.
## Audit exceptions
- `R-1.2 exception:` the field disabled visual is owned by the composed `Field`
(FieldLangs passes `disabled`); the switcher's own `:disabled` treatment lives
in the recipe, so there is no root `[data-disabled]` rule to add.
- `R-1.4 exception:` the invalid visual is owned by the composed `Field`
(FieldLangs passes `invalid`); there is no FieldLangs-level `[data-invalid]`
rule to add.
- `A-1.4 exception:` FieldLangs is a composite CMS pattern with no single APG
pattern; the composed `Field` (textbox) and the switcher (a WAI-ARIA
radiogroup, in both modes) carry the formal patterns.

@ -0,0 +1,41 @@
<script lang="ts">
/**
* Internal body — reads the FieldLangs coordinator from context and renders
* the visual `Field`, feeding it the derived `required` / `invalid` /
* `disabled` / `readonly` state. The switcher is injected inside the control
* by `FieldLangs.Control`, not here.
*/
import type { Snippet } from 'svelte';
import { Field } from '../field';
import { FieldLangsProvider } from '$soma/components/field-langs';
import type { FieldLangsColor, FieldLangsSize, FieldLangsVariant } from './types';
let {
size,
variant,
color,
floatingLabel = false,
children
}: {
size: FieldLangsSize;
variant: FieldLangsVariant;
color: FieldLangsColor;
floatingLabel?: boolean;
children?: Snippet;
} = $props();
const provider = FieldLangsProvider.require();
</script>
<Field
{size}
{variant}
{color}
{floatingLabel}
required={provider.hasRequired}
invalid={provider.isInvalid}
disabled={provider.isDisabled}
readonly={provider.isReadonly}
>
{@render children?.()}
</Field>

@ -0,0 +1,23 @@
<script lang="ts">
// `FieldLangs.Control` — `Field.Control` with the language switcher docked
// inside it, at the coordinator's logical `switcherSide` (RTL-aware via DOM
// order + the control's flex row).
import { Field } from '../field';
import { FieldLangsProvider } from '$soma/components/field-langs';
import Switcher from './field-langs-switcher.svelte';
import type { FieldLangsControlProps } from './types';
let { children, ...rest }: FieldLangsControlProps = $props();
const provider = FieldLangsProvider.require();
</script>
<Field.Control {...rest}>
{#if provider.switcherSide === 'start'}
<Switcher />
{/if}
{@render children?.()}
{#if provider.switcherSide === 'end'}
<Switcher />
{/if}
</Field.Control>

@ -0,0 +1,12 @@
<script lang="ts">
// `FieldLangs.ErrorText` — passthrough over `Field.ErrorText`. Rendered while
// the field is invalid (a required language left empty, by default).
import { Field } from '../field';
import type { FieldLangsErrorTextProps } from './types';
let { children, ...rest }: FieldLangsErrorTextProps = $props();
</script>
<Field.ErrorText {...rest}>
{@render children?.()}
</Field.ErrorText>

@ -0,0 +1,11 @@
<script lang="ts">
// `FieldLangs.HelperText` — passthrough over `Field.HelperText`.
import { Field } from '../field';
import type { FieldLangsHelperTextProps } from './types';
let { children, ...rest }: FieldLangsHelperTextProps = $props();
</script>
<Field.HelperText {...rest}>
{@render children?.()}
</Field.HelperText>

@ -0,0 +1,20 @@
<script lang="ts">
/**
* `FieldLangs.Input` — the localized text input. A thin wrapper over
* `Field.Input` whose value is bound, through the FieldLangs coordinator, to
* `values[activeLang]`; switching the language swaps the shown value.
*/
import { Field } from '../field';
import { FieldLangsProvider } from '$soma/components/field-langs';
import type { FieldLangsInputProps } from './types';
let { ...rest }: FieldLangsInputProps = $props();
const provider = FieldLangsProvider.require();
</script>
<Field.Input
{...rest}
value={provider.activeValue.current}
onValueChange={(v: string) => (provider.activeValue.current = v)}
/>

@ -0,0 +1,11 @@
<script lang="ts">
// `FieldLangs.Label` — passthrough over `Field.Label`.
import { Field } from '../field';
import type { FieldLangsLabelProps } from './types';
let { children, ...rest }: FieldLangsLabelProps = $props();
</script>
<Field.Label {...rest}>
{@render children?.()}
</Field.Label>

@ -0,0 +1,12 @@
<script lang="ts">
// `FieldLangs.RequiredIndicator` — passthrough over `Field.RequiredIndicator`
// (the field-level `*`, shown when any language is required).
import { Field } from '../field';
import type { FieldLangsRequiredIndicatorProps } from './types';
let { children, ...rest }: FieldLangsRequiredIndicatorProps = $props();
</script>
<Field.RequiredIndicator {...rest}>
{@render children?.()}
</Field.RequiredIndicator>

@ -0,0 +1,147 @@
<script lang="ts">
/**
* Internal in-control language switcher — FieldLangs' OWN control in both
* forms, so switching a language fires ONLY `shift-navigate` (no composed
* `ToggleGroup`/`Select` commit semantics leaking into the field):
*
* - **Segmented** (≤3 langs): a horizontal `radiogroup` of buttons that fills
* the control's trailing area, separated segments, the active one a
* solid-primary cell, a set/unset dot per language.
* - **Select** (more langs): a `Popover` trigger + a vertical `radiogroup` of
* the same buttons (with the set/unset dot), so the dropdown shows which
* languages are already filled.
*/
import { Popover } from '../popover';
import { FieldLangsProvider } from '$soma/components/field-langs';
import { ActiveEidos } from '$uix/eidos';
const provider = FieldLangsProvider.require();
const eidos = ActiveEidos.require();
let selectOpen = $state(false);
let menuEl = $state<HTMLElement | null>(null);
const switcherLabel = $derived(
eidos.langs.ts('#?components.field-langs.switcher-label|Select language')
);
type Spec = { code: string; label?: string };
/** Localized language name (autonym) — accessible name / tooltip / dropdown row. */
function fullName(spec: Spec): string {
if (spec.label) return spec.label;
try {
return new Intl.DisplayNames([spec.code], { type: 'language' }).of(spec.code) ?? spec.code;
} catch {
return spec.code;
}
}
/** Compact visible label — the explicit label, else the uppercased code. */
function shortLabel(spec: Spec): string {
return spec.label ?? spec.code.toUpperCase();
}
const activeSpec = $derived(provider.langSpecs.find((s) => s.code === provider.activeLang));
/** Radiogroup keyboard: arrows/Home/End move + select the active language. */
function onKeydown(e: KeyboardEvent) {
if (provider.isDisabled) return;
const group = e.currentTarget as HTMLElement;
const codes = provider.langCodes;
const i = codes.indexOf(provider.activeLang);
if (i < 0) return;
const rtl = getComputedStyle(group).direction === 'rtl';
const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight';
const prevKey = rtl ? 'ArrowRight' : 'ArrowLeft';
let target = -1;
if (e.key === nextKey || e.key === 'ArrowDown') target = (i + 1) % codes.length;
else if (e.key === prevKey || e.key === 'ArrowUp') target = (i - 1 + codes.length) % codes.length;
else if (e.key === 'Home') target = 0;
else if (e.key === 'End') target = codes.length - 1;
else return;
e.preventDefault();
const code = codes[target];
group.querySelector<HTMLElement>(`[data-lang="${CSS.escape(code)}"]`)?.focus();
provider.setActiveLang(code);
}
// Focus the active language when the dropdown opens (so arrow keys work).
$effect(() => {
if (selectOpen && menuEl) {
menuEl.querySelector<HTMLElement>('[data-active]')?.focus();
}
});
</script>
{#snippet langRadio(spec: Spec, long: boolean)}
{@const active = spec.code === provider.activeLang}
<button
type="button"
role="radio"
aria-checked={active}
tabindex={active ? 0 : -1}
disabled={provider.isDisabled}
title={fullName(spec)}
aria-label={fullName(spec)}
data-field-langs-lang-option
data-lang={spec.code}
data-active={active ? '' : undefined}
data-required={provider.isRequiredLang(spec.code) ? '' : undefined}
data-complete={provider.isComplete(spec.code) ? '' : undefined}
onclick={() => {
provider.setActiveLang(spec.code);
selectOpen = false;
}}
>
{long ? fullName(spec) : shortLabel(spec)}
</button>
{/snippet}
{#if provider.switcherKind === 'segmented'}
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- radiogroup uses roving tabindex on its radio children (APG); the group itself is not a tab stop -->
<div
data-field-langs-switcher
data-side={provider.switcherSide}
data-mode="segmented"
role="radiogroup"
aria-label={switcherLabel}
aria-disabled={provider.isDisabled ? 'true' : undefined}
onkeydown={onKeydown}
>
{#each provider.langSpecs as spec (spec.code)}
{@render langRadio(spec, false)}
{/each}
</div>
{:else}
<div
data-field-langs-switcher
data-side={provider.switcherSide}
data-mode="select"
aria-label={switcherLabel}
>
<Popover bind:open={selectOpen}>
<Popover.Trigger disabled={provider.isDisabled} aria-label={switcherLabel}>
{activeSpec ? shortLabel(activeSpec) : ''}
<span aria-hidden="true">▾</span>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content>
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
bind:this={menuEl}
data-field-langs-menu
role="radiogroup"
aria-label={switcherLabel}
onkeydown={onKeydown}
>
{#each provider.langSpecs as spec (spec.code)}
{@render langRadio(spec, true)}
{/each}
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
</div>
{/if}

@ -0,0 +1,28 @@
<script lang="ts">
/**
* `FieldLangs.Textarea` — a multiline localized control. Composes the
* design-system `TextArea` (which participates in the Field context, so it
* inherits disabled / required / invalid) bound, via the coordinator, to
* `values[activeLang]`. The language switcher docks in the top corner on the
* textarea's border, so the field's default floating label still applies.
*/
import { TextArea } from '../textarea';
import { FieldLangsProvider } from '$soma/components/field-langs';
import Switcher from './field-langs-switcher.svelte';
import type { FieldLangsTextareaProps } from './types';
let { placeholder, ...rest }: FieldLangsTextareaProps = $props();
const provider = FieldLangsProvider.require();
</script>
<div data-field-langs-textarea>
<Switcher />
<TextArea
{...rest}
value={provider.activeValue.current}
onValueChange={(v: string) => (provider.activeValue.current = v)}
>
<TextArea.Input {placeholder} />
</TextArea>
</div>

@ -0,0 +1,227 @@
/*
* FieldLangs recipe — the language switcher is FieldLangs' own control docked
* INSIDE the field control, filling its trailing area flush against the border.
* Segmented = a full-height radiogroup of separated cells (active = solid
* primary); select = a ghost Popover trigger + a vertical radiogroup menu. Each
* language carries a set/unset dot.
*/
/* The root only carries FieldLangs' state attrs; the composed Field fills it. */
[data-field-langs] {
display: block;
inline-size: 100%;
min-inline-size: 0;
}
/*
* The in-control switcher font follows the SAME "one typographic step below the
* control" as the field label — the fixed `md − sm` delta derived from the
* control font (field.css label doctrine), NOT a divergent size ladder — so the
* switcher stays coherent with the label at every size. `--_fls-bar` is the
* corner-switcher clearance for the multiline textarea. Defined on `[data-field]`
* so it can read the field's control-font token.
*/
[data-field-langs] [data-field] {
--_fls-font: calc(
var(--_field-control-font-size) - (var(--font-size-md) - var(--font-size-sm))
);
--_fls-bar: calc(var(--_fls-font) * 1.3 + var(--space-1) * 4 + 2px);
}
/* Zero the control's padding on the switcher edge so it docks flush. */
[data-field-langs][data-switcher-side='end'] [data-field-control] {
padding-inline-end: 0;
}
[data-field-langs][data-switcher-side='start'] [data-field-control] {
padding-inline-start: 0;
}
/* The switcher fills the full control height on its edge. */
[data-field-langs-switcher] {
display: inline-flex;
flex: 0 0 auto;
align-items: stretch;
align-self: stretch;
min-inline-size: 0;
}
/* ── Segmented radiogroup — full-height cells, separated ─────────────────────── */
[data-field-langs][data-switcher-side='end']
[data-field-control]
[data-field-langs-switcher][data-mode='segmented'] {
border-inline-start: var(--border-width-thin) solid var(--color-border-subtle);
border-start-end-radius: var(--field-control-radius);
border-end-end-radius: var(--field-control-radius);
overflow: hidden;
}
[data-field-langs][data-switcher-side='start']
[data-field-control]
[data-field-langs-switcher][data-mode='segmented'] {
border-inline-end: var(--border-width-thin) solid var(--color-border-subtle);
border-start-start-radius: var(--field-control-radius);
border-end-start-radius: var(--field-control-radius);
overflow: hidden;
}
[data-field-langs-switcher][data-mode='segmented'] [data-field-langs-lang-option] {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border: 0;
background: transparent;
color: var(--color-content-secondary);
font-family: inherit;
font-size: var(--_fls-font);
line-height: 1;
padding-inline: var(--space-2);
cursor: pointer;
}
/* A separator between adjacent segments. */
[data-field-langs-switcher][data-mode='segmented']
[data-field-langs-lang-option]
+ [data-field-langs-lang-option] {
border-inline-start: var(--border-width-thin) solid var(--color-border-subtle);
}
/* The active language: a solid-primary cell filling its full-height segment. */
[data-field-langs-switcher][data-mode='segmented'] [data-field-langs-lang-option][data-active] {
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
}
[data-field-langs-switcher][data-mode='segmented'] [data-field-langs-lang-option]:disabled {
cursor: not-allowed;
color: var(--color-content-disabled);
}
[data-field-langs-switcher][data-mode='segmented'] [data-field-langs-lang-option]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: calc(-1 * var(--focus-ring-width));
}
/* ── Select trigger (ghost, in-control) ─────────────────────────────────────── */
[data-field-langs-switcher][data-mode='select'] {
align-items: center;
}
[data-field-langs-switcher][data-mode='select'] [data-popover-trigger] {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border: 0;
background: transparent;
color: var(--color-content-secondary);
font-family: inherit;
font-size: var(--_fls-font);
line-height: 1;
padding-inline: var(--space-2);
cursor: pointer;
}
/* ── Dropdown menu (portaled) — vertical radiogroup rows ─────────────────────── */
[data-field-langs-menu] {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-inline-size: 9rem;
}
[data-field-langs-menu] [data-field-langs-lang-option] {
display: inline-flex;
align-items: center;
gap: var(--space-2);
inline-size: 100%;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-content-primary);
font-family: inherit;
font-size: var(--font-size-sm);
line-height: 1.2;
padding: var(--space-1) var(--space-2);
cursor: pointer;
text-align: start;
}
[data-field-langs-menu] [data-field-langs-lang-option][data-active] {
background: var(--color-primary-surface);
color: var(--color-primary-text);
}
[data-field-langs-menu] [data-field-langs-lang-option]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: calc(-1 * var(--focus-ring-width));
}
/*
* Set/unset dot per language: muted when empty, green (affirm) once the
* language has content, risk while a required language is still empty.
*/
[data-field-langs-lang-option]::after {
content: '';
flex: 0 0 auto;
inline-size: 0.5em; /* literal: status dot, relative so it scales with the font */
block-size: 0.5em; /* literal: status dot, relative so it scales with the font */
border-radius: var(--radius-full);
background: var(--color-content-muted);
}
/* In the dropdown, push the dot to the trailing edge of the row. */
[data-field-langs-menu] [data-field-langs-lang-option]::after {
margin-inline-start: auto;
}
[data-field-langs-lang-option][data-required]:not([data-complete])::after {
background: var(--color-risk-solid);
}
[data-field-langs-lang-option][data-complete]::after {
background: var(--color-affirm-solid);
}
/* ── Multiline (textarea) — the switcher docks in the TOP CORNER on the
* textarea's border (like the single-line trailing switcher), NOT a bar. The
* textarea is the field's control at the top, so the floating label keeps its
* DEFAULT style; only the resting line shifts to clear the corner switcher. ─── */
[data-field-langs-textarea] {
position: relative;
}
/* The text starts below the corner switcher; the field's DEFAULT floating label
(centred on the control at `control-height/2`) is left untouched — the resting
label sits on the switcher's row, the text below. */
[data-field-langs-textarea] [data-textarea-input] {
padding-block-start: var(--_fls-bar);
}
[data-field-langs-textarea] [data-field-langs-switcher] {
position: absolute;
inset-block-start: var(--space-1);
inset-inline-end: var(--space-1);
z-index: 1;
align-self: auto;
background: var(--color-surface-raised);
border: var(--border-width-thin) solid var(--color-border-subtle);
border-radius: var(--radius-sm);
overflow: hidden;
}
[data-field-langs][data-switcher-side='start']
[data-field-langs-textarea]
[data-field-langs-switcher] {
inset-inline-end: auto;
inset-inline-start: var(--space-1);
}
[data-field-langs-textarea]
[data-field-langs-switcher][data-mode='segmented']
[data-field-langs-lang-option] {
padding-block: var(--space-1);
}

@ -0,0 +1,38 @@
<script lang="ts">
/**
* Eidos `<FieldLangs>` — root. Wraps the headless FieldLangs coordinator and
* composes the visual `Field` with FieldLangs' own in-control language
* switcher (in `<FieldLangsBody>`, which reads the coordinator via context).
*/
import './field-langs.css';
import { ActiveEidos } from '$uix/eidos';
import * as FieldLangs from '$soma/components/field-langs';
import Body from './field-langs-body.svelte';
import type { FieldLangsProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
floatingLabel = false,
values = $bindable({}),
activeLang = $bindable<string | undefined>(undefined),
children,
...rest
}: FieldLangsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<FieldLangs.Provider
{...rest}
bind:values
bind:activeLang
data-size={resolvedSize}
data-variant={variant}
>
<Body size={resolvedSize} {variant} {color} {floatingLabel}>
{@render children?.()}
</Body>
</FieldLangs.Provider>

@ -0,0 +1,60 @@
// FieldLangs — eidos compound API, disciplined option C.
//
// import { FieldLangs } from '$uix/eidos/components/field-langs';
//
// <FieldLangs languages={['en', 'es', 'fr']} required={['en']} bind:values>
// <FieldLangs.Label>Product name <FieldLangs.RequiredIndicator /></FieldLangs.Label>
// <FieldLangs.Control>
// <FieldLangs.Input placeholder="Name…" />
// </FieldLangs.Control>
// <FieldLangs.HelperText>Localize per language.</FieldLangs.HelperText>
// <FieldLangs.ErrorText>Required in every marked language.</FieldLangs.ErrorText>
// </FieldLangs>
//
// The segmented language switcher is rendered automatically at `placement`
// ('bottom' | 'top'); it is not composed by the consumer.
import FieldLangsComponent from './field-langs.svelte';
import Label from './field-langs-label.svelte';
import Control from './field-langs-control.svelte';
import Input from './field-langs-input.svelte';
import Textarea from './field-langs-textarea.svelte';
import HelperText from './field-langs-helper-text.svelte';
import ErrorText from './field-langs-error-text.svelte';
import RequiredIndicator from './field-langs-required-indicator.svelte';
type FieldLangsNamespace = typeof FieldLangsComponent & {
Label: typeof Label;
Control: typeof Control;
Input: typeof Input;
Textarea: typeof Textarea;
HelperText: typeof HelperText;
ErrorText: typeof ErrorText;
RequiredIndicator: typeof RequiredIndicator;
};
const FieldLangs = FieldLangsComponent as FieldLangsNamespace;
FieldLangs.Label = Label;
FieldLangs.Control = Control;
FieldLangs.Input = Input;
FieldLangs.Textarea = Textarea;
FieldLangs.HelperText = HelperText;
FieldLangs.ErrorText = ErrorText;
FieldLangs.RequiredIndicator = RequiredIndicator;
export { FieldLangs };
export default FieldLangs;
export type {
FieldLangsProps,
FieldLangsLabelProps as LabelProps,
FieldLangsControlProps as ControlProps,
FieldLangsInputProps as InputProps,
FieldLangsTextareaProps as TextareaProps,
FieldLangsHelperTextProps as HelperTextProps,
FieldLangsErrorTextProps as ErrorTextProps,
FieldLangsRequiredIndicatorProps as RequiredIndicatorProps,
FieldLangsSize,
FieldLangsVariant,
FieldLangsColor
} from './types';

@ -0,0 +1,57 @@
import type { ProviderProps } from '$soma/components/field-langs';
import type {
ControlProps,
ErrorTextProps,
HelperTextProps,
InputProps as FieldInputProps,
LabelProps,
RequiredIndicatorProps
} from '$soma/components/field';
import type { TextAreaProps } from '../textarea';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type FieldLangsSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type FieldLangsVariant = ControlVariant;
export type FieldLangsColor = ColorRole;
/**
* Props for the eidos `<FieldLangs>`.
*
* Soma owns the values map, the active language and the completion / validity
* state. Eidos adds the visual `size` / `variant` / `color` — forwarded to the
* composed `Field` — plus the segmented language switcher.
*/
export type FieldLangsProps = ProviderProps & {
/** Visual size. Accepts a responsive map resolved by ActiveEidos. @default 'md' */
size?: ResponsiveProp<FieldLangsSize>;
/** Visual variant for the control shell. @default 'surface' */
variant?: FieldLangsVariant;
/** Accent palette for focus and required indicator. @default 'primary' */
color?: FieldLangsColor;
/**
* Float the label: it starts as a placeholder inside the control and floats
* above on focus / when a value is present. Forwarded to the composed
* `Field`. @default false
*/
floatingLabel?: boolean;
};
export type FieldLangsLabelProps = LabelProps;
export type FieldLangsControlProps = ControlProps;
/** `FieldLangs.Input` binds the value per-language itself, so `value` is not a prop. */
export type FieldLangsInputProps = Omit<FieldInputProps, 'value' | 'onValueChange'>;
/**
* `FieldLangs.Textarea` — a multiline localized control. Forwards `TextArea`
* props (rows / autosize / maxLength / …); FieldLangs binds the value
* per-language, so `value` / `onValueChange` are not props.
*/
export type FieldLangsTextareaProps = Omit<
TextAreaProps,
'value' | 'onValueChange' | 'children'
> & {
/** Placeholder for the textarea (forwarded to `TextArea.Input`). */
placeholder?: string;
};
export type FieldLangsHelperTextProps = HelperTextProps;
export type FieldLangsErrorTextProps = ErrorTextProps;
export type FieldLangsRequiredIndicatorProps = RequiredIndicatorProps;

@ -0,0 +1,19 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the field-langs component. Merged under
* `components.field-langs.*` by `ActiveUix` (via `componentLangs` barrel).
*
* Morfo references entries by relative idlangref
* (`v.translationRef('switcher-label', ...)` → `components.field-langs.*`).
*/
export const fieldLangsLangs = {
label: {
es: 'Campo localizado',
en: 'Localized field'
},
'switcher-label': {
es: 'Seleccionar idioma',
en: 'Select language'
}
} satisfies LangNode;

@ -33,6 +33,7 @@ import { editableLangs } from './editable';
import { fabLangs } from './fab';
import { feedLangs } from './feed';
import { fieldLangs } from './field';
import { fieldLangsLangs } from './field-langs';
import { fileUploadLangs } from './file-upload';
import { floatPanelLangs } from './float-panel';
import { formLangs } from './form';
@ -138,6 +139,7 @@ export const componentLangs = {
fab: fabLangs,
feed: feedLangs,
field: fieldLangs,
'field-langs': fieldLangsLangs,
'file-upload': fileUploadLangs,
'float-panel': floatPanelLangs,
form: formLangs,

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { validateMorfo } from '../schema';
import { fieldLangsMorfo } from './field-langs';
describe('fieldLangsMorfo', () => {
it('passes shape + invariant validation', () => {
expect(() => validateMorfo(fieldLangsMorfo)).not.toThrow();
});
it('declares the FieldLangs-owned parts', () => {
const kebabs = fieldLangsMorfo.parts.map((p) => p.kebab).sort();
expect(kebabs).toEqual(['lang-option', 'menu', 'provider', 'switcher', 'textarea'].sort());
});
it('declares the language-switch event as a frame-level shift.navigate', () => {
const event = fieldLangsMorfo.events?.find((e) => e.name === 'shift-navigate');
expect(event).toBeDefined();
expect(event!.semantic.family).toBe('shift');
// `shift` is a structural/frame family — it carries no intent (only
// `commit`/`signal` require one), so validation must accept it as-is.
expect('intent' in event!.semantic).toBe(false);
});
it('carries the per-language completion metadata on the lang-option part', () => {
const option = fieldLangsMorfo.parts.find((p) => p.kebab === 'lang-option')!;
const attrs = option.data.map((d) => d.attr);
expect(attrs).toContain('data-lang');
expect(attrs).toContain('data-active');
expect(attrs).toContain('data-required');
expect(attrs).toContain('data-complete');
});
});

@ -0,0 +1,161 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* FieldLangs — a localized-input wrapper around `Field` that holds one value
* per language and switches which language is being edited through a compact
* switcher docked INSIDE the field control (like a trailing unit selector).
*
* Surface (composition, not a fork — the picker pattern A27): the root owns the
* shared `values` map + `activeLang`; it composes the eidos `<Field>` and, at
* the logical `switcherSide` of the control, an in-control switcher whose form
* adapts to the language count — a segmented `ToggleGroup` for ≤3 languages,
* the design-system `Select` (ghost) for more (or forced via `switcher`).
*
* <FieldLangs languages={['en','es','fr']} required={['en']} bind:values>
* <FieldLangs.Label>Product name</FieldLangs.Label>
* <FieldLangs.Control><FieldLangs.Input /></FieldLangs.Control>
* <FieldLangs.ErrorText />
* </FieldLangs>
*
* The `Field` and `ToggleGroup` surfaces keep their own morfo contracts; this
* morfo only declares FieldLangs' own parts (root, switcher, per-language
* option) plus the language-switch event.
*/
export const fieldLangsMorfo = {
name: 'FieldLangs',
kebab: 'field-langs',
scope: ['soma', 'sema', 'eidos'],
// The language switch fires `shift-navigate`; its perceptual signature is
// the `shift` family base (a "noticed" visual settle, no sound by default).
// The switch-press tap comes from the composed ToggleGroup's own pack — no
// per-component tuning needed here.
expression: 'family-default',
texts: {
label: '#?components.field-langs.label|Localized field',
'switcher-label': '#?components.field-langs.switcher-label|Select language'
},
events: [
{
// The active editing language changed — a context/régimen shift
// between the per-language values (book cap. 27: `shift`, not a state
// fixation). In segmented mode the composed ToggleGroup also fires its
// own `commit-toggle` for the switch press; this event marks the
// higher level "you moved to another language context". `shift` takes
// no intent (only `commit`/`signal` require one).
name: 'shift-navigate',
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-active-lang', value: v.propRef('activeLang'), emit: 'value' },
{
attr: 'data-switcher-side',
values: ['start', 'end'],
value: v.propRef('switcherSide')
},
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
aria: []
},
{
// The in-control switcher. Segmented mode = FieldLangs' OWN radiogroup
// of `LangOption` buttons (NOT the composed ToggleGroup — its
// `commit-toggle` semantics are wrong for a language-context
// navigation, which is `shift-navigate`; composing it would also leak
// its chrome into the control). Select mode = the design-system
// `<Select>` (ghost). FieldLangs adds its identity marker + `data-side`
// (which control edge it docks on, logical / RTL-aware) + `data-mode`.
// Private: the consumer never composes it — the Control injects it.
name: 'Switcher',
kebab: 'switcher',
archetype: 'group',
kind: 'private',
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-side', values: ['start', 'end'], value: v.propRef('side') },
{ attr: 'data-mode', values: ['segmented', 'select'], value: v.propRef('mode') }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('switcher-label', 'Select language'),
severity: 'recommended'
}
]
},
{
// One radio button per supported language in SEGMENTED mode — a
// FieldLangs-owned `<button role="radio">`. Carries the per-language
// identity + completion metadata the recipe styles (the set/unset dot,
// the solid-primary active pill). Optional — absent in select mode.
// Private — generated from `languages`, never composed.
name: 'LangOption',
kebab: 'lang-option',
archetype: 'option',
kind: 'private',
defaultElement: 'button',
role: 'radio',
optional: true,
data: [
{ attr: 'data-lang', value: v.propRef('lang'), emit: 'value' },
{ attr: 'data-active', value: v.propRef('active'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-complete', value: v.propRef('complete'), severity: 'optional' }
],
aria: [{ attr: 'aria-checked', value: v.propRef('active'), ariaBoolean: true }]
},
{
// The multiline control shell — a `position: relative` wrapper around a
// composed `TextArea` with the switcher docked in its top corner (so
// the field's default floating label keeps working). Public: the
// consumer composes `<FieldLangs.Textarea>` instead of
// `<FieldLangs.Control><FieldLangs.Input/></FieldLangs.Control>` for a
// multiline localized field. Optional — the single-line path omits it.
name: 'Textarea',
kebab: 'textarea',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// The dropdown list in SELECT mode — a vertical radiogroup of
// `LangOption` buttons inside a composed `Popover`. FieldLangs-owned,
// so selecting a language fires only `shift-navigate`; no `Select`
// commit semantics leak into the field. Optional — absent in segmented
// mode. Private — generated from `languages`.
name: 'Menu',
kebab: 'menu',
archetype: 'group',
kind: 'private',
defaultElement: 'div',
optional: true,
data: [],
aria: [
{
attr: 'aria-label',
value: v.translationRef('switcher-label', 'Select language'),
severity: 'recommended'
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,76 @@
<script lang="ts">
/**
* Soma `FieldLangs.Provider` — headless root of the localized field. Creates
* the FieldLangsProvider (values map + active language + completion state)
* and provides context; the visual `Field` + `ToggleGroup` switcher are
* composed inside it by the eidos wrapper.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FieldLangsProvider } from '../field-langs-provider.svelte';
import type { FieldLangsProviderProps, LangCode, LangValues } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'field-langs'),
languages,
required = [],
values = $bindable({} as LangValues),
onValuesChange = () => {},
activeLang = $bindable<LangCode | undefined>(undefined),
onLangChange = () => {},
switcherSide = 'end',
switcher = 'auto',
disabled = false,
readonly = false,
invalid = false,
ref = $bindable<HTMLElement | null>(null),
children,
child,
...restProps
}: FieldLangsProviderProps = $props();
const sharedValues = writableActive<LangValues>(
() => values,
(v) => {
values = v;
onValuesChange(v);
}
);
const sharedActiveLang = writableActive<LangCode>(
() => activeLang ?? '',
(v) => {
activeLang = v;
onLangChange(v);
}
);
const state = FieldLangsProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
values: sharedValues,
activeLang: sharedActiveLang,
languages: readableActive(() => languages),
required: readableActive(() => required),
switcherSide: readableActive(() => switcherSide),
switcher: readableActive(() => switcher),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
invalid: readableActive(() => invalid)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,18 @@
// FieldLangs — headless localized-field coordinator. The visual composition
// (Field + ToggleGroup switcher) lives in the eidos wrapper; this layer owns
// the values map, the active language and the completion / validity state.
export { default as Provider } from './components/field-langs.svelte';
export { FieldLangsProvider } from './field-langs-provider.svelte';
export type {
FieldLangsProviderProps as ProviderProps,
FieldLangsSnippetProps as ProviderSnippetProps,
FieldLangsSwitcherSide,
FieldLangsSwitcherMode,
FieldLangsSwitcherKind,
LangCode,
LangSpec,
LangValues
} from './types';

@ -0,0 +1,165 @@
import { context, type WithRefOpts } from '../../provider';
import { writableActive, type ActiveProps, type State, type StateProps } from '$libs/reactive';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type {
FieldLangsSwitcherKind,
FieldLangsSwitcherMode,
FieldLangsSwitcherSide,
LangCode,
LangSpec,
LangValues
} from './types';
import { fieldLangsMorfo } from '../../../morfo/components/field-langs';
/** Above this many languages, `switcher: 'auto'` picks the native `<select>`. */
const SEGMENTED_MAX = 3;
interface FieldLangsOpts
extends
WithRefOpts,
StateProps<{ values: LangValues; activeLang: LangCode }>,
ActiveProps<{
languages: LangSpec[];
required: LangCode[];
switcherSide: FieldLangsSwitcherSide;
switcher: FieldLangsSwitcherMode;
disabled: boolean;
readonly: boolean;
invalid: boolean;
}> {}
/**
* FieldLangs coordinator. Owns the shared `values` map + `activeLang`, derives
* per-language completion and the field-level invalid state (any required
* language left empty), and exposes the value/selection proxies the composed
* `Field.Input` and `ToggleGroup` switcher bind to.
*
* Headless: renders the root `<div>` and provides context; the eidos wrapper
* composes the visual `Field` + `ToggleGroup` around it.
*/
export class FieldLangsProvider {
readonly opts: FieldLangsOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<FieldLangsProvider>('FieldLangs');
static get(): FieldLangsProvider | undefined {
return this.ctx.getOr(undefined) as FieldLangsProvider | undefined;
}
static require(): FieldLangsProvider {
return this.ctx.get();
}
static create(opts: FieldLangsOpts) {
return new FieldLangsProvider(opts);
}
private constructor(opts: FieldLangsOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(fieldLangsMorfo, {
props: {
activeLang: () => this.activeLang,
switcherSide: () => this.switcherSide,
disabled: () => this.isDisabled,
required: () => this.hasRequired,
invalid: () => this.isInvalid
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: FieldLangsProvider.ctx,
syncAttrs: true
});
}
// ── Language set ───────────────────────────────────────────────────────────
readonly langSpecs = $derived.by(() =>
this.opts.languages.current.map(
(l): { code: LangCode; label?: string; dir?: 'ltr' | 'rtl' } =>
typeof l === 'string' ? { code: l } : l
)
);
readonly langCodes = $derived.by(() => this.langSpecs.map((s) => s.code));
readonly requiredSet = $derived.by(() => new Set(this.opts.required.current));
// ── Switcher form ───────────────────────────────────────────────────────────
/** Logical edge the in-control switcher docks on. */
readonly switcherSide = $derived.by<FieldLangsSwitcherSide>(() => this.opts.switcherSide.current);
/** Resolved switcher form — `'auto'` becomes segmented for ≤3 languages, else select. */
readonly switcherKind = $derived.by<FieldLangsSwitcherKind>(() => {
const mode = this.opts.switcher.current;
if (mode !== 'auto') return mode;
return this.langCodes.length <= SEGMENTED_MAX ? 'segmented' : 'select';
});
/** Active language, guarded to a member of `langCodes` (falls back to the first). */
readonly activeLang = $derived.by<LangCode>(() => {
const current = this.opts.activeLang.current;
const codes = this.langCodes;
if (current && codes.includes(current)) return current;
return codes[0] ?? '';
});
// ── Completion / validity ──────────────────────────────────────────────────
/**
* Lifted set of complete languages (non-empty trimmed value) — A31: the
* per-switch derivations do O(1) `.has()` instead of scanning the map.
*/
readonly completeSet = $derived.by(() => {
const values = this.opts.values.current;
return new Set(this.langCodes.filter((c) => (values[c] ?? '').trim().length > 0));
});
readonly hasRequired = $derived.by(() => this.opts.required.current.length > 0);
readonly missingRequired = $derived.by(() =>
this.opts.required.current.filter((c) => !this.completeSet.has(c))
);
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
readonly isInvalid = $derived.by(
() => this.opts.invalid.current || this.missingRequired.length > 0
);
isRequiredLang(code: LangCode): boolean {
return this.requiredSet.has(code);
}
isComplete(code: LangCode): boolean {
return this.completeSet.has(code);
}
// ── Value / selection proxies for the composed editor + switcher ────────────
/** The value of the active language — the `Field.Input` binds to this. */
readonly activeValue: State<string> = writableActive<string>(
() => this.opts.values.current[this.activeLang] ?? '',
(v) => {
this.opts.values.current = { ...this.opts.values.current, [this.activeLang]: v };
}
);
/** Single-selection array for the composed `ToggleGroup` switcher. */
readonly activeLangArray: State<string[]> = writableActive<string[]>(
() => [this.activeLang],
(arr) => {
const next = arr[0];
if (next) this.setActiveLang(next);
}
);
/** Switch the active editing language, firing the `shift.navigate` signal. */
setActiveLang(code: LangCode): void {
if (code === this.activeLang) return;
if (!this.langCodes.includes(code)) return;
this.opts.activeLang.current = code;
void this.runtime.trigger('shift-navigate');
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,101 @@
import type { Snippet } from 'svelte';
import type { WithChild, OnChangeFn, PrimitiveDivAttributes } from '../../types';
/** A language code, e.g. `'en'`, `'es'`, `'pt-BR'`. */
export type LangCode = string;
/**
* A supported language: either a bare code (its display name is resolved via
* `Intl.DisplayNames`) or an explicit descriptor with a custom label / writing
* direction.
*/
export type LangSpec = LangCode | { code: LangCode; label?: string; dir?: 'ltr' | 'rtl' };
/** Per-language value map, keyed by language code. */
export type LangValues = Record<LangCode, string>;
/** Which logical edge of the control the in-control switcher docks on (RTL-aware). */
export type FieldLangsSwitcherSide = 'start' | 'end';
/**
* Switcher form. `'auto'` (default) picks a segmented toggle for ≤3 languages
* and a native `<select>` for more; `'segmented'` / `'select'` force one.
*/
export type FieldLangsSwitcherMode = 'auto' | 'segmented' | 'select';
/** The resolved switcher form after `'auto'` is decided. */
export type FieldLangsSwitcherKind = 'segmented' | 'select';
/** Derived booleans exposed by the Provider (for `child` snippets / bridges). */
export type FieldLangsSnippetProps = {
activeLang: LangCode;
isInvalid: boolean;
isDisabled: boolean;
isRequired: boolean;
missingRequired: LangCode[];
};
/**
* Root `FieldLangs.Provider` — a localized-input coordinator. Owns the shared
* `values` map + `activeLang`, derives per-language completion and the
* field-level invalid state (any required language left empty), and exposes an
* `activeValue` proxy the composed `Field.Input` binds to.
*
* Headless: it renders a `<div>` and provides context. The visual composition
* (the `Field` + the `ToggleGroup` switcher) lives in the eidos wrapper.
*/
export type FieldLangsProviderProps = WithChild<
PrimitiveDivAttributes & {
/** DOM id. Auto-generated when omitted. */
id?: string;
/** Children snippet. */
children?: Snippet;
// ── Languages ──
/** Supported languages. The first entry is the default active language. */
languages: LangSpec[];
/**
* Subset of `languages` that must be filled for the field to be valid.
* @default []
*/
required?: LangCode[];
// ── Value ──
/** Per-language values map. Bindable. @default {} */
values?: LangValues;
/** Called when the values map changes. */
onValuesChange?: OnChangeFn<LangValues>;
// ── Active language ──
/**
* The language currently being edited. Bindable. Defaults to the first
* entry of `languages`; an out-of-range value falls back to it.
*/
activeLang?: LangCode;
/** Called when the active editing language changes. */
onLangChange?: OnChangeFn<LangCode>;
// ── Switcher ──
/**
* Which logical edge of the control the switcher docks on (flips with
* RTL). @default 'end'
*/
switcherSide?: FieldLangsSwitcherSide;
/**
* Switcher form: `'auto'` = segmented for ≤3 languages, native
* `<select>` for more; or force `'segmented'` / `'select'`. @default 'auto'
*/
switcher?: FieldLangsSwitcherMode;
// ── State flags ──
/** Disable the whole field. @default false */
disabled?: boolean;
/** Mark the field read-only. @default false */
readonly?: boolean;
/**
* Explicit invalid override. OR-merged with the derived
* missing-required state. @default false
*/
invalid?: boolean;
}
>;

@ -0,0 +1,605 @@
<script lang="ts">
import {
FieldLangs,
type FieldLangsColor,
type FieldLangsSize,
type FieldLangsVariant
} from '$uix/eidos/components/field-langs';
import type {
FieldLangsSwitcherMode,
FieldLangsSwitcherSide
} from '$soma/components/field-langs';
import { compileMorfo } from '$uix/morfo';
import { fieldLangsMorfo } from '@/uix/morfo/components/field-langs';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
const uix = getActiveUix();
let tab = $state<Tab>('live');
// ── soma props ───────────────────────────────────────────────────────
const LANG_PRESETS: { label: string; langs: string[] }[] = [
{ label: 'EN · ES', langs: ['en', 'es'] },
{ label: 'EN · ES · FR', langs: ['en', 'es', 'fr'] },
{ label: 'EN · ES · FR · DE', langs: ['en', 'es', 'fr', 'de'] },
{ label: '6 languages', langs: ['en', 'es', 'fr', 'de', 'pt', 'ja'] }
];
let languages = $state<string[]>(['en', 'es', 'fr']);
let requiredLangs = $state<string[]>(['en']);
let values = $state<Record<string, string>>({});
let activeLang = $state<string>('en');
let switcherSide = $state<FieldLangsSwitcherSide>('end');
let switcher = $state<FieldLangsSwitcherMode>('auto');
let multiline = $state(false);
let floatingLabel = $state(false);
let disabled = $state(false);
let readonly = $state(false);
let invalid = $state(false);
// ── eidos props ──────────────────────────────────────────────────────
let size = $state<FieldLangsSize>('md');
let variant = $state<FieldLangsVariant>('surface');
let color = $state<FieldLangsColor>('primary');
// Keep `activeLang` + `requiredLangs` coherent as the language set changes.
$effect(() => {
if (!languages.includes(activeLang)) activeLang = languages[0];
});
$effect(() => {
const pruned = requiredLangs.filter((c) => languages.includes(c));
if (pruned.length !== requiredLangs.length) requiredLangs = pruned;
});
function toggleRequired(code: string) {
requiredLangs = requiredLangs.includes(code)
? requiredLangs.filter((c) => c !== code)
: [...requiredLangs, code];
}
// ── Derived readout ──────────────────────────────────────────────────
const missingRequired = $derived(
requiredLangs.filter((c) => (values[c] ?? '').trim().length === 0)
);
const isValid = $derived(!invalid && missingRequired.length === 0);
const compiled = compileMorfo(fieldLangsMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const variants: FieldLangsVariant[] = ['surface', 'outline', 'ghost'];
const colors: FieldLangsColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const sizes: FieldLangsSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const sides: FieldLangsSwitcherSide[] = ['end', 'start'];
const modes: FieldLangsSwitcherMode[] = ['auto', 'segmented', 'select'];
function langName(code: string): string {
try {
return new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code;
} catch {
return code;
}
}
// ── Sema trace ───────────────────────────────────────────────────────
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$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();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { FieldLangs } from '$uix/eidos/components/field-langs';",
` let values = $state<Record<string, string>>({});`,
'</' + 'script>',
'',
'<FieldLangs',
` languages={${JSON.stringify(languages)}}`,
requiredLangs.length > 0 && ` required={${JSON.stringify(requiredLangs)}}`,
' bind:values',
switcherSide !== 'end' && ` switcherSide="${switcherSide}"`,
switcher !== 'auto' && ` switcher="${switcher}"`,
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
'>',
' <FieldLangs.Label>Product name <FieldLangs.RequiredIndicator /></FieldLangs.Label>',
' <FieldLangs.Control>',
' <FieldLangs.Input placeholder="Name…" />',
' </FieldLangs.Control>',
' <FieldLangs.HelperText>Provide the name for each language.</FieldLangs.HelperText>',
' <FieldLangs.ErrorText>Required in every marked language.</FieldLangs.ErrorText>',
'</FieldLangs>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · Field langs</div>
<h1 data-uix-page-title>FieldLangs</h1>
<p data-uix-page-lede>
A localized-input wrapper around <code>Field</code>: one value per language, switched through a
compact switcher docked <strong>inside the control</strong>. Soma owns the values map, the active
language and the required-per-language validity; Eidos composes the visual <code>Field</code> with
an adaptive switcher — FieldLangs' own segmented radiogroup for ≤3 languages, an own
<code>Popover</code> dropdown for more — and marks each language's set / required state
with a dot.
</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>sizes</span>5</span>
<span data-uix-meta-pill><span data-uix-meta-key>tokens</span>9 / *</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div style="inline-size: 22rem; max-inline-size: 100%;">
<FieldLangs
{languages}
required={requiredLangs}
bind:values
bind:activeLang
{switcherSide}
{switcher}
{floatingLabel}
{disabled}
{readonly}
{invalid}
{size}
{variant}
{color}
>
<FieldLangs.Label>Product name <FieldLangs.RequiredIndicator /></FieldLangs.Label>
{#if multiline}
<FieldLangs.Textarea
placeholder={floatingLabel ? ' ' : `Description in ${langName(activeLang)}…`}
rows={4}
/>
{:else}
<FieldLangs.Control>
<FieldLangs.Input
placeholder={floatingLabel ? ' ' : `Name in ${langName(activeLang)}…`}
data-perm-step="0"
data-perm-mode={'type="Hola mundo"'}
/>
</FieldLangs.Control>
{/if}
<FieldLangs.HelperText>Provide the name for each language.</FieldLangs.HelperText>
<FieldLangs.ErrorText>Required in every marked language.</FieldLangs.ErrorText>
</FieldLangs>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>switch language or type to see events</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>active</span>
<span data-uix-stage-trace-event>{activeLang}</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 the values map + active language +
validity. <span data-uix-layer-badge="eidos">eidos</span> owns size, variant, color and the switcher.
</p>
<div data-uix-subsection-head>Language set · presets</div>
<div data-uix-controls>
<div style="display: flex; flex-wrap: wrap; gap: var(--uix-space-1); padding: var(--uix-space-2) 0;">
{#each LANG_PRESETS as p (p.label)}
<button
data-uix-chip
data-active={languages.join(',') === p.langs.join(',')}
onclick={() => (languages = [...p.langs])}>{p.label}</button
>
{/each}
</div>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · required per language
<span data-uix-control-hint>first language is the default</span>
</div>
<div data-uix-controls>
{#each languages as code (code)}
<label data-uix-control>
<span data-uix-control-label>{code.toUpperCase()} <span data-uix-control-hint>{langName(code)}</span></span>
<span data-uix-switch>
<input
type="checkbox"
checked={requiredLangs.includes(code)}
onchange={() => toggleRequired(code)}
/>
<span data-uix-switch-label>{requiredLangs.includes(code) ? 'required' : 'optional'}</span>
</span>
</label>
{/each}
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · switcher + state
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>switcherSide <span data-uix-control-hint>logical, flips in RTL</span></span>
<span data-uix-chips role="radiogroup">
{#each sides as s}
<button data-uix-chip data-active={switcherSide === s} onclick={() => (switcherSide = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>switcher <span data-uix-control-hint>auto = segmented ≤3, else select</span></span>
<span data-uix-chips role="radiogroup">
{#each modes as m}
<button data-uix-chip data-active={switcher === m} onclick={() => (switcher = m)}>{m}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>multiline <span data-uix-control-hint>Field.Input ↔ Textarea</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={multiline} />
<span data-uix-switch-label>{multiline ? 'textarea' : 'input'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>floatingLabel <span data-uix-control-hint>label floats on focus / value</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={floatingLabel} />
<span data-uix-switch-label>{floatingLabel ? 'on' : 'off'}</span>
</span>
</label>
{#each ['disabled', 'readonly', 'invalid'] as flag}
<label data-uix-control>
<span data-uix-control-label>{flag}</span>
<span data-uix-switch>
{#if flag === 'disabled'}
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
{:else if flag === 'readonly'}
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>{invalid ? 'on' : 'off'}</span>
{/if}
</span>
</label>
{/each}
</div>
<div data-uix-subsection-head>Live value readout</div>
<div style="display: grid; grid-template-columns: auto auto 1fr; gap: var(--uix-space-1) var(--uix-space-3); padding: var(--uix-space-3); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-subtle); font-size: var(--font-size-sm);">
<span style="color: var(--color-content-secondary); font-weight: 600;">lang</span>
<span style="color: var(--color-content-secondary); font-weight: 600;">flags</span>
<span style="color: var(--color-content-secondary); font-weight: 600;">value</span>
{#each languages as code (code)}
<code style="font-family: monospace;">{code}{activeLang === code ? ' ◂' : ''}</code>
<span style="font-size: var(--font-size-xs);">
{requiredLangs.includes(code) ? 'required' : 'optional'}
· {(values[code] ?? '').trim() ? 'complete' : 'empty'}
</span>
<code style="font-family: monospace;">{values[code] || '(empty)'}</code>
{/each}
</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: var(--uix-space-2) var(--uix-space-3); padding: var(--uix-space-3); margin-block-start: var(--space-2); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); font-size: var(--font-size-sm);">
<span style="color: var(--color-content-secondary);">activeLang:</span>
<code style="font-family: monospace;">{activeLang}</code>
<span style="color: var(--color-content-secondary);">missingRequired:</span>
<code style="font-family: monospace;">{missingRequired.length ? missingRequired.join(', ') : '(none)'}</code>
<span style="color: var(--color-content-secondary);">valid:</span>
<code style="font-family: monospace;">{isValid}</code>
</div>
<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>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>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>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · languages, required, switcher, size, variant, color</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>
<p data-uix-section-desc>
A localized / per-language field is a CMS pattern, not an APG one: none of Ark UI, Bits UI,
Radix or React Aria ships it. The relevant references are the localization UIs of Strapi,
Payload, Directus and Sanity.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>Feature</th><th>Strapi</th><th>Payload</th><th>Directus</th><th>Sanity</th><th>Ark / Bits / Radix / RAC</th><th>UIX</th><th>Decision</th>
</tr>
</thead>
<tbody>
<tr><td class="name">Per-language value map</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>❌</td><td>✅</td><td>Core: <code>Record&lt;code, string&gt;</code>, bindable.</td></tr>
<tr><td class="name">Language switcher</td><td>entry</td><td>field</td><td>tabs</td><td>tabs</td><td>❌</td><td>✅</td><td>Own radiogroup / Popover dropdown.</td></tr>
<tr><td class="name">First language = default</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>❌</td><td>✅</td><td>First of <code>languages</code>.</td></tr>
<tr><td class="name">Required per language (subset)</td><td>⚠️ global</td><td>✅</td><td>✅</td><td>⚠️</td><td>❌</td><td>✅</td><td>Invalid while any required is empty.</td></tr>
<tr><td class="name">Completion cue on switches</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>❌</td><td>✅</td><td><code>*</code> risk → affirm per switch.</td></tr>
<tr><td class="name">Switcher inside the control</td><td>—</td><td>—</td><td>—</td><td>—</td><td>❌</td><td>✅</td><td>Docked affix, logical side.</td></tr>
<tr><td class="name">Adaptive switcher (segmented / dropdown)</td><td>—</td><td>—</td><td>—</td><td>—</td><td>❌</td><td>✅</td><td>Own radiogroup ≤3, own Popover dropdown for more.</td></tr>
<tr><td class="name">Fallback value as placeholder</td><td>—</td><td>✅</td><td>—</td><td>✅</td><td>❌</td><td>⏳</td><td>Deferred to v2.</td></tr>
<tr><td class="name">Copy from another language</td><td>—</td><td>✅</td><td>✅</td><td>—</td><td>❌</td><td>⏳</td><td>Deferred to v2.</td></tr>
<tr><td class="name">Deep Form integration (per-locale names)</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>n/a</td><td>⏳</td><td>MVP is <code>bind:values</code>; Form 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">languages</td><td>soma</td><td class="default">—</td><td><code>string[]</code> or <code>{'{ code, label?, dir? }'}[]</code>. First = default active.</td></tr>
<tr><td class="name">required</td><td>soma</td><td class="default">[]</td><td>Subset of <code>languages</code> that must be filled.</td></tr>
<tr><td class="name">values</td><td>soma</td><td class="default">{'{}'}</td><td>Bindable <code>Record&lt;code, string&gt;</code>.</td></tr>
<tr><td class="name">activeLang</td><td>soma</td><td class="default">languages[0]</td><td>Bindable active editing language.</td></tr>
<tr><td class="name">switcherSide</td><td>soma</td><td class="default">end</td><td>Logical control edge the switcher docks on (<code>start</code> | <code>end</code>), flips in RTL.</td></tr>
<tr><td class="name">switcher</td><td>soma</td><td class="default">auto</td><td><code>auto</code> = segmented ≤3 langs, own Popover dropdown for more; or force <code>segmented</code> / <code>select</code>.</td></tr>
<tr><td class="name">disabled / readonly / invalid</td><td>soma</td><td class="default">false</td><td><code>invalid</code> OR-merges with missing-required.</td></tr>
<tr><td class="name">size</td><td>eidos</td><td class="default">md</td><td>xs | sm | md | lg | xl</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">primary</td><td>Field focus ring / required accent.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
FieldLangs declares only its own parts — the root, the switcher and the per-language option.
The composed <code>Field</code> and <code>Select</code> surfaces keep their own morfo contracts.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Kind</th><th>Archetype</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.kind}</td>
<td class="default">{part.archetype ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events</div>
<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></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>
</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 + perceptual signature
</h2>
<p data-uix-section-desc>
Switching the editing language fires <code>shift-navigate</code> — a context shift (book
cap. 27), the <code>shift</code> family default (a "noticed" visual settle, no sound by
default). It is the switch's <strong>only</strong> semantic event — the own radiogroup fires no <code>commit-toggle</code> into the control.
</p>
<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 target = stageRef?.querySelector('[data-field-langs]') as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target
});
}}>▶ 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 live in <code>src/uix/eidos/components/field-langs/field-langs.css</code>. FieldLangs
only positions + compacts the in-control switcher and adds the per-language required cue;
the field rides on Field's recipe; the segmented switch is FieldLangs' own radiogroup.
</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-field-langs]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Root column — switcher + field stacked, gap.</td>
</tr>
<tr>
<td class="name"><code>[data-field-langs-switcher][data-side]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Dock the switcher inside the control, logical side + divider.</td>
</tr>
<tr>
<td class="name"><code>[data-field-langs-lang-option][data-active]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Active language = a soft raised pill (flush, no border).</td>
</tr>
<tr>
<td class="name"><code>[data-field-langs-lang-option][data-required]::after</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>The <code>*</code> cue — risk while empty, affirm when complete.</td>
</tr>
<tr>
<td class="name"><code>[data-field] · [data-toggle-group]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Composed recipes — Field control chrome + segmented switcher.</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">Switcher role</td><td>Both modes are a WAI-ARIA <code>radiogroup</code> (each language a <code>role="radio"</code>, roving tabindex, arrow / Home / End, RTL-aware); select mode wraps the vertical radiogroup in a <code>Popover</code>.</td></tr>
<tr><td class="name">Language names</td><td>Each switch shows the compact code but exposes the full localized language name (autonym) as its accessible name + tooltip.</td></tr>
<tr><td class="name">Field wiring</td><td><code>Field.Label</code>'s <code>for=</code> targets the input; helper / error text ride <code>aria-describedby</code>; <code>aria-invalid</code> reflects the required-per-language state.</td></tr>
<tr><td class="name">Required cue</td><td>The per-switch <code>*</code> is decorative (<code>::after</code>); requiredness is conveyed at the field level and in the error text.</td></tr>
<tr><td class="name">Language switch</td><td>Switching fires <code>shift-navigate</code>; the input value swaps to the newly-active language.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.