diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 52b3be2..81a94f3 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mark `SwitchGroup` as deprecated, prefer `Field` instead ([#3232](https://github.com/tailwindlabs/headlessui/pull/3232)) +### Changed + +- Use native `fieldset` instead of `div` by default for `
` component ([#3237](https://github.com/tailwindlabs/headlessui/pull/3237)) + ## [2.0.3] - 2024-05-07 ### Fixed diff --git a/packages/@headlessui-react/src/components/field/field.test.tsx b/packages/@headlessui-react/src/components/field/field.test.tsx index d1c8b88..cd7bf63 100644 --- a/packages/@headlessui-react/src/components/field/field.test.tsx +++ b/packages/@headlessui-react/src/components/field/field.test.tsx @@ -55,7 +55,7 @@ describe('Rendering', () => { let fieldset = container.firstChild let field = fieldset?.firstChild - expect(fieldset).toHaveAttribute('aria-disabled', 'true') + expect(fieldset).toHaveAttribute('disabled') expect(field).toHaveAttribute('aria-disabled', 'true') }) }) diff --git a/packages/@headlessui-react/src/components/fieldset/fieldset.test.tsx b/packages/@headlessui-react/src/components/fieldset/fieldset.test.tsx index b7501fd..7fbd571 100644 --- a/packages/@headlessui-react/src/components/fieldset/fieldset.test.tsx +++ b/packages/@headlessui-react/src/components/fieldset/fieldset.test.tsx @@ -22,10 +22,24 @@ describe('Rendering', () => { let fieldset = container.firstChild + expect(fieldset).toBeInstanceOf(HTMLFieldSetElement) + expect(fieldset).not.toHaveAttribute('role', 'group') + }) + + it('should render a `Fieldset` using a custom component', async () => { + let { container } = render( +
+ +
+ ) + + let fieldset = container.firstChild + + expect(fieldset).toBeInstanceOf(HTMLSpanElement) expect(fieldset).toHaveAttribute('role', 'group') }) - it('should add an `aria-disabled` attribute when disabling the `Fieldset`', async () => { + it('should forward the `disabled` attribute when disabling the `Fieldset`', async () => { let { container } = render(
@@ -34,10 +48,33 @@ describe('Rendering', () => { let fieldset = container.firstChild - expect(fieldset).toHaveAttribute('role', 'group') + expect(fieldset).toHaveAttribute('disabled') + }) + + it('should add an `aria-disabled` attribute when disabling the `Fieldset` when using another element via the `as` prop', async () => { + let { container } = render( +
+ +
+ ) + + let fieldset = container.firstChild + expect(fieldset).toHaveAttribute('aria-disabled', 'true') }) + it('should make nested inputs disabled when the fieldset is disabled', async () => { + let { container } = render( +
+ +
+ ) + + let fieldset = container.firstChild + + expect(fieldset?.firstChild).toBeDisabled() + }) + it('should link a `Fieldset` to a nested `Legend`', async () => { let { container } = render(
diff --git a/packages/@headlessui-react/src/components/fieldset/fieldset.tsx b/packages/@headlessui-react/src/components/fieldset/fieldset.tsx index 81f3972..e23b157 100644 --- a/packages/@headlessui-react/src/components/fieldset/fieldset.tsx +++ b/packages/@headlessui-react/src/components/fieldset/fieldset.tsx @@ -1,15 +1,17 @@ 'use client' import React, { useMemo, type ElementType, type Ref } from 'react' +import { useResolvedTag } from '../../hooks/use-resolved-tag' +import { useSyncRefs } from '../../hooks/use-sync-refs' import { DisabledProvider, useDisabled } from '../../internal/disabled' import type { Props } from '../../types' import { forwardRefWithAs, render, type HasDisplayName } from '../../utils/render' import { useLabels } from '../label/label' -let DEFAULT_FIELDSET_TAG = 'div' as const +let DEFAULT_FIELDSET_TAG = 'fieldset' as const type FieldsetRenderPropArg = {} -type FieldsetPropsWeControl = 'aria-controls' +type FieldsetPropsWeControl = 'aria-labelledby' | 'aria-disabled' | 'role' export type FieldsetProps = Props< TTag, @@ -27,17 +29,26 @@ function FieldsetFn( let providedDisabled = useDisabled() let { disabled = providedDisabled || false, ...theirProps } = props + let [tag, resolveTag] = useResolvedTag(props.as ?? DEFAULT_FIELDSET_TAG) + let fieldsetRef = useSyncRefs(ref, resolveTag) + let [labelledBy, LabelProvider] = useLabels() let slot = useMemo(() => ({ disabled }) satisfies FieldsetRenderPropArg, [disabled]) - let ourProps = { - ref, - role: 'group', - - 'aria-labelledby': labelledBy, - 'aria-disabled': disabled || undefined, - } + let ourProps = + tag === 'fieldset' + ? { + ref: fieldsetRef, + 'aria-labelledby': labelledBy, + disabled: disabled || undefined, + } + : { + ref: fieldsetRef, + role: 'group', + 'aria-labelledby': labelledBy, + 'aria-disabled': disabled || undefined, + } return ( diff --git a/packages/@headlessui-react/src/hooks/use-resolved-tag.ts b/packages/@headlessui-react/src/hooks/use-resolved-tag.ts new file mode 100644 index 0000000..ce09066 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-resolved-tag.ts @@ -0,0 +1,33 @@ +import { useCallback, useState } from 'react' + +/** + * Resolve the actual rendered tag of a DOM node. If the `tag` provided is + * already a string we can use that as-is. This will happen when the `as` prop is + * not used or when it's used with a string value. + * + * If an actual component is used, then we need to do some more work because + * then we actually need to render the component to know what the tag name is. + */ +export function useResolvedTag(tag: T) { + let tagName = typeof tag === 'string' ? tag : undefined + let [resolvedTag, setResolvedTag] = useState(tagName) + + return [ + // The resolved tag name + tagName ?? resolvedTag, + + // This callback should be passed to the `ref` of a component + useCallback( + (ref: any) => { + // Tag name is already known and it's a string, no need to re-render + if (tagName) return + + if (ref instanceof HTMLElement) { + // Tag name is not known yet, render the component to find out + setResolvedTag(ref.tagName.toLowerCase()) + } + }, + [tagName] + ), + ] as const +}