Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react/src/checkbox/root/CheckboxRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
26 changes: 26 additions & 0 deletions packages/react/src/field/control/FieldControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<Field.Control />', () => {
Expand Down Expand Up @@ -56,6 +57,31 @@ describe('<Field.Control />', () => {
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(
<Form errors={{ message: 'Server error' }}>
<Field.Root name="message" validationMode="onChange" validate={validate}>
<Field.Control onValueChange={handleValueChange} />
<Field.Error />
</Field.Root>
</Form>,
);

const control = screen.getByRole<HTMLInputElement>('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(
<Field.Root validationMode="onChange">
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/field/description/FieldDescription.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ describe('<Field.Description />', () => {
);
});

it('does not register an empty description id', () => {
render(
<Field.Root>
<Field.Control aria-describedby="external-description" />
<Field.Description id="">Message</Field.Description>
</Field.Root>,
);

expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby', 'external-description');
});

it('reflects the disabled state from Field.Item', async () => {
await render(
<Field.Root>
Expand Down
34 changes: 34 additions & 0 deletions packages/react/src/field/error/FieldError.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,40 @@ describe('<Field.Error />', () => {
expect(screen.getByTestId('default-error')).toHaveTextContent('Username is reserved');
});

it('renders client validation error arrays as a list', async () => {
await render(
<Form>
<Field.Root validate={() => ['First error', 'Second error']}>
<Field.Control />
<Field.Error data-testid="default-error" />
</Field.Root>
<button type="submit">submit</button>
</Form>,
);

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(
<Field.Root invalid>
<Field.Control aria-describedby="external-description" />
<Field.Error id="">Message</Field.Error>
</Field.Root>,
);

expect(screen.getByRole('textbox')).toHaveAttribute(
'aria-describedby',
'external-description',
);
});

it('ignores empty Form error arrays', async () => {
await render(
<Form errors={{ username: [] }}>
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/field/error/FieldError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
Expand All @@ -88,7 +88,7 @@ export const FieldError = React.forwardRef(function FieldError(
))}
</ul>
) : (
(error[0] ?? '')
error[0]
);
}

Expand Down
27 changes: 27 additions & 0 deletions packages/react/src/field/item/FieldItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,31 @@ describe('<Field.Item />', () => {
expect(onValueChange.mock.calls.length).toBe(1);
});
});

it('associates a Field.Item label with a parent checkbox', async () => {
const { user } = await render(
<Field.Root>
<CheckboxGroup allValues={['a', 'b']}>
<Field.Item>
<Field.Label>
<Checkbox.Root parent data-testid="parent" />
Toggle all
</Field.Label>
</Field.Item>
<Checkbox.Root value="a" data-testid="a" />
<Checkbox.Root value="b" data-testid="b" />
</CheckboxGroup>
</Field.Root>,
);

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');
});
});
7 changes: 1 addition & 6 deletions packages/react/src/field/item/FieldItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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, {
Expand All @@ -46,7 +41,7 @@ export const FieldItem = React.forwardRef(function FieldItem(
});

return (
<LabelableProvider controlId={controlId}>
<LabelableProvider>
<FieldItemContext.Provider value={fieldItemContext}>{element}</FieldItemContext.Provider>
</LabelableProvider>
);
Expand Down
87 changes: 57 additions & 30 deletions packages/react/src/field/label/FieldLabel.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -74,55 +75,81 @@ describe('<Field.Label />', () => {
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(
<Field.Root>
<Field.Control />
<Field.Label nativeLabel render={<div />}>
Label
</Field.Label>
<Field.Label render={<EmptyLabel />}>Label</Field.Label>
</Field.Root>,
);

expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Base UI: <Field.Label> expected a <label> element because the `nativeLabel` prop is true. ' +
'Rendering a non-<label> disables native label association, so `htmlFor` will not ' +
'work. Use a real <label> in the `render` prop, or set `nativeLabel` to `false`.',
),
);
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});

it('errors if nativeLabel=false but ref is a label', async () => {
it('errors if nativeLabel=true but ref is not a label', async () => {
const errorSpy = vi
.spyOn(console, 'error')
.mockName('console.error')
.mockImplementation(() => {});

await render(
<Field.Root>
<Field.Control />
<Field.Label nativeLabel={false}>Label</Field.Label>
</Field.Root>,
);
try {
await render(
<Field.Root>
<Field.Control />
<Field.Label nativeLabel render={<div />}>
Label
</Field.Label>
</Field.Root>,
);

expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Base UI: <Field.Label> expected a <label> element because the `nativeLabel` prop is true. ' +
'Rendering a non-<label> disables native label association, so `htmlFor` will not ' +
'work. Use a real <label> in the `render` prop, or set `nativeLabel` to `false`.',
),
);
} finally {
errorSpy.mockRestore();
}
});

expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Base UI: <Field.Label> expected a non-<label> element because the `nativeLabel` prop is false. ' +
'Rendering a <label> assumes native label behavior while Base UI treats it as ' +
'non-native, which can cause unexpected pointer behavior. Use a non-<label> in the ' +
'`render` prop, or set `nativeLabel` to `true`.',
),
);
errorSpy.mockRestore();
it('errors if nativeLabel=false but ref is a label', async () => {
const errorSpy = vi
.spyOn(console, 'error')
.mockName('console.error')
.mockImplementation(() => {});

try {
await render(
<Field.Root>
<Field.Control />
<Field.Label nativeLabel={false}>Label</Field.Label>
</Field.Root>,
);

expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Base UI: <Field.Label> expected a non-<label> element because the `nativeLabel` prop is false. ' +
'Rendering a <label> assumes native label behavior while Base UI treats it as ' +
'non-native, which can cause unexpected pointer behavior. Use a non-<label> in the ' +
'`render` prop, or set `nativeLabel` to `true`.',
),
);
} finally {
errorSpy.mockRestore();
}
});
});
});
78 changes: 78 additions & 0 deletions packages/react/src/field/root/FieldRoot.react17.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, vi } from 'vitest';
import * as React from 'react';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { Field } from '@base-ui/react/field';
import { screen } from '@mui/internal-test-utils';
import { createRenderer } from '#test-utils';

vi.mock('@base-ui/utils/safeReact', async (importOriginal) => {
const original = await importOriginal<typeof import('@base-ui/utils/safeReact')>();

return {
SafeReact: {
...original.SafeReact,
captureOwnerStack: undefined,
useId: undefined,
},
};
});

describe('<Field.Root /> with the React 17 id fallback', () => {
const { render } = createRenderer();

it('allows mount-time imperative validation before the fallback id is assigned', async () => {
function TestCase() {
const actionsRef = React.useRef<Field.Root.Actions>(null);

useIsoLayoutEffect(() => {
actionsRef.current?.validate();
}, []);

return (
<Field.Root actionsRef={actionsRef} validate={() => 'Mount-time error'}>
<Field.Error />
</Field.Root>
);
}

await render(<TestCase />);

expect(await screen.findByText('Mount-time error')).toBeVisible();
});

it('reports label mismatches without the owner-stack API', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

try {
await render(
<Field.Root>
<Field.Label render={<div />}>Label</Field.Label>
</Field.Root>,
);

expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('<Field.Label> expected a <label> element'),
);
} finally {
errorSpy.mockRestore();
}
});

it('reports non-native label mismatches without the owner-stack API', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

try {
await render(
<Field.Root>
<Field.Label nativeLabel={false}>Label</Field.Label>
</Field.Root>,
);

expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('<Field.Label> expected a non-<label> element'),
);
} finally {
errorSpy.mockRestore();
}
});
});
Loading
Loading