@@ -20,11 +20,11 @@ import {
2020import { vi } from 'vitest' ;
2121import { expect } from 'chai' ;
2222import { spy } from 'sinon' ;
23- import { createRenderer , describeConformance } from '#test-utils' ;
23+ import { createRenderer , describeConformance , isJSDOM } from '#test-utils' ;
2424import { LabelableProvider } from '../../labelable-provider' ;
2525
2626describe ( '<Field.Root />' , ( ) => {
27- const { render } = createRenderer ( ) ;
27+ const { render, renderToString } = createRenderer ( ) ;
2828 const { render : renderStrict } = createRenderer ( { strict : true } ) ;
2929
3030 describeConformance ( < Field . Root /> , ( ) => ( {
@@ -68,7 +68,7 @@ describe('<Field.Root />', () => {
6868 it ( 'preserves null initial control ids' , async ( ) => {
6969 await render (
7070 < Field . Root >
71- < LabelableProvider initialControlId = { null } >
71+ < LabelableProvider controlId = { null } >
7272 < Field . Label > Label</ Field . Label >
7373 < Field . Control data-testid = "control" />
7474 </ LabelableProvider >
@@ -149,6 +149,116 @@ describe('<Field.Root />', () => {
149149 } ) ;
150150 } ) ;
151151
152+ it . skipIf ( isJSDOM ) ( 'does not set `aria-labelledby` during SSR when Field.Label is absent' , ( ) => {
153+ renderToString (
154+ < Field . Root >
155+ < Select . Root >
156+ < Select . Trigger data-testid = "trigger" >
157+ < Select . Value placeholder = "Pick one" />
158+ </ Select . Trigger >
159+ </ Select . Root >
160+ </ Field . Root > ,
161+ ) ;
162+
163+ expect ( screen . getByTestId ( 'trigger' ) ) . to . not . have . attribute ( 'aria-labelledby' ) ;
164+ } ) ;
165+
166+ it . skipIf ( isJSDOM ) (
167+ 'keeps `aria-labelledby` valid when toggling from Checkbox.Root to Select.Root after hydration' ,
168+ async ( ) => {
169+ function TestCase ( ) {
170+ const [ showSelect , setShowSelect ] = React . useState ( false ) ;
171+
172+ return (
173+ < React . Fragment >
174+ < Field . Root >
175+ < Field . Label nativeLabel = { false } render = { < div /> } data-testid = "label" >
176+ Label
177+ </ Field . Label >
178+ { showSelect ? (
179+ < Select . Root >
180+ < Select . Trigger data-testid = "trigger" >
181+ < Select . Value placeholder = "Pick one" />
182+ </ Select . Trigger >
183+ </ Select . Root >
184+ ) : (
185+ < Checkbox . Root data-testid = "checkbox" />
186+ ) }
187+ </ Field . Root >
188+ < button type = "button" onClick = { ( ) => setShowSelect ( ( prev ) => ! prev ) } >
189+ Toggle
190+ </ button >
191+ </ React . Fragment >
192+ ) ;
193+ }
194+
195+ const { hydrate } = renderToString ( < TestCase /> ) ;
196+ const label = screen . getByTestId ( 'label' ) ;
197+ const checkbox = screen . getByTestId ( 'checkbox' ) ;
198+
199+ expect ( label . id ) . to . not . equal ( '' ) ;
200+ expect ( checkbox ) . to . not . have . attribute ( 'aria-labelledby' ) ;
201+
202+ hydrate ( ) ;
203+ await waitFor ( ( ) => {
204+ expect ( screen . getByTestId ( 'checkbox' ) ) . to . have . attribute ( 'aria-labelledby' , label . id ) ;
205+ } ) ;
206+ fireEvent . click ( screen . getByRole ( 'button' , { name : 'Toggle' } ) ) ;
207+
208+ const trigger = screen . getByTestId ( 'trigger' ) ;
209+ expect ( trigger ) . to . have . attribute ( 'aria-labelledby' , label . id ) ;
210+
211+ fireEvent . click ( screen . getByRole ( 'button' , { name : 'Toggle' } ) ) ;
212+
213+ const checkboxAfterToggle = screen . getByTestId ( 'checkbox' ) ;
214+ expect ( checkboxAfterToggle ) . to . have . attribute ( 'aria-labelledby' , label . id ) ;
215+ } ,
216+ ) ;
217+
218+ it . skipIf ( isJSDOM ) (
219+ 'removes `aria-labelledby` when Field.Label is removed after hydration' ,
220+ async ( ) => {
221+ function TestCase ( ) {
222+ const [ showLabel , setShowLabel ] = React . useState ( true ) ;
223+
224+ return (
225+ < React . Fragment >
226+ < Field . Root >
227+ { showLabel ? (
228+ < Field . Label nativeLabel = { false } render = { < div /> } data-testid = "label" >
229+ Label
230+ </ Field . Label >
231+ ) : null }
232+ < Select . Root >
233+ < Select . Trigger data-testid = "trigger" >
234+ < Select . Value placeholder = "Pick one" />
235+ </ Select . Trigger >
236+ </ Select . Root >
237+ </ Field . Root >
238+ < button type = "button" onClick = { ( ) => setShowLabel ( false ) } >
239+ Remove Label
240+ </ button >
241+ </ React . Fragment >
242+ ) ;
243+ }
244+
245+ const { hydrate } = renderToString ( < TestCase /> ) ;
246+ const label = screen . getByTestId ( 'label' ) ;
247+ const trigger = screen . getByTestId ( 'trigger' ) ;
248+
249+ expect ( trigger ) . to . not . have . attribute ( 'aria-labelledby' ) ;
250+
251+ hydrate ( ) ;
252+ await waitFor ( ( ) => {
253+ expect ( screen . getByTestId ( 'trigger' ) ) . to . have . attribute ( 'aria-labelledby' , label . id ) ;
254+ } ) ;
255+ fireEvent . click ( screen . getByRole ( 'button' , { name : 'Remove Label' } ) ) ;
256+
257+ expect ( screen . queryByTestId ( 'label' ) ) . to . equal ( null ) ;
258+ expect ( screen . getByTestId ( 'trigger' ) ) . to . not . have . attribute ( 'aria-labelledby' ) ;
259+ } ,
260+ ) ;
261+
152262 it . skipIf ( reactMajor < 19 ) (
153263 'does not loop when a control is unmounted and remounted' ,
154264 async ( ) => {
0 commit comments