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.
v/src/ling/svelte/LingInput.svelte

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>

Powered by TurnKey Linux.