341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/* ============================================================================
|
|||
|
|
Footer - figma frame 140:7813
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
ONE component for all four published variants:
|
||
|
|
light EN 140:7812 | dark EN 140:7814 | light AR 140:8713 | dark AR 140:8685
|
||
|
|
`surface` picks the palette and the logo cut; direction comes from `<html dir>`
|
||
|
|
and every offset is a logical property, so the Arabic variants are the same
|
||
|
|
markup mirrored - there is no RTL branch anywhere below.
|
||
|
|
|
||
|
|
STYLING: Tailwind utilities for layout and spacing, tier-2 semantic tokens for
|
||
|
|
everything that carries meaning (`text-[var(--text-primary)]`). No raw hex, no
|
||
|
|
tier-1 primitive, no component stylesheet.
|
||
|
|
|
||
|
|
TWO DELIBERATE DEVIATIONS FROM THE PUBLISHED FRAMES, both noted inline:
|
||
|
|
1. the two EN frames disagree on the sitemap (140:7812 carries the real IA
|
||
|
|
but no column headings, 140:7814 carries headings but stale labels) -
|
||
|
|
this takes the newer IA and the headings;
|
||
|
|
2. `text/tertiary` (#949492) on white is ~2.9:1, so the small labels use
|
||
|
|
`--text-secondary` instead. Revert the two lines marked (a11y) for
|
||
|
|
pixel-exact parity.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import { Link } from "react-router";
|
||
|
|
|
||
|
|
import type {
|
||
|
|
FooterCopy,
|
||
|
|
FooterLink,
|
||
|
|
FooterPartner,
|
||
|
|
FooterProps,
|
||
|
|
FooterSocialLink,
|
||
|
|
} from "./types";
|
||
|
|
import {
|
||
|
|
DEFAULT_FOOTER_COPY,
|
||
|
|
DEFAULT_FOOTER_PARTNERS,
|
||
|
|
DEFAULT_FOOTER_SOCIAL_LINKS,
|
||
|
|
FOOTER_ASSETS,
|
||
|
|
FOOTER_EU_EMBLEM_SIZE,
|
||
|
|
FOOTER_WORDMARK_SIZE,
|
||
|
|
defaultFooterLinkGroups,
|
||
|
|
} from "./content";
|
||
|
|
import { FooterSocialIcon } from "./FooterSocialIcon";
|
||
|
|
|
||
|
|
const cx = (...parts: readonly (string | false | undefined)[]): string =>
|
||
|
|
parts.filter(Boolean).join(" ");
|
||
|
|
|
||
|
|
function definedOnly<T extends object>(source: Partial<T> | undefined): Partial<T> {
|
||
|
|
if (!source) return {};
|
||
|
|
return Object.fromEntries(
|
||
|
|
Object.entries(source).filter(([, value]) => value !== undefined),
|
||
|
|
) as Partial<T>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Visible focus ring on every interactive element, straight off the focus tokens. */
|
||
|
|
const FOCUS_RING = cx(
|
||
|
|
"rounded-[var(--radius-control)]",
|
||
|
|
"focus-visible:[outline:var(--focus-ring-width)_solid_var(--focus-ring-color)]",
|
||
|
|
"focus-visible:[outline-offset:var(--focus-ring-offset)]",
|
||
|
|
);
|
||
|
|
|
||
|
|
/** Only `color` - `transition-colors` would also fade `outline-color`, which
|
||
|
|
makes the focus ring arrive late. */
|
||
|
|
const TRANSITION =
|
||
|
|
"transition-[color] duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
|
||
|
|
|
||
|
|
/** Body/Medium Bold links, as in figma 380:12385. */
|
||
|
|
const LINK_CLASS = cx(
|
||
|
|
FOCUS_RING,
|
||
|
|
TRANSITION,
|
||
|
|
"body-md-bold inline-block text-[var(--text-primary)]",
|
||
|
|
"hover:text-[var(--text-link)] hover:underline underline-offset-4",
|
||
|
|
);
|
||
|
|
|
||
|
|
/** Label/Medium, used for the column headings and the "implemented by" label. */
|
||
|
|
const LABEL_CLASS = "label-md uppercase";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Internal links go through the router so the footer navigates client-side;
|
||
|
|
* anything flagged `external` stays a plain anchor and opens in a new tab.
|
||
|
|
*/
|
||
|
|
function FooterNavLink({ link, className }: { link: FooterLink; className: string }) {
|
||
|
|
if (link.external) {
|
||
|
|
return (
|
||
|
|
<a href={link.href} className={className} target="_blank" rel="noopener noreferrer">
|
||
|
|
{link.label}
|
||
|
|
</a>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<Link to={link.href} className={className} prefetch="intent">
|
||
|
|
{link.label}
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Footer({
|
||
|
|
surface = "dark",
|
||
|
|
locale = "en",
|
||
|
|
dir,
|
||
|
|
linkGroups,
|
||
|
|
socialLinks = DEFAULT_FOOTER_SOCIAL_LINKS,
|
||
|
|
partners = DEFAULT_FOOTER_PARTNERS,
|
||
|
|
copy: copyOverrides,
|
||
|
|
homeHref,
|
||
|
|
className,
|
||
|
|
id,
|
||
|
|
}: FooterProps) {
|
||
|
|
const isDark = surface === "dark";
|
||
|
|
// A CMS field that has not been filled in arrives as undefined; spreading it
|
||
|
|
// would blank the default, so only defined overrides are applied.
|
||
|
|
const copy: FooterCopy = { ...DEFAULT_FOOTER_COPY[locale], ...definedOnly(copyOverrides) };
|
||
|
|
const groups = linkGroups ?? defaultFooterLinkGroups(locale);
|
||
|
|
const home = homeHref ?? `/${locale}`;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<footer
|
||
|
|
id={id}
|
||
|
|
dir={dir}
|
||
|
|
data-surface={surface}
|
||
|
|
className={cx(
|
||
|
|
// data-surface paints background-color and color itself (app.css), so
|
||
|
|
// the footer needs no colour utility of its own.
|
||
|
|
"w-full px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-2xl">
|
||
|
|
{/* ---- brand + sitemap (figma 140:7684 / 140:8686) ---------------- */}
|
||
|
|
<div className="flex flex-col gap-2xl lg:flex-row lg:gap-3xl">
|
||
|
|
{/* --- brand column (figma 140:7685 / 140:8687) ----------------- */}
|
||
|
|
<div
|
||
|
|
className={cx(
|
||
|
|
"flex flex-col gap-md",
|
||
|
|
// the divider sits on the inline-end of the brand block, which is
|
||
|
|
// the left edge once <html dir="rtl"> mirrors the row.
|
||
|
|
"lg:w-[345px] lg:shrink-0 lg:border-e lg:border-[var(--border-subtle)] lg:pe-2xl",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Link to={home} aria-label={copy.homeLabel} className={cx(FOCUS_RING, "w-fit")}>
|
||
|
|
<img
|
||
|
|
src={isDark ? FOOTER_ASSETS.wordmarkInverse : FOOTER_ASSETS.wordmark}
|
||
|
|
alt="SHARP-ER"
|
||
|
|
width={FOOTER_WORDMARK_SIZE.width}
|
||
|
|
height={FOOTER_WORDMARK_SIZE.height}
|
||
|
|
className="block h-[27px] w-[196px]"
|
||
|
|
loading="lazy"
|
||
|
|
decoding="async"
|
||
|
|
/>
|
||
|
|
</Link>
|
||
|
|
|
||
|
|
<p className="body-sm text-[var(--text-secondary)]">{copy.tagline}</p>
|
||
|
|
|
||
|
|
<p className="body-sm max-w-[46ch] text-[var(--text-primary)]">
|
||
|
|
{copy.description}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
{/* --- EU visibility (figma 140:7688 / 238:2325) -------------- */}
|
||
|
|
<div className="flex items-center gap-2xs pt-2xs">
|
||
|
|
<img
|
||
|
|
src={isDark ? FOOTER_ASSETS.euEmblemInverse : FOOTER_ASSETS.euEmblem}
|
||
|
|
alt={copy.euEmblemAlt}
|
||
|
|
width={FOOTER_EU_EMBLEM_SIZE.width}
|
||
|
|
height={FOOTER_EU_EMBLEM_SIZE.height}
|
||
|
|
className="block h-[32px] w-[48px] shrink-0"
|
||
|
|
loading="lazy"
|
||
|
|
decoding="async"
|
||
|
|
/>
|
||
|
|
<p className="body-sm-bold max-w-[24ch] text-[var(--text-primary)]">
|
||
|
|
{copy.euStatement}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{socialLinks.length > 0 && <SocialRow copy={copy} links={socialLinks} />}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* --- sitemap columns (figma 380:12402) ------------------------ */}
|
||
|
|
<nav aria-label={copy.navLabel} className="flex flex-1 flex-col gap-2xl sm:flex-row sm:gap-0">
|
||
|
|
{groups.map((group, index) => (
|
||
|
|
<div
|
||
|
|
key={group.id}
|
||
|
|
className={cx(
|
||
|
|
"flex min-w-0 flex-col gap-md sm:flex-1",
|
||
|
|
// the rule sits on the inline-start of every column but the
|
||
|
|
// first, so it mirrors with the row under dir="rtl".
|
||
|
|
index > 0 && "sm:border-s sm:border-[var(--border-subtle)] sm:ps-xl",
|
||
|
|
index < groups.length - 1 && "sm:pe-xl",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{/* (a11y) figma uses text/tertiary; see the header note. */}
|
||
|
|
<h2 className={cx(LABEL_CLASS, "text-[var(--text-secondary)]")}>
|
||
|
|
{group.heading}
|
||
|
|
</h2>
|
||
|
|
<ul className="flex flex-col gap-sm">
|
||
|
|
{group.links.map((link: FooterLink, linkIndex: number) => (
|
||
|
|
<li key={`${link.href}-${linkIndex}`}>
|
||
|
|
<FooterNavLink link={link} className={LINK_CLASS} />
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* ---- implemented-by bar (figma 228:3062 / 140:8710) ------------- */}
|
||
|
|
{partners.length > 0 && (
|
||
|
|
<div className="flex flex-col gap-lg border-t border-[var(--border-default)] pt-lg sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
{/* Figma paints this label background/accent on dark and
|
||
|
|
text/secondary on light; --text-accent is the readable green. */}
|
||
|
|
<p
|
||
|
|
className={cx(
|
||
|
|
LABEL_CLASS,
|
||
|
|
"text-[var(--text-secondary)] surface-dark:text-[var(--text-accent)]",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{copy.implementedByLabel}
|
||
|
|
</p>
|
||
|
|
<ul className="flex flex-wrap items-center gap-lg">
|
||
|
|
{partners.map((partner) => (
|
||
|
|
<li key={partner.id} className="flex items-center">
|
||
|
|
<PartnerMark isDark={isDark} partner={partner} />
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</footer>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
Consortium logo. The three marks are shown together and never on their own
|
||
|
|
(communication guideline), so this renders one mark of that row - each keeps
|
||
|
|
its own designed box rather than a shared height rule.
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
function PartnerMark({ isDark, partner }: { isDark: boolean; partner: FooterPartner }) {
|
||
|
|
const image = (
|
||
|
|
<img
|
||
|
|
src={isDark ? partner.srcInverse : partner.src}
|
||
|
|
alt={partner.name}
|
||
|
|
width={partner.width}
|
||
|
|
height={partner.height}
|
||
|
|
style={{ width: `${partner.width}px`, height: `${partner.height}px` }}
|
||
|
|
className="block"
|
||
|
|
loading="lazy"
|
||
|
|
decoding="async"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!partner.href) return image;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<a
|
||
|
|
href={partner.href}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className={cx(FOCUS_RING, "inline-flex")}
|
||
|
|
>
|
||
|
|
{image}
|
||
|
|
</a>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
Social row. Not in the published frames - the draft copy asks for it, so it
|
||
|
|
closes the brand column. The negative inline-start margin cancels the icon
|
||
|
|
padding so the glyphs optically align with the text above them.
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
function SocialRow({
|
||
|
|
copy,
|
||
|
|
links,
|
||
|
|
}: {
|
||
|
|
copy: FooterCopy;
|
||
|
|
links: readonly FooterSocialLink[];
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className="pt-2xs">
|
||
|
|
<h2 className="sr-only">{copy.socialHeading}</h2>
|
||
|
|
<ul className="-ms-2xs flex flex-wrap items-center gap-2xs">
|
||
|
|
{links.map((link) => (
|
||
|
|
<li key={link.platform}>
|
||
|
|
<a
|
||
|
|
href={link.href}
|
||
|
|
aria-label={link.label}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className={cx(
|
||
|
|
FOCUS_RING,
|
||
|
|
TRANSITION,
|
||
|
|
"inline-flex items-center justify-center p-2xs",
|
||
|
|
"text-[var(--text-secondary)] hover:text-[var(--text-link)]",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<FooterSocialIcon
|
||
|
|
platform={link.platform}
|
||
|
|
className="h-[var(--control-icon-size)] w-[var(--control-icon-size)]"
|
||
|
|
/>
|
||
|
|
</a>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default Footer;
|
||
|
|
|
||
|
|
/* ============================================================================
|
||
|
|
Footer.stories-ish - usage, as a comment only (no Storybook in this project)
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// 1. Dark English footer - the default, matches figma 140:7814.
|
||
|
|
// <html lang="en" dir="ltr"> already carries the direction.
|
||
|
|
<Footer locale="en" />
|
||
|
|
|
||
|
|
// 2. Light English footer - figma 140:7812.
|
||
|
|
<Footer surface="light" locale="en" />
|
||
|
|
|
||
|
|
// 3. Arabic, dark. Same markup; the RTL mirroring comes from <html dir="rtl">,
|
||
|
|
// so nothing is passed for direction inside the real app.
|
||
|
|
<Footer locale="ar" />
|
||
|
|
|
||
|
|
// 4. Light Arabic - figma 140:8713. `dir` is only needed when the footer is
|
||
|
|
// previewed inside a document that is not in that locale.
|
||
|
|
<Footer surface="light" locale="ar" dir="rtl" />
|
||
|
|
|
||
|
|
// 5. Strapi-driven: every list is a prop, so the CMS payload maps 1:1.
|
||
|
|
<Footer
|
||
|
|
surface="dark"
|
||
|
|
locale="en"
|
||
|
|
linkGroups={cms.footer.groups}
|
||
|
|
socialLinks={cms.footer.social}
|
||
|
|
partners={cms.footer.partners}
|
||
|
|
copy={{ description: cms.footer.blurb }}
|
||
|
|
/>
|
||
|
|
|
||
|
|
// 6. Sitemap-only footer, no consortium row and no social row.
|
||
|
|
<Footer partners={[]} socialLinks={[]} />
|
||
|
|
|
||
|
|
========================================================================= */
|