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