initial commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/* ============================================================================
|
||||
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={[]} />
|
||||
|
||||
========================================================================= */
|
||||
Reference in New Issue
Block a user