Button
v0.5.0StableA flexible button component with variants, sizes, loading and disabled states.
registry/behivetech/atoms/button
button.tsx
1import { type ButtonHTMLAttributes } from "react";2import { Slot } from "@radix-ui/react-slot";3import { getClassName } from "@bht-component-registry/class-names";4import styles from "./button.module.scss";56export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";7export type ButtonSize = "sm" | "md" | "lg";89export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {10 /** Controls the color/style treatment; defaults to primary */11 variant?: ButtonVariant;12 /** Controls the size; defaults to md */13 size?: ButtonSize;14 /** Shows a spinner and disables the button while an action is in flight */15 loading?: boolean;16 /** Renders the button as a square icon-only control with no visible label spacing */17 iconOnly?: boolean;18 /** Additional class names to merge with the component root element */19 className?: string;20 /** Button label/content */21 children?: React.ReactNode;22 /** Renders the button's props onto its single child element (via Radix `Slot`) instead of a `<button>` */23 asChild?: boolean;24}2526/**27 * A button with `variant`, `size`, `loading` and `iconOnly` treatments.28 *29 * **Styling hook:** the `ghost` and `secondary` (outlined) variants' label30 * colour is read from the `--button-label-color` custom property (default: the31 * `primary` role). A surface where `primary` falls under 4.5:1, such as Dark32 * `surface-container-high` in the-behive's Dark tokens (4.02:1), sets it on its33 * own class, e.g. `--button-label-color: var(--md-sys-color-secondary)`. Inside34 * a `Modal` or `Drawer` set it through their `className` or on a class inside35 * the body: their content portals to `body`, so a wrapper around them never36 * reaches the buttons. `primary` and `danger` ignore it.37 */38export const Button = ({39 variant = "primary",40 size = "md",41 loading = false,42 iconOnly = false,43 disabled,44 className,45 children,46 asChild = false,47 ...rest48}: ButtonProps) => {49 const [rootClass, getChildClass] = getClassName({50 className,51 rootClass: "BHT__Button",52 modifiers: {53 [variant]: true,54 [size]: true,55 loading,56 disabled: !!disabled,57 "icon-only": iconOnly,58 },59 styles,60 });6162 const Comp = (asChild ? Slot : "button") as React.ElementType;6364 return (65 <Comp66 {...rest}67 className={rootClass}68 disabled={disabled || loading}69 aria-busy={loading || undefined}70 >71 {asChild ? (72 children73 ) : (74 <>75 {loading && (76 <span className={getChildClass("spinner")} aria-hidden="true" />77 )}78 <span className={getChildClass("label")}>{children}</span>79 </>80 )}81 </Comp>82 );83};
button.module.scss
1// Material Design 3 button.2//3// The public API is unchanged — `variant` × `size`, plus `loading`, `iconOnly`4// and `asChild`. What changed is that every value now comes from an5// `--md-sys-*` token, so the component re-themes with the palette instead of6// carrying a brand of its own.7//8// Variant names map onto MD3's button types rather than being renamed, because9// the prop is part of this package's published API:10//11// primary -> Filled secondary -> Outlined12// ghost -> Text danger -> Filled, on the error role13//14// Each color falls back to the pre-MD3 `--color-*` name, which15// `cms.base-styles` aliases to the matching MD3 role. That keeps this working16// against an older base-styles instead of rendering unstyled.17//18// **Structure.** Modifiers belong to the root only — a child never carries19// `--sm`. Inside a modifier block, `&.BHT__Button` is the root element20// (compound) and `.BHT__Button` without the `&` is a descendant (a child node).21// The ghost and secondary (outlined) variants' label colour is the one public22// styling hook: `--button-label-color` (default `primary`).23// Pseudo-class blocks (`&:hover`, `&:disabled`) are states rather than24// modifiers and stay unwrapped.2526.BHT__Button {27 // `isolation` keeps the state layer's stacking context local, so a button28 // inside a positioned parent can't have its ::after escape behind a sibling.29 position: relative;30 isolation: isolate;31 display: inline-flex;32 align-items: center;33 justify-content: center;34 gap: var(--spacing-2, 0.5rem);35 border: 1px solid transparent;36 // MD3 buttons are pill-shaped. This is the single most visible change from37 // the previous styling, which used the 6px `--radius-md`.38 border-radius: var(--md-sys-shape-corner-full, 9999px);39 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));40 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);41 font-weight: var(--md-sys-typescale-label-large-weight, 500);42 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);43 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);44 cursor: pointer;45 white-space: nowrap;46 text-decoration: none;47 user-select: none;48 transition:49 background var(--md-sys-motion-duration-short-3, 150ms)50 var(--md-sys-motion-easing-standard, ease),51 color var(--md-sys-motion-duration-short-3, 150ms)52 var(--md-sys-motion-easing-standard, ease),53 border-color var(--md-sys-motion-duration-short-3, 150ms)54 var(--md-sys-motion-easing-standard, ease);5556 // --- Children ------------------------------------------------------------5758 &__label {59 display: inline-flex;60 align-items: center;61 }6263 &__spinner {64 display: inline-block;65 width: 1em;66 height: 1em;67 border: 2px solid currentColor;68 border-top-color: transparent;69 border-radius: var(--md-sys-shape-corner-full, 9999px);70 animation: button-spin 0.6s linear infinite;71 }7273 // --- State layer ---------------------------------------------------------74 //75 // **This replaces six hardcoded brand colors and two `filter: brightness()`76 // rules, and it is the whole reason the component re-themes.**77 //78 // MD3 expresses interaction as a translucent veil of the *content* color over79 // the container. `currentColor` is already that content color in every80 // variant — `on-primary` on a filled button, `--button-label-color` (default81 // `primary`) on an outlined or text one — so one rule serves all four82 // variants and follows the palette for free. The old `rgba(247, 179, 52, 0.1)`83 // was a fixed amber that stayed amber no matter what `--color-primary` was84 // set to, and `filter: brightness()` lightens on a dark surface and washes85 // out on a light one.86 &::after {87 content: "";88 position: absolute;89 inset: 0;90 border-radius: inherit;91 background: currentColor;92 opacity: 0;93 pointer-events: none;94 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)95 var(--md-sys-motion-easing-standard, ease);96 }9798 &:hover:not(:disabled)::after {99 opacity: var(--md-sys-state-hover-opacity, 0.08);100 }101102 &:focus-visible::after {103 opacity: var(--md-sys-state-focus-opacity, 0.1);104 }105106 &:active:not(:disabled)::after {107 opacity: var(--md-sys-state-pressed-opacity, 0.1);108 }109110 // --- Touch target --------------------------------------------------------111 //112 // **The component previously had no minimum target, and `sm` (32px) and the113 // default `md` (40px) both sat under the 48dp floor.** Rather than inflating114 // the visual size — which would reflow every layout already built on these115 // buttons — an invisible pseudo-element extends the *hit area* to 48px while116 // the button keeps its drawn height.117 //118 // Deliberately px, not rem. A touch target is a physical property of the119 // finger, not of the type size, so it must not shrink when a reader sets a120 // smaller font.121 &::before {122 content: "";123 position: absolute;124 left: 0;125 right: 0;126 top: 50%;127 translate: 0 -50%;128 height: 100%;129 min-height: 48px;130 }131132 // --- Focus ---------------------------------------------------------------133 //134 // There was no focus style at all before this: a keyboard user had no idea135 // which control they were on. `:focus-visible` rather than `:focus` so a136 // pointer click doesn't leave a ring behind.137 &:focus-visible {138 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));139 outline-offset: 2px;140 }141142 // --- Size modifiers ------------------------------------------------------143 //144 // Heights are unchanged, so existing layouts keep their rhythm; the 48px145 // floor above is what makes the two smaller ones accessible.146 &--sm {147 &.BHT__Button {148 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);149 min-height: 2rem;150 }151 }152153 &--md {154 &.BHT__Button {155 padding: var(--spacing-2, 0.5rem) var(--spacing-5, 1.25rem);156 min-height: 2.5rem;157 }158 }159160 &--lg {161 &.BHT__Button {162 font-size: var(--md-sys-typescale-title-medium-size, 1rem);163 padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);164 min-height: 3rem;165 }166 }167168 // --- Variant modifiers ---------------------------------------------------169170 &--primary {171 &.BHT__Button {172 background: var(--md-sys-color-primary, var(--color-primary));173 color: var(--md-sys-color-on-primary, var(--color-on-primary));174 border-color: transparent;175 }176 }177178 &--secondary {179 &.BHT__Button {180 background: transparent;181 color: var(182 --button-label-color,183 var(--md-sys-color-primary, var(--color-primary))184 );185 border-color: var(--md-sys-color-outline, var(--color-border-strong));186 }187 }188189 // The ghost and secondary labels read `--button-label-color` so a raised190 // surface that can't take a `primary` label (4.02:1 on the-behive's Dark191 // `surface-container-high`, under the 4.5:1 small-text rule) re-themes it192 // from its own class, the way Modal re-themes Card through `--card-border`,193 // instead of selecting into these classes.194 &--ghost {195 &.BHT__Button {196 background: transparent;197 color: var(198 --button-label-color,199 var(--md-sys-color-primary, var(--color-primary))200 );201 border-color: transparent;202 }203 }204205 &--danger {206 &.BHT__Button {207 background: var(--md-sys-color-error, var(--color-error));208 color: var(--md-sys-color-on-error, #ffffff);209 border-color: transparent;210 }211 }212213 // --- Icon-only -----------------------------------------------------------214 //215 // The width rules previously read `.Button--sm` and the label rule216 // `.Button__label`, both missing the `BHT__` the markup actually carries, so217 // every rule in this block matched nothing and icon-only buttons had no218 // width at all.219 &--icon-only {220 &.BHT__Button {221 padding: 0;222223 &--sm {224 width: 2rem;225 }226227 &--md {228 width: 2.5rem;229 }230231 &--lg {232 width: 3rem;233 }234 }235236 .BHT__Button {237 &__label {238 display: inline-flex;239 align-items: center;240 justify-content: center;241 }242 }243 }244245 // --- Disabled / loading --------------------------------------------------246 //247 // MD3 expresses disabled as 38% of the content color on a 12% container248 // rather than a blanket opacity, but a flat `opacity` is what the rest of249 // this library uses and changing it here alone would make Button the odd one250 // out. Worth revisiting across the set, not in isolation.251 &--disabled {252 &.BHT__Button {253 opacity: 0.45;254 cursor: not-allowed;255 pointer-events: none;256 }257 }258259 &:disabled {260 opacity: 0.45;261 cursor: not-allowed;262 pointer-events: none;263 }264265 &--loading {266 &.BHT__Button {267 cursor: wait;268 pointer-events: none;269 }270 }271}272273@keyframes button-spin {274 to {275 transform: rotate(360deg);276 }277}
index.ts
1export { Button } from "./button.js";2export type { ButtonProps, ButtonVariant, ButtonSize } from "./button.js";
button.example.tsx
1import { Button } from "./button.js";23export const PrimaryButton = () => <Button variant="primary">Primary</Button>;4export const SecondaryButton = () => (5 <Button variant="secondary">Secondary</Button>6);7export const GhostButton = () => <Button variant="ghost">Ghost</Button>;8export const DangerButton = () => <Button variant="danger">Danger</Button>;9export const SmallButton = () => <Button size="sm">Small</Button>;10export const LargeButton = () => <Button size="lg">Large</Button>;11export const LoadingButton = () => <Button loading>Loading</Button>;12export const DisabledButton = () => <Button disabled>Disabled</Button>;13export const IconOnlyButton = () => (14 <Button iconOnly aria-label="Delete" variant="danger">15 ✕16 </Button>17);
button.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Button } from "./button.js";45describe("Button", () => {6 it("renders children", () => {7 render(<Button>Click me</Button>);8 expect(screen.getByText("Click me")).toBeTruthy();9 });1011 it("is disabled when disabled prop is true", () => {12 render(<Button disabled>Disabled</Button>);13 expect(screen.getByRole("button")).toBeDisabled();14 });1516 it("is disabled when loading", () => {17 render(<Button loading>Loading</Button>);18 expect(screen.getByRole("button")).toBeDisabled();19 });2021 it("calls onClick when clicked", async () => {22 const onClick = vi.fn();23 render(<Button onClick={onClick}>Click me</Button>);24 await userEvent.click(screen.getByRole("button"));25 expect(onClick).toHaveBeenCalledTimes(1);26 });2728 it("does not call onClick when disabled", async () => {29 const onClick = vi.fn();30 render(31 <Button disabled onClick={onClick}>32 Disabled33 </Button>,34 );35 await userEvent.click(screen.getByRole("button"));36 expect(onClick).not.toHaveBeenCalled();37 });3839 it("applies the icon-only modifier class", () => {40 render(41 <Button iconOnly aria-label="Delete">42 ✕43 </Button>,44 );45 expect(screen.getByRole("button").className).toContain("icon-only");46 });4748 it("renders its single child directly via asChild instead of a <button>", () => {49 render(50 <Button asChild>51 <a href="/somewhere">Go</a>52 </Button>,53 );54 const link = screen.getByRole("link", { name: "Go" });55 expect(link).toBeTruthy();56 expect(screen.queryByRole("button")).toBeNull();57 });58});5960describe("Button ghost variant", () => {61 it("renders the ghost modifier that carries the --button-label-color hook", () => {62 render(<Button variant="ghost">Cancel</Button>);63 expect(screen.getByRole("button").className).toContain(64 "BHT__Button--ghost",65 );66 });67});