diff --git a/packages/react/src/checkbox/root/CheckboxRoot.tsx b/packages/react/src/checkbox/root/CheckboxRoot.tsx index 47419321965..7b20f9ea323 100644 --- a/packages/react/src/checkbox/root/CheckboxRoot.tsx +++ b/packages/react/src/checkbox/root/CheckboxRoot.tsx @@ -181,7 +181,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot( const registerFieldInput = validation.registerInput; const registeredInputValue = groupContext ? value : undefined; const registerInput = React.useCallback( - (element: HTMLInputElement | null) => + (element: HTMLInputElement) => registerFieldInput(element, { controlRef, value: registeredInputValue }), [registerFieldInput, registeredInputValue], ); diff --git a/packages/react/src/field/control/FieldControl.test.tsx b/packages/react/src/field/control/FieldControl.test.tsx index 02dc737d371..2a4f3d6f603 100644 --- a/packages/react/src/field/control/FieldControl.test.tsx +++ b/packages/react/src/field/control/FieldControl.test.tsx @@ -1,6 +1,7 @@ import { expect, vi } from 'vitest'; import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils'; import { Field } from '@base-ui/react/field'; +import { Form } from '@base-ui/react/form'; import { describeConformance, isJSDOM } from '#test-utils'; describe('', () => { @@ -56,6 +57,31 @@ describe('', () => { expect(validate.mock.lastCall?.[0]).toBe('a'); }); + it('does not clear errors or validate when change is prevented', async () => { + const validate = vi.fn(); + const handleValueChange = vi.fn(); + + await render( +
+ + + + +
, + ); + + const control = screen.getByRole('textbox'); + control.addEventListener('input', (event) => event.preventDefault(), { + capture: true, + once: true, + }); + fireEvent.input(control, { cancelable: true, target: { value: 'a' } }); + + expect(handleValueChange).toHaveBeenCalledTimes(1); + expect(validate).not.toHaveBeenCalled(); + expect(screen.getByText('Server error')).toBeInTheDocument(); + }); + it('shows a required error when a prefilled value is cleared', async () => { await render( diff --git a/packages/react/src/field/description/FieldDescription.test.tsx b/packages/react/src/field/description/FieldDescription.test.tsx index fc6df38af5f..1806d90a979 100644 --- a/packages/react/src/field/description/FieldDescription.test.tsx +++ b/packages/react/src/field/description/FieldDescription.test.tsx @@ -40,6 +40,17 @@ describe('', () => { ); }); + it('does not register an empty description id', () => { + render( + + + Message + , + ); + + expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby', 'external-description'); + }); + it('reflects the disabled state from Field.Item', async () => { await render( diff --git a/packages/react/src/field/error/FieldError.test.tsx b/packages/react/src/field/error/FieldError.test.tsx index 30a3d75dfad..4d9dcc75338 100644 --- a/packages/react/src/field/error/FieldError.test.tsx +++ b/packages/react/src/field/error/FieldError.test.tsx @@ -211,6 +211,40 @@ describe('', () => { expect(screen.getByTestId('default-error')).toHaveTextContent('Username is reserved'); }); + it('renders client validation error arrays as a list', async () => { + await render( +
+ ['First error', 'Second error']}> + + + + +
, + ); + + fireEvent.click(screen.getByText('submit')); + + const list = screen.getByTestId('default-error').querySelector('ul'); + expect(list).not.toBe(null); + expect(list?.querySelectorAll('li')).toHaveLength(2); + expect(screen.getByText('First error')).not.toBe(null); + expect(screen.getByText('Second error')).not.toBe(null); + }); + + it('does not register an empty error id', async () => { + await render( + + + Message + , + ); + + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + 'external-description', + ); + }); + it('ignores empty Form error arrays', async () => { await render(
diff --git a/packages/react/src/field/error/FieldError.tsx b/packages/react/src/field/error/FieldError.tsx index d36688d1ced..c7708f6a964 100644 --- a/packages/react/src/field/error/FieldError.tsx +++ b/packages/react/src/field/error/FieldError.tsx @@ -78,7 +78,7 @@ export const FieldError = React.forwardRef(function FieldError( error = validityData.errors; } - let errorMessage: React.ReactNode = error ?? ''; + let errorMessage: React.ReactNode = error; if (Array.isArray(error)) { errorMessage = error.length > 1 ? ( @@ -88,7 +88,7 @@ export const FieldError = React.forwardRef(function FieldError( ))} ) : ( - (error[0] ?? '') + error[0] ); } diff --git a/packages/react/src/field/item/FieldItem.test.tsx b/packages/react/src/field/item/FieldItem.test.tsx index 0ec5c706694..2bc27d361d6 100644 --- a/packages/react/src/field/item/FieldItem.test.tsx +++ b/packages/react/src/field/item/FieldItem.test.tsx @@ -81,4 +81,31 @@ describe('', () => { expect(onValueChange.mock.calls.length).toBe(1); }); }); + + it('associates a Field.Item label with a parent checkbox', async () => { + const { user } = await render( + + + + + + Toggle all + + + + + + , + ); + + const label = screen.getByText('Toggle all').closest('label') as HTMLLabelElement; + const parent = screen.getByTestId('parent'); + + expect(label).toHaveAttribute('for'); + expect(label.control).toHaveAttribute('type', 'checkbox'); + await user.click(screen.getByText('Toggle all')); + expect(parent).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('a')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('b')).toHaveAttribute('aria-checked', 'true'); + }); }); diff --git a/packages/react/src/field/item/FieldItem.tsx b/packages/react/src/field/item/FieldItem.tsx index 450849cc2f0..6860da6e41a 100644 --- a/packages/react/src/field/item/FieldItem.tsx +++ b/packages/react/src/field/item/FieldItem.tsx @@ -7,7 +7,6 @@ import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import { FieldItemContext } from './FieldItemContext'; import { LabelableProvider } from '../../internals/labelable-provider'; -import { useCheckboxGroupContext } from '../../checkbox-group/CheckboxGroupContext'; /** * Groups individual items in a checkbox group or radio group with a label and description. @@ -32,10 +31,6 @@ export const FieldItem = React.forwardRef(function FieldItem( const disabled = rootDisabled || disabledProp; const state: FieldItemState = { ...fieldState, disabled }; - const checkboxGroupContext = useCheckboxGroupContext(); - const hasParentCheckbox = checkboxGroupContext?.allValues !== undefined; - const controlId = hasParentCheckbox ? checkboxGroupContext?.parent.id : undefined; - const fieldItemContext: FieldItemContext = React.useMemo(() => ({ disabled }), [disabled]); const element = useRenderElement('div', componentProps, { @@ -46,7 +41,7 @@ export const FieldItem = React.forwardRef(function FieldItem( }); return ( - + {element} ); diff --git a/packages/react/src/field/label/FieldLabel.test.tsx b/packages/react/src/field/label/FieldLabel.test.tsx index 7d0cbf688ea..dae463800fb 100644 --- a/packages/react/src/field/label/FieldLabel.test.tsx +++ b/packages/react/src/field/label/FieldLabel.test.tsx @@ -1,4 +1,5 @@ import { expect } from 'vitest'; +import * as React from 'react'; import { Field } from '@base-ui/react/field'; import { screen } from '@mui/internal-test-utils'; import { createRenderer, describeConformance } from '#test-utils'; @@ -74,55 +75,81 @@ describe('', () => { errorSpy.mockRestore(); }); - it('errors if nativeLabel=true but ref is not a label', async () => { + it('does not warn when the render function returns no element', async () => { const errorSpy = vi .spyOn(console, 'error') .mockName('console.error') .mockImplementation(() => {}); + const EmptyLabel = React.forwardRef(function EmptyLabel() { + return null; + }); + await render( - - }> - Label - + }>Label , ); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Base UI: expected a