Expand i18n locales and improve reviews UI
Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* The language switcher, in two shapes.
|
||||
*
|
||||
* `menu` is the header control: a compact button showing the current language's code,
|
||||
* opening a list of the three. `inline` is the footer's plain row of three links. Both
|
||||
* opening a searchable list. `inline` is the footer's plain row of links. Both
|
||||
* are built from the same list and the same hrefs, because they are the same control
|
||||
* and a reader who finds one and then the other should not have to work out whether
|
||||
* they do the same thing.
|
||||
@@ -67,25 +67,44 @@ const currentCode = locale.toUpperCase();
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<nav class="lang-list" aria-label={t('lang.switcher')}>
|
||||
{
|
||||
links.map((other) => (
|
||||
<a
|
||||
href={other.href}
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
>
|
||||
<span>{other.label}</span>
|
||||
{other.current && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="m5 13 4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
<div class="lang-panel">
|
||||
<div class="lang-search">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={`${t('lang.label')}…`}
|
||||
aria-label={t('lang.switcher')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-lang-search
|
||||
/>
|
||||
</div>
|
||||
<nav class="lang-list" aria-label={t('lang.switcher')}>
|
||||
{
|
||||
links.map((other) => (
|
||||
<a
|
||||
href={other.href}
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
data-lang-option
|
||||
data-search={`${other.label} ${other.code}`}
|
||||
>
|
||||
<span>{other.label}</span>
|
||||
<span class="lang-option-code">{other.code.toUpperCase()}</span>
|
||||
{other.current && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="m5 13 4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<nav class="lang-switch" aria-label={t('lang.switcher')}>
|
||||
@@ -135,22 +154,52 @@ const currentCode = locale.toUpperCase();
|
||||
|
||||
/* The same panel the account menu uses, so the two controls beside each other in the
|
||||
header open into the same shape. */
|
||||
.lang-list {
|
||||
.lang-panel {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
|
||||
min-width: 168px;
|
||||
width: min(260px, calc(100vw - 24px));
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 6px; display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.lang-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
min-height: 40px; padding: 0 10px;
|
||||
color: var(--faint); background: var(--card2);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
}
|
||||
.lang-search:focus-within {
|
||||
color: var(--text);
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 18%, transparent);
|
||||
}
|
||||
.lang-search input {
|
||||
width: 100%; min-width: 0; padding: 9px 0;
|
||||
border: 0; outline: 0; background: transparent;
|
||||
color: var(--text); font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.lang-search input::placeholder { color: var(--faint); opacity: 1; }
|
||||
.lang-search input::-webkit-search-cancel-button { cursor: pointer; }
|
||||
.lang-list {
|
||||
max-height: min(360px, calc(100dvh - 150px));
|
||||
margin-top: 6px; padding-right: 2px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
overflow-y: auto; overscroll-behavior: contain;
|
||||
scrollbar-width: thin; scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
.lang-list a {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
font-size: 13.5px; color: var(--muted);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.lang-list a[hidden] { display: none; }
|
||||
.lang-list a:hover { color: var(--text); background: var(--card2); }
|
||||
.lang-list a[aria-current] { color: var(--text); font-weight: 600; }
|
||||
.lang-list a svg { margin-left: auto; color: var(--amber); flex-shrink: 0; }
|
||||
.lang-option-code {
|
||||
margin-left: auto; color: var(--faint);
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
|
||||
}
|
||||
.lang-list a svg { color: var(--amber); flex-shrink: 0; }
|
||||
|
||||
/*
|
||||
* Narrow: the globe alone.
|
||||
@@ -196,6 +245,17 @@ const currentCode = locale.toUpperCase();
|
||||
*/
|
||||
let wired = false;
|
||||
|
||||
const normalise = (value: string): string =>
|
||||
value.normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLocaleLowerCase().trim();
|
||||
|
||||
const resetSearch = (menu: HTMLDetailsElement): void => {
|
||||
const input = menu.querySelector<HTMLInputElement>('[data-lang-search]');
|
||||
if (input) input.value = '';
|
||||
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
|
||||
option.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeAll = (except?: Element | null): void => {
|
||||
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
|
||||
if (menu !== except) menu.open = false;
|
||||
@@ -221,5 +281,28 @@ const currentCode = locale.toUpperCase();
|
||||
// Back to the control that opened it, not to the top of the document.
|
||||
open.querySelector('summary')?.focus();
|
||||
});
|
||||
|
||||
document.addEventListener('input', (event) => {
|
||||
const input = (event.target as Element | null)?.closest<HTMLInputElement>('[data-lang-search]');
|
||||
if (!input) return;
|
||||
const menu = input.closest<HTMLDetailsElement>('[data-lang-menu]');
|
||||
if (!menu) return;
|
||||
const query = normalise(input.value);
|
||||
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
|
||||
option.hidden = !normalise(option.dataset.search ?? '').includes(query);
|
||||
}
|
||||
});
|
||||
|
||||
// `toggle` does not bubble, so capture it. Starting in the search field makes a
|
||||
// long language list keyboard-friendly; clearing on close keeps every reopen useful.
|
||||
document.addEventListener('toggle', (event) => {
|
||||
const menu = event.target as HTMLDetailsElement;
|
||||
if (!menu.matches?.('[data-lang-menu]')) return;
|
||||
if (menu.open) {
|
||||
requestAnimationFrame(() => menu.querySelector<HTMLInputElement>('[data-lang-search]')?.focus());
|
||||
} else {
|
||||
resetSearch(menu);
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user