From ea1bb7e249e2acb49e904f859281eb11dc46f382 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 7 Aug 2026 11:00:54 +1000 Subject: [PATCH] Give form components a ref to their underlying DOM control (#363) * Give form components a ref to their underlying DOM control Primitives that render the control themselves (InputGroupInput, ComboboxInput) take a plain `ref`. Composed fields (TextField, ComboboxField, Checkbox) take `inputRef` and no plain `ref`, so a ref can never silently resolve to a wrapper element. Checkbox's `inputRef` is widened from React Aria's ref-object-only type to React's `Ref`, bridged with `useObjectRef`, so callback refs (what React Hook Form hands out) work. * Correct the Checkbox inputRef bridge comment React Aria merges the ref itself, so a callback ref would work at runtime. The bridge is there because RAC's declared type is ref-object-only. * Document React Hook Form integration and drop icon gallery's ref workaround Add a docs page covering the Controller pattern, why register does not fit a value-based onChange, and how shouldFocusError depends on inputRef being wired up. Add a runnable example using real react-hook-form. Replace the icon gallery's querySelector ref hack with TextField's inputRef now that it exists. * Make react-hook-form resolvable in the docs playground Add it as a runtime module specifier and to the editor's type-dependency allowlist so the React Hook Form example's "Open in playground" link compiles and renders instead of erroring on the unresolved import. * Set validationBehavior to aria in the React Hook Form example Fields default to native browser validation, which blocks submission before Controller's rules ever run. Document the aria behaviour and apply it in the example so it models the pattern people should extend. * Move the React Hook Form guide into Guides and make its examples live Group the guide under a new Form libraries section so a second form library can sit beside it, replace the fenced snippets with runnable examples, drop the reset button, and announce the submitted state through a live region that is in the DOM before the message arrives. * Add a second field to the React Hook Form Controller example Give the main example an Email field alongside Name so submitting an empty form demonstrates focusing the first invalid field, the behaviour the guide's focus-on-error section describes. * Group the React Hook Form guide under Forms and validate its examples with Zod Fold the form-libraries group into the Forms guide, so the guide becomes content/docs/forms/index.mdx and React Hook Form sits beside it at /forms/react-hook-form. Cut the guide's padding and drop the Why not register section, which restated the intro. Replace the examples' inline Controller rules with a Zod schema and zodResolver, infer the form value types from the schema instead of passing a generic, and derive the submitted state from formState.isSubmitSuccessful rather than mirroring it in useState. Add @hookform/resolvers, and register it and zod in the playground scope and type allowlist so the examples still open in the playground. * Add a TanStack Form guide with live examples * Fix the validationBehavior explanation on the React Hook Form guide * Flatten the Forms guide into the top-level Guides list Move forms/index.mdx, forms/react-hook-form.mdx, and forms/tanstack-form.mdx up to the docs root so fumadocs renders Guides as a flat list instead of a collapsible folder. Update the root meta.json order and fix the cross-links that pointed at the old nested URLs. * Restructure the form library guides and focus invalid fields generically --- apps/docs/content/docs/forms.mdx | 3 +- apps/docs/content/docs/meta.json | 2 + apps/docs/content/docs/react-hook-form.mdx | 87 +++++++++ apps/docs/content/docs/tanstack-form.mdx | 112 +++++++++++ apps/docs/package.json | 3 + .../docs/scripts/generate-playground-scope.ts | 14 +- .../docs/scripts/generate-playground-types.ts | 9 + apps/docs/src/components/icon-gallery.tsx | 8 +- .../forms/react-hook-form-checkbox.tsx | 52 +++++ .../src/examples/forms/react-hook-form.tsx | 66 +++++++ .../examples/forms/tanstack-form-checkbox.tsx | 74 +++++++ .../docs/src/examples/forms/tanstack-form.tsx | 85 +++++++++ .../src/checkbox/checkbox.browser.test.tsx | 90 +++++++++ .../@luke-ui/react/src/checkbox/index.tsx | 23 ++- .../combobox-field.browser.test.tsx | 142 ++++++++++++++ .../react/src/combobox-field/index.tsx | 13 +- .../src/combobox-field/primitive/input.tsx | 7 +- .../@luke-ui/react/src/text-field/index.tsx | 12 +- .../react/src/text-field/primitive/index.tsx | 7 +- .../text-field/text-field.browser.test.tsx | 116 +++++++++++ pnpm-lock.yaml | 180 ++++++++++++++++++ pnpm-workspace.yaml | 3 + 22 files changed, 1090 insertions(+), 18 deletions(-) create mode 100644 apps/docs/content/docs/react-hook-form.mdx create mode 100644 apps/docs/content/docs/tanstack-form.mdx create mode 100644 apps/docs/src/examples/forms/react-hook-form-checkbox.tsx create mode 100644 apps/docs/src/examples/forms/react-hook-form.tsx create mode 100644 apps/docs/src/examples/forms/tanstack-form-checkbox.tsx create mode 100644 apps/docs/src/examples/forms/tanstack-form.tsx diff --git a/apps/docs/content/docs/forms.mdx b/apps/docs/content/docs/forms.mdx index c91e31d3..97df9f74 100644 --- a/apps/docs/content/docs/forms.mdx +++ b/apps/docs/content/docs/forms.mdx @@ -89,4 +89,5 @@ Luke UI supports browser validation, custom validation, and server validation. T - Pass `validationErrors` to React Aria's `Form` for server errors. Key each error by the field `name`. -Luke UI does not provide recipes for third-party form libraries. Start with native form behaviour. +Start with native form behaviour. When a form library owns the form state, read +[React Hook Form](/react-hook-form) or [TanStack Form](/tanstack-form). diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index f671bfdf..7bfef02b 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -18,6 +18,8 @@ "token-reference", "---Guides---", "forms", + "react-hook-form", + "tanstack-form", "quality" ] } diff --git a/apps/docs/content/docs/react-hook-form.mdx b/apps/docs/content/docs/react-hook-form.mdx new file mode 100644 index 00000000..60dfff14 --- /dev/null +++ b/apps/docs/content/docs/react-hook-form.mdx @@ -0,0 +1,87 @@ +--- +title: React Hook Form +description: Wire Luke UI fields to React Hook Form with Controller. +--- + +React Hook Form owns the form state, and Luke UI renders the controls. + +## Initialise the form + +Call `useForm` with `defaultValues` and a resolver, and keep the result as `form`. React Hook Form +takes the form's value types from the Zod schema, so no hand-written type repeats it. + +```tsx +const schema = z.object({ + email: z.email('Enter an email address in the form you@example.com.'), + name: z.string().min(1, 'Enter your name.'), +}); + +const form = useForm({ + defaultValues: { email: '', name: '' }, + resolver: zodResolver(schema), +}); +``` + +## Integrate components + +Wrap each control in `Controller`. Its `render` prop hands you `field` and `fieldState`. Pass +`field.value`, `field.onChange`, and `field.onBlur` to the control, and give `field.ref` to +`inputRef`. + + + +A checkbox reads its value from `isSelected`. + + + +`TextField` and `Checkbox` render a label, description, and error message around the control, so +`inputRef` is what reaches the input underneath. A primitive that renders the control itself, such +as `ComboboxInput`, takes `field.ref` on `ref`. + +## Validation + +Read the message from `fieldState.error` and pass `fieldState.invalid` to `isInvalid`. + +```tsx + ( + + )} +/> +``` + +Set `validationBehavior="aria"` on every field a `Controller` wraps, so React Hook Form stays the +only thing deciding whether the form is valid. Fields default to `validationBehavior="native"`, +which hands each field's error state to the browser. React Aria then calls `setCustomValidity` on a +field the resolver rejected, so the browser blocks the form before the `submit` event fires and +pressing the button does nothing. + +Read [Validation](/components/forms/validation) for the other `validationBehavior` options. + +## Focus the first invalid field + +React Hook Form focuses the first invalid control after a failed submission, using the ref each +field registered. A field that never receives `field.ref` stays unfocused, and the person filling in +the form gets an error message without being taken to it. + +Set `shouldFocusError: false` on `useForm` to turn this off. + +## Submitting data + +Wrap the submit handler in `form.handleSubmit`. It runs the schema first, then calls the handler +with the values. + +```tsx +
saveAccount(values))}> +``` diff --git a/apps/docs/content/docs/tanstack-form.mdx b/apps/docs/content/docs/tanstack-form.mdx new file mode 100644 index 00000000..2ac84497 --- /dev/null +++ b/apps/docs/content/docs/tanstack-form.mdx @@ -0,0 +1,112 @@ +--- +title: TanStack Form +description: Wire Luke UI fields to TanStack Form with form.Field. +--- + +TanStack Form owns the form state, and Luke UI renders the controls. + +## Initialise the form + +Call `useForm` with `defaultValues` and a submit handler. `revalidateLogic` holds validation back +until the first submit, then revalidates each field as it changes. + +```tsx +const schema = z.object({ + email: z.email('Enter an email address in the form you@example.com.'), + name: z.string().min(1, 'Enter your name.'), +}); + +const form = useForm({ + defaultValues: { email: '', name: '' }, + onSubmit: ({ value }) => saveAccount(value), + validationLogic: revalidateLogic({ mode: 'submit', modeAfterSubmission: 'change' }), + validators: { onDynamic: schema, onSubmit: schema }, +}); +``` + +TanStack Form accepts any Standard Schema validator, so Zod needs no resolver package. The form's +value types come from `defaultValues`, and TypeScript checks the schema against them. + +## Integrate components + +Give `form.Field` a `name` and a children function. Read the value from `field.state.value`, pass +`field.handleChange` to `onChange`, and pass `field.handleBlur` to `onBlur`. + + + +A checkbox reads its value from `isSelected`. + + + +## Validation + +Read the message from `field.state.meta.errors` and pass `field.state.meta.isValid` to `isInvalid`. + +```tsx + + {(field) => ( + + )} + +``` + +Set `validationBehavior="aria"` on every field a `form.Field` wraps, so TanStack Form stays the only +thing deciding whether the form is valid. Fields default to `validationBehavior="native"`, which +hands each field's error state to the browser. React Aria then calls `setCustomValidity` on a field +the schema rejected, so the browser blocks the form before the `submit` event fires and pressing the +button does nothing. + +Read [Validation](/components/forms/validation) for the other `validationBehavior` options. + +## Focus the first invalid field + +TanStack Form leaves focus where it is after a failed submission. Hold a ref to the `` element +and search it for the first control marked `aria-invalid` from `onSubmitInvalid`. + +```tsx +const FOCUSABLE_SELECTOR = + 'input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'; + +function focusFirstInvalidField(form: HTMLFormElement | null) { + const invalid = form?.querySelector('[aria-invalid="true"]'); + if (!invalid) return; + const control = invalid.matches(FOCUSABLE_SELECTOR) + ? invalid + : invalid.querySelector(FOCUSABLE_SELECTOR); + if (control instanceof HTMLElement) control.focus(); +} +``` + +`TextField`, `Checkbox`, and `ComboboxField` each mark the control itself, so the first branch +matches. The second branch covers a grouped control that marks a wrapping element instead. One ref +on the form covers every field, whatever the form grows to hold. + +Reach for `inputRef` when you need a ref to one specific control. `TextField` and `Checkbox` render +a label, description, and error message around the control, so `inputRef` is what reaches the input +underneath. A primitive that renders the control itself, such as `ComboboxInput`, takes a plain +`ref`. + +## Submitting data + +Call `form.handleSubmit()` from the form's `onSubmit`, after `event.preventDefault()`. + +```tsx + { + event.preventDefault(); + void form.handleSubmit(); + }} + ref={formRef} +> +``` + +TanStack Form runs the schema, then calls `onSubmit` with the values or `onSubmitInvalid` with the +form API. diff --git a/apps/docs/package.json b/apps/docs/package.json index d7ede437..9ed3acd3 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -31,9 +31,11 @@ }, "dependencies": { "@catppuccin/palette": "catalog:", + "@hookform/resolvers": "catalog:", "@luke-ui/react": "workspace:*", "@monaco-editor/react": "catalog:", "@react-aria/utils": "catalog:", + "@tanstack/react-form": "catalog:", "@tanstack/react-router": "catalog:", "@tanstack/react-start": "catalog:", "@tanstack/start-static-server-functions": "catalog:", @@ -49,6 +51,7 @@ "react-aria-components": "catalog:", "react-dom": "catalog:", "react-error-boundary": "catalog:", + "react-hook-form": "catalog:", "react-resizable-panels": "catalog:", "spin-doctor": "catalog:", "sucrase": "catalog:", diff --git a/apps/docs/scripts/generate-playground-scope.ts b/apps/docs/scripts/generate-playground-scope.ts index cee62b97..bf621135 100644 --- a/apps/docs/scripts/generate-playground-scope.ts +++ b/apps/docs/scripts/generate-playground-scope.ts @@ -32,7 +32,19 @@ const lukeUiSpecifiers = Object.keys(reactPackageJson.exports) .sort(); const baseSpecifiers = ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime']; -const specifiers = [...lukeUiSpecifiers, ...baseSpecifiers]; + +// Third-party packages that docs examples import directly (beyond React +// internals above). Add a package here — and to the types allowlist in +// generate-playground-types.ts, if its payload cost is reasonable — whenever +// an example needs to import it in the playground. +const thirdPartySpecifiers = [ + '@hookform/resolvers/zod', + '@tanstack/react-form', + 'react-hook-form', + 'zod', +]; + +const specifiers = [...lukeUiSpecifiers, ...baseSpecifiers, ...thirdPartySpecifiers]; const SPECIFIER_TO_IDENTIFIER_RE = /^@|[^a-zA-Z0-9]+/g; diff --git a/apps/docs/scripts/generate-playground-types.ts b/apps/docs/scripts/generate-playground-types.ts index 7e8021b4..5eadc9e9 100644 --- a/apps/docs/scripts/generate-playground-types.ts +++ b/apps/docs/scripts/generate-playground-types.ts @@ -121,6 +121,8 @@ const typesReactDir = resolvePackageDir(docsPackageJsonPath, '@types/react'); const recipesDir = resolvePackageDir(docsPackageJsonPath, '@vanilla-extract/recipes'); const racDir = resolvePackageDir(docsPackageJsonPath, 'react-aria-components'); const racPackageJsonPath = join(racDir, 'package.json'); +const reactFormDir = resolvePackageDir(docsPackageJsonPath, '@tanstack/react-form'); +const reactFormPackageJsonPath = join(reactFormDir, 'package.json'); const externalTypePackages: Array<[string, string]> = [ ['@types/react', typesReactDir], ['@types/react-dom', resolvePackageDir(docsPackageJsonPath, '@types/react-dom')], @@ -137,6 +139,13 @@ const externalTypePackages: Array<[string, string]> = [ ['@internationalized/date', resolvePackageDir(racPackageJsonPath, '@internationalized/date')], ['@internationalized/number', resolvePackageDir(racPackageJsonPath, '@internationalized/number')], ['@internationalized/string', resolvePackageDir(racPackageJsonPath, '@internationalized/string')], + ['react-hook-form', resolvePackageDir(docsPackageJsonPath, 'react-hook-form')], + ['@hookform/resolvers', resolvePackageDir(docsPackageJsonPath, '@hookform/resolvers')], + ['@tanstack/react-form', reactFormDir], + ['@tanstack/form-core', resolvePackageDir(reactFormPackageJsonPath, '@tanstack/form-core')], + ['@tanstack/react-store', resolvePackageDir(reactFormPackageJsonPath, '@tanstack/react-store')], + ['@tanstack/store', resolvePackageDir(reactFormPackageJsonPath, '@tanstack/store')], + ['zod', resolvePackageDir(docsPackageJsonPath, 'zod')], ]; for (const [packageName, packageDir] of externalTypePackages) { addTypesPackage(packageName, packageDir); diff --git a/apps/docs/src/components/icon-gallery.tsx b/apps/docs/src/components/icon-gallery.tsx index 9f78ce84..b4fedcb5 100644 --- a/apps/docs/src/components/icon-gallery.tsx +++ b/apps/docs/src/components/icon-gallery.tsx @@ -106,14 +106,10 @@ export function IconGallery(): JSX.Element { return (
-
{ - inputRef.current = node?.querySelector('input') ?? null; - }} - > +
} diff --git a/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx b/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx new file mode 100644 index 00000000..4cd9c903 --- /dev/null +++ b/apps/docs/src/examples/forms/react-hook-form-checkbox.tsx @@ -0,0 +1,52 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Box } from '@luke-ui/react/box'; +import { Button } from '@luke-ui/react/button'; +import { Checkbox } from '@luke-ui/react/checkbox'; +import { Text } from '@luke-ui/react/text'; +import { Controller, useForm } from 'react-hook-form'; +import * as z from 'zod'; + +const schema = z.object({ + terms: z.boolean().refine((accepted) => accepted, { + error: 'Accept the terms of service before you continue.', + }), +}); + +export default () => { + const form = useForm({ + defaultValues: { terms: false }, + resolver: zodResolver(schema), + }); + + return ( + + undefined)}> + + ( + + I accept the terms of service + + )} + /> + + + + + + + {form.formState.isSubmitSuccessful ? 'Terms accepted.' : null} + + + ); +}; diff --git a/apps/docs/src/examples/forms/react-hook-form.tsx b/apps/docs/src/examples/forms/react-hook-form.tsx new file mode 100644 index 00000000..0069b175 --- /dev/null +++ b/apps/docs/src/examples/forms/react-hook-form.tsx @@ -0,0 +1,66 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Box } from '@luke-ui/react/box'; +import { Button } from '@luke-ui/react/button'; +import { Text } from '@luke-ui/react/text'; +import { TextField } from '@luke-ui/react/text-field'; +import { Controller, useForm } from 'react-hook-form'; +import * as z from 'zod'; + +const schema = z.object({ + email: z.email('Enter an email address in the form you@example.com.'), + name: z.string().min(1, 'Enter your name.'), +}); + +export default () => { + const form = useForm({ + defaultValues: { email: '', name: '' }, + resolver: zodResolver(schema), + }); + + return ( + +
undefined)}> + + ( + + )} + /> + ( + + )} + /> + + + + +
+ + {form.formState.isSubmitSuccessful ? `Submitted: ${form.getValues('name')}` : null} + +
+ ); +}; diff --git a/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx b/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx new file mode 100644 index 00000000..3dac688c --- /dev/null +++ b/apps/docs/src/examples/forms/tanstack-form-checkbox.tsx @@ -0,0 +1,74 @@ +import { Box } from '@luke-ui/react/box'; +import { Button } from '@luke-ui/react/button'; +import { Checkbox } from '@luke-ui/react/checkbox'; +import { Text } from '@luke-ui/react/text'; +import { revalidateLogic, useForm } from '@tanstack/react-form'; +import { useRef } from 'react'; +import * as z from 'zod'; + +const schema = z.object({ + terms: z.boolean().refine((accepted) => accepted, { + error: 'Accept the terms of service before you continue.', + }), +}); + +const FOCUSABLE_SELECTOR = + 'input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'; + +function focusFirstInvalidField(form: HTMLFormElement | null) { + const invalid = form?.querySelector('[aria-invalid="true"]'); + if (!invalid) return; + const control = invalid.matches(FOCUSABLE_SELECTOR) + ? invalid + : invalid.querySelector(FOCUSABLE_SELECTOR); + if (control instanceof HTMLElement) control.focus(); +} + +export default () => { + const formRef = useRef(null); + + const form = useForm({ + defaultValues: { terms: false }, + onSubmit: () => undefined, + onSubmitInvalid: () => focusFirstInvalidField(formRef.current), + validationLogic: revalidateLogic({ mode: 'submit', modeAfterSubmission: 'change' }), + validators: { onDynamic: schema, onSubmit: schema }, + }); + + return ( + +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + ref={formRef} + > + + + {(field) => ( + + I accept the terms of service + + )} + + + + + +
+ + state.isSubmitSuccessful}> + {(isSubmitSuccessful) => (isSubmitSuccessful ? 'Terms accepted.' : null)} + + +
+ ); +}; diff --git a/apps/docs/src/examples/forms/tanstack-form.tsx b/apps/docs/src/examples/forms/tanstack-form.tsx new file mode 100644 index 00000000..6ad265dd --- /dev/null +++ b/apps/docs/src/examples/forms/tanstack-form.tsx @@ -0,0 +1,85 @@ +import { Box } from '@luke-ui/react/box'; +import { Button } from '@luke-ui/react/button'; +import { Text } from '@luke-ui/react/text'; +import { TextField } from '@luke-ui/react/text-field'; +import { revalidateLogic, useForm } from '@tanstack/react-form'; +import { useRef } from 'react'; +import * as z from 'zod'; + +const schema = z.object({ + email: z.email('Enter an email address in the form you@example.com.'), + name: z.string().min(1, 'Enter your name.'), +}); + +const FOCUSABLE_SELECTOR = + 'input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'; + +function focusFirstInvalidField(form: HTMLFormElement | null) { + const invalid = form?.querySelector('[aria-invalid="true"]'); + if (!invalid) return; + const control = invalid.matches(FOCUSABLE_SELECTOR) + ? invalid + : invalid.querySelector(FOCUSABLE_SELECTOR); + if (control instanceof HTMLElement) control.focus(); +} + +export default () => { + const formRef = useRef(null); + + const form = useForm({ + defaultValues: { email: '', name: '' }, + onSubmit: () => undefined, + onSubmitInvalid: () => focusFirstInvalidField(formRef.current), + validationLogic: revalidateLogic({ mode: 'submit', modeAfterSubmission: 'change' }), + validators: { onDynamic: schema, onSubmit: schema }, + }); + + return ( + +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + ref={formRef} + > + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + +
+ + (state.isSubmitSuccessful ? state.values.name : '')}> + {(submittedName) => (submittedName ? `Submitted: ${submittedName}` : null)} + + +
+ ); +}; diff --git a/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx b/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx index 9cda055c..b20810cd 100644 --- a/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx +++ b/packages/@luke-ui/react/src/checkbox/checkbox.browser.test.tsx @@ -1,3 +1,4 @@ +import { createRef } from 'react'; import { afterEach, expect, test } from 'vite-plus/test'; import { cdp, page, userEvent } from 'vite-plus/test/context'; import type { Locator } from 'vite-plus/test/context'; @@ -301,6 +302,95 @@ test('a valid checkbox keeps its accent interaction colours', async () => { ); }); +// `inputRef` must land on the hidden ``, not on the wrapper +// `
` React Aria's own `ref` targets. +test('resolves an inputRef object to the checkbox input, not a wrapper', async () => { + const ref = createRef(); + renderVisual( + + Terms + , + ); + + const checkbox = page.getByRole('checkbox', { name: 'Terms' }); + await expect.element(checkbox).toBeVisible(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current).toBe(checkbox.element()); +}); + +// React Aria types its own `inputRef` as a ref object, so this is the case our +// widened prop plus the `useObjectRef` bridge exists for: React Hook Form's +// `field.ref` is a callback. +test('resolves a callback inputRef to the checkbox input', async () => { + const resolved: Array = []; + renderVisual( + { + resolved.push(node); + }} + name="terms" + > + Terms + , + ); + + const checkbox = page.getByRole('checkbox', { name: 'Terms' }); + await expect.element(checkbox).toBeVisible(); + + expect(resolved.at(-1)).toBeInstanceOf(HTMLInputElement); + expect(resolved.at(-1)).toBe(checkbox.element()); +}); + +test('forwards name to the input so a native form submit collects it', async () => { + const scene = renderVisual( +
+ Terms +
, + ); + + const checkbox = page.getByRole('checkbox', { name: 'Terms' }); + await expect.element(checkbox).toHaveAttribute('name', 'terms'); + + const form = scene.element().querySelector('form'); + if (form == null) throw new Error('Expected the form element.'); + expect(new FormData(form).get('terms')).toBe(null); + + // The input itself is visually hidden behind the indicator, so the clickable + // content label is the only hit target — same as every other test in this file. + await userEvent.click(contentFor(checkbox)); + expect(new FormData(form).get('terms')).toBe('on'); +}); + +test('forwards onBlur to the input', async () => { + const blurs: Array = []; + renderVisual( + <> + { + blurs.push('terms'); + }} + > + Terms + + + , + ); + + const checkbox = page.getByRole('checkbox', { name: 'Terms' }); + await expect.element(checkbox).toBeVisible(); + + // Tabbed rather than clicked: the input is visually hidden behind the indicator, + // so it is not its own hit target. + await userEvent.tab(); + await expect.element(checkbox).toHaveFocus(); + expect(blurs).toEqual([]); + + await userEvent.click(page.getByRole('button', { name: 'Next' })); + expect(blurs).toEqual(['terms']); +}); + /** Focuses `checkbox` with a real keyboard press and returns once it is held. */ async function pressViaKeyboard(checkbox: Locator) { await userEvent.tab(); diff --git a/packages/@luke-ui/react/src/checkbox/index.tsx b/packages/@luke-ui/react/src/checkbox/index.tsx index b62eafa8..e9b57b13 100644 --- a/packages/@luke-ui/react/src/checkbox/index.tsx +++ b/packages/@luke-ui/react/src/checkbox/index.tsx @@ -1,4 +1,5 @@ -import type { JSX, ReactNode } from 'react'; +import { useObjectRef } from '@react-aria/utils'; +import type { JSX, ReactNode, Ref } from 'react'; import type { CheckboxFieldProps as RacCheckboxFieldProps } from 'react-aria-components/Checkbox'; import { FieldDescription, FieldError } from '../field/primitive/index.js'; import type { FieldErrorProps } from '../field/primitive/index.js'; @@ -12,11 +13,21 @@ import { } from './primitive/index.js'; import type { CheckboxProps as PrimitiveCheckboxProps } from './primitive/index.js'; -type _CheckboxOmit = DistributiveOmit; +type _CheckboxOmit = DistributiveOmit; interface _CheckboxProps extends _CheckboxOmit { /** Checkbox label content. */ children: ReactNode; + /** + * Forwarded to the underlying `` element. + * + * Composed fields take no plain `ref`: `inputRef` is the only way to reach the + * control, so a ref can never silently resolve to a wrapper element instead. + * + * Widened from React Aria's own `inputRef`, which only takes a ref object, so a + * callback ref (what form libraries hand out) is accepted too. + */ + inputRef?: Ref; /** * Visual size of the checkbox control. * @@ -54,10 +65,14 @@ export type CheckboxProps = Prettify<_CheckboxProps>; /** A labelled checkbox with optional description and validation message. */ export function Checkbox(props: CheckboxProps): JSX.Element { - const { children, description, errorMessage, ...checkboxProps } = props; + const { children, description, errorMessage, inputRef, ...checkboxProps } = props; + // React Aria types its own `inputRef` as a ref object, so a callback ref is a type + // error even though it would work: RAC merges the ref itself. `useObjectRef` gives + // the declared type what it asks for rather than leaning on that internal detail. + const objectInputRef = useObjectRef(inputRef); return ( - + diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx index d0bb4c01..8e5313d3 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx @@ -1,3 +1,4 @@ +import { createRef } from 'react'; import { afterEach, expect, test } from 'vite-plus/test'; import { page, userEvent } from 'vite-plus/test/context'; import { @@ -6,7 +7,10 @@ import { } from '../recipes/combobox.css.js'; import { cleanupVisual, renderVisual } from '../test-utils/render-visual.js'; import { ComboboxField } from './index.js'; +import { ComboboxInputGroup } from './primitive/input-group.js'; +import { ComboboxInput } from './primitive/input.js'; import { ComboboxItem } from './primitive/item.js'; +import { ComboboxRoot } from './primitive/root.js'; type CountryItem = { id: string; @@ -187,6 +191,144 @@ test('the indicator icon adds no text to the accessible name', async () => { await expect.element(input).toHaveAccessibleName('Invalid'); }); +// The primitive renders the control itself, so it takes a plain `ref`. +test('ComboboxInput resolves a ref object to the input element', async () => { + const ref = createRef(); + renderVisual( + aria-label="Country" defaultItems={countryItems}> + + + + , + ); + + const input = page.getByRole('combobox', { name: 'Country' }); + await expect.element(input).toBeVisible(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current).toBe(input.element()); +}); + +test('ComboboxInput resolves a callback ref to the input element', async () => { + const resolved: Array = []; + renderVisual( + aria-label="Country" defaultItems={countryItems}> + + { + resolved.push(node); + }} + /> + + , + ); + + const input = page.getByRole('combobox', { name: 'Country' }); + await expect.element(input).toBeVisible(); + + expect(resolved.at(-1)).toBeInstanceOf(HTMLInputElement); + expect(resolved.at(-1)).toBe(input.element()); +}); + +// The composed field takes no plain `ref`, so `inputRef` must reach the editable +// text input rather than the root `
` or the control group around it. +test('ComboboxField resolves inputRef to the input element, not a wrapper', async () => { + const ref = createRef(); + renderVisual( + + {renderCountryItem} + , + ); + + const input = page.getByRole('combobox', { name: 'Country' }); + await expect.element(input).toBeVisible(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current).toBe(input.element()); +}); + +test('ComboboxField resolves a callback inputRef to the input element', async () => { + const resolved: Array = []; + renderVisual( + { + resolved.push(node); + }} + label="Country" + name="country" + > + {renderCountryItem} + , + ); + + const input = page.getByRole('combobox', { name: 'Country' }); + await expect.element(input).toBeVisible(); + + expect(resolved.at(-1)).toBeInstanceOf(HTMLInputElement); + expect(resolved.at(-1)).toBe(input.element()); +}); + +// `name` lands on the hidden input React Aria renders for form submission, not on +// the visible combobox — the visible one holds the filter text, which is not the +// value. The submitted value is the selected key by default (`formValue`). +test('ComboboxField forwards name so a native form submit collects the selected key', async () => { + const scene = renderVisual( +
+ + {renderCountryItem} + +
, + ); + + const input = page.getByRole('combobox', { name: 'Country' }); + await expect.element(input).toBeVisible(); + expect(input.element()).not.toHaveAttribute('name'); + + const form = scene.element().querySelector('form'); + if (form == null) throw new Error('Expected the form element.'); + expect(new FormData(form).get('country')).toBe(''); + + await userEvent.click(input); + await userEvent.click(page.getByRole('option', { name: 'Canada' })); + + expect(new FormData(form).get('country')).toBe('ca'); +}); + +test('ComboboxField forwards onBlur to the input', async () => { + const blurs: Array = []; + renderVisual( + <> + { + blurs.push('country'); + }} + > + {renderCountryItem} + + + , + ); + + const input = page.getByRole('combobox', { name: 'Country' }); + await expect.element(input).toBeVisible(); + + await userEvent.click(input); + await expect.element(input).toHaveFocus(); + expect(blurs).toEqual([]); + + // Clicking the input opens the listbox, whose popover would swallow the click on + // the button behind it; Escape closes it without moving focus. + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + + await userEvent.click(page.getByRole('button', { name: 'Next' })); + expect(blurs).toEqual(['country']); +}); + /** The control group wrapping the combobox input labelled `name`. */ function getControl(name: string) { const control = page diff --git a/packages/@luke-ui/react/src/combobox-field/index.tsx b/packages/@luke-ui/react/src/combobox-field/index.tsx index 1091a464..7e80510e 100644 --- a/packages/@luke-ui/react/src/combobox-field/index.tsx +++ b/packages/@luke-ui/react/src/combobox-field/index.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, JSX } from 'react'; +import type { CSSProperties, JSX, Ref } from 'react'; import type { ComboBoxProps as RacComboBoxProps } from 'react-aria-components/ComboBox'; import type { FieldSlotProps } from '../field/compose-field.js'; import { composeField } from '../field/compose-field.js'; @@ -40,6 +40,14 @@ interface _ComboboxFieldProps /** Item content for the listbox (render prop or static children). */ children: ComboboxListBoxProps['children']; + /** + * Forwarded to the inner `` element. + * + * Composed fields take no plain `ref`: `inputRef` is the only way to reach the + * control, so a ref can never silently resolve to a wrapper element instead. + */ + inputRef?: Ref; + /** Props forwarded to the inner listbox. */ listBoxProps?: DistributiveOmit, 'children' | 'items' | 'loadMoreItem'>; @@ -77,6 +85,7 @@ export function ComboboxField(props: ComboboxFieldProps): J const [fieldSlotProps, restProps] = composeField(props); const { children, + inputRef, listBoxProps, loadMoreItem: loadMoreItemProp, loadingState, @@ -120,7 +129,7 @@ export function ComboboxField(props: ComboboxFieldProps): J size={size} {...comboboxRootProps}> - + {isInteractive ? ( diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx index 28a65f16..96b59661 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'react'; +import type { JSX, Ref } from 'react'; import { useContext } from 'react'; import type { InputProps as RacInputProps } from 'react-aria-components/ComboBox'; import { ComboBoxStateContext, Input as RacInput } from 'react-aria-components/ComboBox'; @@ -12,6 +12,11 @@ import { useComboboxSize } from './size-context.js'; type _ComboboxInputOmit = DistributiveOmit; interface _ComboboxInputProps extends _ComboboxInputOmit { className?: RacInputProps['className']; + /** + * Forwarded to the underlying `` element. Accepts a callback ref or a ref + * object, so form libraries that hand out callback refs work without a bridge. + */ + ref?: Ref; size?: ComboboxSize; } diff --git a/packages/@luke-ui/react/src/text-field/index.tsx b/packages/@luke-ui/react/src/text-field/index.tsx index 7aa64c94..a4623ae9 100644 --- a/packages/@luke-ui/react/src/text-field/index.tsx +++ b/packages/@luke-ui/react/src/text-field/index.tsx @@ -1,4 +1,4 @@ -import type { JSX, ReactNode } from 'react'; +import type { JSX, ReactNode, Ref } from 'react'; import type { InputProps as RacInputProps, TextFieldProps as RacTextFieldProps, @@ -23,6 +23,13 @@ type _TextFieldOmit = DistributiveOmit` element. + * + * Composed fields take no plain `ref`: `inputRef` is the only way to reach the + * control, so a ref can never silently resolve to a wrapper element instead. + */ + inputRef?: Ref; /** Placeholder text for the input. */ placeholder?: string; /** Element shown before the input value. */ @@ -50,6 +57,7 @@ export function TextField(props: TextFieldProps): JSX.Element { const [fieldSlotProps, restProps] = composeField(props); const { inputClassName, + inputRef, placeholder, prefix, size = 'medium', @@ -62,7 +70,7 @@ export function TextField(props: TextFieldProps): JSX.Element { {prefix != null ? {prefix} : null} - + {suffix != null ? {suffix} : null} diff --git a/packages/@luke-ui/react/src/text-field/primitive/index.tsx b/packages/@luke-ui/react/src/text-field/primitive/index.tsx index 101c5309..4e0079d9 100644 --- a/packages/@luke-ui/react/src/text-field/primitive/index.tsx +++ b/packages/@luke-ui/react/src/text-field/primitive/index.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, JSX } from 'react'; +import type { ComponentProps, JSX, Ref } from 'react'; import { createContext, use } from 'react'; import type { GroupProps as RacGroupProps } from 'react-aria-components/Group'; import { Group as RacGroup } from 'react-aria-components/Group'; @@ -62,6 +62,11 @@ type _InputGroupInputOmit = DistributiveOmit` element. Accepts a callback ref or a ref + * object, so form libraries that hand out callback refs work without a bridge. + */ + ref?: Ref; } /** diff --git a/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx b/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx index 2152a1e8..0c55c31e 100644 --- a/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx +++ b/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx @@ -1,3 +1,4 @@ +import { createRef } from 'react'; import { afterEach, expect, test } from 'vite-plus/test'; import { page, userEvent } from 'vite-plus/test/context'; import { ComboboxField } from '../combobox-field/index.js'; @@ -214,6 +215,121 @@ test('the indicator lands after the input and before a trailing suffix', async ( expect(indicatorRect.left).toBeLessThan(suffixRect.left); }); +// The primitive renders the control itself, so it takes a plain `ref`. Both ref +// shapes are covered: React Hook Form hands out a callback ref, so the callback +// arm is the one that decides whether the component is usable with it at all. +test('InputGroupInput resolves a ref object to the input element', async () => { + const ref = createRef(); + renderVisual( + + + , + ); + + const input = page.getByRole('textbox', { name: 'Amount' }); + await expect.element(input).toBeVisible(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current).toBe(input.element()); +}); + +test('InputGroupInput resolves a callback ref to the input element', async () => { + const resolved: Array = []; + renderVisual( + + { + resolved.push(node); + }} + /> + , + ); + + const input = page.getByRole('textbox', { name: 'Amount' }); + await expect.element(input).toBeVisible(); + + expect(resolved.at(-1)).toBeInstanceOf(HTMLInputElement); + expect(resolved.at(-1)).toBe(input.element()); +}); + +// The composed field takes no plain `ref`, so `inputRef` is the only way in — and +// it must land on the editable control, never on the wrapper `
` or the group. +test('TextField resolves inputRef to the input element, not a wrapper', async () => { + const ref = createRef(); + renderVisual(); + + const input = page.getByRole('textbox', { name: 'Email' }); + await expect.element(input).toBeVisible(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current).toBe(input.element()); +}); + +test('TextField resolves a callback inputRef to the input element', async () => { + const resolved: Array = []; + renderVisual( + { + resolved.push(node); + }} + label="Email" + name="email" + />, + ); + + const input = page.getByRole('textbox', { name: 'Email' }); + await expect.element(input).toBeVisible(); + + expect(resolved.at(-1)).toBeInstanceOf(HTMLInputElement); + expect(resolved.at(-1)).toBe(input.element()); +}); + +// `name` and `onBlur` are the other half of uncontrolled use: without them a caller +// has to reach through `inputRef` to do what a native `` does for free. +test('TextField forwards name to the input so a native form submit collects it', async () => { + const scene = renderVisual( +
+ + , + ); + + const input = page.getByRole('textbox', { name: 'Email' }); + await expect.element(input).toHaveAttribute('name', 'email'); + + await userEvent.fill(input, 'ada@example.com'); + + const form = scene.element().querySelector('form'); + if (form == null) throw new Error('Expected the form element.'); + expect(new FormData(form).get('email')).toBe('ada@example.com'); +}); + +test('TextField forwards onBlur to the input', async () => { + const blurs: Array = []; + renderVisual( + <> + { + blurs.push('email'); + }} + /> + + , + ); + + const input = page.getByRole('textbox', { name: 'Email' }); + await expect.element(input).toBeVisible(); + + await userEvent.click(input); + await expect.element(input).toHaveFocus(); + expect(blurs).toEqual([]); + + await userEvent.click(page.getByRole('button', { name: 'Next' })); + expect(blurs).toEqual(['email']); +}); + // `inputStates.invalid` must not match `:has(:invalid)`: that matches a required, // empty input from first render — before any interaction or submit — while // `aria-invalid` stays null, painting an untouched required field invalid even diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c09993fa..e11786b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@changesets/cli': specifier: ^2.31.1 version: 2.31.1 + '@hookform/resolvers': + specifier: ^5.6.0 + version: 5.6.0 '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0 @@ -48,6 +51,9 @@ catalogs: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3 + '@tanstack/react-form': + specifier: ^1.33.3 + version: 1.33.3 '@tanstack/react-router': specifier: ^1.170.18 version: 1.170.18 @@ -171,6 +177,9 @@ catalogs: react-error-boundary: specifier: ^6.1.2 version: 6.1.2 + react-hook-form: + specifier: ^7.84.0 + version: 7.84.0 react-resizable-panels: specifier: ^4.12.2 version: 4.12.2 @@ -246,6 +255,9 @@ importers: '@catppuccin/palette': specifier: 'catalog:' version: 1.8.0 + '@hookform/resolvers': + specifier: 'catalog:' + version: 5.6.0(@standard-schema/spec@1.1.0)(ajv-errors@3.0.0(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.84.0(react@19.2.7))(zod@4.4.3) '@luke-ui/react': specifier: workspace:* version: link:../../packages/@luke-ui/react @@ -255,6 +267,9 @@ importers: '@react-aria/utils': specifier: 'catalog:' version: 3.34.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-form': + specifier: 'catalog:' + version: 1.33.3(@tanstack/react-start@1.168.34(@voidzero-dev/vite-plus-core@0.2.7(@arethetypeswrong/core@0.18.5)(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.22)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-router': specifier: 'catalog:' version: 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -300,6 +315,9 @@ importers: react-error-boundary: specifier: 'catalog:' version: 6.1.2(react@19.2.7) + react-hook-form: + specifier: 'catalog:' + version: 7.84.0(react@19.2.7) react-resizable-panels: specifier: 'catalog:' version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1128,6 +1146,84 @@ packages: tailwindcss: optional: true + '@hookform/resolvers@5.6.0': + resolution: {integrity: sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==} + peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@typeschema/main': '>=0.13.7' + '@vinejs/vine': ^2.0.0 || ^3.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^0.7.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^3.10.3 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 + nope-validator: '>=0.12.0' + react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=3.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@typeschema/main': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -3074,6 +3170,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/addon-a11y@10.5.5': resolution: {integrity: sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==} peerDependencies: @@ -3283,10 +3382,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/form-core@1.33.3': + resolution: {integrity: sha512-htLxe/50GpUxbi2arJleh6uQkw72UOy+3Q0d1AadO3lfBTjs1e51GzyrKk/w8I7qXSkaSnF/JbYlyE+cwbJGNw==} + '@tanstack/history@1.162.0': resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + + '@tanstack/react-form@1.33.3': + resolution: {integrity: sha512-lkzI/y15fHC8lKvzsLXFLLqGWroa+okvV2cKRCGAL+d0Kdf040fdMZbKh6uCXDMc08Ngpl8G3VZFnZ5KVUkUIw==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + '@tanstack/react-router@1.170.18': resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} engines: {node: '>=20.19'} @@ -3342,6 +3462,12 @@ packages: vite: optional: true + '@tanstack/react-store@0.11.0': + resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-store@0.9.3': resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: @@ -3426,6 +3552,9 @@ packages: resolution: {integrity: sha512-ntkDyGx0PE0opIlWNAMpkMb8qkjR4uyCUOfC0CiT0STM25+EcwPuwYNfDXXeVObMrTAPgsQ4yOj3xdY0Xr4ptw==} engines: {node: '>=22.12.0'} + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} @@ -7182,6 +7311,12 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + react-hook-form@7.84.0: + resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -8959,6 +9094,16 @@ snapshots: optionalDependencies: tailwindcss: 4.3.3 + '@hookform/resolvers@5.6.0(@standard-schema/spec@1.1.0)(ajv-errors@3.0.0(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.84.0(react@19.2.7))(zod@4.4.3)': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.84.0(react@19.2.7) + optionalDependencies: + '@standard-schema/spec': 1.1.0 + ajv: 8.20.0 + ajv-errors: 3.0.0(ajv@8.20.0) + zod: 4.4.3 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -10729,6 +10874,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@storybook/addon-a11y@10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.5)(react@19.2.7)(vite-plus@0.2.7(@arethetypeswrong/core@0.18.5)(@opentelemetry/api@1.9.0)(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(@voidzero-dev/vite-plus-core@0.2.7(@arethetypeswrong/core@0.18.5)(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.22)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.22)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0)))': dependencies: '@storybook/global': 5.0.0 @@ -10919,8 +11066,28 @@ snapshots: tailwindcss: 4.3.3 vite: '@voidzero-dev/vite-plus-core@0.2.7(@arethetypeswrong/core@0.18.5)(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.22)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0)' + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/form-core@1.33.3': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.11.0 + '@tanstack/history@1.162.0': {} + '@tanstack/pacer-lite@0.1.1': {} + + '@tanstack/react-form@1.33.3(@tanstack/react-start@1.168.34(@voidzero-dev/vite-plus-core@0.2.7(@arethetypeswrong/core@0.18.5)(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.22)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/form-core': 1.33.3 + '@tanstack/react-store': 0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@tanstack/react-start': 1.168.34(@voidzero-dev/vite-plus-core@0.2.7(@arethetypeswrong/core@0.18.5)(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.22)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0) + transitivePeerDependencies: + - react-dom + '@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/history': 1.162.0 @@ -11004,6 +11171,13 @@ snapshots: - vite-plugin-solid - webpack + '@tanstack/react-store@0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.11.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/store': 0.9.3 @@ -11146,6 +11320,8 @@ snapshots: dependencies: '@tanstack/router-core': 1.171.15 + '@tanstack/store@0.11.0': {} + '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.162.0': {} @@ -15405,6 +15581,10 @@ snapshots: dependencies: react: 19.2.7 + react-hook-form@7.84.0(react@19.2.7): + dependencies: + react: 19.2.7 + react-is@17.0.2: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.7): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4cd7b2ea..501b400e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,6 +61,7 @@ catalog: '@capsizecss/vanilla-extract': ^2.0.4 '@catppuccin/palette': ^1.8.0 '@changesets/cli': ^2.31.1 + '@hookform/resolvers': ^5.6.0 '@monaco-editor/react': ^4.7.0 '@netlify/vite-plugin-tanstack-start': ^1.3.17 '@react-aria/utils': ^3.34.1 @@ -70,6 +71,7 @@ catalog: '@storybook/addon-vitest': ^10.5.5 '@storybook/react-vite': ^10.5.5 '@tailwindcss/vite': ^4.3.3 + '@tanstack/react-form': ^1.33.3 '@tanstack/react-router': ^1.170.18 '@tanstack/react-start': ^1.168.34 '@tanstack/router-cli': ^1.167.21 @@ -111,6 +113,7 @@ catalog: react-aria-components: ^1.20.0 react-dom: ^19.2.7 react-error-boundary: ^6.1.2 + react-hook-form: ^7.84.0 react-resizable-panels: ^4.12.2 serve: ^14.2.6 spin-doctor: ^0.0.1 -- 2.51.2