Skip to content
Draft
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
66 changes: 66 additions & 0 deletions packages/react/src/checkbox/root/CheckboxRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,72 @@ describe('<Checkbox.Root />', () => {
});
});

// Association is re-checked whenever the control commits. Label changes that occur with no
// control rerender at all (e.g. next to a memoized control) are not tracked in v1.
describe('fallback `aria-labelledby` with an independently rendered label', () => {
it('tracks a label toggled in a different container than the wrapped control', async () => {
function WrappedTestCase() {
const [showLabel, setShowLabel] = React.useState(false);

return (
<div>
<div>{showLabel && <label htmlFor="checkbox-input">Label</label>}</div>
<div>
<Checkbox.Root id="checkbox-input" />
</div>
<button type="button" onClick={() => setShowLabel((prev) => !prev)}>
Toggle label
</button>
</div>
);
}

await render(<WrappedTestCase />);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toHaveAttribute('aria-labelledby');

fireEvent.click(screen.getByRole('button', { name: 'Toggle label' }));

await waitFor(() => {
expect(checkbox).toHaveAttribute('aria-labelledby', screen.getByText('Label').id);
});

fireEvent.click(screen.getByRole('button', { name: 'Toggle label' }));

await waitFor(() => {
expect(checkbox).not.toHaveAttribute('aria-labelledby');
});
});

it('tracks a label whose `htmlFor` is retargeted to the control', async () => {
function RetargetTestCase() {
const [target, setTarget] = React.useState('other-input');

return (
<div>
<label htmlFor={target}>Label</label>
<Checkbox.Root id="checkbox-input" />
<button type="button" onClick={() => setTarget('checkbox-input')}>
Retarget
</button>
</div>
);
}

await render(<RetargetTestCase />);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toHaveAttribute('aria-labelledby');

fireEvent.click(screen.getByRole('button', { name: 'Retarget' }));

await waitFor(() => {
expect(checkbox).toHaveAttribute('aria-labelledby', screen.getByText('Label').id);
});
});
});

it('can render a native button', async () => {
const { container, user } = await render(<Checkbox.Root render={<button />} nativeButton />);

Expand Down
166 changes: 166 additions & 0 deletions packages/react/src/field/control/FieldControl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, vi } from 'vitest';
import * as React from 'react';
import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
import { Field } from '@base-ui/react/field';
import { Form } from '@base-ui/react/form';
Expand Down Expand Up @@ -98,6 +99,171 @@ describe('<Field.Control />', () => {
expect(screen.getByText('Required')).toBeInTheDocument();
});

describe('filled state ownership', () => {
it('publishes an empty state when a filled control is replaced by a fresh one', async () => {
function TestCase() {
const [instance, setInstance] = React.useState(0);

return (
<Field.Root data-testid="root">
<Field.Control key={instance} />
<button type="button" onClick={() => setInstance(1)}>
Replace
</button>
</Field.Root>
);
}

await render(<TestCase />);

fireEvent.change(screen.getByRole('textbox'), { target: { value: 'a' } });
expect(screen.getByTestId('root')).toHaveAttribute('data-filled', '');

fireEvent.click(screen.getByRole('button', { name: 'Replace' }));

expect(screen.getByRole('textbox')).toHaveValue('');
expect(screen.getByTestId('root')).not.toHaveAttribute('data-filled');
});

it('does not let a superseded control clear the active control state', async () => {
function TestCase() {
const [value, setValue] = React.useState('a');

return (
<Field.Root data-testid="root">
<Field.Control value={value} onValueChange={setValue} />
<Field.Control defaultValue="filled" />
<button type="button" onClick={() => setValue('')}>
Clear first
</button>
</Field.Root>
);
}

await render(<TestCase />);

expect(screen.getByTestId('root')).toHaveAttribute('data-filled', '');

fireEvent.click(screen.getByRole('button', { name: 'Clear first' }));

expect(screen.getByTestId('root')).toHaveAttribute('data-filled', '');
});

it('keeps the active control readable after a superseded control unmounts', async () => {
const validate = vi.fn<(value: unknown) => string | null>(() => null);

function TestCase() {
const actionsRef = React.useRef<Field.Root.Actions>(null);
const [oldMounted, setOldMounted] = React.useState(true);

return (
<div>
<Field.Root validate={validate} actionsRef={actionsRef}>
{oldMounted && <Field.Control key="old" defaultValue="old" />}
<Field.Control key="new" defaultValue="new" />
</Field.Root>
<button type="button" onClick={() => setOldMounted(false)}>
Unmount old
</button>
<button type="button" onClick={() => actionsRef.current?.validate()}>
Validate
</button>
</div>
);
}

await render(<TestCase />);

fireEvent.click(screen.getByRole('button', { name: 'Unmount old' }));
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));

expect(validate).toHaveBeenCalledTimes(1);
expect(validate.mock.lastCall?.[0]).toBe('new');
});

it('lets a remaining control publish after the owning control unmounts', async () => {
function TestCase() {
const [value, setValue] = React.useState('a');
const [mounted, setMounted] = React.useState(true);

return (
<Field.Root data-testid="root">
<Field.Control value={value} onValueChange={setValue} />
{mounted && <Field.Control defaultValue="filled" />}
<button type="button" onClick={() => setMounted(false)}>
Unmount second
</button>
<button type="button" onClick={() => setValue('')}>
Clear first
</button>
</Field.Root>
);
}

await render(<TestCase />);

expect(screen.getByTestId('root')).toHaveAttribute('data-filled', '');

fireEvent.click(screen.getByRole('button', { name: 'Unmount second' }));
fireEvent.click(screen.getByRole('button', { name: 'Clear first' }));

expect(screen.getByTestId('root')).not.toHaveAttribute('data-filled');
});
});

