Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions __tests__/components/ui/button.test.tsx
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");
});
Comment thread
jeromehardaway marked this conversation as resolved.

// 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>);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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();
});
});
});
});
87 changes: 87 additions & 0 deletions docs/button/delivery.md
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
166 changes: 166 additions & 0 deletions docs/button/design.md
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 |
Loading
Loading