diff --git a/.cursor/rules/versioning-with-npm.mdc b/.cursor/rules/versioning-with-npm.mdc new file mode 100644 index 00000000..cfa2eb25 --- /dev/null +++ b/.cursor/rules/versioning-with-npm.mdc @@ -0,0 +1,74 @@ + +You are an expert release manager for a Yarn 4 monorepo who uses the npm CLI for quick version bumps and patch releases. + +# Versioning With npm CLI + +## Policy +- Prefer small, fast patch releases for incremental work. +- Treat new components and minor fixes as patch releases when they are additive and low-risk. +- Reserve minor/major only for notable feature waves or breaking changes. + +Note: While the repo supports Changesets for broader release coordination, this rule documents the npm CLI flow for quick iterations. + +## What Counts As β€œSmall” +- Additive components (new UI or form wrappers) without breaking changes +- Bug fixes, perf tweaks, a11y refinements, copy/docs updates +- Internal refactors that don’t change public APIs + +## Pre-flight +- Clean working tree: no uncommitted changes +- On a release-worthy branch (e.g., `main`) +- Build and tests pass: `yarn build && yarn test` + +## Patch Bump (Single Workspace) +For the published package `@lambdacurry/forms`: + +```bash +# Bump version with custom message +npm version patch -w @lambdacurry/forms -m "Add DateField component and fix TextField accessibility" + +# The -m flag creates the git commit automatically with your message +# No need for separate git add/commit steps + +## Post-version Steps +After running `npm version patch`, you'll need to: + +1. **Return to top level**: `cd ../..` (if you're in the package directory) +2. **Update lockfile**: `yarn install` to update `yarn.lock` with the new version +3. **Commit lockfile**: `git add yarn.lock && git commit -m "Update yarn.lock for @lambdacurry/forms vX.Y.Z"` + +This ensures the lockfile reflects the new package version and maintains consistency across the monorepo. +``` + +Guidelines: +- Keep the summary one line and human-readable. +- Examples: "Add DateField; fix TextField aria; smaller bundle". +- This updates `packages/components/package.json` and creates a normal commit without tags. + +## Open PR and Merge +- Push your branch and open a PR. +- When the PR merges into `main`, GitHub CI publishes the package. No manual tagging or `npm publish` needed. + +## Minor / Major (When Needed) +- Minor: larger feature sets or notable additions across multiple components + ```bash + npm version minor -w @lambdacurry/forms -m "Add comprehensive form validation and new field types" + ``` +- Major: any breaking change (API removals/renames, behavior changes) + ```bash + npm version major -w @lambdacurry/forms -m "Breaking: rename onSubmit to handleSubmit; remove deprecated props" + ``` + +## Summary Message Tips +- Keep it under ~100 chars; list 2–3 highlights separated by semicolons +- Focus on user-visible changes first; include critical fixes +- Avoid noisy implementation detail; link to PR/issue in the PR body + +## Coordination With Changesets +- Use this npm CLI flow for quick, low-risk patches. +- For multi-package changes, coordinated releases, or richer changelogs, prefer Changesets (`yarn changeset`) and follow the existing repo workflow. + + +## Coordination With Changesets +- Use this npm CLI flow for quick, low-risk patches. +- For multi-package changes, coordinated releases, or richer changelogs, prefer Changesets (`yarn changeset`) and follow the existing repo workflow. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6fac22a3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `apps/docs`: Storybook docs, examples, and UI tests. +- `packages/components`: Source for `@lambdacurry/forms` (`src/**`, built to `dist/`). +- `types/`: Shared ambient types. +- `.changeset/`: Versioning and release metadata. +- Root configs: `biome.json`, `turbo.json`, `tsconfig.json`, `package.json` (Yarn workspaces). + +## Build, Test, and Development Commands +- `yarn dev`: Run all workspace dev tasks via Turbo. +- `yarn build`: Build all packages/apps. +- `yarn serve`: Serve built Storybook (`apps/docs`). +- `yarn test`: Run workspace tests (Storybook test-runner in `apps/docs`). +- `yarn format-and-lint` | `:fix`: Check/auto-fix with Biome. +- Per workspace (examples): + - `yarn workspace @lambdacurry/forms build` + - `yarn workspace @lambdacurry/forms-docs dev` + +## Coding Style & Naming Conventions +- Indentation: 2 spaces; max line width 120; single quotes (Biome enforced). +- TypeScript + React (ES modules). Keep components pure and typed. +- Filenames: kebab-case (e.g., `text-field.tsx`, `data-table-filter/**`). +- Components/Types: PascalCase; hooks: camelCase with `use*` prefix. +- Imports: organized automatically (Biome). Prefer local `index.ts` barrels when useful. + +## Testing Guidelines +- Framework: Storybook Test Runner (Playwright under the hood) in `apps/docs`. +- Naming: co-locate tests as `*.test.tsx` near stories/components. +- Run: `yarn test` (CI-like) or `yarn workspace @lambdacurry/forms-docs test:local`. +- Cover critical interactions (forms, validation, a11y, filter behavior). Add stories to exercise states. + +## Commit & Pull Request Guidelines +- Commits: short imperative subject, optional scope, concise body explaining rationale. + - Example: `Fix: remove deprecated dropdown select`. +- PRs: clear description, linked issues, screenshots or Storybook links, notes on testing. +- Required checks: `yarn format-and-lint` passes; build succeeds; tests updated/added. +- Versioning: when changing published package(s), add a Changeset (`yarn changeset`) before merge. + +## Security & Configuration +- Node `22.9.0` (`.nvmrc`) and Yarn 4 (`packageManager`). +- Do not commit secrets. Keep large artifacts out of VCS (`dist`, `node_modules`). +- PR previews for Storybook are published via GitHub Pages; verify links in PR comments. + +## Cursor Rules Review +- `.cursor/rules/react-typescript-patterns.mdc` (Always): React 19 + TS conventions, refs, props/types, naming. +- `.cursor/rules/ui-component-patterns.mdc` (Always): Radix + Tailwind 4 + CVA patterns, a11y, performance. +- `.cursor/rules/form-component-patterns.mdc`: Remix Hook Form + Zod wrappers, errors, server actions. +- `.cursor/rules/storybook-testing.mdc`: Storybook play tests, router stub decorator, local/CI flows. +- `.cursor/rules/monorepo-organization.mdc`: Imports/exports, package boundaries, Turbo/Vite/TS paths. +- `.cursor/rules/versioning-with-npm.mdc`: npm CLI version bumps (patch-first), CI publishes on merge. + +When to review before starting work +- Building/refactoring UI components: react-typescript-patterns + ui-component-patterns. +- Form-aware components or validation: form-component-patterns. +- Writing/updating stories or interaction tests: storybook-testing. +- Moving files, changing exports/imports, adding deps/build entries: monorepo-organization. +- Complex UI (data table, Radix primitives, variants): ui-component-patterns for a11y/perf. + +Quick checklist +- Files/names: kebab-case files; PascalCase components; named exports only. +- Types: explicit props interfaces; React 19 ref patterns; organize imports (Biome). +- Forms: Zod schemas, proper messages, `fetcher.Form`, show `FormMessage` errors. +- Tests: per-story decorators, semantic queries, three-phase play tests; run `yarn test`. +- Monorepo: no cross-package relative imports; verify `exports`, TS `paths`, Turbo outputs. diff --git a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx b/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx deleted file mode 100644 index e9df0d3e..00000000 --- a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { DropdownMenuSelect } from '@lambdacurry/forms/remix-hook-form/dropdown-menu-select'; -import { Button } from '@lambdacurry/forms/ui/button'; -import { DropdownMenuSelectItem } from '@lambdacurry/forms/ui/dropdown-menu-select-field'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, within } from '@storybook/test'; -import { type ActionFunctionArgs, Form, useFetcher } from 'react-router'; -import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form'; -import { z } from 'zod'; -import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; - -const AVAILABLE_FRUITS = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'orange', label: 'Orange' }, - { value: 'grape', label: 'Grape' }, - { value: 'strawberry', label: 'Strawberry' }, -] as const; - -const formSchema = z.object({ - fruit: z.string({ - required_error: 'Please select a fruit', - }), -}); - -type FormData = z.infer; - -const ControlledDropdownMenuSelectExample = () => { - const fetcher = useFetcher<{ message: string; selectedFruit: string }>(); - const methods = useRemixForm({ - resolver: zodResolver(formSchema), - defaultValues: { - fruit: '', - }, - fetcher, - submitConfig: { - action: '/', - method: 'post', - }, - submitHandlers: { - onValid: (data) => { - fetcher.submit( - createFormData({ - fruit: data.fruit, - }), - { - method: 'post', - action: '/', - }, - ); - }, - }, - }); - - return ( - -
-
- - {AVAILABLE_FRUITS.map((fruit) => ( - - {fruit.label} - - ))} - - - {fetcher.data?.selectedFruit && ( -
-

Submitted with fruit:

-

- {AVAILABLE_FRUITS.find((fruit) => fruit.value === fetcher.data?.selectedFruit)?.label} -

-
- )} -
-
-
- ); -}; - -const handleFormSubmission = async (request: Request) => { - const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); - - if (errors) { - return { errors }; - } - - return { message: 'Fruit selected successfully', selectedFruit: data.fruit }; -}; - -const meta: Meta = { - title: 'RemixHookForm/DropdownMenuSelect', - component: DropdownMenuSelect, - parameters: { layout: 'centered' }, - tags: ['autodocs'], - decorators: [ - withReactRouterStubDecorator({ - routes: [ - { - path: '/', - Component: ControlledDropdownMenuSelectExample, - action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), - }, - ], - }), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - parameters: { - docs: { - description: { - story: 'A dropdown menu select component for selecting a single option.', - }, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Open the dropdown - const dropdownButton = canvas.getByRole('button', { name: 'Select an option' }); - await userEvent.click(dropdownButton); - - // Select an option (portal renders outside the canvas) - const option = screen.getByRole('menuitem', { name: 'Banana' }); - await userEvent.click(option); - - // Submit the form - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Check if the selected option is displayed - await expect(await canvas.findByText('Banana')).toBeInTheDocument(); - }, -}; diff --git a/apps/docs/src/remix-hook-form/select-custom.stories.tsx b/apps/docs/src/remix-hook-form/select-custom.stories.tsx new file mode 100644 index 00000000..d62f7382 --- /dev/null +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -0,0 +1,254 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Select } from '@lambdacurry/forms/remix-hook-form/select'; +import { Button } from '@lambdacurry/forms/ui/button'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from '@storybook/test'; +import * as React from 'react'; +import { type ActionFunctionArgs, useFetcher } from 'react-router'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; + +const formSchema = z.object({ + region: z.string().min(1, 'Please select a region'), + theme: z.string().min(1, 'Please select a theme'), + fruit: z.string().min(1, 'Please select a fruit'), +}); + +type FormData = z.infer; + +const regionOptions = [ + { label: 'California', value: 'CA' }, + { label: 'Ontario', value: 'ON' }, + { label: 'New York', value: 'NY' }, + { label: 'Quebec', value: 'QC' }, + { label: 'Texas', value: 'TX' }, +]; + +const themeOptions = [ + { label: 'Default', value: 'default' }, + { label: 'Purple', value: 'purple' }, + { label: 'Green', value: 'green' }, +]; + +const fruitOptions = [ + { label: '🍎 Apple', value: 'apple' }, + { label: '🍊 Orange', value: 'orange' }, + { label: '🍌 Banana', value: 'banana' }, + { label: 'πŸ‡ Grape', value: 'grape' }, +]; + +// Custom Trigger (purple themed) +const PurpleTrigger = React.forwardRef>( + (props, ref) => ( + + + + ); +};`, + }, + }, + }, + decorators: [selectRouterDecorator], + play: async ({ canvasElement, step }) => { + await step('Test US State Selection', testUSStateSelection); + await step('Test Canada Province Selection', testCanadaProvinceSelection); + await step('Test Form Submission', testFormSubmission); + }, +}; + +export const ValidationErrors: Story = { + decorators: [selectRouterDecorator], + play: testValidationErrors, +}; + diff --git a/apps/docs/src/remix-hook-form/select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx new file mode 100644 index 00000000..261505f3 --- /dev/null +++ b/apps/docs/src/remix-hook-form/select.test.tsx @@ -0,0 +1,82 @@ +import { expect, userEvent, within } from '@storybook/test'; +import { StoryContext } from '@storybook/react'; + +// Test selecting a US state +export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Find and click the US state dropdown + const stateDropdown = canvas.getByLabelText('US State'); + await userEvent.click(stateDropdown); + + // Select a state (e.g., California) + const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); + await userEvent.click(californiaOption); + + // Wait for the popover to close and the selection to be applied + await expect(stateDropdown).toHaveTextContent('California'); +}; + +// Test selecting a Canadian province +export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Find and click the Canada province dropdown + const provinceDropdown = canvas.getByLabelText('Canadian Province'); + await userEvent.click(provinceDropdown); + + // Select a province (e.g., Ontario) + const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); + await userEvent.click(ontarioOption); + + // Wait for the popover to close and the selection to be applied + await expect(provinceDropdown).toHaveTextContent('Ontario'); +}; + +// Test form submission +export const testFormSubmission = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Select a state + const stateDropdown = canvas.getByLabelText('US State'); + await userEvent.click(stateDropdown); + const californiaOption = await within(document.body).findByRole('option', { name: 'California' }); + await userEvent.click(californiaOption); + await expect(stateDropdown).toHaveTextContent('California'); + + // Select a province + const provinceDropdown = canvas.getByLabelText('Canadian Province'); + await userEvent.click(provinceDropdown); + const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' }); + await userEvent.click(ontarioOption); + await expect(provinceDropdown).toHaveTextContent('Ontario'); + + // Select a custom region + const regionDropdown = canvas.getByLabelText('Custom Region'); + await userEvent.click(regionDropdown); + const customOption = await within(document.body).findByRole('option', { name: 'New York' }); + await userEvent.click(customOption); + await expect(regionDropdown).toHaveTextContent('New York'); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify the submission (mock response would be shown) + await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument(); +}; + +// Test validation errors +export const testValidationErrors = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Submit the form without selecting anything + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify error messages + await expect(canvas.findByText('Please select a state')).resolves.toBeInTheDocument(); + await expect(canvas.findByText('Please select a province')).resolves.toBeInTheDocument(); + await expect(canvas.findByText('Please select a region')).resolves.toBeInTheDocument(); +}; + diff --git a/package.json b/package.json index 69d36fbb..80343420 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,7 @@ "name": "forms", "version": "0.2.0", "private": true, - "workspaces": [ - "apps/*", - "packages/*" - ], + "workspaces": ["apps/*", "packages/*"], "scripts": { "start": "yarn dev", "dev": "turbo run dev", @@ -21,12 +18,17 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", + "@playwright/test": "^1.54.2", + "@types/react-dom": "^19", "turbo": "^2.3.3" }, "dependencies": { "@changesets/cli": "^2.27.11", + "@remix-run/react": "^2.17.0", + "react-dom": "^19.1.1", "react-phone-number-input": "^3.4.12", - "react-router-dom": "^7.6.2" + "react-router-dom": "^7.6.2", + "react-stately": "^3.40.0" }, "packageManager": "yarn@4.9.1" } diff --git a/packages/components/package.json b/packages/components/package.json index 3638749c..40735d67 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/forms", - "version": "0.19.3", + "version": "0.19.4", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -13,9 +13,17 @@ "types": "./dist/remix-hook-form/index.d.ts", "import": "./dist/remix-hook-form/index.js" }, + "./remix-hook-form/*": { + "types": "./dist/remix-hook-form/*.d.ts", + "import": "./dist/remix-hook-form/*.js" + }, "./ui": { "types": "./dist/ui/index.d.ts", "import": "./dist/ui/index.js" + }, + "./ui/*": { + "types": "./dist/ui/*.d.ts", + "import": "./dist/ui/*.js" } }, "files": [ @@ -46,6 +54,7 @@ "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-slider": "^1.3.4", "@radix-ui/react-slot": "^1.2.3", diff --git a/packages/components/src/remix-hook-form/canada-province-select.tsx b/packages/components/src/remix-hook-form/canada-province-select.tsx new file mode 100644 index 00000000..fb697b29 --- /dev/null +++ b/packages/components/src/remix-hook-form/canada-province-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Select, type SelectProps } from './select'; +import { CANADA_PROVINCES } from '../ui/data/canada-provinces'; + +export type CanadaProvinceSelectProps = Omit; + +export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) { + return ( + + ); +} + diff --git a/packages/components/src/ui/canada-province-select.tsx b/packages/components/src/ui/canada-province-select.tsx new file mode 100644 index 00000000..f004f78d --- /dev/null +++ b/packages/components/src/ui/canada-province-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Select, type SelectProps } from './select'; +import { CANADA_PROVINCES } from './data/canada-provinces'; + +export type CanadaProvinceSelectProps = Omit; + +export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) { + return ( + + )); + SearchInput.displayName = SearchInput.displayName || 'SelectSearchInput'; + + const CheckIcon = components?.CheckIcon || DefaultCheckIcon; + const ChevronIcon = components?.ChevronIcon || DefaultChevronIcon; return ( - + - + {selectedOption?.label || placeholder} + + - - - - No option found. - - - {options.map((option) => ( - { - onValueChange?.(option.value); - setOpen(false); - }} - className="flex items-center" - > - - {option.label} - - ))} - - - + +
+
+ setQuery(e.target.value)} + placeholder="Search..." + ref={(el) => { + if (el) queueMicrotask(() => el.focus()); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const toSelect = enterCandidate; + if (toSelect) { + onValueChange?.(toSelect.value); + setQuery(''); + popoverState.close(); + triggerRef.current?.focus(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setQuery(''); + popoverState.close(); + triggerRef.current?.focus(); + } + }} + className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border-0" + /> +
+
    + {filtered.length === 0 &&
  • No results.
  • } + {filtered.map((option) => { + const isSelected = option.value === value; + const isEnterCandidate = query.trim() !== '' && enterCandidate?.value === option.value && !isSelected; + return ( +
  • + { + onValueChange?.(option.value); + setQuery(''); + popoverState.close(); + }} + className={cn( + 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded', + 'text-gray-900', + isSelected ? 'bg-gray-100' : 'hover:bg-gray-100', + isEnterCandidate && 'bg-gray-50', + itemClassName, + )} + role="option" + aria-selected={isSelected} + data-selected={isSelected ? 'true' : 'false'} + selected={isSelected} + > + {isSelected && } + + {option.label} + + +
  • + ); + })} +
+
); } + diff --git a/packages/components/src/ui/us-state-select.tsx b/packages/components/src/ui/us-state-select.tsx new file mode 100644 index 00000000..1572b949 --- /dev/null +++ b/packages/components/src/ui/us-state-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Select, type SelectProps } from './select'; +import { US_STATES } from './data/us-states'; + +export type USStateSelectProps = Omit; + +export function USStateSelect(props: USStateSelectProps) { + return ( +