@@ -0,0 +1,225 @@
|
||||
---
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* Text, not flags, in both. A flag is a country: Spanish is not Spain, Dutch is not the
|
||||
* Netherlands, and English belongs to nobody in particular. Each language is written in
|
||||
* its own name, because someone looking for Nederlands is not scanning for "Dutch".
|
||||
*
|
||||
* Every link points at this same page in that language, built from the unprefixed path,
|
||||
* so this works identically on a mint page: /mint/kashu.me and /es/mint/kashu.me are
|
||||
* the same mint. There is no "switch to Spanish and land on the home page" case, which
|
||||
* is the usual failure of a switcher that only knows about the site root.
|
||||
*
|
||||
* The header shape is a `<details>` rather than a scripted dropdown. It opens, closes
|
||||
* and takes the keyboard with no JavaScript at all, which matters because the whole
|
||||
* point of this control is being able to leave a page you cannot read. The script below
|
||||
* only adds what `<details>` does not do by itself: closing on Escape and on a click
|
||||
* elsewhere.
|
||||
*/
|
||||
import { LOCALES, localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
/** `menu` is the header control; `inline` is the footer row. */
|
||||
variant?: 'menu' | 'inline';
|
||||
}
|
||||
const { variant = 'inline' } = Astro.props;
|
||||
|
||||
const { locale, path } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
const links = LOCALES.map((other) => ({
|
||||
code: other.code,
|
||||
label: other.label,
|
||||
href: localePath(path, other.code),
|
||||
current: other.code === locale,
|
||||
}));
|
||||
|
||||
/*
|
||||
* Two letters in the header, because the full name of the current language is a word
|
||||
* the reader already knows they are reading. The code is the locale's own, uppercased,
|
||||
* so a fourth language needs nothing added here.
|
||||
*/
|
||||
const currentCode = locale.toUpperCase();
|
||||
---
|
||||
|
||||
{
|
||||
variant === 'menu' ? (
|
||||
<details class="lang-menu" data-lang-menu>
|
||||
<summary aria-haspopup="true">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" />
|
||||
</svg>
|
||||
{/* The code alone does not say what it is, so the label is in the tree for a
|
||||
screen reader and out of the way for everyone else. */}
|
||||
<span class="sr-only">{t('lang.label')}</span>
|
||||
<span class="lang-code">{currentCode}</span>
|
||||
<svg class="lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</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>
|
||||
</details>
|
||||
) : (
|
||||
<nav class="lang-switch" aria-label={t('lang.switcher')}>
|
||||
{
|
||||
/*
|
||||
* The current language is still a link, and it goes where it says it goes.
|
||||
* `aria-current` is what tells a screen reader it is where you already are, and
|
||||
* the weight is what tells everyone else.
|
||||
*/
|
||||
links.map((other) => (
|
||||
<a
|
||||
href={other.href}
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
>
|
||||
{other.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
/* ---------- the header control ---------- */
|
||||
|
||||
.lang-menu { position: relative; flex-shrink: 0; }
|
||||
|
||||
.lang-menu summary {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 10px; border-radius: 8px;
|
||||
color: var(--muted); font-size: 13.5px; font-weight: 500;
|
||||
cursor: pointer; list-style: none; user-select: none;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
/* The default disclosure triangle, in both engines that draw one. */
|
||||
.lang-menu summary::-webkit-details-marker { display: none; }
|
||||
.lang-menu summary::marker { content: ''; }
|
||||
|
||||
.lang-menu summary:hover { color: var(--text); background: var(--card2); }
|
||||
.lang-menu[open] summary { color: var(--text); background: var(--card2); }
|
||||
|
||||
.lang-code { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
|
||||
.lang-caret { color: var(--faint); transition: transform var(--dur-quick) var(--ease-out); }
|
||||
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
|
||||
|
||||
/* The same panel the account menu uses, so the two controls beside each other in the
|
||||
header open into the same shape. */
|
||||
.lang-list {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
|
||||
min-width: 168px;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 6px; display: flex; flex-direction: column; gap: 2px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.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: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; }
|
||||
|
||||
/*
|
||||
* Narrow: the globe alone.
|
||||
*
|
||||
* Below 760px the header is already dropping things (the search affordance goes at
|
||||
* the same breakpoint) and four nav items, an account control and this have to share
|
||||
* one row. The globe is the part that makes the control findable; the code and the
|
||||
* caret are the parts that only confirm what it is once you have found it. The
|
||||
* accessible name stays either way, because it was never the visible text.
|
||||
*/
|
||||
@media (max-width: 760px) {
|
||||
.lang-menu summary { padding: 7px 8px; gap: 0; }
|
||||
.lang-code,
|
||||
.lang-caret { display: none; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.lang-menu summary { padding: 6px 6px; }
|
||||
}
|
||||
|
||||
/* ---------- the footer row ---------- */
|
||||
|
||||
.lang-switch { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.lang-switch a {
|
||||
font-size: 12.5px; color: var(--faint);
|
||||
transition: color var(--dur-quick);
|
||||
}
|
||||
.lang-switch a:hover { color: var(--text); }
|
||||
.lang-switch a[aria-current] { color: var(--text); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { onReady } from '../scripts/reveal';
|
||||
|
||||
/**
|
||||
* The two things `<details>` does not do on its own.
|
||||
*
|
||||
* Everything else about this control (open, close, keyboard, focus) is the element's
|
||||
* own behaviour and works with this script blocked, which is the point of using one.
|
||||
*
|
||||
* Attached to the document once rather than to the element, because the view
|
||||
* transition router replaces the header on every navigation and a per-element
|
||||
* listener would be re-bound, and leak, once per page visited.
|
||||
*/
|
||||
let wired = false;
|
||||
|
||||
const closeAll = (except?: Element | null): void => {
|
||||
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
|
||||
if (menu !== except) menu.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
onReady(() => {
|
||||
if (wired) return;
|
||||
wired = true;
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// A click inside the open menu is either the summary (which toggles itself) or a
|
||||
// link (which is about to navigate). Neither wants closing from here.
|
||||
closeAll(target?.closest('[data-lang-menu]'));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
const open = document.querySelector<HTMLDetailsElement>('[data-lang-menu][open]');
|
||||
if (!open) return;
|
||||
open.open = false;
|
||||
// Back to the control that opened it, not to the top of the document.
|
||||
open.querySelector('summary')?.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user