feat(ui): Reusable Button component enhancement#1131
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ghost variant and secondary/danger colors to Button - Wrap with React.forwardRef and extend ButtonHTMLAttributes - Replace defaultProps with destructure parameter defaults - Add barrel export (src/components/ui/index.ts) with @ui alias in tsconfig - Update 4 callsites: cta, contact-form, PlayerSetup, admin-dashboard - Remove dead btnPrimary/btnGhost CSS from cta.module.css - Add JSDoc for variant/color matrix and path→Anchor behavior - Add 21-case test suite covering all spec acceptance criteria - Consolidate planning artifacts to docs/button/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR enhances the shared UI Button component to better support reuse across the app (new ghost variant, new semantic colors, ref/attribute pass-through), adds a @ui barrel export, updates several callsites to use the shared button, and introduces a dedicated Button test suite plus supporting design/spec docs.
Changes:
- Extend
ButtonAPI withghostvariant andsecondary/dangercolors; switch toforwardRefandButtonHTMLAttributes+ rest spread. - Add
@uibarrel export + TS path alias and migrate 4 callsites to the shared Button; remove CTA dead CSS. - Add Button-focused docs and a 21-case
vitesttest suite.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Adds @ui alias for importing from the UI barrel. |
| src/containers/software-factory/cta/index.tsx | Migrates CTA buttons to the shared Button component. |
| src/containers/software-factory/cta/cta.module.css | Removes now-unused CTA button styles. |
| src/components/ui/index.ts | Adds UI barrel export for Button. |
| src/components/ui/button/index.tsx | Implements new variants/colors, forwardRef, and native-attribute pass-through. |
| src/components/game/PlayerSetup.tsx | Replaces raw submit button with shared Button. |
| src/components/forms/contact-form.tsx | Simplifies submit button styling by relying on shared Button defaults. |
| src/components/admin-dashboard-components.tsx | Replaces raw “Add User” button with shared Button. |
| docs/button/tasks.md | Adds task breakdown for the Button enhancement work. |
| docs/button/spec.md | Adds feature spec for Button enhancements and acceptance criteria. |
| docs/button/plan.md | Adds implementation plan and technical notes for the Button work. |
| docs/button/design.md | Adds design rationale and API notes for the Button enhancements. |
| docs/button/delivery.md | Adds delivery/verification plan for the Button enhancement feature. |
| tests/components/ui/button.test.tsx | Adds new test suite covering key Button behaviors and variant/color matrix rendering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| interface ButtonProps | ||
| extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "id" | "onClick"> { | ||
| children: React.ReactNode; | ||
| type?: "button" | "submit" | "reset"; | ||
| variant?: "contained" | "outlined" | "texted"; | ||
| color?: "primary" | "light"; | ||
| variant?: "contained" | "outlined" | "texted" | "ghost"; | ||
| color?: "primary" | "light" | "secondary" | "danger"; | ||
| size?: "xs" | "sm" | "md" | "lg"; | ||
| shape?: "tw-rounded" | "square" | "ellipse"; | ||
| fullwidth?: boolean; | ||
| active?: boolean; | ||
| disabled?: boolean; | ||
| iconButton?: boolean; | ||
| onClick?: () => void; | ||
| className?: string; | ||
| path?: string; | ||
| label?: string; | ||
| hover?: "default" | "light" | false; | ||
| /** string | number to accept data-layer ids (HTML id is string-only) */ | ||
| id?: string | number; | ||
| /** () => void to stay compatible with Anchor's onClick signature */ | ||
| onClick?: () => void; | ||
| } | ||
|
|
||
| const Button = ({ | ||
| children, | ||
| type, | ||
| variant, | ||
| color, | ||
| size, | ||
| shape, | ||
| fullwidth, | ||
| active, | ||
| disabled, | ||
| iconButton, | ||
| label, | ||
| className, | ||
| path, | ||
| onClick, | ||
| hover, | ||
| }: ButtonProps) => { | ||
| const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | ||
| ( | ||
| { | ||
| children, | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| id: _id, | ||
| type = "button", |
There was a problem hiding this comment.
Fixed. id is now destructured, coerced to string via String(id), and explicitly passed to both <button> and <Anchor> as id={domId}. The string | number override is kept since ButtonType.id is typed as IDType = string | number and callsites spread it directly.
| ...rest | ||
| }, | ||
| ref | ||
| ) => { |
There was a problem hiding this comment.
Fixed. ...rest is now spread onto <Anchor> as {...(rest as React.HTMLAttributes<HTMLAnchorElement>)} so aria-* and data-* attrs pass through in link mode.
| /** () => void to stay compatible with Anchor's onClick signature */ | ||
| onClick?: () => void; |
There was a problem hiding this comment.
Fixed. Removed onClick from the Omit and changed the type to React.MouseEventHandler<HTMLButtonElement>. Consumers can now use (e) => e.preventDefault() style handlers. At the <Anchor> call site a cast to any is used because Anchor's onClick resolves to an intersection type (MouseEventHandler<HTMLAnchorElement> & (() => void)) that TypeScript cannot satisfy without it — widening Anchor itself is a separate change.
| const fontUpgrade = variant !== "texted" ? "[font-family:var(--font-headline)]" : ""; | ||
|
|
||
| const classnames = clsx( | ||
| variant !== "texted" && baseClass, | ||
| variant !== "texted" && baseNotFullWidthClass, | ||
| variant === "contained" && [containedPrimaryBtn, containedLightBtn], | ||
| variant === "outlined" && [outlinedPrimaryBtn, outlinedLightBtn], | ||
| variant !== "texted" && baseLayoutClass, | ||
| variant !== "texted" && variant !== "ghost" && baseBorderClass, | ||
| variant !== "texted" && variant !== "ghost" && baseNotFullWidthClass, | ||
| variant === "ghost" && !fullwidth && "tw-inline-flex", | ||
| variant === "contained" && [containedPrimaryBtn, containedLightBtn, containedSecondaryBtn, containedDangerBtn], | ||
| variant === "outlined" && [outlinedPrimaryBtn, outlinedLightBtn, outlinedSecondaryBtn, outlinedDangerBtn], | ||
| variant === "ghost" && ghostBtn, |
There was a problem hiding this comment.
Fixed. Added disabled && 'tw-opacity-50 tw-cursor-not-allowed' to the clsx composition, restoring the visual disabled state that was previously handled by the now-deleted .btnPrimary:disabled CSS rule.
- Forward id to DOM (coerce string|number to string via String(id)) - Spread ...rest onto <Anchor> for aria-*/data-* pass-through in link mode - Widen onClick to React.MouseEventHandler<HTMLButtonElement> - Add tw-opacity-50 tw-cursor-not-allowed for disabled visual state - Fix test assertion to verify disabled opacity class Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| import Button from "@ui/button"; | ||
|
|
||
| describe("Button", () => { | ||
| // T009 — renders children, default type, disabled state |
There was a problem hiding this comment.
Remove speckit specific nomenclature such as T009. Keep general comments and add tsdoc at the model-level as well
There was a problem hiding this comment.
Fixed. Removed T009–T012 speckit identifiers from all section comments (kept the descriptions). Added module-level TSDoc at the top of the test file.
| @@ -1,45 +1,64 @@ | |||
| /* eslint-disable react/button-has-type */ | |||
| import React from "react"; | |||
There was a problem hiding this comment.
Add module-level tsdoc
There was a problem hiding this comment.
Fixed. Added a @module TSDoc block at the top of button/index.tsx (above the eslint-disable directive) covering variants, colors, and rendering modes.
| color: #f8f9fa; | ||
| } | ||
|
|
||
| .btnPrimary, |
There was a problem hiding this comment.
This is being taken into consideration in cslx now, or did we lose this styling completely?
There was a problem hiding this comment.
The disabled styling is preserved. disabled && 'tw-opacity-50 tw-cursor-not-allowed' is composed in the Button's clsx call (line 220 of src/components/ui/button/index.tsx). The old CSS rule that was at this location in the module is fully replaced by those Tailwind utilities — nothing was lost.
| @@ -1,3 +1,4 @@ | |||
| import { Button } from "@ui"; | |||
There was a problem hiding this comment.
Check for other modules and cta examples. Do we normally use named exports for components in React?
There was a problem hiding this comment.
Checked. The project convention is import Button from "@ui/button" (default import via path alias) — used by ~25 existing callsites. Named barrel imports (import { Button } from "@ui") were introduced only on the 3 files migrated in this PR (cta/index.tsx, admin-dashboard-components.tsx, PlayerSetup.tsx). All 3 are now updated to import Button from "@ui/button" to match the existing convention.
- Remove speckit T009-T012 ids from test comments; add module TSDoc to test file - Add module-level TSDoc to src/components/ui/button/index.tsx - Normalize Button imports to path-based default import across 3 files migrated in this PR (cta, admin-dashboard, PlayerSetup) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| }); | ||
|
|
||
| it("passes data-testid through to the button element", () => { | ||
| render(<Button data-testid="my-btn">Click</Button>); |
There was a problem hiding this comment.
Why use test ids in production code? Common in successful open source codebases
| shape?: "tw-rounded" | "square" | "ellipse"; | ||
| fullwidth?: boolean; | ||
| active?: boolean; | ||
| disabled?: boolean; |
There was a problem hiding this comment.
Why remove the disable feature?
| interface ButtonProps | ||
| extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "id" | "onClick"> { | ||
| children: React.ReactNode; | ||
| type?: "button" | "submit" | "reset"; |
There was a problem hiding this comment.
I saw type used in button components
Summary
ghostvariant andsecondary/dangercolors to the sharedButtoncomponentReact.forwardRefand extendButtonHTMLAttributes— refs and native HTML attributes pass through automaticallydefaultPropswith destructure parameter defaultssrc/components/ui/index.ts) with@uialias intsconfig.jsoncta,contact-form,PlayerSetup,admin-dashboardbtnPrimary/btnGhostCSS fromcta.module.csspath→Anchor behaviorTest plan
npx tsc --noEmit— zero errorspnpm vitest run __tests__/components/ui/button.test.tsx— all 21 cases pass<Button variant="ghost" color="secondary">renders without background or borderHTMLButtonElementimport { Button } from "@ui"resolves correctlyCloses #896
🤖 Generated with Claude Code