239 lines
7.6 KiB
TypeScript
239 lines
7.6 KiB
TypeScript
/* ============================================================================
|
|||
|
|
CARD - shared building blocks
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
Every card in this directory is assembled from the pieces below, so the
|
||
|
|
surface, radius, border, elevation and link behaviour are decided once.
|
||
|
|
|
||
|
|
STYLING RULE for this whole directory:
|
||
|
|
* colour and radius come from the tier-2 Tailwind bridge
|
||
|
|
(bg-surface-raised, text-text-accent, border-border-subtle,
|
||
|
|
rounded-surface, ...) - never a hex, never a tier-1 primitive;
|
||
|
|
* tokens without a bridge utility are spent as arbitrary values,
|
||
|
|
e.g. shadow-[var(--elevation-card)];
|
||
|
|
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
|
||
|
|
numbers stay readable;
|
||
|
|
* everything directional uses logical utilities (ps-/pe-/start-/end-)
|
||
|
|
so the Arabic build mirrors without a second stylesheet.
|
||
|
|
|
||
|
|
Nothing here is surface-aware by hand: the same markup is correct on
|
||
|
|
[data-surface="dark"] and [data-surface="light"] because every colour is a
|
||
|
|
role the surface re-points.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
||
|
|
import { Link } from "react-router";
|
||
|
|
|
||
|
|
import "./card.css";
|
||
|
|
import type {
|
||
|
|
CardElement,
|
||
|
|
CardIconName,
|
||
|
|
CardImage,
|
||
|
|
CardLinkTarget,
|
||
|
|
} from "./types";
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
LINK
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** Anything with a scheme, a protocol-relative host or a fragment is not a route. */
|
||
|
|
const NON_ROUTE_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* React Router sits in the hoisted root of this workspace, where it resolves
|
||
|
|
* its React types from the @types/react 18 that Strapi pulls in, so its props
|
||
|
|
* are declared against a ReactNode that is not ours (no bigint, and a
|
||
|
|
* ReactPortal that still requires children). Re-declaring the component once
|
||
|
|
* here keeps every call site below honest instead of casting at each of them.
|
||
|
|
* Delete this alias when the workspace resolves to a single @types/react.
|
||
|
|
*/
|
||
|
|
const RouterLink = Link as unknown as (props: {
|
||
|
|
to: string;
|
||
|
|
className?: string;
|
||
|
|
target?: string;
|
||
|
|
rel?: string;
|
||
|
|
children: ReactNode;
|
||
|
|
}) => ReactElement;
|
||
|
|
|
||
|
|
export interface CardLinkProps extends CardLinkTarget {
|
||
|
|
className?: string;
|
||
|
|
children: ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Routes internal hrefs through React Router so navigation stays client-side,
|
||
|
|
* and falls back to a plain anchor for external links, mailto: and fragments.
|
||
|
|
*/
|
||
|
|
export function CardLink({ href, className, children, ...rest }: CardLinkProps) {
|
||
|
|
if (NON_ROUTE_HREF.test(href)) {
|
||
|
|
return (
|
||
|
|
<a href={href} className={className} {...rest}>
|
||
|
|
{children}
|
||
|
|
</a>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<RouterLink to={href} className={className} {...rest}>
|
||
|
|
{children}
|
||
|
|
</RouterLink>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
ICON
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** The assets exported from the Figma icon frame, served from public/. */
|
||
|
|
const CARD_ICON_SRC: Record<CardIconName, string> = {
|
||
|
|
"arrow-right": "url('/assets/icons/arrow-right.svg')",
|
||
|
|
"arrow-up": "url('/assets/icons/arrow-up.svg')",
|
||
|
|
linkedin: "url('/assets/icons/linkedin.svg')",
|
||
|
|
mail: "url('/assets/icons/mail.svg')",
|
||
|
|
globe: "url('/assets/icons/globe.svg')",
|
||
|
|
};
|
||
|
|
|
||
|
|
export interface CardIconProps {
|
||
|
|
name: CardIconName;
|
||
|
|
/** Designed box in px - 14 for the 14x14 icons, 10 for the trend arrow. */
|
||
|
|
size: number;
|
||
|
|
/** Mirror in RTL. True for the horizontal arrow, false for the vertical one. */
|
||
|
|
directional?: boolean;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Draws an exported SVG as a mask over a currentColor fill, so the icon keeps
|
||
|
|
* the geometry Figma exported while taking its colour from the token in force.
|
||
|
|
* Always decorative - the adjacent text carries the meaning.
|
||
|
|
*/
|
||
|
|
export function CardIcon({ name, size, directional, className }: CardIconProps) {
|
||
|
|
return (
|
||
|
|
<span
|
||
|
|
aria-hidden="true"
|
||
|
|
className={cx(
|
||
|
|
"sh-card-icon",
|
||
|
|
directional && "sh-card-icon--directional",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
style={
|
||
|
|
{
|
||
|
|
"--sh-card-icon-src": CARD_ICON_SRC[name],
|
||
|
|
width: size,
|
||
|
|
height: size,
|
||
|
|
} as CSSProperties
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
SHELL
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export interface CardShellProps {
|
||
|
|
/** `li` inside a list, `section` for a landmark, `article` by default. */
|
||
|
|
as?: CardElement;
|
||
|
|
/**
|
||
|
|
* The card contains a stretched link. Adds the hover lift and lets the
|
||
|
|
* ::after overlay in card.css position against the card.
|
||
|
|
*/
|
||
|
|
linked?: boolean;
|
||
|
|
className?: string;
|
||
|
|
children: ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The surface every card shares: raised fill, hairline border, 12px corner
|
||
|
|
* (Figma radius/lg) and the card elevation.
|
||
|
|
*/
|
||
|
|
export function CardShell({
|
||
|
|
as = "article",
|
||
|
|
linked = false,
|
||
|
|
className,
|
||
|
|
children,
|
||
|
|
}: CardShellProps) {
|
||
|
|
const Tag = as;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Tag
|
||
|
|
className={cx(
|
||
|
|
"sh-card",
|
||
|
|
linked && "sh-card--linked",
|
||
|
|
"relative flex bg-surface-raised text-text-primary",
|
||
|
|
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</Tag>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
MEDIA
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export interface CardMediaProps {
|
||
|
|
image: CardImage;
|
||
|
|
/** Sets the box: an aspect-ratio utility, a fixed size, or both per breakpoint. */
|
||
|
|
className?: string;
|
||
|
|
/** Overlays that sit on the image, e.g. the category badge. */
|
||
|
|
children?: ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A media box whose height is decided by the wrapper, never by the file, so
|
||
|
|
* the layout is stable before the image loads.
|
||
|
|
*/
|
||
|
|
export function CardMedia({ image, className, children }: CardMediaProps) {
|
||
|
|
return (
|
||
|
|
<div className={cx("sh-card-media relative overflow-hidden", className)}>
|
||
|
|
<img
|
||
|
|
src={image.src}
|
||
|
|
srcSet={image.srcSet}
|
||
|
|
sizes={image.sizes}
|
||
|
|
alt={image.alt}
|
||
|
|
loading={image.priority ? "eager" : "lazy"}
|
||
|
|
fetchPriority={image.priority ? "high" : undefined}
|
||
|
|
decoding="async"
|
||
|
|
className="absolute inset-0 block size-full object-cover"
|
||
|
|
/>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
TEXT ATOMS
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** The small accent kicker above a card title ("PROGRAMME", "DIALOGUE REPORT"). */
|
||
|
|
export function CardEyebrow({ children }: { children: ReactNode }) {
|
||
|
|
return <p className="label-md uppercase text-text-accent">{children}</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The "Read more ->" affordance. Decorative: the stretched title link already
|
||
|
|
* carries the destination, so announcing this again would just be noise.
|
||
|
|
*/
|
||
|
|
export function CardActionHint({ label }: { label: string }) {
|
||
|
|
return (
|
||
|
|
<p
|
||
|
|
aria-hidden="true"
|
||
|
|
className="inline-flex items-center gap-xs body-sm-bold text-text-accent"
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
<CardIcon name="arrow-right" size={14} directional />
|
||
|
|
</p>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
UTIL
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** Joins class names, dropping anything falsy. */
|
||
|
|
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||
|
|
return parts.filter(Boolean).join(" ");
|
||
|
|
}
|