describe('focused state ownership', () => {
it('releases the focused state when the focused control unmounts', async () => {
function TestCase() {
const [mounted, setMounted] = React.useState(true);

return (
<Field.Root data-testid="root">
{mounted && <Field.Control />}
<button type="button" onClick={() => setMounted(false)}>
Remove
</button>
</Field.Root>
);
}

await render(<TestCase />);

fireEvent.focus(screen.getByRole('textbox'));
expect(screen.getByTestId('root')).toHaveAttribute('data-focused', '');

fireEvent.click(screen.getByRole('button', { name: 'Remove' }));

expect(screen.getByTestId('root')).not.toHaveAttribute('data-focused');
});

it('does not let a blurred control release the focused state of another control', async () => {
function TestCase() {
const [mounted, setMounted] = React.useState(true);

return (
<Field.Root data-testid="root">
{mounted && <Field.Control data-testid="first" />}
<Field.Control data-testid="second" />
<button type="button" onClick={() => setMounted(false)}>
Remove first
</button>
</Field.Root>
);
}

await render(<TestCase />);

fireEvent.focus(screen.getByTestId('first'));
fireEvent.blur(screen.getByTestId('first'));
fireEvent.focus(screen.getByTestId('second'));
expect(screen.getByTestId('root')).toHaveAttribute('data-focused', '');

fireEvent.click(screen.getByRole('button', { name: 'Remove first' }));

expect(screen.getByTestId('root')).toHaveAttribute('data-focused', '');
});
});

