-
-
Notifications
You must be signed in to change notification settings - Fork 78
feat(ui): Reusable Button component enhancement #1131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aamarin
wants to merge
7
commits into
master
Choose a base branch
from
896-reusable-button-plan
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
2f2350d
docs: Add Button component enhancement design spec (#896)
aamarin 47b0a02
docs: Add speckit spec/plan/tasks for Button enhancement (#896)
aamarin 47f2d1d
docs: Apply speckit.clarify resolutions to spec and tasks (#896)
aamarin bc95426
feat(ui): Reusable Button component enhancement (#896)
aamarin e6c6e35
docs: Add delivery plan for Button component enhancement (#896)
aamarin 54a63cd
fix(ui): Address Button review comments from PR #1131
aamarin 5c4c4e1
fix(ui): Address Button self-review comments from PR #1131
aamarin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /** | ||
| * @module button.test | ||
| * Unit tests for the Button UI component — covers rendering, disabled state, | ||
| * ref forwarding, native attribute pass-through, variant/color matrix, and link mode. | ||
| */ | ||
| import React from "react"; | ||
| import { render, screen } from "@testing-library/react"; | ||
| import Button from "@ui/button"; | ||
|
|
||
| describe("Button", () => { | ||
| // renders children, default type, disabled state | ||
| it("renders children", () => { | ||
| render(<Button>Click me</Button>); | ||
| expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("defaults to type='button'", () => { | ||
| render(<Button>Submit</Button>); | ||
| expect(screen.getByRole("button")).toHaveAttribute("type", "button"); | ||
| }); | ||
|
|
||
| it("sets disabled HTML attribute and applies opacity class", () => { | ||
| render(<Button disabled>Disabled</Button>); | ||
| const btn = screen.getByRole("button"); | ||
| expect(btn).toBeDisabled(); | ||
| expect(btn).toHaveClass("tw-opacity-50", "tw-cursor-not-allowed"); | ||
| }); | ||
|
|
||
| // forwardRef, aria-label, data-testid pass-through | ||
| it("forwards ref to the underlying HTMLButtonElement", () => { | ||
| const ref = React.createRef<HTMLButtonElement>(); | ||
| render(<Button ref={ref}>Ref</Button>); | ||
| expect(ref.current).toBeInstanceOf(HTMLButtonElement); | ||
| }); | ||
|
|
||
| it("passes aria-label through to the button element", () => { | ||
| render(<Button aria-label="close dialog">X</Button>); | ||
| expect(screen.getByRole("button", { name: "close dialog" })).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("passes data-testid through to the button element", () => { | ||
| render(<Button data-testid="my-btn">Click</Button>); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why use test ids in production code? Common in successful open source codebases |
||
| expect(screen.getByTestId("my-btn")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| // path→Anchor, className merge, ghost variant | ||
| it("renders as an anchor element when path is set", () => { | ||
| render(<Button path="/about">About</Button>); | ||
| expect(screen.queryByRole("button")).not.toBeInTheDocument(); | ||
| expect(screen.getByRole("link", { name: "About" })).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("merges custom className with base classes", () => { | ||
| render(<Button className="custom-class">Merge</Button>); | ||
| const btn = screen.getByRole("button"); | ||
| expect(btn.className).toContain("custom-class"); | ||
| expect(btn.className).toContain("tw-font-bold"); | ||
| }); | ||
|
|
||
| it("ghost variant has no border or background base classes", () => { | ||
| render(<Button variant="ghost">Ghost</Button>); | ||
| const btn = screen.getByRole("button"); | ||
| expect(btn.className).not.toContain("tw-border-solid"); | ||
| // hover tints are expected; only non-hover base bg classes are disallowed | ||
| const baseBgClasses = btn.className.split(" ").filter((c) => c.startsWith("tw-bg-")); | ||
| expect(baseBgClasses).toHaveLength(0); | ||
| }); | ||
|
|
||
| // variant × color matrix | ||
| const variants = ["contained", "outlined", "ghost"] as const; | ||
| const colors = ["primary", "light", "secondary", "danger"] as const; | ||
|
|
||
| variants.forEach((variant) => { | ||
| colors.forEach((color) => { | ||
| it(`renders without throwing: variant="${variant}" color="${color}"`, () => { | ||
| expect(() => | ||
| render( | ||
| <Button variant={variant} color={color}> | ||
| {`${variant}/${color}`} | ||
| </Button> | ||
| ) | ||
| ).not.toThrow(); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| # Delivery Plan: Reusable Button Component Enhancement (896) | ||
|
|
||
| **Feature**: `896-reusable-button-plan` | **Date**: 2026-05-27 | ||
| **Source**: `docs/button/tasks.md` (22 tasks) | ||
| **Parent issue**: #896 | ||
|
|
||
| > **Note**: `speckit.analyze` was not run before this delivery plan was created. | ||
| > The spec has no `[NEEDS CLARIFICATION]` markers and task numbering was verified | ||
| > manually. The implementation was committed in `bc954268` before this plan was | ||
| > written (retrospective delivery plan). | ||
|
|
||
| --- | ||
|
|
||
| ## PR Decomposition | ||
|
|
||
| | PR | Tasks | Files Touched | Size | Merge Condition | | ||
| |----|-------|--------------|------|----------------| | ||
| | TBD | T001–T022 | `src/components/ui/button/index.tsx` (modified), `__tests__/components/ui/button.test.tsx` (created), `src/components/ui/index.ts` (created), `src/containers/software-factory/cta/index.tsx` (modified), `src/containers/software-factory/cta/cta.module.css` (modified), `src/components/forms/contact-form.tsx` (modified), up to 2 additional callsites (modified) | M | Zero TS errors, all tests green, 3–5 callsites updated, no `defaultProps` remaining | | ||
|
|
||
| **Rationale**: Single PR. US1–US4 are mutually dependent — US4 callsite updates require | ||
| the new API from US1+US2, and US3 tests verify the component changes that underpin | ||
| everything. A reviewer needs the full picture to assess correctness. The diff is | ||
| M-size (6–8 files) but reviewable in one pass. | ||
|
|
||
| **PR closes**: `Closes #896` | ||
|
|
||
| --- | ||
|
|
||
| ## Issue Grouping Map | ||
|
|
||
| **Grouping pattern**: Single issue (#896 — parent already exists, no child issues) | ||
| **Rationale**: All 4 user stories ship in one PR under one issue. No child issues | ||
| created — US1–US4 story breakdown is tracked in `tasks.md` and `delivery.md` only. | ||
|
|
||
| --- | ||
|
|
||
| ## Parallelization Waves | ||
|
|
||
| | Wave | Mode | Tasks | Gate / Notes | | ||
| |------|------|-------|-------------| | ||
| | 0 | Sequential | T001 → T002 | T001 must pass before any edits; T002 confirms structure before writing | | ||
| | 1 | Sequential | T003 → T004 → T005 → T006 | All touch `button/index.tsx` — sequential to avoid conflicts; T006 is tsc gate | | ||
| | 2 | Sequential | T007 → T008 | T007 depends on T003–T005 output; T008 is tsc gate | | ||
| | 3 | Sequential | T009 → T010 → T011 → T012 → T013 | Test file built incrementally; T013 is vitest gate | | ||
| | 4 | Parallel then sequential | T014 ‖ T017, then T015 → T016 → T018 | T014 (barrel) and T017 (grep) touch different files; T015/T016/T018 are sequential callsite updates | | ||
| | 5 | Parallel then sequential | T019 ‖ T020 ‖ T021 → T022 | T019/T020/T021 are independent read-only checks; T022 is final merge gate | | ||
|
|
||
| **Single-agent order** (recommended — S/M feature, mostly sequential): | ||
| T001 → T002 → T003 → T004 → T005 → T006 → T007 → T008 → T009 → T010 → T011 → T012 → T013 → T014 → T017 → T015 → T016 → T018 → T019 → T020 → T021 → T022 | ||
|
|
||
| --- | ||
|
|
||
| ## Agent Fanning Instructions | ||
|
|
||
| Single agent recommended for this M feature. Most waves are sequential because tasks | ||
| share `button/index.tsx`. Only Wave 4 and Wave 5 offer genuine parallelism: | ||
|
|
||
| **Wave 4 fanning (2 agents after T013 passes):** | ||
|
|
||
| **Agent A prompt:** | ||
| ``` | ||
| Create `src/components/ui/index.ts` with the following content: | ||
| export { default as Button } from "./button"; | ||
| Then run `npx tsc --noEmit` and confirm zero errors. | ||
| Branch: 896-reusable-button-plan | ||
| ``` | ||
|
|
||
| **Agent B prompt:** | ||
| ``` | ||
| Grep `src/` for remaining `<button className=` patterns (excluding | ||
| `src/components/ui/button/` itself and specialized `aria-pressed` components). | ||
| Report the file paths and line numbers. Do not edit anything yet. | ||
| Branch: 896-reusable-button-plan | ||
| ``` | ||
|
|
||
| **Fan-in gate after Wave 4:** `npx tsc --noEmit && pnpm vitest run __tests__/components/ui/` | ||
|
|
||
| --- | ||
|
|
||
| ## Verification Checklist | ||
|
|
||
| - [x] `delivery.md` written to `docs/button/delivery.md` | ||
| - [x] PR count justified (single PR — mutual dependency rationale) | ||
| - [x] Issue count matches grouping pattern (4 children + parent, not 1:1) | ||
| - [x] Every task assigned to exactly one wave | ||
| - [x] PR closes #896 (single issue, no child issues) | ||
| - [ ] PR created with `Closes #896` in description |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| # Design: Reusable Button Component Enhancement | ||
|
|
||
| **Issue:** [#896](https://github.com/Vets-Who-Code/vets-who-code-app/issues/896) | ||
| **Branch:** `896-reusable-button-plan` | ||
| **Date:** 2026-05-26 | ||
|
|
||
| --- | ||
|
|
||
| ## Problem | ||
|
|
||
| Raw `<button>` elements with inline Tailwind or CSS module classes exist alongside | ||
| the shared `Button` component, producing inconsistent styles and accessibility. The | ||
| existing `Button` at `src/components/ui/button/index.tsx` is already well-built and | ||
| brand-aligned but has three gaps: | ||
|
|
||
| 1. No `forwardRef` — third-party integrations (Radix, react-hook-form) can't obtain a ref. | ||
| 2. No `React.ButtonHTMLAttributes` spread — native attrs (`aria-*`, `data-*`, `form`, `name`) are silently swallowed. | ||
| 3. Missing semantic variants: `ghost`, `secondary`, `danger`. | ||
| 4. Uses deprecated `defaultProps` pattern (React 18+). | ||
| 5. No tests. | ||
| 6. No barrel export from `src/components/ui/index.ts`. | ||
|
|
||
| --- | ||
|
|
||
| ## What Is NOT Changing | ||
|
|
||
| - The two-prop API (`variant` + `color`) is kept — changing it would break ~15 callers. | ||
| - `isLoading` is **out of scope**. Loading state is caller responsibility; the existing | ||
| pattern (`disabled={isSubmitting}` + conditional children text) is correct and | ||
| consistent. A `LoadingButton` wrapper can be added later if the pattern becomes | ||
| repeated enough to justify it. | ||
| - No CVA, no new dependencies. `clsx` stays. | ||
| - No CSS module file. Tailwind utilities only. | ||
| - No Storybook (not in project). | ||
|
|
||
| --- | ||
|
|
||
| ## Architecture | ||
|
|
||
| Single file change: `src/components/ui/button/index.tsx`. | ||
|
|
||
| ### Updated `ButtonProps` | ||
|
|
||
| ```ts | ||
| interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { | ||
| children: React.ReactNode; | ||
| // variant: add 'ghost' | ||
| variant?: "contained" | "outlined" | "texted" | "ghost"; | ||
| // color: add 'secondary' (navy) and 'danger' (destructive red alias) | ||
| color?: "primary" | "light" | "secondary" | "danger"; | ||
| size?: "xs" | "sm" | "md" | "lg"; | ||
| shape?: "tw-rounded" | "square" | "ellipse"; | ||
| fullwidth?: boolean; | ||
| active?: boolean; | ||
| iconButton?: boolean; | ||
| path?: string; | ||
| label?: string; | ||
| hover?: "default" | "light" | false; | ||
| // 'disabled' and 'onClick' are now inherited from HTMLButtonAttributes | ||
| // 'type' is now inherited from HTMLButtonAttributes | ||
| className?: string; // still explicit for clarity | ||
| } | ||
| ``` | ||
|
|
||
| Extending `HTMLButtonAttributes` means `disabled`, `type`, `onClick`, `aria-*`, | ||
| `data-*`, `form`, `name`, `id` all pass through via `...rest` spread. | ||
|
|
||
| ### `forwardRef` wrapper | ||
|
|
||
| ```ts | ||
| const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | ||
| ({ variant = "contained", color = "primary", size = "md", ...rest }, ref) => { | ||
| // ...class composition unchanged... | ||
| return <button ref={ref} className={classnames} {...rest} />; | ||
| } | ||
| ); | ||
| ``` | ||
|
|
||
| `defaultProps` is removed; all defaults move to destructure parameters. | ||
|
|
||
| When `path` is provided the component renders an `Anchor` — `ref` is not forwarded | ||
| in that case (same behavior as today). | ||
|
|
||
| ### New variant/color classes | ||
|
|
||
| **`ghost` variant** — transparent background, no border, subtle hover tint: | ||
| - `ghost` + `primary`: text-primary, hover:bg-primary/10 | ||
| - `ghost` + `light`: text-white, hover:bg-white/10 | ||
| - `ghost` + `secondary`: text-navy, hover:bg-navy/10 | ||
|
|
||
| **`secondary` color** — navy fill (VWC secondary brand color): | ||
| - `contained` + `secondary`: bg-secondary, text-white, hover: deeper navy | ||
| - `outlined` + `secondary`: border-secondary, text-secondary, hover: navy fill | ||
|
|
||
| **`danger` color** — semantic alias for destructive actions (same red token as | ||
| `primary`, distinct prop so callers signal intent explicitly): | ||
| - `contained` + `danger`: same red fill as primary, distinct semantic meaning | ||
| - `outlined` + `danger`: red border + text, hover: red fill | ||
|
|
||
| --- | ||
|
|
||
| ## Barrel Export | ||
|
|
||
| Create `src/components/ui/index.ts` exporting `Button` (and other UI primitives | ||
| already in the directory). Callers can then import from `@ui` instead of `@ui/button`. | ||
|
|
||
| --- | ||
|
|
||
| ## Tests | ||
|
|
||
| File: `__tests__/components/ui/button.test.tsx` | ||
|
|
||
| | Test | Assertion | | ||
| |---|---| | ||
| | Renders children | text content present | | ||
| | Defaults to `type="button"` | no accidental form submit | | ||
| | `disabled` sets HTML attribute | `getByRole('button')` has `disabled` | | ||
| | `disabled` applies opacity class | className contains opacity utility | | ||
| | `path` renders Anchor | element is `<a>`, not `<button>` | | ||
| | `forwardRef` forwards to DOM node | ref.current is HTMLButtonElement | | ||
| | `aria-label` passes through | attribute present on element | | ||
| | `className` merges with base | both classes present | | ||
| | Each `variant` + `color` combo | renders without throwing | | ||
| | `ghost` variant | no border/background base classes | | ||
|
|
||
| Test setup follows existing pattern: `@testing-library/react` + `vitest`, located in | ||
| `__tests__/components/ui/`. | ||
|
|
||
| --- | ||
|
|
||
| ## Callsite Updates (3–5 files) | ||
|
|
||
| Target components that bypass `Button` with raw elements or over-ride it with | ||
| redundant `className` declarations: | ||
|
|
||
| 1. **`src/containers/software-factory/cta/index.tsx`** | ||
| Replace `<button className={styles.btnPrimary}>` → `<Button variant="contained">` | ||
| Replace `<button className={styles.btnGhost}>` → `<Button variant="ghost" color="light">` | ||
| Chip toggle buttons are left as-is (specialized pressed-state UI, not CTAs). | ||
|
|
||
| 2. **`src/components/forms/contact-form.tsx`** (line 174) | ||
| Remove the large `className` override that re-declares colors already in the | ||
| component (`tw-bg-red`, `tw-text-white`, hover states). Use `variant="contained" | ||
| color="primary" fullwidth` instead. | ||
|
|
||
| 3. Additional files TBD during implementation — grep for `<button` in `src/` will | ||
| surface remaining candidates; target 3–5 total. | ||
|
|
||
| --- | ||
|
|
||
| ## JSDoc | ||
|
|
||
| Add a brief JSDoc block to `ButtonProps` and `Button` covering: what the component | ||
| does, the variant/color matrix, and the `path` → Anchor behavior. | ||
|
|
||
| --- | ||
|
|
||
| ## Design Decisions Log | ||
|
|
||
| | Decision | Rationale | | ||
| |---|---| | ||
| | Keep two-prop API (variant + color) | Changing to flat variant would break ~15 callers | | ||
| | Drop `isLoading` | Caller responsibility; existing pattern is consistent | | ||
| | `danger` as color alias | Same token as primary but semantic distinction for destructive actions | | ||
| | No CVA | Not in codebase; adding a dependency for one component is not justified | | ||
| | Chip buttons in software-factory CTA left alone | Specialized toggle UI with `aria-pressed`, not generic CTAs | |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.