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
50 changes: 49 additions & 1 deletion packages/react/src/checkbox/root/CheckboxRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { act, fireEvent, screen } from '@mui/internal-test-utils';
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
import { Checkbox } from '@base-ui/react/checkbox';
import { CheckboxGroup } from '@base-ui/react/checkbox-group';
import { Field } from '@base-ui/react/field';
Expand Down Expand Up @@ -1000,6 +1000,54 @@ describe('<Checkbox.Root />', () => {
expect(checkbox).to.have.attribute('aria-checked', 'false');
});

it('sets `aria-labelledby` from a sibling label associated with the hidden input', async () => {
await render(
<div>
<label htmlFor="checkbox-input">Label</label>
<Checkbox.Root id="checkbox-input" />
</div>,
);

const label = screen.getByText('Label');
expect(label.id).not.to.equal('');
expect(screen.getByRole('checkbox')).to.have.attribute('aria-labelledby', label.id);
});

it('updates fallback `aria-labelledby` when the hidden input id changes', async () => {
function TestCase() {
const [id, setId] = React.useState('checkbox-input-a');

return (
<React.Fragment>
<label htmlFor="checkbox-input-a">Label A</label>
<label htmlFor="checkbox-input-b">Label B</label>
<Checkbox.Root id={id} />
<button type="button" onClick={() => setId('checkbox-input-b')}>
Toggle
</button>
</React.Fragment>
);
}

await render(<TestCase />);

const checkbox = screen.getByRole('checkbox');
const labelA = screen.getByText('Label A');

expect(labelA.id).to.not.equal('');
expect(checkbox).to.have.attribute('aria-labelledby', labelA.id);

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

await waitFor(() => {
const labelB = screen.getByText('Label B');

expect(labelB.id).to.not.equal('');
expect(labelA.id).to.not.equal(labelB.id);
expect(checkbox).to.have.attribute('aria-labelledby', labelB.id);
});
});

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