it.skipIf(isJSDOM)('should sync focused state when autoFocus is used with SSR', async () => {
vi.spyOn(console, 'error')
.mockName('console.error')
Expand Down
49 changes: 37 additions & 12 deletions packages/react/src/field/control/FieldControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,45 @@ export const FieldControl = React.forwardRef(function FieldControl(

const id = useLabelableId({ id: idProp });

const inputRef = React.useRef<HTMLInputElement>(null);

// The field's `filled` state belongs to whichever control currently owns the shared input ref,
// which is the last one to attach. Publishing it unconditionally stops a fresh control from
// inheriting the state of the control it replaced, while the ownership check stops a
// superseded control from clearing the active one's state when it rerenders. A null ref means
// the owner unmounted, so the next control to get here reclaims it.
useIsoLayoutEffect(() => {
const hasExternalValue = valueProp != null;
if (validation.inputRef.current?.value || (hasExternalValue && valueProp !== '')) {
setFilled(true);
} else if (hasExternalValue && valueProp === '') {
setFilled(false);
if (validation.inputRef.current !== null && validation.inputRef.current !== inputRef.current) {
return;
}

validation.inputRef.current = inputRef.current;
setFilled(valueProp != null ? valueProp !== '' : Boolean(inputRef.current?.value));
}, [validation.inputRef, setFilled, valueProp]);

const inputRef = React.useRef<HTMLElement>(null);
const focusedRef = React.useRef(false);

const updateFocused = useStableCallback((focused: boolean) => {
focusedRef.current = focused;
setFocused(focused);
});

// A control removed while focused never fires blur, which would leave the field focused
// forever. Only release the state when this control is the one still holding it.
useIsoLayoutEffect(
() => () => {
if (focusedRef.current) {
setFocused(false);
}
},
[setFocused],
);

useIsoLayoutEffect(() => {
if (autoFocus && inputRef.current === activeElement(ownerDocument(inputRef.current))) {
setFocused(true);
updateFocused(true);
}
}, [autoFocus, setFocused]);
}, [autoFocus, updateFocused]);

const [valueUnwrapped] = useControlled({
controlled: valueProp,
Expand All @@ -98,9 +121,11 @@ export const FieldControl = React.forwardRef(function FieldControl(

const isControlled = valueProp !== undefined;
const value = isControlled ? valueUnwrapped : undefined;
const getValueFromInput = useStableCallback(() => validation.inputRef.current?.value);
// Read this control's own element, not the mutable shared ref, so the active registration
// stays readable regardless of which control last touched the shared ref.
const getValueFromInput = useStableCallback(() => inputRef.current?.value);

useRegisterFieldControl(validation.inputRef, id, value, getValueFromInput, !disabled, nameProp);
useRegisterFieldControl(inputRef, id, value, getValueFromInput, !disabled, nameProp);

const element = useRenderElement('input', componentProps, {
ref: [forwardedRef, inputRef],
Expand Down Expand Up @@ -128,11 +153,11 @@ export const FieldControl = React.forwardRef(function FieldControl(
}
},
onFocus() {
setFocused(true);
updateFocused(true);
},
onBlur(event) {
setTouched(true);
setFocused(false);
updateFocused(false);

if (validationMode === 'onBlur') {
validation.commit(event.currentTarget.value);
Expand Down
33 changes: 32 additions & 1 deletion packages/react/src/otp-field/root/OTPFieldRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, vi } from 'vitest';
import * as React from 'react';
import { SafeReact } from '@base-ui/utils/safeReact';
import { act, fireEvent, screen } from '@mui/internal-test-utils';
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
import { OTPField as OTPFieldBase } from '@base-ui/react/otp-field';
import { Field } from '@base-ui/react/field';
import { Form } from '@base-ui/react/form';
Expand Down Expand Up @@ -713,6 +713,37 @@ describe('<OTPField.Root />', () => {
});

describe('accessibility', () => {
it('associates a wrapping native label when inputs mount after the root', async () => {
function TestCase() {
const [show, setShow] = React.useState(false);

return (
<div>
<label>
Code
<OTPFieldBase.Root length={2}>
{show && [<OTPFieldBase.Input key={0} />, <OTPFieldBase.Input key={1} />]}
</OTPFieldBase.Root>
</label>
<button type="button" onClick={() => setShow(true)}>
Show
</button>
</div>
);
}

await render(<TestCase />);

fireEvent.click(screen.getByRole('button', { name: 'Show' }));

await waitFor(() => {
expect(screen.getByRole('group')).toHaveAttribute(
'aria-labelledby',
screen.getByText('Code').id,
);
});
});

it('forwards root `aria-describedby` to the group', async () => {
await render(<OTPField aria-describedby="description-id" />);

Expand Down
Loading