diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 7511c2b2c..18e1c7c32 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -171,10 +171,13 @@ test('footer newsletter form posts to /api/newsletter and renders success state' await page.goto('/'); const footer = page.locator('footer'); - await footer.getByLabel('Email address').fill('reader@acme.com'); + const input = footer.getByLabel('Email'); + // Regression guard: the disclosure once sat inside the flex row and the input collapsed to 26px. + expect((await input.boundingBox())?.width ?? 0).toBeGreaterThan(160); + await input.fill('reader@acme.com'); await footer.getByRole('button', { name: 'Subscribe' }).click(); - await expect(page.getByText("✓ You're subscribed!")).toBeVisible(); + await expect(footer.getByRole('status')).toContainText('Subscribed.'); expect(payload).toMatchObject({ email: 'reader@acme.com', policy_version: GROWTH_FORM_POLICY_VERSION, diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index be756c59d..316c2f6c6 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -14,6 +14,7 @@ @import "../styles/docs.css"; @import "../styles/landing.css"; @import "../styles/marketing.css"; +@import "../styles/forms.css"; @import "../styles/pages.css"; /* Shared workspace components live outside this app's automatic content diff --git a/apps/website/src/components/form/Field.spec.tsx b/apps/website/src/components/form/Field.spec.tsx new file mode 100644 index 000000000..250822d49 --- /dev/null +++ b/apps/website/src/components/form/Field.spec.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import React, { useContext } from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Field } from './Field'; +import { FieldContext } from './field-context'; + +function Probe() { + const ctx = useContext(FieldContext); + return ; +} + +describe('Field', () => { + it('labels the control by id and marks optional fields', () => { + render( + + + + ); + const label = screen.getByText('Work email', { selector: 'label' }); + expect(label.getAttribute('for')).toBe('f-email'); + expect(screen.getByText('(optional)')).toBeTruthy(); + expect(screen.getByTestId('probe').id).toBe('f-email'); + }); + + it('wires help and error text through aria-describedby and sets aria-invalid', () => { + render( + + + + ); + const probe = screen.getByTestId('probe'); + expect(probe.getAttribute('aria-describedby')).toBe('f-email-help f-email-error'); + expect(probe.getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByText('Enter a full address, like jordan@acme.dev.').id).toBe('f-email-error'); + expect(screen.getByText('We reply from a real inbox.').id).toBe('f-email-help'); + }); + + it('omits aria-describedby when there is nothing to describe', () => { + render( + + + + ); + expect(screen.getByTestId('probe').getAttribute('aria-describedby')).toBeNull(); + expect(screen.getByTestId('probe').getAttribute('aria-invalid')).toBeNull(); + }); +}); diff --git a/apps/website/src/components/form/Field.tsx b/apps/website/src/components/form/Field.tsx new file mode 100644 index 000000000..b02861aed --- /dev/null +++ b/apps/website/src/components/form/Field.tsx @@ -0,0 +1,41 @@ +'use client'; +import type { ReactNode } from 'react'; +import { FieldContext } from './field-context'; + +interface FieldProps { + /** Control id. The label's `for` and the control's `id` both use it. */ + id: string; + label: ReactNode; + optional?: boolean; + help?: ReactNode; + /** Error copy. Present means the field is invalid. */ + error?: string | null; + children: ReactNode; +} + +export function Field({ id, label, optional = false, help, error, children }: FieldProps) { + const helpId = help ? `${id}-help` : undefined; + const errorId = error ? `${id}-error` : undefined; + const describedBy = [helpId, errorId].filter(Boolean).join(' ') || undefined; + return ( +
+ + + {children} + + {help ? ( +

+ {help} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/website/src/components/form/FormCard.spec.tsx b/apps/website/src/components/form/FormCard.spec.tsx new file mode 100644 index 000000000..91bbb775c --- /dev/null +++ b/apps/website/src/components/form/FormCard.spec.tsx @@ -0,0 +1,16 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import { FormCard } from './FormCard'; + +describe('FormCard', () => { + it('renders the card shell and forwards the compact flag', () => { + const { container, rerender } = render(body); + const card = container.querySelector('[data-ui="form-card"]'); + expect(card?.textContent).toBe('body'); + expect(card?.getAttribute('data-compact')).toBeNull(); + rerender(body); + expect(container.querySelector('[data-ui="form-card"]')?.getAttribute('data-compact')).toBe(''); + }); +}); diff --git a/apps/website/src/components/form/FormCard.tsx b/apps/website/src/components/form/FormCard.tsx new file mode 100644 index 000000000..eeb86a819 --- /dev/null +++ b/apps/website/src/components/form/FormCard.tsx @@ -0,0 +1,14 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +interface FormCardProps extends HTMLAttributes { + children: ReactNode; + compact?: boolean; +} + +export function FormCard({ children, compact = false, ...rest }: FormCardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/website/src/components/form/FormStatus.spec.tsx b/apps/website/src/components/form/FormStatus.spec.tsx new file mode 100644 index 000000000..d71051379 --- /dev/null +++ b/apps/website/src/components/form/FormStatus.spec.tsx @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FormStatus } from './FormStatus'; + +describe('FormStatus', () => { + it('announces success politely', () => { + render(); + const status = screen.getByRole('status'); + expect(status.getAttribute('data-tone')).toBe('success'); + expect(status.textContent).toContain('Sent.'); + expect(status.textContent).toContain('Expect a reply within one business day.'); + }); + + it('announces failure as an alert and renders an action', () => { + render( + + Download the PDF directly + + ); + const alert = screen.getByRole('alert'); + expect(alert.getAttribute('data-tone')).toBe('failure'); + expect(screen.getByRole('link', { name: 'Download the PDF directly' })).toBeTruthy(); + }); + + it('announces the stale tone as an alert', () => { + render(); + const alert = screen.getByRole('alert'); + expect(alert.getAttribute('data-tone')).toBe('stale'); + expect(alert.textContent).toContain('This page is out of date.'); + }); +}); diff --git a/apps/website/src/components/form/FormStatus.tsx b/apps/website/src/components/form/FormStatus.tsx new file mode 100644 index 000000000..08feb1926 --- /dev/null +++ b/apps/website/src/components/form/FormStatus.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +type Tone = 'success' | 'failure' | 'stale'; + +interface FormStatusProps { + tone: Tone; + title: string; + detail?: ReactNode; + /** Optional follow-up: a link, a retry button, a refresh button. */ + children?: ReactNode; +} + +const ICON: Record = { success: '✓', failure: '!', stale: '↻' }; + +export function FormStatus({ tone, title, detail, children }: FormStatusProps) { + const role = tone === 'success' ? 'status' : 'alert'; + return ( +
+ +
+

+ {title} + {detail ? <> {detail} : null} +

+ {children ?
{children}
: null} +
+
+ ); +} diff --git a/apps/website/src/components/form/SubmitButton.spec.tsx b/apps/website/src/components/form/SubmitButton.spec.tsx new file mode 100644 index 000000000..92c44fc43 --- /dev/null +++ b/apps/website/src/components/form/SubmitButton.spec.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SubmitButton } from './SubmitButton'; + +describe('SubmitButton', () => { + it('renders both labels so width is stable, exposes only the active one, and disables while pending', () => { + const { rerender } = render(Send to Brian); + const button = screen.getByRole('button', { name: 'Send to Brian' }) as HTMLButtonElement; + expect(button.type).toBe('submit'); + expect(button.disabled).toBe(false); + expect(button.getAttribute('data-pending')).toBeNull(); + expect(button.getAttribute('data-submit')).toBe(''); + expect(button.getAttribute('data-ui')).toBe('button'); + expect(button.querySelector('[data-slot="pending"]')?.textContent).toBe('Sending…'); + + rerender(Send to Brian); + const pending = screen.getByRole('button', { name: 'Sending…' }) as HTMLButtonElement; + expect(pending.disabled).toBe(true); + expect(pending.getAttribute('data-pending')).toBe(''); + expect(pending.getAttribute('aria-busy')).toBe('true'); + }); + + it('forwards button props such as variant, size, and aria-describedby', () => { + render( + + Subscribe + + ); + const button = screen.getByRole('button', { name: 'Subscribe' }); + expect(button.getAttribute('data-variant')).toBe('secondary'); + expect(button.getAttribute('data-size')).toBe('lg'); + expect(button.getAttribute('aria-describedby')).toBe('disc'); + }); +}); diff --git a/apps/website/src/components/form/SubmitButton.tsx b/apps/website/src/components/form/SubmitButton.tsx new file mode 100644 index 000000000..20ea0c2d0 --- /dev/null +++ b/apps/website/src/components/form/SubmitButton.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { Button, type ButtonProps } from '../ui/Button'; + +type SubmitButtonProps = Omit, 'type' | 'children'> & { + children: ReactNode; + pending?: boolean; + pendingLabel: string; +}; + +/** + * Both labels render in the same grid cell (see forms.css) so the button + * keeps its width when the label swaps. The inactive label is hidden from + * layout by visibility and from assistive tech by aria-hidden. + */ +export function SubmitButton({ children, pending = false, pendingLabel, disabled, ...rest }: SubmitButtonProps) { + return ( + + ); +} diff --git a/apps/website/src/components/form/controls.spec.tsx b/apps/website/src/components/form/controls.spec.tsx new file mode 100644 index 000000000..7dad28551 --- /dev/null +++ b/apps/website/src/components/form/controls.spec.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Field } from './Field'; +import { Select, TextArea, TextInput } from './controls'; + +describe('form controls', () => { + it('TextInput takes id, described-by, and invalid from the surrounding Field', () => { + render( + + + + ); + const input = screen.getByLabelText('Work email') as HTMLInputElement; + expect(input.id).toBe('c-email'); + expect(input.getAttribute('data-ui')).toBe('form-control'); + expect(input.getAttribute('aria-describedby')).toBe('c-email-error'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.type).toBe('email'); + expect(input.autocomplete).toBe('email'); + }); + + it('TextArea marks itself multiline and Select renders its options', () => { + render( + <> + +