diff --git a/packages/react/src/checkbox/root/CheckboxRoot.test.tsx b/packages/react/src/checkbox/root/CheckboxRoot.test.tsx
index d975a7bca17..ea09868d861 100644
--- a/packages/react/src/checkbox/root/CheckboxRoot.test.tsx
+++ b/packages/react/src/checkbox/root/CheckboxRoot.test.tsx
@@ -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';
@@ -1000,6 +1000,54 @@ describe('', () => {
expect(checkbox).to.have.attribute('aria-checked', 'false');
});
+ it('sets `aria-labelledby` from a sibling label associated with the hidden input', async () => {
+ await render(
+
+
+
+
,
+ );
+
+ 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 (
+
+
+
+
+
+
+ );
+ }
+
+ await render();
+
+ 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(} nativeButton />);
diff --git a/packages/react/src/checkbox/root/CheckboxRoot.tsx b/packages/react/src/checkbox/root/CheckboxRoot.tsx
index 52fce82b5ec..eac7f179271 100644
--- a/packages/react/src/checkbox/root/CheckboxRoot.tsx
+++ b/packages/react/src/checkbox/root/CheckboxRoot.tsx
@@ -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 {
@@ -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,
@@ -175,6 +177,13 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
const inputRef = React.useRef(null);
const mergedInputRef = useMergedRefs(inputRefProp, inputRef, validation.inputRef);
+ const ariaLabelledBy = useAriaLabelledBy(
+ ariaLabelledByProp,
+ labelId,
+ inputRef,
+ !nativeButton,
+ inputId ?? undefined,
+ );
useIsoLayoutEffect(() => {
if (inputRef.current) {
@@ -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);
diff --git a/packages/react/src/field/item/FieldItem.tsx b/packages/react/src/field/item/FieldItem.tsx
index a5618555958..b111ad19d85 100644
--- a/packages/react/src/field/item/FieldItem.tsx
+++ b/packages/react/src/field/item/FieldItem.tsx
@@ -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]);
@@ -43,7 +43,7 @@ export const FieldItem = React.forwardRef(function FieldItem(
});
return (
-
+
{element}
);
diff --git a/packages/react/src/field/label/FieldLabel.tsx b/packages/react/src/field/label/FieldLabel.tsx
index ac0de40fbc9..686316e0e4f 100644
--- a/packages/react/src/field/label/FieldLabel.tsx
+++ b/packages/react/src/field/label/FieldLabel.tsx
@@ -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(null);
@@ -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],
diff --git a/packages/react/src/field/root/FieldRoot.test.tsx b/packages/react/src/field/root/FieldRoot.test.tsx
index ca15f69b841..105a3bff477 100644
--- a/packages/react/src/field/root/FieldRoot.test.tsx
+++ b/packages/react/src/field/root/FieldRoot.test.tsx
@@ -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('', () => {
- const { render } = createRenderer();
+ const { render, renderToString } = createRenderer();
const { render: renderStrict } = createRenderer({ strict: true });
describeConformance(, () => ({
@@ -68,7 +68,7 @@ describe('', () => {
it('preserves null initial control ids', async () => {
await render(
-
+
Label
@@ -149,6 +149,116 @@ describe('', () => {
});
});
+ it.skipIf(isJSDOM)('does not set `aria-labelledby` during SSR when Field.Label is absent', () => {
+ renderToString(
+
+
+
+
+
+
+ ,
+ );
+
+ 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 (
+
+
+ } data-testid="label">
+ Label
+
+ {showSelect ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+ }
+
+ const { hydrate } = renderToString();
+ 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 (
+
+
+ {showLabel ? (
+ } data-testid="label">
+ Label
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const { hydrate } = renderToString();
+ 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 () => {
diff --git a/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx b/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
index 761f1d0dd23..ea695cb79a1 100644
--- a/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
+++ b/packages/react/src/fieldset/legend/FieldsetLegend.test.tsx
@@ -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('', () => {
- const { render } = createRenderer();
+ const { render, renderToString } = createRenderer();
describeConformance(, () => ({
refInstanceof: window.HTMLDivElement,
@@ -35,4 +35,33 @@ describe('', () => {
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();
+
+ 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(
+
+ Legend
+ ,
+ );
+
+ 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);
+ });
+ },
+ );
});
diff --git a/packages/react/src/fieldset/root/FieldsetRoot.tsx b/packages/react/src/fieldset/root/FieldsetRoot.tsx
index 9c4210be3f7..dc0e237d03b 100644
--- a/packages/react/src/fieldset/root/FieldsetRoot.tsx
+++ b/packages/react/src/fieldset/root/FieldsetRoot.tsx
@@ -15,7 +15,6 @@ export const FieldsetRoot = React.forwardRef(function FieldsetRoot(
forwardedRef: React.ForwardedRef,
) {
const { render, className, disabled = false, ...elementProps } = componentProps;
-
const [legendId, setLegendId] = React.useState(undefined);
const state: FieldsetRoot.State = {
diff --git a/packages/react/src/labelable-provider/LabelableProvider.tsx b/packages/react/src/labelable-provider/LabelableProvider.tsx
index 9dee01050af..279e95caad8 100644
--- a/packages/react/src/labelable-provider/LabelableProvider.tsx
+++ b/packages/react/src/labelable-provider/LabelableProvider.tsx
@@ -14,11 +14,12 @@ export const LabelableProvider: React.FC = function Lab
props,
) {
const defaultId = useBaseUiId();
+ const initialControlId = props.controlId === undefined ? defaultId : props.controlId;
const [controlId, setControlIdState] = React.useState(
- props.initialControlId === undefined ? defaultId : props.initialControlId,
+ initialControlId,
);
- const [labelId, setLabelId] = React.useState(undefined);
+ const [labelId, setLabelId] = React.useState(props.labelId);
const [messageIds, setMessageIds] = React.useState([]);
const registrationsRef = useRefWithInit(() => new Map());
@@ -98,7 +99,8 @@ export const LabelableProvider: React.FC = function Lab
};
export interface LabelableProviderProps {
- initialControlId?: string | null | undefined;
+ controlId?: string | null | undefined;
+ labelId?: string | undefined;
children?: React.ReactNode;
}
diff --git a/packages/react/src/labelable-provider/useAriaLabelledBy.ts b/packages/react/src/labelable-provider/useAriaLabelledBy.ts
new file mode 100644
index 00000000000..199b6dee823
--- /dev/null
+++ b/packages/react/src/labelable-provider/useAriaLabelledBy.ts
@@ -0,0 +1,75 @@
+'use client';
+import * as React from 'react';
+import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
+import { useBaseUiId } from '../utils/useBaseUiId';
+
+/**
+ * @internal
+ */
+export function useAriaLabelledBy(
+ explicitAriaLabelledBy: string | undefined,
+ labelId: string | undefined,
+ labelSourceRef: React.RefObject,
+ enableFallback = true,
+ labelSourceId?: string,
+) {
+ const [fallbackAriaLabelledBy, setFallbackAriaLabelledBy] = React.useState();
+
+ const generatedLabelId = useBaseUiId(labelSourceId ? `${labelSourceId}-label` : undefined);
+ const ariaLabelledBy = explicitAriaLabelledBy ?? labelId ?? fallbackAriaLabelledBy;
+
+ // Fallback for controls labelled by wrapping/sibling native