Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 86 additions & 20 deletions src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,51 @@ interface Props {
const { locale, pageKey } = Astro.props;
const chrome = t(locale);

const navItems: { key: Exclude<PageKey, "home">; label: string }[] = [
{ key: "features", label: chrome.nav.features },
{ key: "install", label: chrome.nav.install },
{ key: "docs", label: chrome.nav.docs },
{ key: "assistant", label: chrome.nav.assistant },
{ key: "news", label: chrome.nav.news },
{ key: "changelog", label: chrome.nav.changelog },
{ key: "community", label: chrome.nav.community },
{ key: "roadmap", label: chrome.nav.roadmap },
{ key: "compliance", label: chrome.nav.compliance },
{ key: "compare", label: chrome.nav.compare },
{ key: "about", label: chrome.nav.about },
/**
* #131 — the eleven flat links collapsed into four top-level groups, each
* opening a dropdown on hover/focus-within (the language switcher's pattern).
* Compare left the nav on purpose: the page stays live at its URL but nothing
* in the chrome points at it anymore.
*/
const navGroups: {
key: string;
label: string;
items: { key: Exclude<PageKey, "home">; label: string }[];
}[] = [
{
key: "product",
label: chrome.navGroups.product,
items: [
{ key: "features", label: chrome.nav.features },
{ key: "install", label: chrome.nav.install },
{ key: "assistant", label: chrome.nav.assistant },
{ key: "roadmap", label: chrome.nav.roadmap },
],
},
{
key: "docs",
label: chrome.navGroups.docs,
items: [
{ key: "docs", label: chrome.nav.docs },
{ key: "changelog", label: chrome.nav.changelog },
],
},
{
key: "community",
label: chrome.navGroups.community,
items: [
{ key: "news", label: chrome.nav.news },
{ key: "community", label: chrome.nav.community },
],
},
{
key: "company",
label: chrome.navGroups.company,
items: [
{ key: "compliance", label: chrome.nav.compliance },
{ key: "about", label: chrome.nav.about },
],
},
];

const menuLabel = locale === "ar" ? "القائمة" : locale === "fr" ? "Menu" : "Menu";
Expand Down Expand Up @@ -94,14 +127,34 @@ const localeCodes: Record<Locale, string> = { en: "EN", ar: "AR", fr: "FR" };
</a>

<nav class="site-nav" aria-label={locale === "ar" ? "التنقّل الرئيسي" : locale === "fr" ? "Navigation principale" : "Main navigation"}>
{
navItems.map((item) => (
<a
href={localePath(locale, item.key)}
aria-current={pageKey === item.key ? "page" : undefined}
>
{item.label}
</a>
{
navGroups.map((group) => (
<div class="nav-group" data-nav-group={group.key}>
{/* Same mechanism as the language switcher above: the wrapper owns
hover/focus-within, so the panel opens for mouse and keyboard
alike with zero client JS (aria-expanded is omitted on purpose:
it could only ever track half the truth — hover opens the panel
without any event a script could hear, and the links below stay
in the accessibility tree either way). */}
<button type="button" class="nav-trigger" aria-haspopup="true">
{group.label}
<span class="nav-caret" aria-hidden="true">
</span>
</button>
<ul class="nav-submenu">
{group.items.map((item) => (
<li>
<a
href={localePath(locale, item.key)}
aria-current={pageKey === item.key ? "page" : undefined}
>
{item.label}
</a>
</li>
))}
</ul>
</div>
))
}
<div class="nav-panel-langs">
Expand Down Expand Up @@ -216,3 +269,16 @@ const localeCodes: Record<Locale, string> = { en: "EN", ar: "AR", fr: "FR" };
</div>
</div>
</header>

{/* #131 — Escape is the one dismissal gesture CSS cannot see: blurring the
focused item lets :focus-within let go and the panel folds. One delegated
listener per page, the theme toggle's pattern. */}
<script is:inline>
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
var active = document.activeElement;
if (active instanceof HTMLElement && active.closest(".nav-group")) {
active.blur();
}
});
</script>
24 changes: 21 additions & 3 deletions src/i18n/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ export const ui = {
community: "Community",
roadmap: "Roadmap",
compliance: "Compliance",
compare: "Compare",
about: "About",
},
/** #131 — the four top-level groups the nav collapses into. */
navGroups: {
product: "Product",
docs: "Docs",
community: "Community",
company: "Company",
},
actions: {
install: "Install",
readDocs: "Read the docs",
Expand Down Expand Up @@ -127,9 +133,15 @@ export const ui = {
community: "المجتمع",
roadmap: "خارطة الطريق",
compliance: "الامتثال",
compare: "مقارنة",
about: "حول المشروع",
},
/** #131 — المجموعات الأربع التي ينهار إليها التنقّل. */
navGroups: {
product: "المنتج",
docs: "الوثائق",
community: "المجتمع",
company: "الشركة",
},
actions: {
install: "ثبّت كيل",
readDocs: "اقرأ الوثائق",
Expand Down Expand Up @@ -233,9 +245,15 @@ export const ui = {
community: "Communauté",
roadmap: "Feuille de route",
compliance: "Conformité",
compare: "Comparatif",
about: "À propos",
},
/** #131 — les quatre groupes racine du menu. */
navGroups: {
product: "Produit",
docs: "Docs",
community: "Communauté",
company: "Entreprise",
},
actions: {
install: "Installer",
readDocs: "Lire la documentation",
Expand Down
154 changes: 141 additions & 13 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -491,28 +491,112 @@ section {
margin-inline-start: 0.15rem;
}

/* #131 — four groups on one row. Each .nav-group owns hover/focus-within
exactly like .locale-menu above: the panel is always in the DOM and only
opacity-gated, so its links keep their tab stops whether or not it is
showing. */
.site-nav {
display: flex;
flex-wrap: wrap;
flex-wrap: wrap; /* four short labels never reach it — kept as a safety net */
justify-content: center;
gap: 0.1rem 0.9rem;
gap: 0.1rem 0.55rem;
margin-inline: auto;
}

.site-nav a {
color: var(--muted);
text-decoration: none;
.nav-group {
position: relative; /* the dropdown's anchor */
}

.nav-trigger {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.55rem;
border: 0;
border-radius: 6px;
background: none;
color: var(--muted);
font: inherit;
font-size: 0.98rem;
white-space: nowrap;
/* Hover (or focus) opens the panel — with zero client JS there is no click
action for the cursor to promise. */
cursor: default;
}

.site-nav a:hover {
.nav-group:hover .nav-trigger,
.nav-trigger:focus-visible {
color: var(--ink);
background: var(--surface-2);
}

.site-nav a[aria-current="page"] {
.nav-trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

/* The group whose page is open carries the current marker inside. */
.nav-group:has(.nav-submenu a[aria-current="page"]) .nav-trigger {
color: var(--accent);
font-weight: 600;
}

.nav-caret {
font-size: 0.7em;
opacity: 0.7;
transition: transform 0.16s ease;
}

.nav-group:hover .nav-caret,
.nav-group:focus-within .nav-caret {
transform: rotate(180deg);
}

/* No gap between trigger and panel: the pointer must never leave the hover
box while moving into the menu (same reasoning as .locale-list). */
.nav-submenu {
position: absolute;
top: 100%;
inset-inline-start: 0;
margin: 0;
padding: 0.35rem;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
z-index: 60;
}

.nav-group:hover .nav-submenu,
.nav-group:focus-within .nav-submenu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}

.nav-submenu a {
display: block;
padding: 0.5rem 0.65rem;
border-radius: 6px;
color: var(--ink);
text-decoration: none;
font-size: 0.95rem;
white-space: nowrap;
}

.nav-submenu a:hover {
background: var(--surface-2);
color: var(--accent);
}

.nav-submenu a[aria-current="page"] {
color: var(--accent);
font-weight: 600;
}
Expand Down Expand Up @@ -616,15 +700,53 @@ section {
padding: 0.35rem 1.25rem 1rem;
}

.site-header:has(.nav-toggle:checked) .site-nav a {
padding: 0.8rem 0.55rem;
/* #131 — the panel lists every item under its group heading: the trigger
degrades to a plain label and the submenu unrolls in place. */
.site-header:has(.nav-toggle:checked) .nav-trigger {
display: block;
width: 100%;
padding: 0.9rem 0.55rem 0.3rem;
border: 0;
border-radius: 0;
background: none;
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: normal;
/* a heading here, not a control — the links below are the targets */
pointer-events: none;
cursor: default;
}

.site-header:has(.nav-toggle:checked) .nav-caret {
display: none;
}

.site-header:has(.nav-toggle:checked) .nav-submenu {
position: static;
opacity: 1;
transform: none;
pointer-events: auto;
padding: 0;
border: 0;
background: none;
box-shadow: none;
}

.site-header:has(.nav-toggle:checked) .nav-submenu a {
display: block;
padding: 0.75rem 0.55rem;
font-size: 1.02rem;
border-bottom: 1px solid var(--border);
border-radius: 0;
color: var(--muted);
}

.site-header:has(.nav-toggle:checked) .site-nav a:last-child {
border-bottom: 0;
.site-header:has(.nav-toggle:checked) .nav-submenu a:hover,
.site-header:has(.nav-toggle:checked) .nav-submenu a[aria-current="page"] {
color: var(--ink);
background: var(--surface-2);
}

.site-header:has(.nav-toggle:checked) .site-nav .nav-panel-langs {
Expand All @@ -641,7 +763,13 @@ section {
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.92rem;
border-bottom: 0;
color: var(--muted);
text-decoration: none;
}

.nav-panel-langs a:hover {
color: var(--ink);
border-color: var(--accent);
}
}

Expand Down
Loading