Expand Down
11 changes: 10 additions & 1 deletion packages/react/src/checkbox/root/CheckboxRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useFieldItemContext } from '../../field/item/FieldItemContext';
import { useField } from '../../field/useField';
import { useFormContext } from '../../form/FormContext';
import { useLabelableContext } from '../../labelable-provider/LabelableContext';
import { useAriaLabelledBy } from '../../labelable-provider/useAriaLabelledBy';
import { useCheckboxGroupContext } from '../../checkbox-group/CheckboxGroupContext';
import { CheckboxRootContext } from './CheckboxRootContext';
import {
Expand All @@ -45,6 +46,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
checked: checkedProp,
className,
defaultChecked = false,
'aria-labelledby': ariaLabelledByProp,
disabled: disabledProp = false,
id: idProp,
indeterminate = false,
Expand Down Expand Up @@ -175,6 +177,13 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(

const inputRef = React.useRef<HTMLInputElement>(null);
const mergedInputRef = useMergedRefs(inputRefProp, inputRef, validation.inputRef);
const ariaLabelledBy = useAriaLabelledBy(
ariaLabelledByProp,
labelId,
inputRef,
!nativeButton,
inputId ?? undefined,
);

useIsoLayoutEffect(() => {
if (inputRef.current) {
Expand Down Expand Up @@ -297,7 +306,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
'aria-checked': groupIndeterminate ? 'mixed' : checked,
'aria-readonly': readOnly || undefined,
'aria-required': required || undefined,
'aria-labelledby': labelId,
'aria-labelledby': ariaLabelledBy,
[PARENT_CHECKBOX as string]: parent ? '' : undefined,
onFocus() {
setFocused(true);
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/field/item/FieldItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const FieldItem = React.forwardRef(function FieldItem(
// this a more reliable check
const hasParentCheckbox = checkboxGroupContext?.allValues !== undefined;

const initialControlId = hasParentCheckbox ? parentId : undefined;
const controlId = hasParentCheckbox ? parentId : undefined;

const fieldItemContext: FieldItemContext = React.useMemo(() => ({ disabled }), [disabled]);

Expand All @@ -43,7 +43,7 @@ export const FieldItem = React.forwardRef(function FieldItem(
});

return (
<LabelableProvider initialControlId={initialControlId}>
<LabelableProvider controlId={controlId}>
<FieldItemContext.Provider value={fieldItemContext}>{element}</FieldItemContext.Provider>
</LabelableProvider>
);
Expand Down
11 changes: 5 additions & 6 deletions packages/react/src/field/label/FieldLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ export const FieldLabel = React.forwardRef(function FieldLabel(

const fieldRootContext = useFieldRootContext(false);

const { controlId, setLabelId, labelId } = useLabelableContext();
const { controlId, setLabelId, labelId: contextLabelId } = useLabelableContext();

const id = useBaseUiId(idProp);
const generatedLabelId = useBaseUiId(idProp);
const labelId = idProp ?? contextLabelId ?? generatedLabelId;

const labelRef = React.useRef<HTMLElement | null>(null);

Expand Down Expand Up @@ -92,14 +93,12 @@ export const FieldLabel = React.forwardRef(function FieldLabel(
}

useIsoLayoutEffect(() => {
if (id) {
setLabelId(id);
}
setLabelId(labelId);

return () => {
setLabelId(undefined);
};
}, [id, setLabelId]);
}, [labelId, setLabelId]);

const element = useRenderElement('label', componentProps, {
ref: [forwardedRef, labelRef],
Expand Down
116 changes: 113 additions & 3 deletions packages/react/src/field/root/FieldRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ import {
import { vi } from 'vitest';
import { expect } from 'chai';
import { spy } from 'sinon';
import { createRenderer, describeConformance } from '#test-utils';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
import { LabelableProvider } from '../../labelable-provider';

describe('<Field.Root />', () => {
const { render } = createRenderer();
const { render, renderToString } = createRenderer();
const { render: renderStrict } = createRenderer({ strict: true });

describeConformance(<Field.Root />, () => ({
Expand Down Expand Up @@ -68,7 +68,7 @@ describe('<Field.Root />', () => {
it('preserves null initial control ids', async () => {
await render(
<Field.Root>
<LabelableProvider initialControlId={null}>
<LabelableProvider controlId={null}>
<Field.Label>Label</Field.Label>
<Field.Control data-testid="control" />
</LabelableProvider>
Expand Down Expand Up @@ -149,6 +149,116 @@ describe('<Field.Root />', () => {
});
});

it.skipIf(isJSDOM)('does not set `aria-labelledby` during SSR when Field.Label is absent', () => {
renderToString(
<Field.Root>
<Select.Root>
<Select.Trigger data-testid="trigger">
<Select.Value placeholder="Pick one" />
</Select.Trigger>
</Select.Root>
</Field.Root>,
);

expect(screen.getByTestId('trigger')).to.not.have.attribute('aria-labelledby');
});

it.skipIf(isJSDOM)(
'keeps `aria-labelledby` valid when toggling from Checkbox.Root to Select.Root after hydration',
async () => {
function TestCase() {
const [showSelect, setShowSelect] = React.useState(false);

return (
<React.Fragment>
<Field.Root>
<Field.Label nativeLabel={false} render={<div />} data-testid="label">
Label
</Field.Label>
{showSelect ? (
<Select.Root>
<Select.Trigger data-testid="trigger">
<Select.Value placeholder="Pick one" />
</Select.Trigger>
</Select.Root>
) : (
<Checkbox.Root data-testid="checkbox" />
)}
</Field.Root>
<button type="button" onClick={() => setShowSelect((prev) => !prev)}>
Toggle
</button>
</React.Fragment>
);
}

const { hydrate } = renderToString(<TestCase />);
const label = screen.getByTestId('label');
const checkbox = screen.getByTestId('checkbox');

expect(label.id).to.not.equal('');
expect(checkbox).to.not.have.attribute('aria-labelledby');

hydrate();
await waitFor(() => {
expect(screen.getByTestId('checkbox')).to.have.attribute('aria-labelledby', label.id);
});
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));

const trigger = screen.getByTestId('trigger');
expect(trigger).to.have.attribute('aria-labelledby', label.id);

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

const checkboxAfterToggle = screen.getByTestId('checkbox');
expect(checkboxAfterToggle).to.have.attribute('aria-labelledby', label.id);
},
);

it.skipIf(isJSDOM)(
'removes `aria-labelledby` when Field.Label is removed after hydration',
async () => {
function TestCase() {
const [showLabel, setShowLabel] = React.useState(true);

return (
<React.Fragment>
<Field.Root>
{showLabel ? (
<Field.Label nativeLabel={false} render={<div />} data-testid="label">
Label
</Field.Label>
) : null}
<Select.Root>
<Select.Trigger data-testid="trigger">
<Select.Value placeholder="Pick one" />
</Select.Trigger>
</Select.Root>
</Field.Root>
<button type="button" onClick={() => setShowLabel(false)}>
Remove Label
</button>
</React.Fragment>
);
}

const { hydrate } = renderToString(<TestCase />);
const label = screen.getByTestId('label');
const trigger = screen.getByTestId('trigger');

expect(trigger).to.not.have.attribute('aria-labelledby');

hydrate();
await waitFor(() => {
expect(screen.getByTestId('trigger')).to.have.attribute('aria-labelledby', label.id);
});
fireEvent.click(screen.getByRole('button', { name: 'Remove Label' }));

expect(screen.queryByTestId('label')).to.equal(null);
expect(screen.getByTestId('trigger')).to.not.have.attribute('aria-labelledby');
},
);

it.skipIf(reactMajor < 19)(
'does not loop when a control is unmounted and remounted',
async () => {
Expand Down
35 changes: 32 additions & 3 deletions packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createRenderer, screen } from '@mui/internal-test-utils';
import { createRenderer, screen, waitFor } from '@mui/internal-test-utils';
import { Fieldset } from '@base-ui/react/fieldset';
import { expect } from 'chai';
import { describeConformance } from '../../../test/describeConformance';
import { describeConformance, isJSDOM } from '#test-utils';

describe('<Fieldset.Legend />', () => {
const { render } = createRenderer();
const { render, renderToString } = createRenderer();

describeConformance(<Fieldset.Legend />, () => ({
refInstanceof: window.HTMLDivElement,
Expand Down Expand Up @@ -35,4 +35,33 @@ describe('<Fieldset.Legend />', () => {

expect(screen.getByRole('group')).to.have.attribute('aria-labelledby', 'legend-id');
});

it.skipIf(isJSDOM)('does not set `aria-labelledby` during SSR when legend is absent', () => {
renderToString(<Fieldset.Root data-testid="fieldset" />);

expect(screen.getByTestId('fieldset')).to.not.have.attribute('aria-labelledby');
});

it.skipIf(isJSDOM)(
'sets `aria-labelledby` after hydration without a custom legend id',
async () => {
const { hydrate } = renderToString(
<Fieldset.Root data-testid="fieldset">
<Fieldset.Legend data-testid="legend">Legend</Fieldset.Legend>
</Fieldset.Root>,
);

const fieldset = screen.getByTestId('fieldset');
const legend = screen.getByTestId('legend');

expect(legend.id).to.not.equal('');
expect(fieldset).to.not.have.attribute('aria-labelledby');

hydrate();

await waitFor(() => {
expect(screen.getByTestId('fieldset')).to.have.attribute('aria-labelledby', legend.id);
});
},
);
});
1 change: 0 additions & 1 deletion packages/react/src/fieldset/root/FieldsetRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const FieldsetRoot = React.forwardRef(function FieldsetRoot(
forwardedRef: React.ForwardedRef<HTMLElement>,
) {
const { render, className, disabled = false, ...elementProps } = componentProps;

const [legendId, setLegendId] = React.useState<string | undefined>(undefined);

const state: FieldsetRoot.State = {
Expand Down
8 changes: 5 additions & 3 deletions packages/react/src/labelable-provider/LabelableProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ export const LabelableProvider: React.FC<LabelableProvider.Props> = function Lab
props,
) {
const defaultId = useBaseUiId();
const initialControlId = props.controlId === undefined ? defaultId : props.controlId;

const [controlId, setControlIdState] = React.useState<string | null | undefined>(
props.initialControlId === undefined ? defaultId : props.initialControlId,
initialControlId,
);
const [labelId, setLabelId] = React.useState<string | undefined>(undefined);
const [labelId, setLabelId] = React.useState<string | undefined>(props.labelId);
const [messageIds, setMessageIds] = React.useState<string[]>([]);

const registrationsRef = useRefWithInit(() => new Map<symbol, string | null>());
Expand Down Expand Up @@ -98,7 +99,8 @@ export const LabelableProvider: React.FC<LabelableProvider.Props> = function Lab
};

export interface LabelableProviderProps {
initialControlId?: string | null | undefined;
controlId?: string | null | undefined;
labelId?: string | undefined;
children?: React.ReactNode;
}

Expand Down
Loading
Loading