Repository: tailwindlabs/headlessui
Pull request #3237 (2024-05-24 14:26:48)

Use native `fieldset` instead of `div` by default for `<Fieldset />` component
This PR improves the default behavior of our `<Fieldset />` component.

Initially we used a `div` because `fieldset` / `legend` is hard to style otherwise. However, a fieldset on its own is still useful and has some benefits (such as disabling all native form elements if the `fieldset` itself is disabled).

We still setup an internal `DisabledProvider` such that our own components can read from this state and disable themselves accordingly.

This is technically a breaking change, but I doubt that anyone runs into this issue.

If so, you can apply the following diff to fix it:

```diff
- <Fieldset>
+ <Fieldset as="div">
    {/* … */}
  </Fieldset>
```

Note: if you were already using `disabled` on the `<Fieldset disabled>`, and you use `as="div"` then you won't have the default behavior where it disables these form elements.

---

Implementation looks a bit more complex, but essentially added new functionality to compute what the final `tagName` is of a component and based on this information we know which props to render. If a native `fieldset` is used, we can use the `disabled` prop, if another element is used we can render `aria-disabled` and a `role="group"`.

The new internal `useResolveTag` will compute the tag name, and it defaults to the `props.as` value.



vercel[bot]: [vc]: #sjITMEB9FZq+iEa/b9x3Hd4aOS3OOaoAnEoqGMBSEi8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXZ1ZSIsInJvb3REaXJlY3RvcnkiOiJwbGF5Z3JvdW5kcy92dWUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktdnVlLzQ0Vm1KRXJTaDMxbTRGQUZyU3pYRzRoNm1SM1UiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZlYXQtdXNlLWZpZWxkc2V0LXRhZy10YWlsd2luZGxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktdnVlLWdpdC1mZWF0LXVzZS1maWVsZHNldC10YWctdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAifX0seyJuYW1lIjoiaGVhZGxlc3N1aS1yZWFjdCIsInJvb3REaXJlY3RvcnkiOiJwbGF5Z3JvdW5kcy9yZWFjdCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90YWlsd2luZGxhYnMvaGVhZGxlc3N1aS1yZWFjdC9BNThlQk1QUXNrdFlZS0g5SHB5YXl1QXdkWTFyIiwicHJldmlld1VybCI6ImhlYWRsZXNzdWktcmVhY3QtZ2l0LWZlYXQtdXNlLWZpZWxkc2V0LXRhZy10YWlsd2luZGxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktcmVhY3QtZ2l0LWZlYXQtdXNlLWZpZWxkc2V0LXRhZy10YWlsd2luZGxhYnMudmVyY2VsLmFwcCJ9fV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/A58eBMPQsktYYKH9HpyayuAwdY1r)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-feat-use-fieldset-tag-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-feat-use-fieldset-tag-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | May 24, 2024 2:26pm |
| **headlessui-vue** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/44VmJErSh31m4FAFrSzXG4h6mR3U)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-feat-use-fieldset-tag-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-feat-use-fieldset-tag-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | May 24, 2024 2:26pm |




Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 81d5825..81a94f3 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -11,11 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - [internal] Don’t set a focus fallback for Dialog’s in demo mode ([#3194](https://github.com/tailwindlabs/headlessui/pull/3194))
 - Ensure page doesn't scroll down when pressing `Escape` to close the `Dialog` component ([#3218](https://github.com/tailwindlabs/headlessui/pull/3218))
+- Fix crash when toggling between `virtual` and non-virtual mode in `Combobox` component ([#3236](https://github.com/tailwindlabs/headlessui/pull/3236))
+- Ensure tabbing to a portalled `<PopoverPanel>` component moves focus inside (without using `<PortalGroup>`) ([#3239](https://github.com/tailwindlabs/headlessui/pull/3239))
+- Only handle form reset when `defaultValue` is used ([#3240](https://github.com/tailwindlabs/headlessui/pull/3240))
 
 ### Deprecated
 
 - 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 `<Fieldset />` component ([#3237](https://github.com/tailwindlabs/headlessui/pull/3237))
+
 ## [2.0.3] - 2024-05-07
 
 ### Fixed
diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx
index a8d743a..2c7e22a 100644
--- a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx
+++ b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx
@@ -13,6 +13,7 @@ import React, {
 } from 'react'
 import { useActivePress } from '../../hooks/use-active-press'
 import { useControllable } from '../../hooks/use-controllable'
+import { useDefaultValue } from '../../hooks/use-default-value'
 import { useDisposables } from '../../hooks/use-disposables'
 import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
@@ -85,7 +86,7 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
     disabled = providedDisabled || false,
     autoFocus = false,
     checked: controlledChecked,
-    defaultChecked = false,
+    defaultChecked: _defaultChecked,
     onChange: controlledOnChange,
     name,
     value,
@@ -94,7 +95,12 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
     ...theirProps
   } = props
 
-  let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
+  let defaultChecked = useDefaultValue(_defaultChecked)
+  let [checked, onChange] = useControllable(
+    controlledChecked,
+    controlledOnChange,
+    defaultChecked ?? false
+  )
 
   let labelledBy = useLabelledBy()
   let describedBy = useDescribedBy()
@@ -166,8 +172,9 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
   }, [checked, indeterminate, disabled, hover, focus, active, changing, autoFocus])
 
   let reset = useCallback(() => {
+    if (defaultChecked === undefined) return
     return onChange?.(defaultChecked)
-  }, [onChange /* Explicitly ignoring `defaultChecked` */])
+  }, [onChange, defaultChecked])
 
   return (
     <>
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 9f8756b..b5828b2 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -24,6 +24,7 @@ import React, {
 import { useActivePress } from '../../hooks/use-active-press'
 import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
 import { useControllable } from '../../hooks/use-controllable'
+import { useDefaultValue } from '../../hooks/use-default-value'
 import { useDisposables } from '../../hooks/use-disposables'
 import { useElementSize } from '../../hooks/use-element-size'
 import { useEvent } from '../../hooks/use-event'
@@ -125,7 +126,7 @@ enum ActionTypes {
 
   SetActivationTrigger,
 
-  UpdateVirtualOptions,
+  UpdateVirtualConfiguration,
 }
 
 function adjustOrderedState<T>(
@@ -180,7 +181,11 @@ type Actions<T> =
     }
   | { type: ActionTypes.UnregisterOption; id: string }
   | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger }
-  | { type: ActionTypes.UpdateVirtualOptions; options: T[] }
+  | {
+      type: ActionTypes.UpdateVirtualConfiguration
+      options: T[]
+      disabled: ((value: any) => boolean) | null
+    }
 
 let reducers: {
   [P in ActionTypes]: <T>(
@@ -236,16 +241,15 @@ let reducers: {
     }
 
     if (state.virtual) {
+      let { options, disabled } = state.virtual
       let activeOptionIndex =
         action.focus === Focus.Specific
           ? action.idx
           : calculateActiveIndex(action, {
-              resolveItems: () => state.virtual!.options,
+              resolveItems: () => options,
               resolveActiveIndex: () =>
-                state.activeOptionIndex ??
-                state.virtual!.options.findIndex((option) => !state.virtual!.disabled(option)) ??
-                null,
-              resolveDisabled: state.virtual!.disabled,
+                state.activeOptionIndex ?? options.findIndex((option) => !disabled(option)) ?? null,
+              resolveDisabled: disabled,
               resolveId() {
                 throw new Error('Function not implemented.')
               },
@@ -373,14 +377,21 @@ let reducers: {
       activationTrigger: action.trigger,
     }
   },
-  [ActionTypes.UpdateVirtualOptions]: (state, action) => {
-    if (state.virtual?.options === action.options) {
+  [ActionTypes.UpdateVirtualConfiguration]: (state, action) => {
+    if (state.virtual === null) {
+      return {
+        ...state,
+        virtual: { options: action.options, disabled: action.disabled ?? (() => false) },
+      }
+    }
+
+    if (state.virtual.options === action.options && state.virtual.disabled === action.disabled) {
       return state
     }
 
     let adjustedActiveOptionIndex = state.activeOptionIndex
     if (state.activeOptionIndex !== null) {
-      let idx = action.options.indexOf(state.virtual!.options[state.activeOptionIndex])
+      let idx = action.options.indexOf(state.virtual.options[state.activeOptionIndex])
       if (idx !== -1) {
         adjustedActiveOptionIndex = idx
       } else {
@@ -391,7 +402,7 @@ let reducers: {
     return {
       ...state,
       activeOptionIndex: adjustedActiveOptionIndex,
-      virtual: Object.assign({}, state.virtual, { options: action.options }),
+      virtual: { options: action.options, disabled: action.disabled ?? (() => false) },
     }
   },
 }
@@ -425,6 +436,7 @@ function VirtualProvider(props: {
   children: (data: { option: unknown; open: boolean }) => React.ReactElement
 }) {
   let data = useData('VirtualProvider')
+  let { options } = data.virtual!
 
   let [paddingStart, paddingEnd] = useMemo(() => {
     let el = data.optionsRef.current
@@ -441,7 +453,7 @@ function VirtualProvider(props: {
   let virtualizer = useVirtualizer({
     scrollPaddingStart: paddingStart,
     scrollPaddingEnd: paddingEnd,
-    count: data.virtual!.options.length,
+    count: options.length,
     estimateSize() {
       return 40
     },
@@ -454,7 +466,7 @@ function VirtualProvider(props: {
   let [baseKey, setBaseKey] = useState(0)
   useIsoMorphicEffect(() => {
     setBaseKey((v) => v + 1)
-  }, [data.virtual?.options])
+  }, [options])
 
   let items = virtualizer.getVirtualItems()
 
@@ -487,10 +499,7 @@ function VirtualProvider(props: {
               return
             }
 
-            if (
-              data.activeOptionIndex !== null &&
-              data.virtual!.options.length > data.activeOptionIndex
-            ) {
+            if (data.activeOptionIndex !== null && options.length > data.activeOptionIndex) {
               virtualizer.scrollToIndex(data.activeOptionIndex)
             }
           }
@@ -501,13 +510,13 @@ function VirtualProvider(props: {
             <Fragment key={item.key}>
               {React.cloneElement(
                 props.children?.({
-                  option: data.virtual!.options[item.index],
+                  option: options[item.index],
                   open: data.comboboxState === ComboboxState.Open,
                 }),
                 {
                   key: `${baseKey}-${item.key}`,
                   'data-index': item.index,
-                  'aria-setsize': data.virtual!.options.length,
+                  'aria-setsize': options.length,
                   'aria-posinset': item.index + 1,
                   style: {
                     position: 'absolute',
@@ -627,7 +636,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
   let providedDisabled = useDisabled()
   let {
     value: controlledValue,
-    defaultValue,
+    defaultValue: _defaultValue,
     onChange: controlledOnChange,
     form,
     name,
@@ -643,6 +652,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
     nullable: _nullable,
     ...theirProps
   } = props
+  let defaultValue = useDefaultValue(_defaultValue)
   let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
     controlledValue,
     controlledOnChange,
@@ -710,7 +720,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
       defaultValue,
       disabled,
       mode: multiple ? ValueMode.Multi : ValueMode.Single,
-      virtual: state.virtual,
+      virtual: virtual ? state.virtual : null,
       get activeOptionIndex() {
         if (
           defaultToFirstOption.current &&
@@ -719,7 +729,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
         ) {
           if (virtual) {
             let localActiveOptionIndex = virtual.options.findIndex(
-              (option) => !(virtual?.disabled?.(option) ?? false)
+              (option) => !(virtual.disabled?.(option) ?? false)
             )
 
             if (localActiveOptionIndex !== -1) {
@@ -748,8 +758,12 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
 
   useIsoMorphicEffect(() => {
     if (!virtual) return
-    dispatch({ type: ActionTypes.UpdateVirtualOptions, options: virtual.options })
-  }, [virtual, virtual?.options])
+    dispatch({
+      type: ActionTypes.UpdateVirtualConfiguration,
+      options: virtual.options,
+      disabled: virtual.disabled ?? null,
+    })
+  }, [virtual, virtual?.options, virtual?.disabled])
 
   useIsoMorphicEffect(() => {
     state.dataRef.current = data
@@ -875,8 +889,9 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
   let ourProps = ref === null ? {} : { ref }
 
   let reset = useCallback(() => {
+    if (defaultValue === undefined) return
     return theirOnChange?.(defaultValue)
-  }, [theirOnChange /* Explicitly ignoring `defaultValue` */])
+  }, [theirOnChange, defaultValue])
 
   return (
     <LabelProvider
@@ -1757,7 +1772,7 @@ function OptionFn<
   let {
     id = `headlessui-combobox-option-${internalId}`,
     value,
-    disabled = data.virtual?.disabled(value) ?? false,
+    disabled = data.virtual?.disabled?.(value) ?? false,
     order = null,
     ...theirProps
   } = props
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<TTag extends ElementType = typeof DEFAULT_FIELDSET_TAG> = Props<
   TTag,
@@ -27,17 +29,26 @@ function FieldsetFn<TTag extends ElementType = typeof DEFAULT_FIELDSET_TAG>(
   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 (
     <DisabledProvider value={disabled}>
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 1de94ef..9f9ac41 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -24,6 +24,7 @@ import { useActivePress } from '../../hooks/use-active-press'
 import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
 import { useComputed } from '../../hooks/use-computed'
 import { useControllable } from '../../hooks/use-controllable'
+import { useDefaultValue } from '../../hooks/use-default-value'
 import { useDidElementMove } from '../../hooks/use-did-element-move'
 import { useDisposables } from '../../hooks/use-disposables'
 import { useElementSize } from '../../hooks/use-element-size'
@@ -481,7 +482,7 @@ function ListboxFn<
   let providedDisabled = useDisabled()
   let {
     value: controlledValue,
-    defaultValue,
+    defaultValue: _defaultValue,
     form,
     name,
     onChange: controlledOnChange,
@@ -493,9 +494,11 @@ function ListboxFn<
     __demoMode = false,
     ...theirProps
   } = props
+
   const orientation = horizontal ? 'horizontal' : 'vertical'
   let listboxRef = useSyncRefs(ref)
 
+  let defaultValue = useDefaultValue(_defaultValue)
   let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
     controlledValue,
     controlledOnChange,
@@ -660,8 +663,9 @@ function ListboxFn<
   let ourProps = { ref: listboxRef }
 
   let reset = useCallback(() => {
+    if (defaultValue === undefined) return
     return theirOnChange?.(defaultValue)
-  }, [theirOnChange /* Explicitly ignoring `defaultValue` */])
+  }, [theirOnChange, defaultValue])
 
   return (
     <LabelProvider
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 3940555..823b044 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -92,6 +92,7 @@ interface StateDefinition {
 
   beforePanelSentinel: MutableRefObject<HTMLButtonElement | null>
   afterPanelSentinel: MutableRefObject<HTMLButtonElement | null>
+  afterButtonSentinel: MutableRefObject<HTMLButtonElement | null>
 
   __demoMode: boolean
 }
@@ -256,9 +257,19 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
     panelId: null,
     beforePanelSentinel: createRef(),
     afterPanelSentinel: createRef(),
+    afterButtonSentinel: createRef(),
   } as StateDefinition)
   let [
-    { popoverState, button, buttonId, panel, panelId, beforePanelSentinel, afterPanelSentinel },
+    {
+      popoverState,
+      button,
+      buttonId,
+      panel,
+      panelId,
+      beforePanelSentinel,
+      afterPanelSentinel,
+      afterButtonSentinel,
+    },
     dispatch,
   ] = reducerBag
 
@@ -346,6 +357,7 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
       if (root.contains(event.target)) return
       if (beforePanelSentinel.current?.contains?.(event.target)) return
       if (afterPanelSentinel.current?.contains?.(event.target)) return
+      if (afterButtonSentinel.current?.contains?.(event.target)) return
 
       dispatch({ type: ActionTypes.ClosePopover })
     },
@@ -700,6 +712,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
       {visible && !isWithinPanel && isPortalled && (
         <Hidden
           id={sentinelId}
+          ref={state.afterButtonSentinel}
           features={HiddenFeatures.Focusable}
           data-headlessui-focus-guard
           as="button"
diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
index 6d554aa..7c2130a 100644
--- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
+++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
@@ -17,6 +17,7 @@ import React, {
 } from 'react'
 import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
 import { useControllable } from '../../hooks/use-controllable'
+import { useDefaultValue } from '../../hooks/use-default-value'
 import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
 import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -171,15 +172,14 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
   let {
     id = `headlessui-radiogroup-${internalId}`,
     value: controlledValue,
-    defaultValue,
     form,
     name,
     onChange: controlledOnChange,
     by,
     disabled = providedDisabled || false,
+    defaultValue: _defaultValue,
     ...theirProps
   } = props
-
   let compare = useByComparator(by)
   let [state, dispatch] = useReducer(stateReducer, { options: [] } as StateDefinition<TType>)
   let options = state.options as Option<TType>[]
@@ -188,6 +188,7 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
   let internalRadioGroupRef = useRef<HTMLElement | null>(null)
   let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref)
 
+  let defaultValue = useDefaultValue(_defaultValue)
   let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
 
   let firstOption = useMemo(
@@ -304,8 +305,9 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
   let slot = useMemo(() => ({ value }) satisfies RadioGroupRenderPropArg<TType>, [value])
 
   let reset = useCallback(() => {
-    return triggerChange(defaultValue!)
-  }, [triggerChange /* Explicitly ignoring `defaultValue` */])
+    if (defaultValue === undefined) return
+    return triggerChange(defaultValue)
+  }, [triggerChange, defaultValue])
 
   return (
     <DescriptionProvider name="RadioGroup.Description">
diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx
index e1080fa..5a17ce0 100644
--- a/packages/@headlessui-react/src/components/switch/switch.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.tsx
@@ -17,6 +17,7 @@ import React, {
 } from 'react'
 import { useActivePress } from '../../hooks/use-active-press'
 import { useControllable } from '../../hooks/use-controllable'
+import { useDefaultValue } from '../../hooks/use-default-value'
 import { useDisposables } from '../../hooks/use-disposables'
 import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
@@ -146,7 +147,7 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
     id = providedId || `headlessui-switch-${internalId}`,
     disabled = providedDisabled || false,
     checked: controlledChecked,
-    defaultChecked = false,
+    defaultChecked: _defaultChecked,
     onChange: controlledOnChange,
     name,
     value,
@@ -162,7 +163,12 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
     groupContext === null ? null : groupContext.setSwitch
   )
 
-  let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
+  let defaultChecked = useDefaultValue(_defaultChecked)
+  let [checked, onChange] = useControllable(
+    controlledChecked,
+    controlledOnChange,
+    defaultChecked ?? false
+  )
 
   let d = useDisposables()
   let [changing, setChanging] = useState(false)
@@ -232,8 +238,9 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
   )
 
   let reset = useCallback(() => {
+    if (defaultChecked === undefined) return
     return onChange?.(defaultChecked)
-  }, [onChange /* Explicitly ignoring `defaultChecked` */])
+  }, [onChange, defaultChecked])
 
   return (
     <>
diff --git a/packages/@headlessui-react/src/hooks/use-default-value.ts b/packages/@headlessui-react/src/hooks/use-default-value.ts
new file mode 100644
index 0000000..b7a3ada
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-default-value.ts
@@ -0,0 +1,13 @@
+import { useState } from 'react'
+
+/**
+ * Returns a stable value that never changes unless the component is re-mounted.
+ *
+ * This ensures that we can use this value in a dependency array without causing
+ * unnecessary re-renders (because while the incoming `value` can change, the
+ * returned `defaultValue` won't change).
+ */
+export function useDefaultValue<T>(value: T) {
+  let [defaultValue] = useState(value)
+  return defaultValue
+}
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<T extends React.ElementType>(tag: T) {
+  let tagName = typeof tag === 'string' ? tag : undefined
+  let [resolvedTag, setResolvedTag] = useState<string | undefined>(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
+}


Test patch:
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(
+      <Fieldset as="span">
+        <input />
+      </Fieldset>
+    )
+
+    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(
       <Fieldset disabled>
         <input />
@@ -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(
+      <Fieldset as="span" disabled>
+        <input />
+      </Fieldset>
+    )
+
+    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(
+      <Fieldset disabled>
+        <input />
+      </Fieldset>
+    )
+
+    let fieldset = container.firstChild
+
+    expect(fieldset?.firstChild).toBeDisabled()
+  })
+
   it('should link a `Fieldset` to a nested `Legend`', async () => {
     let { container } = render(
       <Fieldset>
diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx
index 3777ac9..02a21de 100644
--- a/packages/@headlessui-react/src/components/popover/popover.test.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx
@@ -16,7 +16,7 @@ import { Keys, MouseButton, click, focus, press, shift } from '../../test-utils/
 import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
 import { Portal } from '../portal/portal'
 import { Transition } from '../transition/transition'
-import { Popover } from './popover'
+import { Popover, PopoverButton, PopoverPanel } from './popover'
 
 jest.mock('../../hooks/use-id')
 
@@ -1303,6 +1303,45 @@ describe('Keyboard interactions', () => {
   })
 
   describe('`Tab` key', () => {
+    it(
+      'should be possible to Tab through the panel contents and end up in the Button again (without PopoverGroup)',
+      suppressConsoleLogs(async () => {
+        render(
+          <Popover>
+            <PopoverButton>Trigger</PopoverButton>
+            <PopoverPanel portal>
+              <a href="/">Link 1</a>
+              <a href="/">Link 2</a>
+            </PopoverPanel>
+          </Popover>
+        )
+
+        // Focus the button of the first Popover
+        getByText('Trigger')?.focus()
+
+        // Open popover
+        await click(getByText('Trigger'))
+
+        // Verify we are focused on the first link
+        await press(Keys.Tab)
+        assertActiveElement(getByText('Link 1'))
+
+        // Verify we are focused on the second link
+        await press(Keys.Tab)
+        assertActiveElement(getByText('Link 2'))
+
+        // Let's Tab again
+        await press(Keys.Tab)
+
+        // Verify that the first Popover is still open
+        assertPopoverButton({ state: PopoverState.Visible })
+        assertPopoverPanel({ state: PopoverState.Visible })
+
+        // Verify that the button is focused again
+        assertActiveElement(getByText('Trigger'))
+      })
+    )
+
     it(
       'should be possible to Tab through the panel contents onto the next Popover.Button',
       suppressConsoleLogs(async () => {