mirror of
https://github.com/XRPLF/xrpl-dev-portal.git
synced 2026-09-27 15:37:57 +00:00
224 lines
7.6 KiB
TypeScript
224 lines
7.6 KiB
TypeScript
import React from 'react';
|
|
import clsx from 'clsx';
|
|
import { Link } from '@redocly/theme/components/Link/Link';
|
|
import { XrplArrowInternalLinkIcon, LoaderIcon } from '../Icons';
|
|
|
|
/**
|
|
* Button — built from the Figma-derived specification.
|
|
*
|
|
* Source: github.com/samiamdesigns/pd-xrpl-developer-docs
|
|
* components/button.md the axes and the reasoning
|
|
* components/button.json every value resolved per mode
|
|
* components/button-examples.md the acceptance checklist
|
|
*
|
|
* See Button.md for how this differs from the current Button, and for
|
|
* the two places the spec is deliberately not followed.
|
|
*/
|
|
|
|
export type ButtonEmphasis = 'strong' | 'standard' | 'subtle';
|
|
export type ButtonIntention = 'brand' | 'neutral';
|
|
export type ButtonContext = 'on-theme' | 'on-inverse' | 'on-saturated';
|
|
|
|
/**
|
|
* The styling axes, as a union rather than three independent enums.
|
|
*
|
|
* `neutral` + `on-saturated` is absent because it has no tokens —
|
|
* `mode-color.neutral.on-saturated` is not in the set, since nothing on a solid
|
|
* brand block is neutral-coloured. Expressing the axes this way makes that
|
|
* combination fail to compile instead of failing at runtime, so you do not have
|
|
* to open Figma to find out which combinations are real.
|
|
*
|
|
* Note: button.md prints these members with `intention` required, which would
|
|
* reject `<Button>Get started</Button>` — the zero-prop default
|
|
* its own examples show. button.json records `"default": "brand"`, so the
|
|
* examples and the JSON agree and the printed union is the outlier. `intention`
|
|
* is optional here. Reported upstream.
|
|
*/
|
|
export type ButtonSurface =
|
|
// brand on an ordinary page (the default)
|
|
| { intention?: 'brand'; context?: 'on-theme' }
|
|
// brand on an inverted block, or on solid brand green
|
|
| { intention?: 'brand'; context: 'on-inverse' | 'on-saturated' }
|
|
// neutral — no on-saturated group exists
|
|
| { intention: 'neutral'; context?: 'on-theme' | 'on-inverse' };
|
|
|
|
/**
|
|
* Emphasis is orthogonal: all three exist in every group, so it is intersected
|
|
* across the union rather than repeated in each member. Keeping the pair
|
|
* separable also lets a wrapper forward the surface as one value — spreading
|
|
* two independently-typed variables would lose the correlation the union
|
|
* exists to enforce.
|
|
*/
|
|
type ButtonVariant = ButtonSurface & { emphasis?: ButtonEmphasis };
|
|
|
|
export type ButtonProps = ButtonVariant & {
|
|
/** Visible label. Always required. */
|
|
children: React.ReactNode;
|
|
/** Decorative, aria-hidden. Not used in current XRPL designs. */
|
|
iconStart?: React.ReactNode;
|
|
/** Decorative. Defaults to the XRPL internal-link arrow; hidden while loading. */
|
|
iconEnd?: React.ReactNode;
|
|
/** Suppress the default trailing arrow entirely. */
|
|
hideIconEnd?: boolean;
|
|
/** Action in flight: aria-busy, activation suppressed, indicator shown. */
|
|
loading?: boolean;
|
|
/** Non-interactive but still focusable and still in the accessibility tree. */
|
|
inactive?: boolean;
|
|
/** Native disabled. Leaves the tab order. */
|
|
disabled?: boolean;
|
|
/** Renders an <a> instead of a <button>. */
|
|
href?: string;
|
|
target?: '_self' | '_blank';
|
|
className?: string;
|
|
} & Omit<
|
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
'disabled' | 'children'
|
|
>;
|
|
|
|
/**
|
|
* intention and context select ONE token group, because that is how the token
|
|
* set groups them — `brand-onInverse` is a group, not brand plus a modifier.
|
|
* kebab-cased here; the camelCase in the source files is a find-and-replace aid
|
|
* there and is deliberately not normalised upstream.
|
|
*/
|
|
const groupClass = (intention: ButtonIntention, context: ButtonContext) =>
|
|
context === 'on-theme' ? intention : `${intention}-${context}`;
|
|
|
|
export const Button: React.FC<ButtonProps> = ({
|
|
intention = 'brand',
|
|
context = 'on-theme',
|
|
emphasis = 'strong',
|
|
children,
|
|
iconStart,
|
|
iconEnd,
|
|
hideIconEnd = false,
|
|
loading = false,
|
|
inactive = false,
|
|
disabled = false,
|
|
href,
|
|
target = '_self',
|
|
className,
|
|
onClick,
|
|
type = 'button',
|
|
...rest
|
|
}) => {
|
|
const classNames = clsx(
|
|
'bds-btn',
|
|
`bds-btn--${groupClass(intention, context)}`,
|
|
`bds-btn--${emphasis}`,
|
|
// The context class carries the focus-ring colour and the disabled group,
|
|
// both of which are chosen by context alone.
|
|
`bds-btn--${context}`,
|
|
{
|
|
'bds-btn--loading': loading,
|
|
'bds-btn--disabled': disabled,
|
|
},
|
|
className
|
|
);
|
|
|
|
/**
|
|
* Three non-interactive states, three code paths. They resolve identical
|
|
* colours in several combinations, so appearance will not tell you which one
|
|
* you built — what distinguishes them is behaviour.
|
|
*
|
|
* element ARIA tab order activates
|
|
* loading <button> aria-busy in no (suppressed)
|
|
* inactive <button> aria-disabled in no (suppressed)
|
|
* disabled <button> native disabled OUT no (native)
|
|
*
|
|
* Collapsing inactive into disabled removes it from the tab order, and a
|
|
* screen-reader user can then no longer find it.
|
|
*/
|
|
const suppressed = loading || inactive;
|
|
|
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (suppressed) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
onClick?.(event);
|
|
};
|
|
|
|
// The loader replacing icon-end in Figma is representation, not contract —
|
|
// the spec explicitly allows toggling visibility instead. The glyph is a
|
|
// placeholder in the design; LoaderIcon is our choice and is reportable back.
|
|
// It spins on its own — the button neither starts nor configures it.
|
|
const trailing = loading ? (
|
|
<span className="bds-btn__icon" aria-hidden="true">
|
|
<LoaderIcon />
|
|
</span>
|
|
) : hideIconEnd ? null : (
|
|
<span className="bds-btn__icon" aria-hidden="true">
|
|
{iconEnd ?? <XrplArrowInternalLinkIcon />}
|
|
</span>
|
|
);
|
|
|
|
// No focus-indicator element and no slot element. Both are Figma authoring
|
|
// devices; building either ships a stray node that no token can remove.
|
|
const content = (
|
|
<>
|
|
{iconStart && (
|
|
<span className="bds-btn__icon" aria-hidden="true">
|
|
{iconStart}
|
|
</span>
|
|
)}
|
|
<span className="bds-btn__label">{children}</span>
|
|
{trailing}
|
|
</>
|
|
);
|
|
|
|
// disabled always renders a <button>, even with href, so there is nothing to
|
|
// navigate to.
|
|
if (href && !disabled && !suppressed) {
|
|
return (
|
|
<Link
|
|
to={href}
|
|
target={target}
|
|
className={classNames}
|
|
// Redocly's LinkProps narrows onClick to `() => void`. It spreads
|
|
// straight onto react-router's Link, which forwards the event, so a
|
|
// handler taking one still receives it. Only reached when not
|
|
// suppressed, so no event guarding depends on this.
|
|
onClick={onClick as unknown as (() => void) | undefined}
|
|
>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// A suppressed link keeps anchor semantics and stays focusable, but drops
|
|
// href entirely rather than relying on preventDefault — so neither a click
|
|
// nor Enter can navigate, and no handler has to hold the line. This is what
|
|
// "non-interactive but still in the accessibility tree" means for an <a>.
|
|
if (href && !disabled) {
|
|
return (
|
|
<a
|
|
role="link"
|
|
tabIndex={0}
|
|
className={classNames}
|
|
aria-busy={loading || undefined}
|
|
aria-disabled="true"
|
|
>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
{...rest}
|
|
type={type}
|
|
className={classNames}
|
|
onClick={handleClick}
|
|
disabled={disabled}
|
|
aria-busy={loading || undefined}
|
|
aria-disabled={inactive || undefined}
|
|
>
|
|
{content}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export default Button;
|