You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
250 lines
11 KiB
250 lines
11 KiB
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import type { Snippet } from 'svelte';
|
|
import type { LingRecord, LingString, SupportedLocale } from '@/ling/lib';
|
|
|
|
type AsType = 'input' | 'textarea';
|
|
type Variant = 'light' | 'dark';
|
|
|
|
let {
|
|
value,
|
|
locales = ['es'],
|
|
defaultLocale,
|
|
label = '',
|
|
placeholder = '',
|
|
required = false,
|
|
invalid = false,
|
|
rows = 3,
|
|
as: asType = 'input' as AsType,
|
|
variant = 'light' as Variant,
|
|
localeTab,
|
|
onUpdate,
|
|
}: {
|
|
value: LingRecord;
|
|
locales?: string[];
|
|
defaultLocale: string;
|
|
label?: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
invalid?: boolean;
|
|
rows?: number;
|
|
as?: AsType;
|
|
/** 'light' (default) para fondos claros · 'dark' para el editor oscuro */
|
|
variant?: Variant;
|
|
localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>;
|
|
onUpdate: (value: LingRecord) => void;
|
|
} = $props();
|
|
|
|
const get = (locale: string): string => value[locale as SupportedLocale] ?? '';
|
|
|
|
let currentLocale = $state(defaultLocale);
|
|
let inputText = $state(get(defaultLocale));
|
|
|
|
// Sincroniza inputText cuando cambia el locale activo O cuando cambia
|
|
// la entidad (value). Usar untrack internamente para que la lectura de
|
|
// get(locale) no genere dependencias extra, pero sí rastreamos value
|
|
// explícitamente para detectar cambios de entidad.
|
|
$effect(() => {
|
|
const locale = currentLocale; // dependencia rastreada
|
|
void value; // dependencia rastreada (cambio de entidad)
|
|
untrack(() => { inputText = get(locale); });
|
|
});
|
|
|
|
const isOptional = $derived(currentLocale !== defaultLocale);
|
|
const charCount = $derived(inputText.length);
|
|
const dynamicPlaceholder = $derived(placeholder || `${currentLocale.toUpperCase()}…`);
|
|
const dark = $derived(variant === 'dark');
|
|
|
|
function activateLocale(locale: string) { currentLocale = locale; }
|
|
|
|
function handleInput() {
|
|
const key = currentLocale as SupportedLocale;
|
|
const defKey = defaultLocale as SupportedLocale;
|
|
let next: LingRecord;
|
|
if (currentLocale === defaultLocale) {
|
|
next = { ...value, [defKey]: inputText };
|
|
} else {
|
|
if (inputText.trim() === '') {
|
|
const { [key]: _, ...rest } = value;
|
|
next = rest as LingRecord;
|
|
} else {
|
|
next = { ...value, [key]: inputText };
|
|
}
|
|
}
|
|
onUpdate(next);
|
|
}
|
|
|
|
function hasContent(locale: string): boolean {
|
|
if (locale === currentLocale) return !!(inputText.trim());
|
|
return !!(get(locale).trim());
|
|
}
|
|
</script>
|
|
|
|
<div class="flex w-full flex-col gap-1.5">
|
|
|
|
{#if label}
|
|
<div class="flex items-baseline justify-between px-0.5">
|
|
<label class={[
|
|
'text-xs font-semibold uppercase tracking-[0.08em] transition-colors duration-200',
|
|
invalid
|
|
? (dark ? 'text-red-400' : 'text-rose-400')
|
|
: (dark ? 'text-gray-500' : 'text-zinc-400'),
|
|
].join(' ')}>
|
|
{label}{#if required}<span class="ml-0.5 {dark ? 'text-red-400' : 'text-rose-400'}">*</span>{/if}
|
|
</label>
|
|
{#if locales.length > 1}
|
|
<span class={[
|
|
'text-[10px] font-medium transition-colors duration-200',
|
|
invalid
|
|
? (dark ? 'text-red-400' : 'text-rose-400')
|
|
: isOptional
|
|
? (dark ? 'text-gray-400' : 'text-zinc-300')
|
|
: (dark ? 'text-indigo-400' : 'text-violet-400'),
|
|
].join(' ')}>
|
|
{#if invalid}requerido{:else if isOptional}opcional{:else if required}requerido{/if}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class={[
|
|
'relative rounded border transition-all duration-200',
|
|
dark
|
|
? invalid
|
|
? 'border-red-800 bg-gray-900'
|
|
: 'border-gray-800 bg-gray-900 focus-within:border-indigo-600'
|
|
: invalid
|
|
? 'border-rose-300 shadow-[0_0_0_3px_rgba(244,63,94,0.08),0_1px_4px_rgba(0,0,0,0.06)]'
|
|
: 'border-zinc-200/80 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.06)] focus-within:border-violet-300/70 focus-within:shadow-[0_0_0_3px_rgba(139,92,246,0.08),0_2px_8px_rgba(0,0,0,0.08)]',
|
|
].join(' ')}>
|
|
|
|
<!-- Tab bar — solo si hay más de un locale -->
|
|
{#if locales.length > 1}
|
|
<div class="flex items-center gap-1 px-2 pt-2 pb-0 h-7">
|
|
<div role="tablist" aria-label="Seleccionar idioma" class="flex items-center gap-1">
|
|
{#each locales as locale (locale)}
|
|
{#if localeTab}
|
|
{@render localeTab({
|
|
locale,
|
|
active: currentLocale === locale,
|
|
hasContent: hasContent(locale),
|
|
activate: () => activateLocale(locale),
|
|
})}
|
|
{:else}
|
|
{@const active = currentLocale === locale}
|
|
{@const filled = hasContent(locale)}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
aria-label={`Idioma ${locale}`}
|
|
onclick={() => activateLocale(locale)}
|
|
class={[
|
|
'li-locale-btn relative flex h-4 w-8 items-center rounded px-1.5 font-mono text-[10px] font-bold uppercase',
|
|
'tracking-wider transition-all duration-150 focus:outline-none',
|
|
active
|
|
? invalid
|
|
? (dark ? 'bg-red-700 text-white' : 'bg-rose-500 text-white')
|
|
: (dark ? 'bg-indigo-600 text-white' : 'bg-violet-600 text-white')
|
|
: filled
|
|
? (dark ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-zinc-200 text-zinc-700 hover:bg-zinc-300')
|
|
: (dark ? 'bg-gray-800 text-gray-500 hover:bg-gray-700 hover:text-gray-300' : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700'),
|
|
].join(' ')}
|
|
>
|
|
{locale}
|
|
{#if locale === defaultLocale && !active}
|
|
<span aria-hidden="true" class={[
|
|
'ml-0.5 h-1 w-1 rounded-full opacity-70',
|
|
invalid ? (dark ? 'bg-red-400' : 'bg-rose-300') : 'bg-green-500',
|
|
].join(' ')}></span>
|
|
{:else if filled && !active}
|
|
<span aria-hidden="true" class={[
|
|
'ml-1 h-1 w-1 rounded-full opacity-80',
|
|
dark ? 'bg-indigo-400' : 'bg-violet-700',
|
|
].join(' ')}></span>
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="ml-auto min-w-[2rem] text-right">
|
|
<span class={['font-mono text-[10px]', dark ? 'text-gray-700' : 'text-zinc-300'].join(' ')}>
|
|
{charCount > 0 ? charCount : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class={['mx-2 mt-1.5 h-px', invalid ? (dark ? 'bg-red-900' : 'bg-rose-100') : (dark ? 'bg-gray-800' : 'bg-zinc-100')].join(' ')}></div>
|
|
{/if}
|
|
|
|
{#if asType === 'textarea'}
|
|
<textarea
|
|
aria-label={`Texto en ${currentLocale}`}
|
|
aria-required={currentLocale === defaultLocale && required}
|
|
aria-invalid={invalid}
|
|
required={currentLocale === defaultLocale && required}
|
|
{rows}
|
|
placeholder={dynamicPlaceholder}
|
|
bind:value={inputText}
|
|
oninput={handleInput}
|
|
class={[
|
|
'w-full resize-none bg-transparent px-2.5 py-2 text-[13px] leading-relaxed focus:outline-none',
|
|
dark ? 'text-gray-200 placeholder:text-gray-700' : 'text-zinc-800 placeholder:text-zinc-300',
|
|
].join(' ')}
|
|
style="height: calc({rows} * 1.625rem + 1.25rem)"
|
|
></textarea>
|
|
{:else}
|
|
<input
|
|
type="text"
|
|
aria-label={`Texto en ${currentLocale}`}
|
|
aria-required={currentLocale === defaultLocale && required}
|
|
aria-invalid={invalid}
|
|
required={currentLocale === defaultLocale && required}
|
|
placeholder={dynamicPlaceholder}
|
|
bind:value={inputText}
|
|
oninput={handleInput}
|
|
class={[
|
|
'w-full bg-transparent px-2.5 py-1.5 text-[13px] focus:outline-none h-8',
|
|
dark ? 'text-gray-200 placeholder:text-gray-700' : 'text-zinc-800 placeholder:text-zinc-300',
|
|
].join(' ')}
|
|
/>
|
|
{/if}
|
|
|
|
{#if invalid}
|
|
<div class={[
|
|
'flex items-center gap-1.5 border-t px-2.5 py-1.5 rounded-b',
|
|
dark ? 'border-red-900 bg-red-950/40' : 'border-rose-100 bg-rose-50/60',
|
|
].join(' ')}>
|
|
<svg class={['h-3 w-3 shrink-0', dark ? 'text-red-400' : 'text-rose-400'].join(' ')} viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
</svg>
|
|
<span class={['text-[10px] font-medium', dark ? 'text-red-400' : 'text-rose-400'].join(' ')}>
|
|
El campo <span class="font-bold">{defaultLocale.toUpperCase()}</span> es obligatorio
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Botones de locale en contexto ve-light (dark editor en modo claro).
|
|
:global(.ve-light) hace que la regla incluya el hash de Svelte en
|
|
.li-locale-btn, dando especificidad suficiente para superar Tailwind. */
|
|
:global(.ve-light) .li-locale-btn:not([aria-selected="true"]) {
|
|
background-color: #d4e0ec !important;
|
|
color: #1a2d3e !important;
|
|
border-color: #a8c4d8 !important;
|
|
}
|
|
:global(.ve-light) .li-locale-btn:not([aria-selected="true"]):hover {
|
|
background-color: #b8ccd8 !important;
|
|
color: #0d1520 !important;
|
|
}
|
|
:global(.ve-light) .li-locale-btn[aria-selected="true"] {
|
|
background-color: #4f46e5 !important;
|
|
color: #ffffff !important;
|
|
border-color: #4f46e5 !important;
|
|
}
|
|
</style>
|