-
Notifications
You must be signed in to change notification settings - Fork 4
feat: focus visually first invalid field across composed forms #1682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a7ef751
eba3ce2
7ff313b
ec6feb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import type { ReactNode } from 'react' | ||
| import { FieldElementRegistryContext, type FieldElementRegistry } from './fieldElementRegistry' | ||
|
|
||
| interface FieldElementRegistryProviderProps { | ||
| registry: FieldElementRegistry | null | undefined | ||
| children: ReactNode | ||
| } | ||
|
|
||
| /** | ||
| * Publishes a `FieldElementRegistry` via context so `useField` can populate it. | ||
| * `SDKFormProvider` wires this automatically; partners who build their own form | ||
| * surface can wrap with this provider directly to opt into cross-form focus. | ||
| */ | ||
| export function FieldElementRegistryProvider({ | ||
| registry, | ||
| children, | ||
| }: FieldElementRegistryProviderProps) { | ||
| return ( | ||
| <FieldElementRegistryContext.Provider value={registry ?? null}> | ||
| {children} | ||
| </FieldElementRegistryContext.Provider> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { renderHook } from '@testing-library/react' | ||
| import { describe, test, expect } from 'vitest' | ||
| import { useFieldElementRegistry } from './fieldElementRegistry' | ||
|
|
||
| function makeElement(): HTMLElement { | ||
| return document.createElement('input') | ||
| } | ||
|
|
||
| describe('FieldElementRegistry', () => { | ||
| test('register stores the element under the given name', () => { | ||
| const { result } = renderHook(() => useFieldElementRegistry()) | ||
| const registry = result.current | ||
| const input = makeElement() | ||
|
|
||
| registry.register('firstName', input) | ||
|
|
||
| expect(registry.get('firstName')).toBe(input) | ||
| expect(registry.names()).toEqual(['firstName']) | ||
| }) | ||
|
|
||
| test('register with null deletes the entry (cleanup path)', () => { | ||
| const { result } = renderHook(() => useFieldElementRegistry()) | ||
| const registry = result.current | ||
| const input = makeElement() | ||
|
|
||
| registry.register('firstName', input) | ||
| expect(registry.get('firstName')).toBe(input) | ||
|
|
||
| registry.register('firstName', null) | ||
|
|
||
| expect(registry.get('firstName')).toBeNull() | ||
| expect(registry.names()).toEqual([]) | ||
| }) | ||
|
|
||
| test('register with null is a no-op when the entry was never registered', () => { | ||
| const { result } = renderHook(() => useFieldElementRegistry()) | ||
| const registry = result.current | ||
|
|
||
| expect(() => { | ||
| registry.register('neverRegistered', null) | ||
| }).not.toThrow() | ||
| expect(registry.get('neverRegistered')).toBeNull() | ||
| expect(registry.names()).toEqual([]) | ||
| }) | ||
|
|
||
| test('register with null only removes the targeted entry', () => { | ||
| const { result } = renderHook(() => useFieldElementRegistry()) | ||
| const registry = result.current | ||
| const firstNameInput = makeElement() | ||
| const lastNameInput = makeElement() | ||
|
|
||
| registry.register('firstName', firstNameInput) | ||
| registry.register('lastName', lastNameInput) | ||
|
|
||
| registry.register('firstName', null) | ||
|
|
||
| expect(registry.get('firstName')).toBeNull() | ||
| expect(registry.get('lastName')).toBe(lastNameInput) | ||
| expect(registry.names()).toEqual(['lastName']) | ||
| }) | ||
|
|
||
| test('register with a new element overwrites the previous one', () => { | ||
| const { result } = renderHook(() => useFieldElementRegistry()) | ||
| const registry = result.current | ||
| const firstInput = makeElement() | ||
| const secondInput = makeElement() | ||
|
|
||
| registry.register('firstName', firstInput) | ||
| registry.register('firstName', secondInput) | ||
|
|
||
| expect(registry.get('firstName')).toBe(secondInput) | ||
| expect(registry.names()).toEqual(['firstName']) | ||
| }) | ||
|
|
||
| test('get returns null for unknown names', () => { | ||
| const { result } = renderHook(() => useFieldElementRegistry()) | ||
| const registry = result.current | ||
|
|
||
| expect(registry.get('unknown')).toBeNull() | ||
| }) | ||
|
|
||
| test('useFieldElementRegistry returns a stable instance across renders', () => { | ||
| const { result, rerender } = renderHook(() => useFieldElementRegistry()) | ||
| const first = result.current | ||
|
|
||
| rerender() | ||
|
|
||
| expect(result.current).toBe(first) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { createContext, useContext, useRef } from 'react' | ||
|
|
||
| /** | ||
| * Per-form map of registered field `name` → DOM element. Populated by `useField` | ||
| * via a ref callback, consumed by `composeSubmitHandler` to focus the visually | ||
| * first invalid field across multiple composed forms. | ||
| * | ||
| * Lives here (next to `useField`) rather than in `partner-hook-utils` so the | ||
| * established module-boundary direction (`partner-hook-utils` → `Common`) is | ||
| * preserved. Partner code consumes it via re-exports from `partner-hook-utils/form`. | ||
| */ | ||
| export interface FieldElementRegistry { | ||
| register: (name: string, element: HTMLElement | null) => void | ||
| get: (name: string) => HTMLElement | null | ||
| names: () => string[] | ||
| } | ||
|
|
||
| function createFieldElementRegistry(): FieldElementRegistry { | ||
| const elements = new Map<string, HTMLElement>() | ||
| return { | ||
| register(name, element) { | ||
| if (element) { | ||
| elements.set(name, element) | ||
| } else { | ||
| elements.delete(name) | ||
| } | ||
| }, | ||
| get(name) { | ||
| return elements.get(name) ?? null | ||
| }, | ||
| names() { | ||
| return Array.from(elements.keys()) | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Creates a stable `FieldElementRegistry` scoped to the caller's lifetime. | ||
| * Intended to be called once per form hook (via `useHookFormInternals`). | ||
| */ | ||
| export function useFieldElementRegistry(): FieldElementRegistry { | ||
| const ref = useRef<FieldElementRegistry | null>(null) | ||
| if (ref.current === null) { | ||
| ref.current = createFieldElementRegistry() | ||
| } | ||
| return ref.current | ||
| } | ||
|
|
||
| export const FieldElementRegistryContext = createContext<FieldElementRegistry | null>(null) | ||
|
|
||
| export function useFieldElementRegistryContext(): FieldElementRegistry | null { | ||
| return useContext(FieldElementRegistryContext) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import type { Control, RegisterOptions } from 'react-hook-form' | ||
| import { useController, useFormContext } from 'react-hook-form' | ||
| import React, { useMemo, type Ref } from 'react' | ||
| import React, { useCallback, useMemo, type Ref } from 'react' | ||
| import { useFieldElementRegistryContext } from './fieldElementRegistry' | ||
| import { createMarkup } from '@/helpers/formattedStrings' | ||
| import { useForkRef } from '@/hooks/useForkRef/useForkRef' | ||
|
|
||
|
|
@@ -64,7 +65,16 @@ export function useField<TValue = string, TRef = HTMLInputElement>({ | |
|
|
||
| const { value } = field | ||
|
|
||
| const ref = useForkRef(field.ref, inputRef) | ||
| const fieldElementRegistry = useFieldElementRegistryContext() | ||
| const registryRef = useCallback( | ||
| (element: HTMLElement | null) => { | ||
| if (!fieldElementRegistry) return | ||
| fieldElementRegistry.register(name, element) | ||
| }, | ||
|
Comment on lines
+68
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor | The new registryRef callback in useField is not tested in useField.test.tsx. While it's exercised indirectly via the composeSubmitHandler.focus.test.tsx integration test, useField's own test file has no coverage for the registry registration/unregistration lifecycle.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added direct coverage in 7ff313b. |
||
| [fieldElementRegistry, name], | ||
| ) | ||
|
|
||
| const ref = useForkRef(field.ref, inputRef, registryRef) | ||
|
|
||
| const handleChange = (updatedValue: TValue) => { | ||
| const value = transform ? transform(updatedValue) : updatedValue | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Minor |
test-coverageThe cleanup/unmount path (register called with null to delete an entry) has no direct test. The integration tests verify registration works but don't explicitly exercise field unregistration when a component unmounts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added direct coverage in 7ff313b. New
fieldElementRegistry.test.tsxexercises the cleanup path explicitly:register(name, null)deleting an existing entry, no-op when the entry was never registered, and isolation (deleting one entry doesn't affect siblings). Also covers overwrite-via-re-register and stable instance across renders.