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..d543ce41 --- /dev/null +++ b/apps/docs/src/remix-hook-form/select-custom.stories.tsx @@ -0,0 +1,253 @@ +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) => ( + - - ); - })} - - - - - ); -} - diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 1d3e56f6..573e0bee 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -1,164 +1,208 @@ +import { Popover } from '@radix-ui/react-popover'; +import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react'; import * as React from 'react'; -import * as SelectPrimitive from '@radix-ui/react-select'; -import { Check, ChevronDown, ChevronUp } from 'lucide-react'; - +import { useOverlayTriggerState } from 'react-stately'; +import { PopoverContent, PopoverTrigger } from './popover'; import { cn } from './utils'; -const Select = SelectPrimitive.Root; +export interface SelectOption { + label: string; + value: string; +} + +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>; +} -const SelectGroup = SelectPrimitive.Group; +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; +} -const SelectValue = SelectPrimitive.Value; +export function Select({ + options, + value, + onValueChange, + placeholder = 'Select an option', + disabled = false, + className, + contentClassName, + itemClassName, + components, + ...buttonProps +}: SelectProps) { + const popoverState = useOverlayTriggerState({}); + 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); -const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - span]:line-clamp-1', - className - )} - {...props} - > - {children} - - - - -)); -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + React.useEffect(() => { + if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); + }, []); -const SelectScrollUpButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + // 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 SelectScrollDownButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; + const selectedOption = options.find((o) => o.value === value); -const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( - - - - - {children} - - - - -)); -SelectContent.displayName = SelectPrimitive.Content.displayName; + const filtered = React.useMemo( + () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), + [options, query], + ); -const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectLabel.displayName = SelectPrimitive.Label.displayName; + // 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 SelectItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - + const Trigger = + components?.Trigger || + React.forwardRef>((props, ref) => ( +