initial commit
This commit is contained in:
@@ -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>
|
||||
------------------------------------------------------------------------- */
|
||||
Reference in New Issue
Block a user