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,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>
------------------------------------------------------------------------- */