293 lines
7.5 KiB
Plaintext
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>
|