152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
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>
|
|
------------------------------------------------------------------------- */
|