initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,62 @@
/* ============================================================================
ARROW ICON - figma `arrow-small-right` 84:4260 / `arrow-small-lift` 140:8078
----------------------------------------------------------------------------
The exported asset is at /assets/icons/arrow-small-right.svg. Its path is
filled with a hard-coded black, so it is applied as a mask over
`currentColor` instead of being dropped in an <img>: that is the only way the
arrow can turn white on a Primary button, dark grey on a Secondary one and
green on a Ghost one while remaining the exact exported bytes.
GEOMETRY, straight from Figma: a 24x24 clipped box (`--control-icon-size`)
with the 12.9996 x 11.414 leaf inset 20.8333% from the left, 25% from the
right and 26.22% top and bottom. The insets are physical on purpose - the
glyph is asymmetric, which app.css names as the one case that cannot be
expressed logically, so mirroring is done by flipping the whole box.
DIRECTION. Figma's left arrow is not a second drawing: node 140:8078 is this
same asset with `-scale-x-100`. So `forward` (the arrow that follows reading
order) is the exported right arrow in LTR and that same box mirrored in RTL,
which is exactly what the Arabic home 85:5029 shows in its hero CTA.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-arrow-icon {
position: relative;
display: inline-block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
overflow: hidden;
}
.sh-arrow-icon__glyph {
position: absolute;
top: 26.22%;
bottom: 26.22%;
left: 20.8333%;
right: 25%;
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/arrow-small-right.svg");
mask-image: url("/assets/icons/arrow-small-right.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
/* Mirror the box, not the glyph, so the 1px asymmetry of the inset mirrors
with it - this reproduces node 140:8078 rather than approximating it. */
.sh-arrow-icon[data-direction="backward"] {
transform: scaleX(-1);
}
[dir="rtl"] .sh-arrow-icon[data-direction="forward"] {
transform: scaleX(-1);
}
[dir="rtl"] .sh-arrow-icon[data-direction="backward"] {
transform: none;
}
}
@@ -0,0 +1,32 @@
import "./ArrowIcon.css";
export type ArrowIconDirection = "forward" | "backward";
export type ArrowIconProps = {
/**
* `forward` follows reading order - it points right in LTR and left in RTL,
* which is what the Arabic home hero does. `backward` is the opposite, for
* "previous" affordances such as pagination.
*/
direction?: ArrowIconDirection;
className?: string;
};
/**
* The arrow that rides along with the CTA label ("Discover SHARP-ER ->").
*
* Purely decorative: the label next to it carries the meaning, so the element
* is hidden from assistive technology. It paints in `currentColor`, so it takes
* the colour of whatever button or link it sits in with no props.
*/
export function ArrowIcon({ direction = "forward", className }: ArrowIconProps) {
return (
<span
aria-hidden="true"
data-direction={direction}
className={className ? `sh-arrow-icon ${className}` : "sh-arrow-icon"}
>
<span className="sh-arrow-icon__glyph" />
</span>
);
}
@@ -0,0 +1,247 @@
/* ============================================================================
BUTTON - figma frame 14:487 (Variant x Size x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens are referenced here. The `--sh-btn-*` custom
properties are component-local plumbing: each variant block binds them to a
tier-2 token once, and the state rules below read them, so the whole 5x3x3
matrix is expressed as five small blocks instead of forty-five selectors.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`w-full`, `mt-lg`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
HEIGHTS. Figma draws the stroke *inside* the frame, so a Secondary button is
the same height as a Primary one. Borders are therefore subtracted from the
padding, which lands every size on its designed box:
sm 6.5*2 + 20 + 1.5*2 = 36 md 10.5*2 + 24 + 1.5*2 = 48
lg 10.5*2 + 28 + 1.5*2 = 52
The 20/24/28 line-heights come from the type role the component puts on the
element (body-sm-bold / body-md-bold / body-lg-bold).
DEVIATIONS FROM FIGMA, deliberate:
* Focus-visible is not drawn in Figma at all. WCAG 2.4.7 requires it, so the
semantic focus ring is applied here; filled variants switch to the inverse
ring colour because a green ring on a green fill is invisible.
* "Ghost / Large / Default" is the one cell in the matrix with no padding
while its own Hover and Disabled cells have it - an authoring slip that
would make the button jump on hover. Padding is kept on all ghost states.
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
Figma's 40% opacity, so the page never shows through a disabled control.
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Base - shared by every variant and size.
A one-cell grid stacks the label and the loading spinner without either
transforms or absolute offsets, so it stays correct under dir="rtl".
------------------------------------------------------------------ */
.sh-btn {
display: inline-grid;
grid-template-areas: "sh-btn-stack";
place-items: center;
border-style: solid;
border-width: var(--control-border-width);
border-radius: var(--radius-control);
background-color: var(--sh-btn-bg);
color: var(--sh-btn-fg);
border-color: var(--sh-btn-border);
text-align: center;
text-decoration: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-btn--block {
display: grid;
inline-size: 100%;
}
.sh-btn__content {
grid-area: sh-btn-stack;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--control-gap);
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
/* The 24px box the icon is drawn inside in Figma. Kept on the slot rather
than on the icon so a consumer's own glyph inherits the same geometry. */
.sh-btn__icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
}
/* ---------------------------------------------------------------------
Sizes - Figma: sm 16/8 (space.md/space.xs), md + lg 24/12 (space.lg/sm)
------------------------------------------------------------------ */
.sh-btn[data-size="sm"] {
padding-inline: calc(var(--control-padding-inline-sm) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-sm) - var(--control-border-width));
}
.sh-btn[data-size="md"] {
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
}
.sh-btn[data-size="lg"] {
padding-inline: calc(var(--control-padding-inline-lg) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-lg) - var(--control-border-width));
}
/* ---------------------------------------------------------------------
Variants - the only place a token name appears twice.
------------------------------------------------------------------ */
.sh-btn[data-variant="primary"] {
--sh-btn-bg: var(--interactive-primary-bg);
--sh-btn-bg-hover: var(--interactive-primary-bg-hover);
--sh-btn-bg-active: var(--interactive-primary-bg-active);
--sh-btn-bg-disabled: var(--interactive-primary-bg-disabled);
--sh-btn-fg: var(--interactive-primary-fg);
--sh-btn-fg-hover: var(--interactive-primary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-primary-fg-disabled);
--sh-btn-border: var(--interactive-primary-border);
--sh-btn-border-hover: var(--interactive-primary-border-hover);
--sh-btn-border-disabled: var(--interactive-primary-border-disabled);
}
.sh-btn[data-variant="secondary"] {
--sh-btn-bg: var(--interactive-secondary-bg);
--sh-btn-bg-hover: var(--interactive-secondary-bg-hover);
--sh-btn-bg-active: var(--interactive-secondary-bg-active);
--sh-btn-bg-disabled: var(--interactive-secondary-bg-disabled);
--sh-btn-fg: var(--interactive-secondary-fg);
--sh-btn-fg-hover: var(--interactive-secondary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-secondary-fg-disabled);
--sh-btn-border: var(--interactive-secondary-border);
--sh-btn-border-hover: var(--interactive-secondary-border-hover);
--sh-btn-border-disabled: var(--interactive-secondary-border-disabled);
}
.sh-btn[data-variant="dark"] {
--sh-btn-bg: var(--interactive-dark-bg);
--sh-btn-bg-hover: var(--interactive-dark-bg-hover);
--sh-btn-bg-active: var(--interactive-dark-bg-active);
--sh-btn-bg-disabled: var(--interactive-dark-bg-disabled);
--sh-btn-fg: var(--interactive-dark-fg);
--sh-btn-fg-hover: var(--interactive-dark-fg-hover);
--sh-btn-fg-disabled: var(--interactive-dark-fg-disabled);
--sh-btn-border: var(--interactive-dark-border);
--sh-btn-border-hover: var(--interactive-dark-border-hover);
--sh-btn-border-disabled: var(--interactive-dark-border-disabled);
}
.sh-btn[data-variant="danger"] {
--sh-btn-bg: var(--interactive-danger-bg);
--sh-btn-bg-hover: var(--interactive-danger-bg-hover);
--sh-btn-bg-active: var(--interactive-danger-bg-active);
--sh-btn-bg-disabled: var(--interactive-danger-bg-disabled);
--sh-btn-fg: var(--interactive-danger-fg);
--sh-btn-fg-hover: var(--interactive-danger-fg-hover);
--sh-btn-fg-disabled: var(--interactive-danger-fg-disabled);
--sh-btn-border: var(--interactive-danger-border);
--sh-btn-border-hover: var(--interactive-danger-border-hover);
--sh-btn-border-disabled: var(--interactive-danger-border-disabled);
}
.sh-btn[data-variant="ghost"] {
--sh-btn-bg: var(--interactive-ghost-bg);
--sh-btn-bg-hover: var(--interactive-ghost-bg-hover);
--sh-btn-bg-active: var(--interactive-ghost-bg-active);
--sh-btn-bg-disabled: var(--interactive-ghost-bg-disabled);
--sh-btn-fg: var(--interactive-ghost-fg);
--sh-btn-fg-hover: var(--interactive-ghost-fg-hover);
--sh-btn-fg-disabled: var(--interactive-ghost-fg-disabled);
--sh-btn-border: var(--interactive-ghost-border);
--sh-btn-border-hover: var(--interactive-ghost-border-hover);
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
}
/* Figma gives the ghost md/lg cells radius.md while every other cell uses
radius.sm - reproduced rather than normalised. */
.sh-btn[data-variant="ghost"][data-size="md"],
.sh-btn[data-variant="ghost"][data-size="lg"] {
border-radius: var(--radius-control-lg);
}
/* ---------------------------------------------------------------------
States
------------------------------------------------------------------ */
@media (hover: hover) {
.sh-btn:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-hover);
color: var(--sh-btn-fg-hover);
border-color: var(--sh-btn-border-hover);
}
}
/* No `-fg-active` / `-border-active` exists in the token contract, so the
pressed state deepens the fill only. */
.sh-btn:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-active);
}
.sh-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* A green ring on a green (or red, or near-black) fill reads as nothing. */
.sh-btn[data-variant="primary"]:focus-visible,
.sh-btn[data-variant="danger"]:focus-visible,
.sh-btn[data-variant="dark"]:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
.sh-btn:disabled,
.sh-btn[aria-disabled="true"] {
background-color: var(--sh-btn-bg-disabled);
color: var(--sh-btn-fg-disabled);
border-color: var(--sh-btn-border-disabled);
cursor: not-allowed;
}
/* ---------------------------------------------------------------------
Loading - the label keeps its box (no reflow) and stays in the
accessibility tree, which `visibility: hidden` would not allow.
------------------------------------------------------------------ */
.sh-btn[data-loading="true"] {
cursor: progress;
}
.sh-btn[data-loading="true"] .sh-btn__content {
opacity: 0;
}
.sh-btn__spinner {
grid-area: sh-btn-stack;
inline-size: 1em;
block-size: 1em;
border: var(--control-border-width) solid currentColor;
border-block-start-color: transparent;
border-radius: var(--radius-pill);
/* The contract publishes fast/base/slow only; two slows is the ~640ms a
spinner wants without inventing a duration token. */
animation: sh-btn-spin calc(var(--motion-duration-slow) * 2) linear infinite;
}
}
@keyframes sh-btn-spin {
to {
rotate: 1turn;
}
}
@@ -0,0 +1,151 @@
import type { ComponentPropsWithRef, ElementType, ReactNode, SyntheticEvent } from "react";
import "./Button.css";
export type ButtonVariant = "primary" | "secondary" | "dark" | "danger" | "ghost";
/** Figma calls these Small / Medium / Large; they resolve to 36 / 48 / 52px. */
export type ButtonSize = "sm" | "md" | "lg";
/**
* Each size owns a published type role rather than a raw font-size, and the
* role's line-height is what makes the button land on its designed height.
*/
const TYPE_ROLE: Record<ButtonSize, string> = {
sm: "body-sm-bold",
md: "body-md-bold",
lg: "body-lg-bold",
};
type ButtonOwnProps = {
variant?: ButtonVariant;
size?: ButtonSize;
/** Rendered in the inline-start 24px slot - the left in LTR, the right in RTL. */
iconStart?: ReactNode;
/** Rendered in the inline-end slot. Pass `<ArrowIcon />` for the CTA arrow. */
iconEnd?: ReactNode;
/** Keeps the label's box, overlays a spinner and sets `aria-busy`. */
isLoading?: boolean;
disabled?: boolean;
/** Stretch to the width of the parent (`display: grid` instead of inline). */
fullWidth?: boolean;
className?: string;
children?: ReactNode;
};
export type ButtonProps<E extends ElementType = "button"> = ButtonOwnProps & {
/**
* The element to render. Defaults to `button`; pass react-router's `Link`
* (plus its `to`) for navigation, or `"a"` for an external href. Props of the
* chosen element are type-checked, so `as={Link}` requires `to`.
*/
as?: E;
} & Omit<ComponentPropsWithRef<E>, keyof ButtonOwnProps | "as">;
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
function blockActivation(event: SyntheticEvent): void {
event.preventDefault();
event.stopPropagation();
}
/**
* The SHARP-ER button - figma frame 14:487.
*
* Polymorphic: renders a native `<button>` by default and anything else through
* `as`. `ref` is an ordinary prop in React 19, so it travels with the rest of
* the element props and lands on whichever element `as` selected.
*/
export function Button<E extends ElementType = "button">({
as,
variant = "primary",
size = "md",
iconStart,
iconEnd,
isLoading = false,
disabled = false,
fullWidth = false,
className,
children,
...rest
}: ButtonProps<E>) {
const Component = (as ?? "button") as ElementType;
const isNativeButton = Component === "button";
const isDisabled = disabled || isLoading;
const elementProps = rest as Record<string, unknown>;
/**
* A native button gets the real `disabled` attribute. Anything else (a Link,
* an anchor) has no such attribute, so it is taken out of the tab order and
* its activation is swallowed instead. Both forms carry `aria-disabled`.
*/
const stateProps: Record<string, unknown> = isNativeButton
? {
// Default to a plain button so a button inside a form never submits it
// by accident; pass type="submit" explicitly when that is the intent.
type: (elementProps.type as string | undefined) ?? "button",
disabled: isDisabled,
}
: isDisabled
? { tabIndex: -1, onClick: blockActivation }
: {};
return (
<Component
{...elementProps}
{...stateProps}
data-variant={variant}
data-size={size}
data-loading={isLoading ? "true" : undefined}
aria-busy={isLoading || undefined}
aria-disabled={isDisabled || undefined}
className={cx("sh-btn", TYPE_ROLE[size], fullWidth && "sh-btn--block", className)}
>
<span className="sh-btn__content">
{iconStart ? <span className="sh-btn__icon">{iconStart}</span> : null}
<span className="sh-btn__label">{children}</span>
{iconEnd ? <span className="sh-btn__icon">{iconEnd}</span> : null}
</span>
{isLoading ? <span className="sh-btn__spinner" aria-hidden="true" /> : null}
</Component>
);
}
/* ----------------------------------------------------------------------------
USAGE (kept as a comment - the project has no Storybook)
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
// The home hero CTA, Home EN 253:4799 / Home AR 85:5029.
// The arrow sits inline-end, so RTL moves it to the left and mirrors it.
<Button as={Link} to="/en/about" size="lg" iconEnd={<ArrowIcon />}>
Discover SHARP-ER
</Button>
// Every variant, all three sizes.
<Button variant="primary">Apply</Button>
<Button variant="secondary" size="sm">Read more</Button>
<Button variant="dark" size="lg">Download the report</Button>
<Button variant="danger">Withdraw application</Button>
<Button variant="ghost" iconEnd={<ArrowIcon />}>All news</Button>
// Submitting a form: aria-busy is set and the label keeps its box.
<Button type="submit" isLoading={isSubmitting} fullWidth>Send message</Button>
// Disabled - aria-disabled on both the native and the link form.
<Button disabled>Applications closed</Button>
<Button as={Link} to="/en/events" disabled>Registration closed</Button>
// A "previous" control, mirrored the other way.
<Button variant="ghost" iconStart={<ArrowIcon direction="backward" />}>
Previous
</Button>
// An external link keeps anchor semantics and prop typing.
<Button as="a" href="https://europa.eu" target="_blank" rel="noreferrer">
European Union
</Button>
------------------------------------------------------------------------- */
@@ -0,0 +1,4 @@
export { Button } from "./Button";
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
export { ArrowIcon } from "./ArrowIcon";
export type { ArrowIconDirection, ArrowIconProps } from "./ArrowIcon";