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