fix(redesign): EntitySelect for language pickers + portal Timezone picker

- Template editors (notification & command) now use EntitySelect for
  locale switching and default to the configured primary locale
  instead of always 'en' when opening, editing, or cloning a config.
- LocaleSelector's add-flow uses EntitySelect for catalog pick;
  custom BCP-47 codes (e.g. de-CH) keep a small dedicated input.
- TimezoneSelector dropdown was being clipped by Card's overflow:hidden
  and backdrop-filter; portalled to <body> with an overlay backdrop and
  styled as a centered modal palette (same pattern as EntitySelect).
- Removed top padding on the timezone scroll list so sticky region
  group headers no longer leak rows above them.
- Extracted shared locale catalog to lib/locales.ts.
This commit is contained in:
2026-04-27 14:18:58 +03:00
parent b320090a56
commit 1bfec521d8
7 changed files with 367 additions and 406 deletions
+103 -279
View File
@@ -1,48 +1,10 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
const CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
// Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']);
@@ -76,11 +38,7 @@
}
function meta(code: string): LocaleMeta {
return CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
return getLocaleMeta(code);
}
function remove(code: string) {
@@ -109,79 +67,48 @@
// --- Add flow ----------------------------------------------------------
let addOpen = $state(false);
let addQuery = $state('');
let addInputEl = $state<HTMLInputElement | null>(null);
let highlightIdx = $state(0);
// Valid BCP 47-ish: 23 letter primary, optional '-' subtag(s) 2-8 chars.
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
const selectedSet = $derived(new Set(codes));
const suggestions = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
const available = CATALOG.filter(l => !selectedSet.has(l.code));
if (!q) return available;
return available.filter(l =>
l.code.includes(q)
|| l.name.toLowerCase().includes(q)
|| l.native.toLowerCase().includes(q),
);
});
/**
* Catalog languages not yet selected, surfaced through EntitySelect.
* Native name is the label so the user sees their own script; the
* English name + code lives in the description for searchability.
*/
const addItems = $derived<EntityItem[]>(
CATALOG
.filter(l => !selectedSet.has(l.code))
.map(l => ({
value: l.code,
label: l.native,
desc: `${l.name} · ${l.code.toUpperCase()}`,
})),
);
const canAddCustom = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
if (!q) return false;
if (!CUSTOM_RE.test(q)) return false;
if (selectedSet.has(q)) return false;
// Skip "custom" entry when it matches an existing catalog entry exactly.
if (CATALOG.some(l => l.code === q)) return false;
let customCode = $state('');
const customCodeValid = $derived.by(() => {
const c = customCode.trim().toLowerCase();
if (!c || !CUSTOM_RE.test(c)) return false;
if (selectedSet.has(c)) return false;
if (CATALOG.some(l => l.code === c)) return false;
return true;
});
function openAdd() {
addOpen = true;
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function closeAdd() {
addOpen = false;
addQuery = '';
}
function addCode(code: string) {
const c = code.trim().toLowerCase();
function addCode(code: string | number | null) {
if (code === null) return;
const c = String(code).trim().toLowerCase();
if (!c) return;
commit([...codes, c]);
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function onAddKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closeAdd(); return; }
const total = suggestions.length + (canAddCustom ? 1 : 0);
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIdx < suggestions.length) {
addCode(suggestions[highlightIdx].code);
} else if (canAddCustom) {
addCode(addQuery);
}
}
function addCustom() {
if (!customCodeValid) return;
addCode(customCode);
customCode = '';
}
$effect(() => { addQuery; highlightIdx = 0; });
// --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null);
@@ -329,77 +256,39 @@
</ul>
{/if}
<!-- Add zone -->
<div class="ls-add" class:ls-add-open={addOpen}>
{#if !addOpen}
<button type="button" class="ls-add-trigger" onclick={openAdd}>
<MdiIcon name="mdiPlus" size={14} />
<span>{t('locales.add')}</span>
</button>
{:else}
<div class="ls-add-panel">
<div class="ls-add-input-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={addInputEl}
bind:value={addQuery}
onkeydown={onAddKeydown}
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
placeholder={t('locales.searchPlaceholder')}
class="ls-add-input"
autocomplete="off"
spellcheck="false"
type="text"
/>
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
<div class="ls-add-list" role="listbox">
{#each suggestions as s, i (s.code)}
<button
type="button"
role="option"
aria-selected={i === highlightIdx}
class="ls-sugg"
class:ls-sugg-hl={i === highlightIdx}
onmouseenter={() => highlightIdx = i}
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
>
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
<span class="ls-sugg-name">{s.name}</span>
<span class="ls-sugg-code">{s.code}</span>
{#if SHIPPED.has(s.code)}
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
</span>
{/if}
</button>
{/each}
{#if canAddCustom}
<button
type="button"
role="option"
aria-selected={highlightIdx === suggestions.length}
class="ls-sugg ls-sugg-custom"
class:ls-sugg-hl={highlightIdx === suggestions.length}
onmouseenter={() => highlightIdx = suggestions.length}
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
>
<MdiIcon name="mdiPlusCircleOutline" size={14} />
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
</button>
{/if}
{#if suggestions.length === 0 && !canAddCustom}
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
{/if}
</div>
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
<div class="ls-add">
<div class="ls-add-row">
<div class="ls-add-picker">
<EntitySelect
items={addItems}
value={null}
placeholder={t('locales.add')}
size="sm"
onselect={addCode}
/>
</div>
{/if}
<div class="ls-add-custom">
<input
type="text"
bind:value={customCode}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
placeholder={t('locales.customPlaceholder')}
class="ls-add-custom-input"
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="ls-add-custom-btn"
disabled={!customCodeValid}
onclick={addCustom}
title={t('locales.addCustom')}
>
<MdiIcon name="mdiPlus" size={14} />
</button>
</div>
</div>
</div>
<p class="ls-hint">
@@ -630,125 +519,60 @@
.ls-add {
margin-top: 0.125rem;
}
.ls-add-trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ls-add-trigger:hover {
border-color: var(--color-primary);
border-style: solid;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
.ls-add-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
overflow: hidden;
animation: ls-pop 0.15s ease-out;
}
@keyframes ls-pop {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.ls-add-input-row {
.ls-add-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
flex-wrap: wrap;
}
.ls-add-input {
.ls-add-picker {
flex: 1;
min-width: 12rem;
}
.ls-add-custom {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
background: transparent;
}
.ls-add-custom-input {
width: 6rem;
border: none;
outline: none;
background: transparent;
font-size: 0.8rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.ls-add-list {
max-height: 14rem;
overflow-y: auto;
scrollbar-width: thin;
}
.ls-sugg {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ls-sugg.ls-sugg-hl {
background: var(--color-muted);
}
.ls-sugg-native {
font-size: 0.9rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-sugg-name {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.ls-sugg-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
font-size: 0.75rem;
color: var(--color-foreground);
padding: 0.25rem 0;
}
.ls-add-custom-input::placeholder {
color: var(--color-muted-foreground);
opacity: 0.7;
}
.ls-sugg.ls-sugg-hl .ls-sugg-code {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
.ls-sugg-shipped {
.ls-add-custom-btn {
display: inline-flex;
align-items: center;
color: var(--color-primary);
opacity: 0.85;
}
.ls-sugg-custom {
border-top: 1px dashed var(--color-border);
color: var(--color-primary);
}
.ls-sugg-custom-label {
font-size: 0.75rem;
font-weight: 500;
}
.ls-sugg-empty {
padding: 0.75rem;
font-size: 0.75rem;
text-align: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-add-custom-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-primary);
}
.ls-add-custom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ---- Hint --------------------------------------------------------- */