initial commit
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/* ============================================================================
|
||||
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(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user