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..c7a39fd1 --- /dev/null +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -0,0 +1,238 @@ +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 clsx from 'clsx'; +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({ + theme: z.string().min(1, 'Please select a theme'), + fruit: z.string().min(1, 'Please select a fruit'), +}); + +type FormData = z.infer; + +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) => ( + + + {fetcher.data?.message &&

{fetcher.data.message}

} + + + ); +}; + +const handleFormSubmission = async (request: Request) => { + const { errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) return { errors }; + + return { message: 'Form submitted successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/Select Customized', + component: Select, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: SelectCustomizationExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], + }), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CustomComponents: Story = { + parameters: { + docs: { + description: { + story: ` +### Select Component Customization + +This story demonstrates customizing the Select component by passing component overrides, similar to TextField: + +- Override the trigger button via \`components.Trigger\` +- Customize option items with \`components.Item\` (receives \`selected\` and ARIA roles) +- Replace the search input with \`components.SearchInput\` + +Example: + +\`\`\`tsx + + + + + + {fetcher.data?.selectedRegions && ( +
+

Selected regions:

+
    + {Object.entries(fetcher.data.selectedRegions).map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+ )} + + + ); +}; + +const handleFormSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { + message: 'Form submitted successfully', + selectedRegions: { + state: data.state, + province: data.province, + region: data.region, + }, + }; +}; + +const meta: Meta = { + title: 'RemixHookForm/Select', + component: Select, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const selectRouterDecorator = withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: RegionSelectExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], +}); + +export const Default: Story = { + parameters: { + docs: { + description: { + story: + 'A select component for selecting options from a dropdown list. Includes specialized components for US states and Canadian provinces.', + }, + source: { + code: ` +const formSchema = z.object({ + state: z.string().min(1, 'Please select a state'), + province: z.string().min(1, 'Please select a province'), + region: z.string().min(1, 'Please select a region'), +}); + +const RegionSelectExample = () => { + const fetcher = useFetcher<{ message: string; selectedRegions: Record }>(); + + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + state: '', + province: '', + region: '', + }, + fetcher, + submitConfig: { action: '/', method: 'post' }, + }); + + return ( + + +
+ + + + + + ); +} + diff --git a/packages/components/src/remix-hook-form/dropdown-menu-select.tsx b/packages/components/src/remix-hook-form/dropdown-menu-select.tsx deleted file mode 100644 index 0969ebe6..00000000 --- a/packages/components/src/remix-hook-form/dropdown-menu-select.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useRemixFormContext } from 'remix-hook-form'; -import { - DropdownMenuSelectField as BaseDropdownMenuSelectField, - type DropdownMenuSelectProps as BaseDropdownMenuSelectProps, -} from '../ui/dropdown-menu-select-field'; - -export type DropdownMenuSelectProps = Omit; - -export function DropdownMenuSelect(props: DropdownMenuSelectProps) { - const { control } = useRemixFormContext(); - - return ; -} diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index dfa14017..fe23071f 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -2,7 +2,6 @@ export * from './checkbox'; export * from './form'; export * from './form-error'; export * from './date-picker'; -export * from './dropdown-menu-select'; export * from './phone-input'; export * from './text-field'; export * from './password-field'; @@ -15,3 +14,7 @@ export * from './data-table-router-form'; export * from './data-table-router-parsers'; export * from './data-table-router-toolbar'; export * from './use-data-table-url-state'; +export * from './select'; +export * from './us-state-select'; +export * from './canada-province-select'; + diff --git a/packages/components/src/remix-hook-form/select.tsx b/packages/components/src/remix-hook-form/select.tsx new file mode 100644 index 00000000..c2d0b648 --- /dev/null +++ b/packages/components/src/remix-hook-form/select.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { useRemixFormContext } from 'remix-hook-form'; +import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; +import { FormField, FormItem } from '../ui/form'; +import { Select as UISelect, type SelectProps as UISelectProps, type SelectUIComponents } from '../ui/select'; + +export interface SelectProps extends Omit { + name: string; + label?: string; + description?: string; + className?: string; + components?: Partial< + { + FormControl: React.ComponentType>; + FormLabel: React.ComponentType>; + FormDescription: React.ComponentType>; + FormMessage: React.ComponentType>; + } & SelectUIComponents + >; +} + +export function Select({ + name, + label, + description, + className, + components, + ...props +}: SelectProps) { + const { control } = useRemixFormContext(); + + return ( + ( + + {label && {label}} + + + + {description && ( + {description} + )} + + + )} + /> + ); +} diff --git a/packages/components/src/remix-hook-form/us-state-select.tsx b/packages/components/src/remix-hook-form/us-state-select.tsx new file mode 100644 index 00000000..332252b0 --- /dev/null +++ b/packages/components/src/remix-hook-form/us-state-select.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Select, type SelectProps } from './select'; +import { US_STATES } from '../ui/data/us-states'; + +export type USStateSelectProps = Omit; + +export function USStateSelect(props: USStateSelectProps) { + return ( + + ); +} + diff --git a/packages/components/src/ui/data/canada-provinces.ts b/packages/components/src/ui/data/canada-provinces.ts new file mode 100644 index 00000000..5f228dad --- /dev/null +++ b/packages/components/src/ui/data/canada-provinces.ts @@ -0,0 +1,17 @@ +import { SelectOption } from '../select'; + +export const CANADA_PROVINCES: SelectOption[] = [ + { value: 'AB', label: 'Alberta' }, + { value: 'BC', label: 'British Columbia' }, + { value: 'MB', label: 'Manitoba' }, + { value: 'NB', label: 'New Brunswick' }, + { value: 'NL', label: 'Newfoundland and Labrador' }, + { value: 'NT', label: 'Northwest Territories' }, + { value: 'NS', label: 'Nova Scotia' }, + { value: 'NU', label: 'Nunavut' }, + { value: 'ON', label: 'Ontario' }, + { value: 'PE', label: 'Prince Edward Island' }, + { value: 'QC', label: 'Quebec' }, + { value: 'SK', label: 'Saskatchewan' }, + { value: 'YT', label: 'Yukon' }, +]; diff --git a/packages/components/src/ui/data/us-states.ts b/packages/components/src/ui/data/us-states.ts new file mode 100644 index 00000000..bc4da2d0 --- /dev/null +++ b/packages/components/src/ui/data/us-states.ts @@ -0,0 +1,55 @@ +import { SelectOption } from '../select'; + +export const US_STATES: SelectOption[] = [ + { value: 'AL', label: 'Alabama' }, + { value: 'AK', label: 'Alaska' }, + { value: 'AZ', label: 'Arizona' }, + { value: 'AR', label: 'Arkansas' }, + { value: 'CA', label: 'California' }, + { value: 'CO', label: 'Colorado' }, + { value: 'CT', label: 'Connecticut' }, + { value: 'DE', label: 'Delaware' }, + { value: 'FL', label: 'Florida' }, + { value: 'GA', label: 'Georgia' }, + { value: 'HI', label: 'Hawaii' }, + { value: 'ID', label: 'Idaho' }, + { value: 'IL', label: 'Illinois' }, + { value: 'IN', label: 'Indiana' }, + { value: 'IA', label: 'Iowa' }, + { value: 'KS', label: 'Kansas' }, + { value: 'KY', label: 'Kentucky' }, + { value: 'LA', label: 'Louisiana' }, + { value: 'ME', label: 'Maine' }, + { value: 'MD', label: 'Maryland' }, + { value: 'MA', label: 'Massachusetts' }, + { value: 'MI', label: 'Michigan' }, + { value: 'MN', label: 'Minnesota' }, + { value: 'MS', label: 'Mississippi' }, + { value: 'MO', label: 'Missouri' }, + { value: 'MT', label: 'Montana' }, + { value: 'NE', label: 'Nebraska' }, + { value: 'NV', label: 'Nevada' }, + { value: 'NH', label: 'New Hampshire' }, + { value: 'NJ', label: 'New Jersey' }, + { value: 'NM', label: 'New Mexico' }, + { value: 'NY', label: 'New York' }, + { value: 'NC', label: 'North Carolina' }, + { value: 'ND', label: 'North Dakota' }, + { value: 'OH', label: 'Ohio' }, + { value: 'OK', label: 'Oklahoma' }, + { value: 'OR', label: 'Oregon' }, + { value: 'PA', label: 'Pennsylvania' }, + { value: 'RI', label: 'Rhode Island' }, + { value: 'SC', label: 'South Carolina' }, + { value: 'SD', label: 'South Dakota' }, + { value: 'TN', label: 'Tennessee' }, + { value: 'TX', label: 'Texas' }, + { value: 'UT', label: 'Utah' }, + { value: 'VT', label: 'Vermont' }, + { value: 'VA', label: 'Virginia' }, + { value: 'WA', label: 'Washington' }, + { value: 'WV', label: 'West Virginia' }, + { value: 'WI', label: 'Wisconsin' }, + { value: 'WY', label: 'Wyoming' }, + { value: 'DC', label: 'District of Columbia' }, +]; diff --git a/packages/components/src/ui/dropdown-menu-select-field.tsx b/packages/components/src/ui/dropdown-menu-select-field.tsx deleted file mode 100644 index 8ca4fb28..00000000 --- a/packages/components/src/ui/dropdown-menu-select-field.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// biome-ignore lint/style/noNamespaceImport: from Radix -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import type * as React from 'react'; -import { createContext, useContext, useState } from 'react'; -import type { Control, FieldPath, FieldValues } from 'react-hook-form'; -import { Button } from './button'; -import { DropdownMenuContent } from './dropdown-menu'; -import { - DropdownMenuCheckboxItem as BaseDropdownMenuCheckboxItem, - DropdownMenuItem as BaseDropdownMenuItem, - DropdownMenuRadioItem as BaseDropdownMenuRadioItem, -} from './dropdown-menu'; -import { - type FieldComponents, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from './form'; - -export interface DropdownMenuSelectProps< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> extends Omit, 'onChange' | 'value'> { - control?: Control; - name: TName; - label?: string; - description?: string; - children: React.ReactNode; - className?: string; - labelClassName?: string; - dropdownClassName?: string; - components?: Partial; -} - -export function DropdownMenuSelectField< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - control, - name, - label, - description, - children, - className, - labelClassName, - dropdownClassName, - components, - ...props -}: DropdownMenuSelectProps) { - const [open, setOpen] = useState(false); - - return ( - ( - - {label && ( - - {label} - - )} - - - - - - - {children} - - - - {description && {description}} - {fieldState.error?.message} - - )} - /> - ); -} - -DropdownMenuSelectField.displayName = 'DropdownMenuSelect'; - -// Context to wire menu items to form field -interface DropdownMenuSelectContextValue { - onValueChange: (value: T) => void; - value: T; -} -const DropdownMenuSelectContext = createContext | null>(null); - -/** Hook to access select context in item wrappers */ -export function useDropdownMenuSelectContext() { - const ctx = useContext(DropdownMenuSelectContext); - if (!ctx) { - throw new Error('useDropdownMenuSelectContext must be used within DropdownMenuSelectField'); - } - return ctx as { onValueChange: (value: T) => void; value: T }; -} - -/** Single-select menu item */ -export function DropdownMenuSelectItem({ - value, - children, - ...props -}: { value: string; children: React.ReactNode } & React.ComponentProps) { - const { onValueChange } = useDropdownMenuSelectContext(); - return ( - onValueChange(value)}> - {children} - - ); -} - -/** Multi-select checkbox menu item */ -export function DropdownMenuSelectCheckboxItem({ - value, - children, - ...props -}: { value: string; children: React.ReactNode } & React.ComponentProps) { - const { onValueChange, value: selected } = useDropdownMenuSelectContext(); - const isChecked = Array.isArray(selected) && selected.includes(value); - const handleChange = () => { - const newValue = isChecked ? selected.filter((v) => v !== value) : [...(selected || []), value]; - onValueChange(newValue); - }; - return ( - - {children} - - ); -} - -/** Radio-select menu item */ -export function DropdownMenuSelectRadioItem({ - value: itemValue, - children, - ...props -}: { value: string; children: React.ReactNode } & React.ComponentProps) { - const { onValueChange } = useDropdownMenuSelectContext(); - return ( - onValueChange(itemValue)}> - {children} - - ); -} diff --git a/packages/components/src/ui/form.tsx b/packages/components/src/ui/form.tsx index 867b5ce7..0fda05ee 100644 --- a/packages/components/src/ui/form.tsx +++ b/packages/components/src/ui/form.tsx @@ -92,13 +92,24 @@ export interface FormControlProps extends React.ComponentProps { } export function FormControl({ Component, ...props }: FormControlProps) { - const { formItemId, formDescriptionId, formMessageId, error, ...restProps } = props; + const context = React.useContext(FormItemContext); + const { + formItemId: fromPropsId, + formDescriptionId: fromPropsDesc, + formMessageId: fromPropsMsg, + error, + ...restProps + } = props; + + const computedId = fromPropsId ?? context.formItemId; + const computedDescriptionId = fromPropsDesc ?? context.formDescriptionId; + const computedMessageId = fromPropsMsg ?? context.formMessageId; const ariaProps = { - id: formItemId, - 'aria-describedby': error ? `${formDescriptionId} ${formMessageId}` : formDescriptionId, + id: computedId, + 'aria-describedby': error ? `${computedDescriptionId} ${computedMessageId}` : computedDescriptionId, 'aria-invalid': !!error, - }; + } as const; if (Component) { return ; @@ -135,17 +146,14 @@ export interface FormMessageProps extends React.HTMLAttributes; } -export function FormMessage({ - Component, - className, - formMessageId, - error, - children, - ...rest -}: FormMessageProps) { +export function FormMessage({ Component, className, formMessageId, error, children, ...rest }: FormMessageProps) { if (Component) { // Ensure custom props do not leak to DOM by not spreading them - return {children}; + return ( + + {children} + + ); } const body = error ? error : children; diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index aa00ae24..0c32f085 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -4,7 +4,6 @@ export * from './checkbox-field'; export * from './date-picker'; export * from './date-picker-field'; export * from './dropdown-menu'; -export * from './dropdown-menu-select-field'; export * from './form'; export * from './form-error-field'; export * from './label'; @@ -30,3 +29,8 @@ export * from './badge'; export * from './command'; export * from './select'; export * from './separator'; +export * from './us-state-select'; +export * from './canada-province-select'; +export * from './data/us-states'; +export * from './data/canada-provinces'; + diff --git a/packages/components/src/ui/phone-input-field.tsx b/packages/components/src/ui/phone-input-field.tsx index 175f547b..05b16e29 100644 --- a/packages/components/src/ui/phone-input-field.tsx +++ b/packages/components/src/ui/phone-input-field.tsx @@ -56,14 +56,12 @@ export const PhoneInputField = function PhoneInputField({ {...field} {...props} ref={ref} - className={cn('w-full', props.className)} + className={cn('w-full', className)} inputClassName={cn('focus-visible:ring-0 focus-visible:ring-offset-0 border-input', inputClassName)} />
- {description && ( - {description} - )} + {description && {description}} {fieldState.error && ( {fieldState.error.message} )} diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 10dcb895..c62399fd 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -1,9 +1,8 @@ -import { Check, ChevronDown } from 'lucide-react'; +import { Popover } from '@radix-ui/react-popover'; +import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react'; import * as React from 'react'; - -import { Button } from './button'; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command'; -import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { useOverlayTriggerState } from 'react-stately'; +import { PopoverContent, PopoverTrigger } from './popover'; import { cn } from './utils'; export interface SelectOption { @@ -11,13 +10,28 @@ export interface SelectOption { value: string; } -interface SelectProps { +export interface SelectUIComponents { + Trigger?: React.ComponentType & React.RefAttributes>; + Item?: React.ComponentType< + React.ButtonHTMLAttributes & { selected?: boolean } & React.RefAttributes + >; + SearchInput?: React.ComponentType< + React.InputHTMLAttributes & React.RefAttributes + >; + CheckIcon?: React.ComponentType>; + ChevronIcon?: React.ComponentType>; +} + +export interface SelectProps extends Omit, 'value' | 'onChange'> { options: SelectOption[]; value?: string; onValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; className?: string; + contentClassName?: string; + itemClassName?: string; + components?: Partial; } export function Select({ @@ -27,47 +41,173 @@ export function Select({ placeholder = 'Select an option', disabled = false, className, + contentClassName, + itemClassName, + components, + ...buttonProps }: SelectProps) { - const [open, setOpen] = React.useState(false); + const popoverState = useOverlayTriggerState({}); + const listboxId = React.useId(); + const [query, setQuery] = React.useState(''); + const triggerRef = React.useRef(null); + const popoverRef = React.useRef(null); + const selectedItemRef = React.useRef(null); + const [menuWidth, setMenuWidth] = React.useState(undefined); + + React.useEffect(() => { + if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); + }, []); + + // Scroll to selected item when dropdown opens + React.useEffect(() => { + if (popoverState.isOpen && selectedItemRef.current) { + // Use setTimeout to ensure the DOM is fully rendered + setTimeout(() => { + selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); + }, 0); + } + }, [popoverState.isOpen]); + + const selectedOption = options.find((o) => o.value === value); + + const filtered = React.useMemo( + () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), + [options, query], + ); + + // Candidate that would be chosen on Enter (exact match else first filtered) + const enterCandidate = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (filtered.length === 0) return undefined; + const exact = q ? filtered.find((o) => o.label.toLowerCase() === q) : undefined; + return exact ?? filtered[0]; + }, [filtered, query]); + + const Trigger = + components?.Trigger || + React.forwardRef>((props, ref) => ( + + {selectedOption?.label || placeholder} + + - - - - No option found. - - - {options.map((option) => ( - { - onValueChange?.(option.value); - setOpen(false); - }} - className="flex items-center" - > - - {option.label} - - ))} - - - + for PopoverContent to ensure keyboard accessibility and focus management + role="listbox" + id={listboxId} + style={{ width: menuWidth ? `${menuWidth}px` : undefined }} + > +
+
+ 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, + )} + // biome-ignore lint/a11y/useSemanticElements: using
  • + ); + })} +
+
); 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 ( +