Skip to content

Commit f740050

Browse files
authored
Use native fieldset instead of div by default for <Fieldset /> component (#3237)
* improve TypeScript types for `Fieldset` component * use `fieldset` instead of `div` by default * only apply `role="group"` when not using a native `fieldset` * apply `disabled` attribute This is necessary if we want to make use of the default fieldset tag (which also disables native form elements) * adjust tests reflecting new changes * conditionally apply props based on rendered element * add `useResolvedTag` hook This allows us to compute the `tag` name of a component. We can use a shortcut based on the `props.as` and/or the `DEFAULT_XXX_TAG` of a component. If this is not known/passed, then we compute it based on the `ref` instead which requires an actual re-render. * use `useResolvedTag` hook * reflect change in `Field` related test * update changelog * inline variable
1 parent 8c3499c commit f740050

File tree

5 files changed

+97
-12
lines changed

5 files changed

+97
-12
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
- Mark `SwitchGroup` as deprecated, prefer `Field` instead ([#3232](https://github.com/tailwindlabs/headlessui/pull/3232))
2121

22+
### Changed
23+
24+
- Use native `fieldset` instead of `div` by default for `<Fieldset />` component ([#3237](https://github.com/tailwindlabs/headlessui/pull/3237))
25+
2226
## [2.0.3] - 2024-05-07
2327

2428
### Fixed

packages/@headlessui-react/src/components/field/field.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('Rendering', () => {
5555
let fieldset = container.firstChild
5656
let field = fieldset?.firstChild
5757

58-
expect(fieldset).toHaveAttribute('aria-disabled', 'true')
58+
expect(fieldset).toHaveAttribute('disabled')
5959
expect(field).toHaveAttribute('aria-disabled', 'true')
6060
})
6161
})

packages/@headlessui-react/src/components/fieldset/fieldset.test.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,24 @@ describe('Rendering', () => {
2222

2323
let fieldset = container.firstChild
2424

25+
expect(fieldset).toBeInstanceOf(HTMLFieldSetElement)
26+
expect(fieldset).not.toHaveAttribute('role', 'group')
27+
})
28+
29+
it('should render a `Fieldset` using a custom component', async () => {
30+
let { container } = render(
31+
<Fieldset as="span">
32+
<input />
33+
</Fieldset>
34+
)
35+
36+
let fieldset = container.firstChild
37+
38+
expect(fieldset).toBeInstanceOf(HTMLSpanElement)
2539
expect(fieldset).toHaveAttribute('role', 'group')
2640
})
2741

28-
it('should add an `aria-disabled` attribute when disabling the `Fieldset`', async () => {
42+
it('should forward the `disabled` attribute when disabling the `Fieldset`', async () => {
2943
let { container } = render(
3044
<Fieldset disabled>
3145
<input />
@@ -34,10 +48,33 @@ describe('Rendering', () => {
3448

3549
let fieldset = container.firstChild
3650

37-
expect(fieldset).toHaveAttribute('role', 'group')
51+
expect(fieldset).toHaveAttribute('disabled')
52+
})
53+
54+
it('should add an `aria-disabled` attribute when disabling the `Fieldset` when using another element via the `as` prop', async () => {
55+
let { container } = render(
56+
<Fieldset as="span" disabled>
57+
<input />
58+
</Fieldset>
59+
)
60+
61+
let fieldset = container.firstChild
62+
3863
expect(fieldset).toHaveAttribute('aria-disabled', 'true')
3964
})
4065

66+
it('should make nested inputs disabled when the fieldset is disabled', async () => {
67+
let { container } = render(
68+
<Fieldset disabled>
69+
<input />
70+
</Fieldset>
71+
)
72+
73+
let fieldset = container.firstChild
74+
75+
expect(fieldset?.firstChild).toBeDisabled()
76+
})
77+
4178
it('should link a `Fieldset` to a nested `Legend`', async () => {
4279
let { container } = render(
4380
<Fieldset>

packages/@headlessui-react/src/components/fieldset/fieldset.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
'use client'
22

33
import React, { useMemo, type ElementType, type Ref } from 'react'
4+
import { useResolvedTag } from '../../hooks/use-resolved-tag'
5+
import { useSyncRefs } from '../../hooks/use-sync-refs'
46
import { DisabledProvider, useDisabled } from '../../internal/disabled'
57
import type { Props } from '../../types'
68
import { forwardRefWithAs, render, type HasDisplayName } from '../../utils/render'
79
import { useLabels } from '../label/label'
810

9-
let DEFAULT_FIELDSET_TAG = 'div' as const
11+
let DEFAULT_FIELDSET_TAG = 'fieldset' as const
1012

1113
type FieldsetRenderPropArg = {}
12-
type FieldsetPropsWeControl = 'aria-controls'
14+
type FieldsetPropsWeControl = 'aria-labelledby' | 'aria-disabled' | 'role'
1315

1416
export type FieldsetProps<TTag extends ElementType = typeof DEFAULT_FIELDSET_TAG> = Props<
1517
TTag,
@@ -27,17 +29,26 @@ function FieldsetFn<TTag extends ElementType = typeof DEFAULT_FIELDSET_TAG>(
2729
let providedDisabled = useDisabled()
2830
let { disabled = providedDisabled || false, ...theirProps } = props
2931

32+
let [tag, resolveTag] = useResolvedTag(props.as ?? DEFAULT_FIELDSET_TAG)
33+
let fieldsetRef = useSyncRefs(ref, resolveTag)
34+
3035
let [labelledBy, LabelProvider] = useLabels()
3136

3237
let slot = useMemo(() => ({ disabled }) satisfies FieldsetRenderPropArg, [disabled])
3338

34-
let ourProps = {
35-
ref,
36-
role: 'group',
37-
38-
'aria-labelledby': labelledBy,
39-
'aria-disabled': disabled || undefined,
40-
}
39+
let ourProps =
40+
tag === 'fieldset'
41+
? {
42+
ref: fieldsetRef,
43+
'aria-labelledby': labelledBy,
44+
disabled: disabled || undefined,
45+
}
46+
: {
47+
ref: fieldsetRef,
48+
role: 'group',
49+
'aria-labelledby': labelledBy,
50+
'aria-disabled': disabled || undefined,
51+
}
4152

4253
return (
4354
<DisabledProvider value={disabled}>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useCallback, useState } from 'react'
2+
3+
/**
4+
* Resolve the actual rendered tag of a DOM node. If the `tag` provided is
5+
* already a string we can use that as-is. This will happen when the `as` prop is
6+
* not used or when it's used with a string value.
7+
*
8+
* If an actual component is used, then we need to do some more work because
9+
* then we actually need to render the component to know what the tag name is.
10+
*/
11+
export function useResolvedTag<T extends React.ElementType>(tag: T) {
12+
let tagName = typeof tag === 'string' ? tag : undefined
13+
let [resolvedTag, setResolvedTag] = useState<string | undefined>(tagName)
14+
15+
return [
16+
// The resolved tag name
17+
tagName ?? resolvedTag,
18+
19+
// This callback should be passed to the `ref` of a component
20+
useCallback(
21+
(ref: any) => {
22+
// Tag name is already known and it's a string, no need to re-render
23+
if (tagName) return
24+
25+
if (ref instanceof HTMLElement) {
26+
// Tag name is not known yet, render the component to find out
27+
setResolvedTag(ref.tagName.toLowerCase())
28+
}
29+
},
30+
[tagName]
31+
),
32+
] as const
33+
}

0 commit comments

Comments
 (0)