Behive Tech Registry

Button

v0.4.0

A flexible button component with variants, sizes, loading and disabled states.

pnpm add @behivetech/atoms.button

registry/behivetech/atoms/button · depends on @behivetech/get-class-name

button.tsx
1import { type ButtonHTMLAttributes } from "react";
2import { Slot } from "@radix-ui/react-slot";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./button.module.scss";
5
6export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
7export type ButtonSize = "sm" | "md" | "lg";
8
9export 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}
25
26export const Button = ({
27 variant = "primary",
28 size = "md",
29 loading = false,
30 iconOnly = false,
31 disabled,
32 className,
33 children,
34 asChild = false,
35 ...rest
36}: ButtonProps) => {
37 const [rootClass, getChildClass] = getClassName({
38 className,
39 rootClass: "BHT__Button",
40 modifiers: {
41 [variant]: true,
42 [size]: true,
43 loading,
44 disabled: !!disabled,
45 "icon-only": iconOnly,
46 },
47 styles,
48 });
49
50 const Comp = (asChild ? Slot : "button") as React.ElementType;
51
52 return (
53 <Comp
54 {...rest}
55 className={rootClass}
56 disabled={disabled || loading}
57 aria-busy={loading || undefined}
58 >
59 {asChild ? (
60 children
61 ) : (
62 <>
63 {loading && (
64 <span className={getChildClass("spinner")} aria-hidden="true" />
65 )}
66 <span className={getChildClass("label")}>{children}</span>
67 </>
68 )}
69 </Comp>
70 );
71};
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 an
5// `--md-sys-*` token, so the component re-themes with the palette instead of
6// carrying a brand of its own.
7//
8// Variant names map onto MD3's button types rather than being renamed, because
9// the prop is part of this package's published API:
10//
11// primary -> Filled secondary -> Outlined
12// ghost -> Text danger -> Filled, on the error role
13//
14// Each color falls back to the pre-MD3 `--color-*` name, which
15// `theme.base-styles` aliases to the matching MD3 role. That keeps this working
16// against an older base-styles instead of rendering unstyled.
17//
18// **Structure.** Modifiers belong to the root only — a child never carries
19// `--sm`. Inside a modifier block, `&.BHT__Button` is the root element
20// (compound) and `.BHT__Button` without the `&` is a descendant (a child node).
21// Pseudo-class blocks (`&:hover`, `&:disabled`) are states rather than
22// modifiers and stay unwrapped.
23
24.BHT__Button {
25 // `isolation` keeps the state layer's stacking context local, so a button
26 // inside a positioned parent can't have its ::after escape behind a sibling.
27 position: relative;
28 isolation: isolate;
29 display: inline-flex;
30 align-items: center;
31 justify-content: center;
32 gap: var(--spacing-2, 0.5rem);
33 border: 1px solid transparent;
34 // MD3 buttons are pill-shaped. This is the single most visible change from
35 // the previous styling, which used the 6px `--radius-md`.
36 border-radius: var(--md-sys-shape-corner-full, 9999px);
37 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
38 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
39 font-weight: var(--md-sys-typescale-label-large-weight, 500);
40 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
41 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
42 cursor: pointer;
43 white-space: nowrap;
44 text-decoration: none;
45 user-select: none;
46 transition:
47 background var(--md-sys-motion-duration-short-3, 150ms)
48 var(--md-sys-motion-easing-standard, ease),
49 color var(--md-sys-motion-duration-short-3, 150ms)
50 var(--md-sys-motion-easing-standard, ease),
51 border-color var(--md-sys-motion-duration-short-3, 150ms)
52 var(--md-sys-motion-easing-standard, ease);
53
54 // --- Children ------------------------------------------------------------
55
56 &__label {
57 display: inline-flex;
58 align-items: center;
59 }
60
61 &__spinner {
62 display: inline-block;
63 width: 1em;
64 height: 1em;
65 border: 2px solid currentColor;
66 border-top-color: transparent;
67 border-radius: var(--md-sys-shape-corner-full, 9999px);
68 animation: button-spin 0.6s linear infinite;
69 }
70
71 // --- State layer ---------------------------------------------------------
72 //
73 // **This replaces six hardcoded brand colors and two `filter: brightness()`
74 // rules, and it is the whole reason the component re-themes.**
75 //
76 // MD3 expresses interaction as a translucent veil of the *content* color over
77 // the container. `currentColor` is already that content color in every
78 // variant — `on-primary` on a filled button, `primary` on an outlined or text
79 // one — so one rule serves all four variants and follows the palette for
80 // free. The old `rgba(247, 179, 52, 0.1)` was a fixed amber that stayed amber
81 // no matter what `--color-primary` was set to, and `filter: brightness()`
82 // lightens on a dark surface and washes out on a light one.
83 &::after {
84 content: "";
85 position: absolute;
86 inset: 0;
87 border-radius: inherit;
88 background: currentColor;
89 opacity: 0;
90 pointer-events: none;
91 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
92 var(--md-sys-motion-easing-standard, ease);
93 }
94
95 &:hover:not(:disabled)::after {
96 opacity: var(--md-sys-state-hover-opacity, 0.08);
97 }
98
99 &:focus-visible::after {
100 opacity: var(--md-sys-state-focus-opacity, 0.1);
101 }
102
103 &:active:not(:disabled)::after {
104 opacity: var(--md-sys-state-pressed-opacity, 0.1);
105 }
106
107 // --- Touch target --------------------------------------------------------
108 //
109 // **The component previously had no minimum target, and `sm` (32px) and the
110 // default `md` (40px) both sat under the 48dp floor.** Rather than inflating
111 // the visual size — which would reflow every layout already built on these
112 // buttons — an invisible pseudo-element extends the *hit area* to 48px while
113 // the button keeps its drawn height.
114 //
115 // Deliberately px, not rem. A touch target is a physical property of the
116 // finger, not of the type size, so it must not shrink when a reader sets a
117 // smaller font.
118 &::before {
119 content: "";
120 position: absolute;
121 left: 0;
122 right: 0;
123 top: 50%;
124 translate: 0 -50%;
125 height: 100%;
126 min-height: 48px;
127 }
128
129 // --- Focus ---------------------------------------------------------------
130 //
131 // There was no focus style at all before this: a keyboard user had no idea
132 // which control they were on. `:focus-visible` rather than `:focus` so a
133 // pointer click doesn't leave a ring behind.
134 &:focus-visible {
135 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
136 outline-offset: 2px;
137 }
138
139 // --- Size modifiers ------------------------------------------------------
140 //
141 // Heights are unchanged, so existing layouts keep their rhythm; the 48px
142 // floor above is what makes the two smaller ones accessible.
143 &--sm {
144 &.BHT__Button {
145 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);
146 min-height: 2rem;
147 }
148 }
149
150 &--md {
151 &.BHT__Button {
152 padding: var(--spacing-2, 0.5rem) var(--spacing-5, 1.25rem);
153 min-height: 2.5rem;
154 }
155 }
156
157 &--lg {
158 &.BHT__Button {
159 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
160 padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);
161 min-height: 3rem;
162 }
163 }
164
165 // --- Variant modifiers ---------------------------------------------------
166
167 &--primary {
168 &.BHT__Button {
169 background: var(--md-sys-color-primary, var(--color-primary));
170 color: var(--md-sys-color-on-primary, var(--color-on-primary));
171 border-color: transparent;
172 }
173 }
174
175 &--secondary {
176 &.BHT__Button {
177 background: transparent;
178 color: var(--md-sys-color-primary, var(--color-primary));
179 border-color: var(--md-sys-color-outline, var(--color-border-strong));
180 }
181 }
182
183 &--ghost {
184 &.BHT__Button {
185 background: transparent;
186 color: var(--md-sys-color-primary, var(--color-primary));
187 border-color: transparent;
188 }
189 }
190
191 &--danger {
192 &.BHT__Button {
193 background: var(--md-sys-color-error, var(--color-error));
194 color: var(--md-sys-color-on-error, #ffffff);
195 border-color: transparent;
196 }
197 }
198
199 // --- Icon-only -----------------------------------------------------------
200 //
201 // The width rules previously read `.Button--sm` and the label rule
202 // `.Button__label`, both missing the `BHT__` the markup actually carries, so
203 // every rule in this block matched nothing and icon-only buttons had no
204 // width at all.
205 &--icon-only {
206 &.BHT__Button {
207 padding: 0;
208
209 &--sm {
210 width: 2rem;
211 }
212
213 &--md {
214 width: 2.5rem;
215 }
216
217 &--lg {
218 width: 3rem;
219 }
220 }
221
222 .BHT__Button {
223 &__label {
224 display: inline-flex;
225 align-items: center;
226 justify-content: center;
227 }
228 }
229 }
230
231 // --- Disabled / loading --------------------------------------------------
232 //
233 // MD3 expresses disabled as 38% of the content color on a 12% container
234 // rather than a blanket opacity, but a flat `opacity` is what the rest of
235 // this library uses and changing it here alone would make Button the odd one
236 // out. Worth revisiting across the set, not in isolation.
237 &--disabled {
238 &.BHT__Button {
239 opacity: 0.45;
240 cursor: not-allowed;
241 pointer-events: none;
242 }
243 }
244
245 &:disabled {
246 opacity: 0.45;
247 cursor: not-allowed;
248 pointer-events: none;
249 }
250
251 &--loading {
252 &.BHT__Button {
253 cursor: wait;
254 pointer-events: none;
255 }
256 }
257}
258
259@keyframes button-spin {
260 to {
261 transform: rotate(360deg);
262 }
263}
index.ts
1export { Button } from "./button.js";
2export type { ButtonProps, ButtonVariant, ButtonSize } from "./button.js";
button.composition.tsx
1import { Button } from "./button.js";
2
3export 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";
4
5describe("Button", () => {
6 it("renders children", () => {
7 render(<Button>Click me</Button>);
8 expect(screen.getByText("Click me")).toBeTruthy();
9 });
10
11 it("is disabled when disabled prop is true", () => {
12 render(<Button disabled>Disabled</Button>);
13 expect(screen.getByRole("button")).toBeDisabled();
14 });
15
16 it("is disabled when loading", () => {
17 render(<Button loading>Loading</Button>);
18 expect(screen.getByRole("button")).toBeDisabled();
19 });
20
21 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 });
27
28 it("does not call onClick when disabled", async () => {
29 const onClick = vi.fn();
30 render(
31 <Button disabled onClick={onClick}>
32 Disabled
33 </Button>,
34 );
35 await userEvent.click(screen.getByRole("button"));
36 expect(onClick).not.toHaveBeenCalled();
37 });
38
39 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 });
47
48 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});