initial commit
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
buildLocalizedHref,
|
||||
getDirection,
|
||||
LanguageSwitcher,
|
||||
type Locale,
|
||||
} from "../LanguageSwitcher";
|
||||
|
||||
import { Brand } from "./Brand";
|
||||
import { DesktopNav } from "./DesktopNav";
|
||||
import { EuBadge } from "./EuBadge";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
|
||||
import { cx } from "./styles";
|
||||
import type { NavItem, NavigationStrings, NavSurface } from "./types";
|
||||
|
||||
export interface NavigationProps {
|
||||
/** Reading locale. Drives the copy, the default hrefs and the arrow keys. */
|
||||
locale?: Locale;
|
||||
/** Current pathname, e.g. "/ar/contact". Used for aria-current="page". */
|
||||
pathname: string;
|
||||
/** Query string, carried across a language switch. */
|
||||
search?: string;
|
||||
/** Fragment, carried across a language switch. */
|
||||
hash?: string;
|
||||
/**
|
||||
* The navigation tree. The app shell feeds this from the Strapi
|
||||
* site-settings single type; omitting it renders the typed default IA.
|
||||
*/
|
||||
items?: readonly NavItem[];
|
||||
/** Which tier-2 surface the bar paints. `:root` is dark, so dark is default. */
|
||||
surface?: NavSurface;
|
||||
/** Override any of the navigation's own strings (e.g. from the CMS). */
|
||||
strings?: Partial<NavigationStrings>;
|
||||
/** Where the logo links. Defaults to the locale home, "/en" or "/ar". */
|
||||
homeHref?: string;
|
||||
showLanguageSwitcher?: boolean;
|
||||
/**
|
||||
* Show the EU badge in the compact bar below xl as well. ON by default:
|
||||
* donor visibility is non-negotiable, and the Figma mobile frame (20:220),
|
||||
* which is logo + hamburger only, would leave the badge out of the header on
|
||||
* every phone, tablet and 1280px-narrow laptop. Below xl the lockup renders
|
||||
* at a reduced size rather than being dropped. Pass `false` only for a
|
||||
* surface where the funding statement is guaranteed elsewhere on screen.
|
||||
*/
|
||||
showEuBadgeOnMobileBar?: boolean;
|
||||
/** Stick the bar to the top of the viewport. */
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHARP-ER site navigation - Figma frame 20:236.
|
||||
*
|
||||
* Logo at the inline start, the mandatory "Funded by the European Union" badge
|
||||
* at the inline end, and the mega-menu groups between them. Everything is laid
|
||||
* out with logical properties, so the Arabic bar mirrors (logo right, EU badge
|
||||
* left) from `<html dir="rtl">` alone - there is no separate Arabic component.
|
||||
*
|
||||
* Below the xl breakpoint the bar collapses to logo + hamburger and the whole
|
||||
* tree moves into a full-screen drawer.
|
||||
*/
|
||||
export function Navigation({
|
||||
locale = "en",
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
items,
|
||||
surface = "dark",
|
||||
strings: stringOverrides,
|
||||
homeHref,
|
||||
showLanguageSwitcher = true,
|
||||
showEuBadgeOnMobileBar = true,
|
||||
sticky = true,
|
||||
className,
|
||||
}: NavigationProps) {
|
||||
const navItems = useMemo(
|
||||
() => items ?? getDefaultNavItems(locale),
|
||||
[items, locale],
|
||||
);
|
||||
const strings = useMemo(
|
||||
() => getNavigationStrings(locale, stringOverrides),
|
||||
[locale, stringOverrides],
|
||||
);
|
||||
|
||||
const resolvedHomeHref = homeHref ?? buildLocalizedHref("/", locale);
|
||||
const direction = getDirection(locale);
|
||||
|
||||
return (
|
||||
<header
|
||||
data-surface={surface}
|
||||
/* overflow-x-clip contains the mega panel's full-bleed backdrop without
|
||||
clipping the panel itself, which overflows vertically. */
|
||||
className={cx(
|
||||
"w-full overflow-x-clip border-b border-[var(--border-subtle)]",
|
||||
sticky && "sticky top-0 z-[var(--layer-header)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"mx-auto flex w-full max-w-[var(--layout-content-max)] items-center justify-between",
|
||||
/* The compact bar carries wordmark + EU lockup + hamburger on a
|
||||
320px screen, so it runs a tighter gap than the desktop bar. */
|
||||
"min-h-[64px] gap-xs px-[var(--layout-gutter)]",
|
||||
"xl:min-h-[72px] xl:gap-lg xl:px-2xl",
|
||||
)}
|
||||
>
|
||||
<Brand
|
||||
href={resolvedHomeHref}
|
||||
label={strings.homeLinkLabel}
|
||||
surface={surface}
|
||||
/>
|
||||
|
||||
<DesktopNav
|
||||
items={navItems}
|
||||
pathname={pathname}
|
||||
strings={strings}
|
||||
direction={direction}
|
||||
className="hidden xl:block"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-xs xl:gap-md">
|
||||
{/* Desktop-only cluster. Visibility lives on this wrapper rather than
|
||||
on the children: two display utilities on one element are resolved
|
||||
by the order Tailwind emits them, not by class-attribute order. */}
|
||||
<div className="hidden items-center gap-md xl:flex">
|
||||
{showLanguageSwitcher && (
|
||||
<LanguageSwitcher
|
||||
locale={locale}
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
hash={hash}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showEuBadgeOnMobileBar && (
|
||||
<EuBadge
|
||||
statement={strings.euStatement}
|
||||
emblemAlt={strings.euEmblemAlt}
|
||||
locale={locale}
|
||||
surface={surface}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEuBadgeOnMobileBar && (
|
||||
<EuBadge
|
||||
statement={strings.euStatement}
|
||||
emblemAlt={strings.euEmblemAlt}
|
||||
locale={locale}
|
||||
surface={surface}
|
||||
/* Below xl the lockup is scaled down - emblem 36x24 instead of
|
||||
48x32, statement wrapped in a narrower box - so it still fits
|
||||
beside the wordmark and the hamburger at 320px. It is scaled,
|
||||
never hidden: the statement stays at Label/Medium, i.e. the
|
||||
same size it is everywhere else, so it remains legible. */
|
||||
className="max-xl:[&>img]:h-[24px] max-xl:[&>img]:w-[36px] max-xl:[&>span]:w-[80px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MobileNav
|
||||
items={navItems}
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
hash={hash}
|
||||
locale={locale}
|
||||
strings={strings}
|
||||
surface={surface}
|
||||
homeHref={resolvedHomeHref}
|
||||
showLanguageSwitcher={showLanguageSwitcher}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
USAGE
|
||||
---------------------------------------------------------------------------
|
||||
// Dark bar on the dark home page, default IA, English:
|
||||
<Navigation locale="en" pathname="/en" />
|
||||
|
||||
// Arabic. The bar mirrors from <html dir="rtl"> - no extra props, no
|
||||
// separate component - and the arrow keys mirror with it.
|
||||
<Navigation locale="ar" pathname="/ar/contact" />
|
||||
|
||||
// Light bar on an interior page:
|
||||
<Navigation locale="en" pathname="/en/about" surface="light" />
|
||||
|
||||
// Fed from Strapi site-settings, with the CMS owning the labels and hrefs:
|
||||
<Navigation
|
||||
locale={locale}
|
||||
pathname={pathname}
|
||||
items={siteSettings.navigation}
|
||||
strings={{ euStatement: siteSettings.euFundingStatement }}
|
||||
/>
|
||||
|
||||
// Wired from a route, using the router's own location so a language switch
|
||||
// keeps the query string and fragment:
|
||||
const { pathname, search, hash } = useLocation();
|
||||
<Navigation locale={locale} pathname={pathname} search={search} hash={hash} />
|
||||
|
||||
// The EU badge is in the compact bar by default. Opt out only where the
|
||||
// funding statement is guaranteed to be visible elsewhere on the screen:
|
||||
<Navigation locale="en" pathname="/en" showEuBadgeOnMobileBar={false} />
|
||||
------------------------------------------------------------------------ */
|
||||
Reference in New Issue
Block a user