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
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,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;
|
||||
@ -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<code, string></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<code, string></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"><{part.defaultElement}></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…
Reference in new issue