Files
design-system/components/Nav.astro

293 lines
7.5 KiB
Plaintext

---
/**
* Nav — LA nav de référence de la constellation. Définie une seule fois ici.
*
* Comportement :
* - sticky en haut, se condense après 16px de scroll (padding réduit)
* - mobile (< 768px) : hamburger accessible — aria-expanded, aria-controls,
* fermeture Échap, focus trap dans le panneau ouvert, retour focus au bouton
* - sans JS : les liens restent visibles (rangée wrappée), rien n'est cassé
* - lien actif : aria-current="page" calculé depuis Astro.url.pathname
*
* Props :
* - links (NavLink[], requis) : { href, label }[]
* - label (string, 'Navigation principale') : aria-label du <nav>
*
* Slots : brand (logo / nom du site, à gauche), actions (CTA / toggle thème,
* rendu dans le panneau).
*/
export interface NavLink {
href: string;
label: string;
}
interface Props {
links: NavLink[];
label?: string;
}
const { links, label = "Navigation principale" } = Astro.props;
const current = Astro.url.pathname.replace(/\/$/, "") || "/";
const isActive = (href: string) => {
const h = href.replace(/\/$/, "") || "/";
return h === "/" ? current === "/" : current === h || current.startsWith(h + "/");
};
---
<header class="tf-nav" data-tf-nav>
<nav aria-label={label}>
<div class="tf-nav__inner">
<div class="tf-nav__brand"><slot name="brand" /></div>
<button
class="tf-nav__toggle"
type="button"
aria-expanded="false"
aria-controls="tf-nav-panel"
data-tf-nav-toggle
>
<span class="tf-nav__toggle-box" aria-hidden="true"
><span class="tf-nav__toggle-bar"></span></span
>
<span>Menu</span>
</button>
<div class="tf-nav__panel" id="tf-nav-panel" data-tf-nav-panel>
<ul class="tf-nav__list">
{
links.map((l) => (
<li>
<a href={l.href} aria-current={isActive(l.href) ? "page" : undefined}>
{l.label}
</a>
</li>
))
}
</ul>
<div class="tf-nav__actions"><slot name="actions" /></div>
</div>
</div>
</nav>
</header>
<style>
.tf-nav {
position: sticky;
inset-block-start: 0;
z-index: var(--z-nav);
background: var(--bg);
border-block-end: 1px solid var(--border);
}
.tf-nav__inner {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-pad);
padding-block: var(--space-md);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-lg);
transition: padding-block var(--dur-base) var(--ease-out);
}
.tf-nav.is-scrolled .tf-nav__inner {
padding-block: var(--space-xs);
}
.tf-nav__brand {
margin-inline-end: auto;
display: flex;
align-items: center;
min-height: 2.75rem;
font-family: var(--font-heading);
font-weight: var(--fw-semibold);
}
.tf-nav__brand :global(a) {
color: var(--text);
text-decoration: none;
}
/* Hamburger — caché tant que JS n'a pas pris la main, et toujours dès md */
.tf-nav__toggle {
display: none;
align-items: center;
gap: var(--space-xs);
min-height: 2.75rem;
min-width: 2.75rem;
padding: var(--space-2xs) var(--space-sm);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
cursor: pointer;
}
.tf-nav__toggle-box {
display: block;
width: 1.125rem;
}
.tf-nav__toggle-bar,
.tf-nav__toggle-box::before,
.tf-nav__toggle-box::after {
content: "";
display: block;
height: 2px;
border-radius: var(--radius-full);
background: currentColor;
}
.tf-nav__toggle-bar {
margin-block: 4px;
}
.tf-nav__panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-md);
flex-basis: 100%;
}
.tf-nav__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs) var(--space-xs);
}
.tf-nav__list a {
display: flex;
align-items: center;
min-height: 2.75rem;
padding-inline: var(--space-sm);
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition: color var(--dur-fast) var(--ease-out),
background-color var(--dur-fast) var(--ease-out);
}
.tf-nav__list a:hover {
color: var(--text);
background: var(--surface-2);
}
.tf-nav__list a[aria-current="page"] {
color: var(--text);
font-weight: var(--fw-semibold);
}
.tf-nav__actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Mobile avec JS : hamburger visible, panneau replié en accordéon */
@media (max-width: 47.9375rem) {
:global(html[data-js]) .tf-nav__toggle {
display: inline-flex;
}
:global(html[data-js]) .tf-nav__panel {
display: none;
flex-direction: column;
align-items: stretch;
padding-block: var(--space-sm) var(--space-md);
}
:global(html[data-js]) .tf-nav.is-open .tf-nav__panel {
display: flex;
}
:global(html[data-js]) .tf-nav__list {
flex-direction: column;
align-items: stretch;
}
}
/* Desktop : panneau inline, jamais de hamburger */
@media (min-width: 48rem) {
.tf-nav__panel {
flex-basis: auto;
}
}
</style>
<script>
const nav = document.querySelector<HTMLElement>("[data-tf-nav]");
if (nav) {
const toggle = nav.querySelector<HTMLButtonElement>("[data-tf-nav-toggle]")!;
const panel = nav.querySelector<HTMLElement>("[data-tf-nav-panel]")!;
const mqDesktop = window.matchMedia("(min-width: 48rem)");
// Condensation au scroll (sticky-puis-compact)
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
nav.classList.toggle("is-scrolled", window.scrollY > 16);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
const isOpen = () => toggle.getAttribute("aria-expanded") === "true";
const focusables = () =>
Array.from(
panel.querySelectorAll<HTMLElement>("a[href], button:not([disabled])")
);
const close = (returnFocus = true) => {
toggle.setAttribute("aria-expanded", "false");
nav.classList.remove("is-open");
if (returnFocus) toggle.focus();
};
const open = () => {
toggle.setAttribute("aria-expanded", "true");
nav.classList.add("is-open");
focusables()[0]?.focus();
};
toggle.addEventListener("click", () => (isOpen() ? close() : open()));
nav.addEventListener("keydown", (e) => {
if (mqDesktop.matches || !isOpen()) return;
if (e.key === "Escape") {
e.preventDefault();
close();
return;
}
// Focus trap : Tab circule entre le bouton et le panneau ouvert
if (e.key === "Tab") {
const items = [toggle, ...focusables()];
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
panel.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest("a")) close(false);
});
mqDesktop.addEventListener("change", () => close(false));
}
</script>