palabras-lang-switcher.svelte (eidos): swap del idioma ACTIVO de un documento multilenguaje. Reflejo del switcher de field-langs: radiogroup segmentado (≤3 idiomas) o Popover con radiogroup (mas), cada opcion con punto de completitud (langFilled) y teclado de radiogroup (flechas/Home/End). Lee la `api`; el motor posee el estado. Api: expone `languageSpecs` (specs crudos con label/dir) para que el switcher honre etiquetas explicitas; LangCode/LangSpec/PalabrasLangValues re-exportados del barrel soma. Compila 0 errores. PENDIENTE (chunk 3): CSS + ubicacion (menubar al final · migas tras Documento) + demo + verificacion en navegador. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
090b273911
commit
fb90bd1405
@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* palabras language switcher (F-A) — swaps the ACTIVE content language of a
|
||||
* multilingual document. Mirrors field-langs' switcher: a segmented radiogroup
|
||||
* for ≤3 languages, a `Popover`-backed radiogroup for more. Each option carries a
|
||||
* filled/empty dot (`langFilled`) so authors see which languages still need
|
||||
* content. Chrome-only: reads the palabras `api`; the engine owns the state.
|
||||
*/
|
||||
import { Popover } from '$uix/eidos/components/popover';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import type { ProviderSnippetProps, LangSpec } from '$soma/components/palabras';
|
||||
|
||||
let {
|
||||
api,
|
||||
variant = 'auto'
|
||||
}: {
|
||||
api: ProviderSnippetProps;
|
||||
/** Force a form, or `'auto'` = segmented for ≤3 languages, select for more. */
|
||||
variant?: 'auto' | 'segmented' | 'select';
|
||||
} = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const label = $derived(
|
||||
eidos.langs.ts('#?components.palabras.lang.switcher|Idioma del contenido')
|
||||
);
|
||||
|
||||
type Spec = { code: string; label?: string };
|
||||
const specs = $derived<Spec[]>(
|
||||
api.languageSpecs.map((s: LangSpec) =>
|
||||
typeof s === 'string' ? { code: s } : { code: s.code, label: s.label }
|
||||
)
|
||||
);
|
||||
const kind = $derived(variant === 'auto' ? (specs.length <= 3 ? 'segmented' : 'select') : variant);
|
||||
|
||||
/** 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. */
|
||||
const shortLabel = (spec: Spec) => spec.label ?? spec.code.toUpperCase();
|
||||
const activeSpec = $derived(specs.find((s) => s.code === api.activeLang));
|
||||
|
||||
let selectOpen = $state(false);
|
||||
let menuEl = $state<HTMLElement | null>(null);
|
||||
|
||||
/** Radiogroup keyboard: arrows / Home / End move + select the active language. */
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const group = e.currentTarget as HTMLElement;
|
||||
const codes = api.languages;
|
||||
const i = codes.indexOf(api.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();
|
||||
api.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 === api.activeLang}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabindex={active ? 0 : -1}
|
||||
title={fullName(spec)}
|
||||
aria-label={fullName(spec)}
|
||||
data-palabras-lang-option
|
||||
data-lang={spec.code}
|
||||
data-active={active ? '' : undefined}
|
||||
data-complete={api.langFilled(spec.code) ? '' : undefined}
|
||||
onclick={() => {
|
||||
api.setActiveLang(spec.code);
|
||||
selectOpen = false;
|
||||
}}
|
||||
>
|
||||
{long ? fullName(spec) : shortLabel(spec)}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if api.multilingual}
|
||||
{#if kind === 'segmented'}
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<!-- radiogroup uses roving tabindex on its radio children (APG); the group is not a tab stop -->
|
||||
<div
|
||||
data-palabras-lang-switcher
|
||||
data-mode="segmented"
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
onkeydown={onKeydown}
|
||||
>
|
||||
{#each specs as spec (spec.code)}
|
||||
{@render langRadio(spec, false)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div data-palabras-lang-switcher data-mode="select" aria-label={label}>
|
||||
<Popover bind:open={selectOpen}>
|
||||
<Popover.Trigger data-palabras-lang-trigger aria-label={label}>
|
||||
{activeSpec ? shortLabel(activeSpec) : ''}
|
||||
<span aria-hidden="true">▾</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content data-palabras-lang-menu-content data-palabras-external-tool>
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<div
|
||||
bind:this={menuEl}
|
||||
data-palabras-lang-menu
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
onkeydown={onKeydown}
|
||||
>
|
||||
{#each specs as spec (spec.code)}
|
||||
{@render langRadio(spec, true)}
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
Loading…
Reference in new issue