fleet/frontend/components/buttons/Button/Button.tsx
2026-04-14 09:30:26 -04:00

175 lines
4 KiB
TypeScript

import React from "react";
import classnames from "classnames";
import Spinner from "components/Spinner";
const baseClass = "button";
export type ButtonVariant =
| "default"
| "alert"
| "pill"
| "grey-pill"
| "link" // Looks like CustomLink with animated underline on hover
| "brand-inverse-icon" // Green icon with text, no underline on hover
| "text-icon"
| "icon" // Buttons without text
| "inverse"
| "inverse-alert"
| "unstyled" // Avoid as much as possible (used in registration breadcrumbs, 404/500, an old button dropdown)
| "unstyled-modal-query"
| "oversized";
export interface IButtonProps {
autofocus?: boolean;
children: React.ReactNode;
className?: string;
disabled?: boolean;
tabIndex?: number;
type?: "button" | "submit" | "reset";
/** Text shown on tooltip when hovering over a button */
title?: string;
/** Default: "default" */
variant?: ButtonVariant;
onClick?:
| ((value?: any) => void)
| ((
evt:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => void);
isLoading?: boolean;
customOnKeyDown?: (e: React.KeyboardEvent) => void;
/** Required for buttons that contain SVG icons using`stroke` instead of`fill` for proper hover styling */
iconStroke?: boolean;
ariaHasPopup?:
| boolean
| "false"
| "true"
| "menu"
| "listbox"
| "tree"
| "grid"
| "dialog";
ariaExpanded?: boolean;
ariaLabel?: string;
/** Small: 1/2 the padding, Wide: 200px */
size?: "small" | "wide" | "default";
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface IButtonState {}
interface Inputs {
button?: HTMLButtonElement;
}
class Button extends React.Component<IButtonProps, IButtonState> {
static defaultProps = {
type: "button",
variant: "default",
};
componentDidMount(): void {
const { autofocus } = this.props;
const {
inputs: { button },
} = this;
if (autofocus && button) {
button.focus();
}
}
setRef = (button: HTMLButtonElement): boolean => {
this.inputs.button = button;
return false;
};
inputs: Inputs = {};
handleClick = (evt: React.MouseEvent<HTMLButtonElement>): void => {
const { disabled, onClick } = this.props;
if (disabled) {
return;
}
if (onClick) {
onClick(evt);
}
};
handleKeyDown = (evt: React.KeyboardEvent<HTMLButtonElement>): void => {
const { disabled, onClick } = this.props;
if (disabled || evt.key !== "Enter") {
return;
}
if (onClick) {
onClick(evt as any);
}
};
render(): JSX.Element {
const { handleClick, handleKeyDown, setRef } = this;
const {
children,
className,
disabled,
tabIndex,
type,
title,
variant,
isLoading,
customOnKeyDown,
iconStroke,
ariaHasPopup,
ariaExpanded,
ariaLabel,
size,
} = this.props;
const fullClassName = classnames(
baseClass,
`${baseClass}--${variant}`,
className,
{
[`${baseClass}--${variant}__small`]: size === "small",
[`${baseClass}__wide`]: size === "wide",
[`${baseClass}--disabled`]: disabled,
[`${baseClass}--icon-stroke`]: iconStroke,
}
);
const onWhite =
variant === "link" ||
variant === "inverse" ||
variant === "brand-inverse-icon" ||
variant === "text-icon" ||
variant === "pill" ||
variant === "grey-pill";
return (
<button
className={fullClassName}
disabled={disabled}
onClick={handleClick}
onKeyDown={customOnKeyDown || handleKeyDown}
tabIndex={tabIndex}
type={type}
title={title}
ref={setRef}
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
aria-label={ariaLabel}
>
<div className={isLoading ? "transparent-text" : "children-wrapper"}>
{children}
</div>
{isLoading && <Spinner small button white={!onWhite} />}
</button>
);
}
}
export default Button;