diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index a1e302fbee..5180969a76 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-- Nothing yet!
+### Fixed
+
+- Reset form-like components when the parent `
+ )
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose alice
+ await click(getComboboxOptions()[0])
+ expect(getComboboxButton()).toHaveTextContent('alice')
+ expect(getComboboxInput()).toHaveValue('alice')
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The combobox should be reset to bob
+ expect(getComboboxButton()).toHaveTextContent('bob')
+ expect(getComboboxInput()).toHaveValue('bob')
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertActiveComboboxOption(getComboboxOptions()[1])
+ })
+
+ it('should be possible to reset to the default value if the form is reset (using objects)', async () => {
+ let handleSubmission = jest.fn()
+
+ let data = [
+ { id: 1, name: 'alice', label: 'Alice' },
+ { id: 2, name: 'bob', label: 'Bob' },
+ { id: 3, name: 'charlie', label: 'Charlie' },
+ ]
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose alice
+ await click(getComboboxOptions()[0])
+ expect(getComboboxButton()).toHaveTextContent('alice')
+ expect(getComboboxInput()).toHaveValue('alice')
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The combobox should be reset to bob
+ expect(getComboboxButton()).toHaveTextContent('bob')
+ expect(getComboboxInput()).toHaveValue('bob')
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertActiveComboboxOption(getComboboxOptions()[1])
+ })
+
it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 9c72d9d080..8e463aa1bb 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -14,6 +14,7 @@ import React, {
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
+ useEffect,
} from 'react'
import { ByComparator, EnsureArray, Expand, Props } from '../../types'
@@ -266,6 +267,7 @@ type _Actions = ReturnType
let ComboboxDataContext = createContext<
| ({
value: unknown
+ defaultValue: unknown
disabled: boolean
mode: ValueMode
activeOptionIndex: number | null
@@ -453,6 +455,7 @@ function ComboboxFn {
@@ -589,6 +592,17 @@ function ComboboxFn(null)
+ let d = useDisposables()
+ useEffect(() => {
+ if (!form.current) return
+ if (defaultValue === undefined) return
+
+ d.addEventListener(form.current, 'reset', () => {
+ onChange(defaultValue)
+ })
+ }, [form, onChange /* Explicitly ignoring `defaultValue` */])
+
return (
@@ -600,9 +614,16 @@ function ComboboxFn
{name != null &&
value != null &&
- objectToFormEntries({ [name]: value }).map(([name, value]) => (
+ objectToFormEntries({ [name]: value }).map(([name, value], idx) => (
{
+ form.current = element?.closest('form') ?? null
+ }
+ : undefined
+ }
{...compact({
key: name,
as: 'input',
@@ -853,6 +874,10 @@ let Input = forwardRefWithAs(function Input<
data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id,
'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined,
'aria-labelledby': labelledby,
+ defaultValue:
+ props.defaultValue ??
+ displayValue?.(data.defaultValue as unknown as TType) ??
+ data.defaultValue,
disabled: data.disabled,
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index 94742c0bdd..84f415d3c9 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -972,6 +972,112 @@ describe('Rendering', () => {
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
})
+ it('should be possible to reset to the default value if the form is reset', async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose alice
+ await click(getListboxOptions()[0])
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The listbox should be reset to bob
+ expect(getListboxButton()).toHaveTextContent('bob')
+
+ // Open listbox
+ await click(getListboxButton())
+ assertActiveListboxOption(getListboxOptions()[1])
+ })
+
+ it('should be possible to reset to the default value if the form is reset (using objects)', async () => {
+ let handleSubmission = jest.fn()
+
+ let data = [
+ { id: 1, name: 'alice', label: 'Alice' },
+ { id: 2, name: 'bob', label: 'Bob' },
+ { id: 3, name: 'charlie', label: 'Charlie' },
+ ]
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose alice
+ await click(getListboxOptions()[0])
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The listbox should be reset to bob
+ expect(getListboxButton()).toHaveTextContent('bob')
+
+ // Open listbox
+ await click(getListboxButton())
+ assertActiveListboxOption(getListboxOptions()[1])
+ })
+
it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 6d9250daab..e7c52bc5a9 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -2,6 +2,7 @@ import React, {
Fragment,
createContext,
createRef,
+ useCallback,
useContext,
useEffect,
useMemo,
@@ -9,7 +10,6 @@ import React, {
useRef,
// Types
- Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
@@ -22,7 +22,7 @@ import { useId } from '../../hooks/use-id'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useComputed } from '../../hooks/use-computed'
import { useSyncRefs } from '../../hooks/use-sync-refs'
-import { Props } from '../../types'
+import { EnsureArray, Props } from '../../types'
import { Features, forwardRefWithAs, PropsForFeatures, render, compact } from '../../utils/render'
import { match } from '../../utils/match'
import { disposables } from '../../utils/disposables'
@@ -38,6 +38,7 @@ import { objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'
+import { useLatestValue } from '../../hooks/use-latest-value'
enum ListboxStates {
Open,
@@ -54,30 +55,20 @@ enum ActivationTrigger {
Other,
}
-type ListboxOptionDataRef = MutableRefObject<{
+type ListboxOptionDataRef = MutableRefObject<{
textValue?: string
disabled: boolean
- value: unknown
+ value: T
domRef: MutableRefObject
}>
-interface StateDefinition {
- listboxState: ListboxStates
-
- orientation: 'horizontal' | 'vertical'
+interface StateDefinition {
+ dataRef: MutableRefObject<_Data>
+ labelId: string | null
- propsRef: MutableRefObject<{
- value: unknown
- onChange(value: unknown): void
- mode: ValueMode
- compare(a: unknown, z: unknown): boolean
- }>
- labelRef: MutableRefObject
- buttonRef: MutableRefObject
- optionsRef: MutableRefObject
+ listboxState: ListboxStates
- disabled: boolean
- options: { id: string; dataRef: ListboxOptionDataRef }[]
+ options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string
activeOptionIndex: number | null
activationTrigger: ActivationTrigger
@@ -87,20 +78,19 @@ enum ActionTypes {
OpenListbox,
CloseListbox,
- SetDisabled,
- SetOrientation,
-
GoToOption,
Search,
ClearSearch,
RegisterOption,
UnregisterOption,
+
+ RegisterLabel,
}
-function adjustOrderedState(
- state: StateDefinition,
- adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i
+function adjustOrderedState(
+ state: StateDefinition,
+ adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i
) {
let currentActiveOption =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
@@ -127,11 +117,9 @@ function adjustOrderedState(
}
}
-type Actions =
+type Actions =
| { type: ActionTypes.CloseListbox }
| { type: ActionTypes.OpenListbox }
- | { type: ActionTypes.SetDisabled; disabled: boolean }
- | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
| {
type: ActionTypes.GoToOption
@@ -140,37 +128,29 @@ type Actions =
}
| { type: ActionTypes.Search; value: string }
| { type: ActionTypes.ClearSearch }
- | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef }
+ | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef }
+ | { type: ActionTypes.RegisterLabel; id: string | null }
| { type: ActionTypes.UnregisterOption; id: string }
let reducers: {
- [P in ActionTypes]: (
- state: StateDefinition,
- action: Extract
- ) => StateDefinition
+ [P in ActionTypes]: (
+ state: StateDefinition,
+ action: Extract, { type: P }>
+ ) => StateDefinition
} = {
[ActionTypes.CloseListbox](state) {
- if (state.disabled) return state
+ if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
return { ...state, activeOptionIndex: null, listboxState: ListboxStates.Closed }
},
[ActionTypes.OpenListbox](state) {
- if (state.disabled) return state
+ if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Open) return state
// Check if we have a selected value that we can make active
let activeOptionIndex = state.activeOptionIndex
- let { value, mode, compare } = state.propsRef.current
- let optionIdx = state.options.findIndex((option) => {
- let optionValue = option.dataRef.current.value
- let selected = match(mode, {
- [ValueMode.Multi]: () =>
- (value as unknown[]).some((option) => compare(option, optionValue)),
- [ValueMode.Single]: () => compare(value, optionValue),
- })
-
- return selected
- })
+ let { isSelected } = state.dataRef.current
+ let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value))
if (optionIdx !== -1) {
activeOptionIndex = optionIdx
@@ -178,16 +158,8 @@ let reducers: {
return { ...state, listboxState: ListboxStates.Open, activeOptionIndex }
},
- [ActionTypes.SetDisabled](state, action) {
- if (state.disabled === action.disabled) return state
- return { ...state, disabled: action.disabled }
- },
- [ActionTypes.SetOrientation](state, action) {
- if (state.orientation === action.orientation) return state
- return { ...state, orientation: action.orientation }
- },
[ActionTypes.GoToOption](state, action) {
- if (state.disabled) return state
+ if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let adjustedState = adjustOrderedState(state)
@@ -207,7 +179,7 @@ let reducers: {
}
},
[ActionTypes.Search]: (state, action) => {
- if (state.disabled) return state
+ if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let wasAlreadySearching = state.searchQuery !== ''
@@ -239,7 +211,7 @@ let reducers: {
}
},
[ActionTypes.ClearSearch](state) {
- if (state.disabled) return state
+ if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
if (state.searchQuery === '') return state
return { ...state, searchQuery: '' }
@@ -250,14 +222,7 @@ let reducers: {
// Check if we need to make the newly registered option active.
if (state.activeOptionIndex === null) {
- let { value, mode, compare } = state.propsRef.current
- let optionValue = action.dataRef.current.value
- let selected = match(mode, {
- [ValueMode.Multi]: () =>
- (value as unknown[]).some((option) => compare(option, optionValue)),
- [ValueMode.Single]: () => compare(value, optionValue),
- })
- if (selected) {
+ if (state.dataRef.current.isSelected(action.dataRef.current.value)) {
adjustedState.activeOptionIndex = adjustedState.options.indexOf(option)
}
}
@@ -277,22 +242,75 @@ let reducers: {
activationTrigger: ActivationTrigger.Other,
}
},
+ [ActionTypes.RegisterLabel]: (state, action) => {
+ return {
+ ...state,
+ labelId: action.id,
+ }
+ },
}
-let ListboxContext = createContext<[StateDefinition, Dispatch] | null>(null)
-ListboxContext.displayName = 'ListboxContext'
-
-function useListboxContext(component: string) {
- let context = useContext(ListboxContext)
+let ListboxActionsContext = createContext<{
+ openListbox(): void
+ closeListbox(): void
+ registerOption(id: string, dataRef: ListboxOptionDataRef): () => void
+ registerLabel(id: string): () => void
+ goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void
+ goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
+ selectOption(id: string): void
+ selectActiveOption(): void
+ onChange(value: unknown): void
+ search(query: string): void
+ clearSearch(): void
+} | null>(null)
+ListboxActionsContext.displayName = 'ListboxActionsContext'
+
+function useActions(component: string) {
+ let context = useContext(ListboxActionsContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useActions)
+ throw err
+ }
+ return context
+}
+type _Actions = ReturnType
+
+let ListboxDataContext = createContext<
+ | ({
+ value: unknown
+ disabled: boolean
+ mode: ValueMode
+ orientation: 'horizontal' | 'vertical'
+ activeOptionIndex: number | null
+ compare(a: unknown, z: unknown): boolean
+ isSelected(value: unknown): boolean
+
+ optionsPropsRef: MutableRefObject<{
+ static: boolean
+ hold: boolean
+ }>
+
+ labelRef: MutableRefObject
+ buttonRef: MutableRefObject
+ optionsRef: MutableRefObject
+ } & Omit, 'dataRef'>)
+ | null
+>(null)
+ListboxDataContext.displayName = 'ListboxDataContext'
+
+function useData(component: string) {
+ let context = useContext(ListboxDataContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent component.`)
- if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useData)
throw err
}
return context
}
+type _Data = ReturnType
-function stateReducer(state: StateDefinition, action: Actions) {
+function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
@@ -340,118 +358,210 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
const orientation = horizontal ? 'horizontal' : 'vertical'
let listboxRef = useSyncRefs(ref)
- let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
+ let [value, theirOnChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
- let reducerBag = useReducer(stateReducer, {
+ let [state, dispatch] = useReducer(stateReducer, {
+ dataRef: createRef(),
listboxState: ListboxStates.Closed,
- propsRef: {
- current: {
- value,
- onChange,
- mode: multiple ? ValueMode.Multi : ValueMode.Single,
- compare: useEvent(
- typeof by === 'string'
- ? (a: TActualType, z: TActualType) => {
- let property = by as unknown as keyof TActualType
- return a?.[property] === z?.[property]
- }
- : by
- ),
- },
- },
- labelRef: createRef(),
- buttonRef: createRef(),
- optionsRef: createRef(),
- disabled,
- orientation,
options: [],
searchQuery: '',
+ labelId: null,
activeOptionIndex: null,
activationTrigger: ActivationTrigger.Other,
- } as StateDefinition)
- let [{ listboxState, propsRef, optionsRef, buttonRef }, dispatch] = reducerBag
+ } as StateDefinition)
- propsRef.current.value = value
- propsRef.current.mode = multiple ? ValueMode.Multi : ValueMode.Single
+ let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false })
- useIsoMorphicEffect(() => {
- propsRef.current.onChange = (value: unknown) => {
- return match(propsRef.current.mode, {
- [ValueMode.Single]() {
- return onChange(value as TType)
- },
- [ValueMode.Multi]() {
- let copy = (propsRef.current.value as TActualType[]).slice()
-
- let { compare } = propsRef.current
- let idx = copy.findIndex((item) =>
- compare(item as unknown as TActualType, value as TActualType)
- )
- if (idx === -1) {
- copy.push(value as TActualType)
- } else {
- copy.splice(idx, 1)
- }
-
- return onChange(copy as unknown as TType)
- },
- })
- }
- }, [onChange, propsRef])
- useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
- useIsoMorphicEffect(
- () => dispatch({ type: ActionTypes.SetOrientation, orientation }),
- [orientation]
+ let labelRef = useRef<_Data['labelRef']['current']>(null)
+ let buttonRef = useRef<_Data['buttonRef']['current']>(null)
+ let optionsRef = useRef<_Data['optionsRef']['current']>(null)
+
+ let compare = useEvent(
+ typeof by === 'string'
+ ? (a, z) => {
+ let property = by as unknown as keyof TActualType
+ return a?.[property] === z?.[property]
+ }
+ : by
+ )
+
+ let isSelected: (value: unknown) => boolean = useCallback(
+ (compareValue) =>
+ match(data.mode, {
+ [ValueMode.Multi]: () =>
+ (value as unknown as EnsureArray).some((option) => compare(option, compareValue)),
+ [ValueMode.Single]: () => compare(value as TType, compareValue),
+ }),
+ [value]
)
+ let data = useMemo<_Data>(
+ () => ({
+ ...state,
+ value,
+ disabled,
+ mode: multiple ? ValueMode.Multi : ValueMode.Single,
+ orientation,
+ compare,
+ isSelected,
+ optionsPropsRef,
+ labelRef,
+ buttonRef,
+ optionsRef,
+ }),
+ [value, disabled, multiple, state]
+ )
+
+ useIsoMorphicEffect(() => {
+ state.dataRef.current = data
+ }, [data])
+
// Handle outside click
useOutsideClick(
- [buttonRef, optionsRef],
+ [data.buttonRef, data.optionsRef],
(event, target) => {
dispatch({ type: ActionTypes.CloseListbox })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
- buttonRef.current?.focus()
+ data.buttonRef.current?.focus()
}
},
- listboxState === ListboxStates.Open
+ data.listboxState === ListboxStates.Open
)
let slot = useMemo>(
- () => ({ open: listboxState === ListboxStates.Open, disabled, value }),
- [listboxState, disabled, value]
+ () => ({ open: data.listboxState === ListboxStates.Open, disabled, value }),
+ [data, disabled, value]
+ )
+
+ let selectOption = useEvent((id: string) => {
+ let option = data.options.find((item) => item.id === id)
+ if (!option) return
+
+ onChange(option.dataRef.current.value)
+ })
+
+ let selectActiveOption = useEvent(() => {
+ if (data.activeOptionIndex !== null) {
+ let { dataRef, id } = data.options[data.activeOptionIndex]
+ onChange(dataRef.current.value)
+
+ // It could happen that the `activeOptionIndex` stored in state is actually null,
+ // but we are getting the fallback active option back instead.
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
+ }
+ })
+
+ let openListbox = useEvent(() => dispatch({ type: ActionTypes.OpenListbox }))
+ let closeListbox = useEvent(() => dispatch({ type: ActionTypes.CloseListbox }))
+
+ let goToOption = useEvent((focus, id, trigger) => {
+ if (focus === Focus.Specific) {
+ return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id: id!, trigger })
+ }
+
+ return dispatch({ type: ActionTypes.GoToOption, focus, trigger })
+ })
+
+ let registerOption = useEvent((id, dataRef) => {
+ dispatch({ type: ActionTypes.RegisterOption, id, dataRef })
+ return () => dispatch({ type: ActionTypes.UnregisterOption, id })
+ })
+
+ let registerLabel = useEvent((id) => {
+ dispatch({ type: ActionTypes.RegisterLabel, id })
+ return () => dispatch({ type: ActionTypes.RegisterLabel, id: null })
+ })
+
+ let onChange = useEvent((value: unknown) => {
+ return match(data.mode, {
+ [ValueMode.Single]() {
+ return theirOnChange?.(value as TType)
+ },
+ [ValueMode.Multi]() {
+ let copy = (data.value as TActualType[]).slice()
+
+ let idx = copy.findIndex((item) => compare(item, value as TActualType))
+ if (idx === -1) {
+ copy.push(value as TActualType)
+ } else {
+ copy.splice(idx, 1)
+ }
+
+ return theirOnChange?.(copy as unknown as TType[])
+ },
+ })
+ })
+
+ let search = useEvent((value: string) => dispatch({ type: ActionTypes.Search, value }))
+ let clearSearch = useEvent(() => dispatch({ type: ActionTypes.ClearSearch }))
+
+ let actions = useMemo<_Actions>(
+ () => ({
+ onChange,
+ registerOption,
+ registerLabel,
+ goToOption,
+ closeListbox,
+ openListbox,
+ selectActiveOption,
+ selectOption,
+ search,
+ clearSearch,
+ }),
+ []
)
let ourProps = { ref: listboxRef }
+ let form = useRef(null)
+ let d = useDisposables()
+ useEffect(() => {
+ if (!form.current) return
+ if (defaultValue === undefined) return
+
+ d.addEventListener(form.current, 'reset', () => {
+ onChange(defaultValue)
+ })
+ }, [form, onChange /* Explicitly ignoring `defaultValue` */])
+
return (
-
-
- {name != null &&
- value != null &&
- objectToFormEntries({ [name]: value }).map(([name, value]) => (
-
- ))}
- {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })}
-
-
+
+
+
+ {name != null &&
+ value != null &&
+ objectToFormEntries({ [name]: value }).map(([name, value], idx) => (
+ {
+ form.current = element?.closest('form') ?? null
+ }
+ : undefined
+ }
+ {...compact({
+ key: name,
+ as: 'input',
+ type: 'hidden',
+ hidden: true,
+ readOnly: true,
+ name,
+ value,
+ })}
+ />
+ ))}
+ {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })}
+
+
+
)
})
@@ -478,8 +588,9 @@ let Button = forwardRefWithAs(function Button,
ref: Ref
) {
- let [state, dispatch] = useListboxContext('Listbox.Button')
- let buttonRef = useSyncRefs(state.buttonRef, ref)
+ let data = useData('Listbox.Button')
+ let actions = useActions('Listbox.Button')
+ let buttonRef = useSyncRefs(data.buttonRef, ref)
let id = `headlessui-listbox-button-${useId()}`
let d = useDisposables()
@@ -492,19 +603,17 @@ let Button = forwardRefWithAs(function Button {
- if (!state.propsRef.current.value)
- dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
+ if (!data.value) actions.goToOption(Focus.First)
})
break
case Keys.ArrowUp:
event.preventDefault()
- dispatch({ type: ActionTypes.OpenListbox })
+ actions.openListbox()
d.nextFrame(() => {
- if (!state.propsRef.current.value)
- dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
+ if (!data.value) actions.goToOption(Focus.Last)
})
break
}
@@ -523,38 +632,38 @@ let Button = forwardRefWithAs(function Button {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
- if (state.listboxState === ListboxStates.Open) {
- dispatch({ type: ActionTypes.CloseListbox })
- d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
+ if (data.listboxState === ListboxStates.Open) {
+ actions.closeListbox()
+ d.nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true }))
} else {
event.preventDefault()
- dispatch({ type: ActionTypes.OpenListbox })
+ actions.openListbox()
}
})
let labelledby = useComputed(() => {
- if (!state.labelRef.current) return undefined
- return [state.labelRef.current.id, id].join(' ')
- }, [state.labelRef.current, id])
+ if (!data.labelId) return undefined
+ return [data.labelId, id].join(' ')
+ }, [data.labelId, id])
let slot = useMemo(
() => ({
- open: state.listboxState === ListboxStates.Open,
- disabled: state.disabled,
- value: state.propsRef.current.value,
+ open: data.listboxState === ListboxStates.Open,
+ disabled: data.disabled,
+ value: data.value,
}),
- [state]
+ [data]
)
let theirProps = props
let ourProps = {
ref: buttonRef,
id,
- type: useResolveButtonType(props, state.buttonRef),
+ type: useResolveButtonType(props, data.buttonRef),
'aria-haspopup': true,
- 'aria-controls': state.optionsRef.current?.id,
- 'aria-expanded': state.disabled ? undefined : state.listboxState === ListboxStates.Open,
+ 'aria-controls': data.optionsRef.current?.id,
+ 'aria-expanded': data.disabled ? undefined : data.listboxState === ListboxStates.Open,
'aria-labelledby': labelledby,
- disabled: state.disabled,
+ disabled: data.disabled,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
@@ -582,15 +691,18 @@ let Label = forwardRefWithAs(function Label,
ref: Ref
) {
- let [state] = useListboxContext('Listbox.Label')
+ let data = useData('Listbox.Label')
let id = `headlessui-listbox-label-${useId()}`
- let labelRef = useSyncRefs(state.labelRef, ref)
+ let actions = useActions('Listbox.Label')
+ let labelRef = useSyncRefs(data.labelRef, ref)
+
+ useIsoMorphicEffect(() => actions.registerLabel(id), [id])
- let handleClick = useEvent(() => state.buttonRef.current?.focus({ preventScroll: true }))
+ let handleClick = useEvent(() => data.buttonRef.current?.focus({ preventScroll: true }))
let slot = useMemo(
- () => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
- [state]
+ () => ({ open: data.listboxState === ListboxStates.Open, disabled: data.disabled }),
+ [data]
)
let theirProps = props
let ourProps = { ref: labelRef, id, onClick: handleClick }
@@ -628,8 +740,9 @@ let Options = forwardRefWithAs(function Options<
PropsForFeatures,
ref: Ref
) {
- let [state, dispatch] = useListboxContext('Listbox.Options')
- let optionsRef = useSyncRefs(state.optionsRef, ref)
+ let data = useData('Listbox.Options')
+ let actions = useActions('Listbox.Options')
+ let optionsRef = useSyncRefs(data.optionsRef, ref)
let id = `headlessui-listbox-options-${useId()}`
let d = useDisposables()
@@ -641,17 +754,17 @@ let Options = forwardRefWithAs(function Options<
return usesOpenClosedState === State.Open
}
- return state.listboxState === ListboxStates.Open
+ return data.listboxState === ListboxStates.Open
})()
useEffect(() => {
- let container = state.optionsRef.current
+ let container = data.optionsRef.current
if (!container) return
- if (state.listboxState !== ListboxStates.Open) return
+ if (data.listboxState !== ListboxStates.Open) return
if (container === getOwnerDocument(container)?.activeElement) return
container.focus({ preventScroll: true })
- }, [state.listboxState, state.optionsRef])
+ }, [data.listboxState, data.optionsRef])
let handleKeyDown = useEvent((event: ReactKeyboardEvent) => {
searchDisposables.dispose()
@@ -661,53 +774,53 @@ let Options = forwardRefWithAs(function Options<
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
- if (state.searchQuery !== '') {
+ if (data.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
- return dispatch({ type: ActionTypes.Search, value: event.key })
+ return actions.search(event.key)
}
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
- if (state.activeOptionIndex !== null) {
- let { dataRef } = state.options[state.activeOptionIndex]
- state.propsRef.current.onChange(dataRef.current.value)
+ if (data.activeOptionIndex !== null) {
+ let { dataRef } = data.options[data.activeOptionIndex]
+ actions.onChange(dataRef.current.value)
}
- if (state.propsRef.current.mode === ValueMode.Single) {
- dispatch({ type: ActionTypes.CloseListbox })
- disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
+ if (data.mode === ValueMode.Single) {
+ actions.closeListbox()
+ disposables().nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true }))
}
break
- case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
+ case match(data.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
- return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
+ return actions.goToOption(Focus.Next)
- case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
+ case match(data.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
- return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
+ return actions.goToOption(Focus.Previous)
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
- return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
+ return actions.goToOption(Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
- return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
+ return actions.goToOption(Focus.Last)
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
- dispatch({ type: ActionTypes.CloseListbox })
- return d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
+ actions.closeListbox()
+ return d.nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true }))
case Keys.Tab:
event.preventDefault()
@@ -716,30 +829,30 @@ let Options = forwardRefWithAs(function Options<
default:
if (event.key.length === 1) {
- dispatch({ type: ActionTypes.Search, value: event.key })
- searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350)
+ actions.search(event.key)
+ searchDisposables.setTimeout(() => actions.clearSearch(), 350)
}
break
}
})
let labelledby = useComputed(
- () => state.labelRef.current?.id ?? state.buttonRef.current?.id,
- [state.labelRef.current, state.buttonRef.current]
+ () => data.labelRef.current?.id ?? data.buttonRef.current?.id,
+ [data.labelRef.current, data.buttonRef.current]
)
let slot = useMemo(
- () => ({ open: state.listboxState === ListboxStates.Open }),
- [state]
+ () => ({ open: data.listboxState === ListboxStates.Open }),
+ [data]
)
let theirProps = props
let ourProps = {
'aria-activedescendant':
- state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
- 'aria-multiselectable': state.propsRef.current.mode === ValueMode.Multi ? true : undefined,
+ data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id,
+ 'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined,
'aria-labelledby': labelledby,
- 'aria-orientation': state.orientation,
+ 'aria-orientation': data.orientation,
id,
onKeyDown: handleKeyDown,
role: 'listbox',
@@ -791,80 +904,62 @@ let Option = forwardRefWithAs(function Option<
ref: Ref
) {
let { disabled = false, value, ...theirProps } = props
- let [state, dispatch] = useListboxContext('Listbox.Option')
+ let data = useData('Listbox.Option')
+ let actions = useActions('Listbox.Option')
+
let id = `headlessui-listbox-option-${useId()}`
let active =
- state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
-
- let { value: optionValue, compare } = state.propsRef.current
-
- let selected = match(state.propsRef.current.mode, {
- [ValueMode.Multi]: () => (optionValue as TType[]).some((option) => compare(option, value)),
- [ValueMode.Single]: () => compare(optionValue, value),
- })
+ data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false
+ let selected = data.isSelected(value)
let internalOptionRef = useRef(null)
+ let bag = useLatestValue['current']>({
+ disabled,
+ value,
+ domRef: internalOptionRef,
+ get textValue() {
+ return internalOptionRef.current?.textContent?.toLowerCase()
+ },
+ })
let optionRef = useSyncRefs(ref, internalOptionRef)
useIsoMorphicEffect(() => {
- if (state.listboxState !== ListboxStates.Open) return
+ if (data.listboxState !== ListboxStates.Open) return
if (!active) return
- if (state.activationTrigger === ActivationTrigger.Pointer) return
+ if (data.activationTrigger === ActivationTrigger.Pointer) return
let d = disposables()
d.requestAnimationFrame(() => {
internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' })
})
return d.dispose
- }, [internalOptionRef, active, state.listboxState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex])
-
- let bag = useRef({ disabled, value, domRef: internalOptionRef })
-
- useIsoMorphicEffect(() => {
- bag.current.disabled = disabled
- }, [bag, disabled])
- useIsoMorphicEffect(() => {
- bag.current.value = value
- }, [bag, value])
- useIsoMorphicEffect(() => {
- bag.current.textValue = internalOptionRef.current?.textContent?.toLowerCase()
- }, [bag, internalOptionRef])
+ }, [internalOptionRef, active, data.listboxState, data.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ data.activeOptionIndex])
- let select = useEvent(() => state.propsRef.current.onChange(value))
-
- useIsoMorphicEffect(() => {
- dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag })
- return () => dispatch({ type: ActionTypes.UnregisterOption, id })
- }, [bag, id])
+ useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id])
let handleClick = useEvent((event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
- select()
- if (state.propsRef.current.mode === ValueMode.Single) {
- dispatch({ type: ActionTypes.CloseListbox })
- disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
+ actions.onChange(value)
+ if (data.mode === ValueMode.Single) {
+ actions.closeListbox()
+ disposables().nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true }))
}
})
let handleFocus = useEvent(() => {
- if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
- dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
+ if (disabled) return actions.goToOption(Focus.Nothing)
+ actions.goToOption(Focus.Specific, id)
})
let handleMove = useEvent(() => {
if (disabled) return
if (active) return
- dispatch({
- type: ActionTypes.GoToOption,
- focus: Focus.Specific,
- id,
- trigger: ActivationTrigger.Pointer,
- })
+ actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
})
let handleLeave = useEvent(() => {
if (disabled) return
if (!active) return
- dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
+ actions.goToOption(Focus.Nothing)
})
let slot = useMemo(
diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx
index 990f0a6044..94bb93566d 100644
--- a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx
+++ b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx
@@ -576,6 +576,116 @@ describe('Rendering', () => {
})
)
+ it(
+ 'should be possible to reset to the default value if the form is reset',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ // Bob is the defaultValue
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Choose alice
+ await click(getRadioGroupOptions()[0])
+
+ // Alice is now chosen
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // Bob should be submitted again
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+ })
+ )
+
+ it(
+ 'should be possible to reset to the default value if the form is reset (using objects)',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ let data = [
+ { id: 1, name: 'alice', label: 'Alice' },
+ { id: 2, name: 'bob', label: 'Bob' },
+ { id: 3, name: 'charlie', label: 'Charlie' },
+ ]
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+
+ // Choose alice
+ await click(getRadioGroupOptions()[0])
+
+ // Alice is now chosen
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '1',
+ 'assignee[name]': 'alice',
+ 'assignee[label]': 'Alice',
+ })
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // Bob should be submitted again
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+ })
+ )
+
it(
'should still call the onChange listeners when choosing new values',
suppressConsoleLogs(async () => {
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 d26a3f07de..493d62bcc2 100644
--- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
+++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
@@ -6,13 +6,13 @@ import React, {
useRef,
// Types
- ContextType,
ElementType,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
+ useEffect,
} from 'react'
import { Props, Expand } from '../../types'
@@ -33,6 +33,8 @@ import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import { useLatestValue } from '../../hooks/use-latest-value'
+import { useDisposables } from '../../hooks/use-disposables'
interface Option {
id: string
@@ -79,26 +81,45 @@ let reducers: {
},
}
-let RadioGroupContext = createContext<{
+let RadioGroupDataContext = createContext<
+ | ({
+ value: unknown
+ firstOption?: Option
+ containsCheckedOption: boolean
+ disabled: boolean
+ compare(a: unknown, z: unknown): boolean
+ } & StateDefinition)
+ | null
+>(null)
+RadioGroupDataContext.displayName = 'RadioGroupDataContext'
+
+function useData(component: string) {
+ let context = useContext(RadioGroupDataContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useData)
+ throw err
+ }
+ return context
+}
+type _Data = ReturnType
+
+let RadioGroupActionsContext = createContext<{
registerOption(option: Option): () => void
change(value: unknown): boolean
- value: unknown
- firstOption?: Option
- containsCheckedOption: boolean
- disabled: boolean
- compare(a: unknown, z: unknown): boolean
} | null>(null)
-RadioGroupContext.displayName = 'RadioGroupContext'
+RadioGroupActionsContext.displayName = 'RadioGroupActionsContext'
-function useRadioGroupContext(component: string) {
- let context = useContext(RadioGroupContext)
+function useActions(component: string) {
+ let context = useContext(RadioGroupActionsContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent component.`)
- if (Error.captureStackTrace) Error.captureStackTrace(err, useRadioGroupContext)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useActions)
throw err
}
return context
}
+type _Actions = ReturnType
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
@@ -262,17 +283,13 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id })
})
- let api = useMemo>(
- () => ({
- registerOption,
- firstOption,
- containsCheckedOption,
- change: triggerChange,
- disabled,
- value,
- compare,
- }),
- [registerOption, firstOption, containsCheckedOption, triggerChange, disabled, value, compare]
+ let radioGroupData = useMemo<_Data>(
+ () => ({ value, firstOption, containsCheckedOption, disabled, compare, ...state }),
+ [value, firstOption, containsCheckedOption, disabled, compare, state]
+ )
+ let radioGroupActions = useMemo<_Actions>(
+ () => ({ registerOption, change: triggerChange }),
+ [registerOption, triggerChange]
)
let ourProps = {
@@ -286,35 +303,55 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
let slot = useMemo>(() => ({ value }), [value])
+ let form = useRef(null)
+ let d = useDisposables()
+ useEffect(() => {
+ if (!form.current) return
+ if (defaultValue === undefined) return
+
+ d.addEventListener(form.current, 'reset', () => {
+ triggerChange(defaultValue!)
+ })
+ }, [form, triggerChange /* Explicitly ignoring `defaultValue` */])
+
return (
-
- {name != null &&
- value != null &&
- objectToFormEntries({ [name]: value }).map(([name, value]) => (
-
- ))}
- {render({
- ourProps,
- theirProps,
- slot,
- defaultTag: DEFAULT_RADIO_GROUP_TAG,
- name: 'RadioGroup',
- })}
-
+
+
+ {name != null &&
+ value != null &&
+ objectToFormEntries({ [name]: value }).map(([name, value], idx) => (
+ {
+ form.current = element?.closest('form') ?? null
+ }
+ : undefined
+ }
+ {...compact({
+ key: name,
+ as: 'input',
+ type: 'radio',
+ checked: value != null,
+ hidden: true,
+ readOnly: true,
+ name,
+ value,
+ })}
+ />
+ ))}
+ {render({
+ ourProps,
+ theirProps,
+ slot,
+ defaultTag: DEFAULT_RADIO_GROUP_TAG,
+ name: 'RadioGroup',
+ })}
+
+
)
@@ -364,33 +401,19 @@ let Option = forwardRefWithAs(function Option<
let { addFlag, removeFlag, hasFlag } = useFlags(OptionState.Empty)
let { value, disabled = false, ...theirProps } = props
- let propsRef = useRef({ value, disabled })
-
- useIsoMorphicEffect(() => {
- propsRef.current.value = value
- }, [value, propsRef])
- useIsoMorphicEffect(() => {
- propsRef.current.disabled = disabled
- }, [disabled, propsRef])
+ let propsRef = useLatestValue({ value, disabled })
- let {
- registerOption,
- disabled: radioGroupDisabled,
- change,
- firstOption,
- containsCheckedOption,
- value: radioGroupValue,
- compare,
- } = useRadioGroupContext('RadioGroup.Option')
+ let data = useData('RadioGroup.Option')
+ let actions = useActions('RadioGroup.Option')
useIsoMorphicEffect(
- () => registerOption({ id, element: internalOptionRef, propsRef }),
- [id, registerOption, internalOptionRef, props]
+ () => actions.registerOption({ id, element: internalOptionRef, propsRef }),
+ [id, actions, internalOptionRef, props]
)
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
- if (!change(value)) return
+ if (!actions.change(value)) return
addFlag(OptionState.Active)
internalOptionRef.current?.focus()
@@ -403,10 +426,10 @@ let Option = forwardRefWithAs(function Option<
let handleBlur = useEvent(() => removeFlag(OptionState.Active))
- let isFirstOption = firstOption?.id === id
- let isDisabled = radioGroupDisabled || disabled
+ let isFirstOption = data.firstOption?.id === id
+ let isDisabled = data.disabled || disabled
- let checked = compare(radioGroupValue as TType, value)
+ let checked = data.compare(data.value as TType, value)
let ourProps = {
ref: optionRef,
id,
@@ -418,7 +441,7 @@ let Option = forwardRefWithAs(function Option<
tabIndex: (() => {
if (isDisabled) return -1
if (checked) return 0
- if (!containsCheckedOption && isFirstOption) return 0
+ if (!data.containsCheckedOption && isFirstOption) return 0
return -1
})(),
onClick: isDisabled ? undefined : handleClick,
diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx
index 811f0832ee..542edd2e78 100644
--- a/packages/@headlessui-react/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx
@@ -229,6 +229,43 @@ describe('Rendering', () => {
expect(handleSubmission).toHaveBeenLastCalledWith({})
})
+ it('should be possible to reset to the default value if the form is reset', async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ // Bob is the defaultValue
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Toggle the switch
+ await click(getSwitch())
+
+ // Bob should not be active anymore
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // Bob should be submitted again
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+ })
+
it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx
index 4a41107715..0ba1e3cb70 100644
--- a/packages/@headlessui-react/src/components/switch/switch.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.tsx
@@ -11,6 +11,7 @@ import React, {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
Ref,
+ useEffect,
} from 'react'
import { Props } from '../../types'
@@ -26,6 +27,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit } from '../../utils/form'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'
+import { useDisposables } from '../../hooks/use-disposables'
interface StateDefinition {
switch: HTMLButtonElement | null
@@ -165,6 +167,17 @@ let SwitchRoot = forwardRefWithAs(function Switch<
onKeyPress: handleKeyPress,
}
+ let d = useDisposables()
+ useEffect(() => {
+ let form = internalSwitchRef.current?.closest('form')
+ if (!form) return
+ if (defaultChecked === undefined) return
+
+ d.addEventListener(form, 'reset', () => {
+ onChange(defaultChecked)
+ })
+ }, [internalSwitchRef, onChange /* Explicitly ignoring `defaultValue` */])
+
return (
<>
{name != null && checked && (
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx
index 0924972a12..eb97906e70 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx
@@ -212,29 +212,28 @@ let Tabs = forwardRefWithAs(function Tabs {
+ dispatch({ type: ActionTypes.RegisterTab, tab })
+ return () => dispatch({ type: ActionTypes.UnregisterTab, tab })
+ })
+
+ let registerPanel = useEvent((panel) => {
+ dispatch({ type: ActionTypes.RegisterPanel, panel })
+ return () => dispatch({ type: ActionTypes.UnregisterPanel, panel })
+ })
+
+ let change = useEvent((index: number) => {
+ if (realSelectedIndex.current !== index) {
+ onChangeRef.current(index)
+ }
+
+ if (!isControlled) {
+ dispatch({ type: ActionTypes.SetSelectedIndex, index })
+ }
+ })
+
let realSelectedIndex = useLatestValue(isControlled ? props.selectedIndex : state.selectedIndex)
- let tabsActions: _Actions = useMemo(
- () => ({
- registerTab(tab) {
- dispatch({ type: ActionTypes.RegisterTab, tab })
- return () => dispatch({ type: ActionTypes.UnregisterTab, tab })
- },
- registerPanel(panel) {
- dispatch({ type: ActionTypes.RegisterPanel, panel })
- return () => dispatch({ type: ActionTypes.UnregisterPanel, panel })
- },
- change(index: number) {
- if (realSelectedIndex.current !== index) {
- onChangeRef.current(index)
- }
-
- if (!isControlled) {
- dispatch({ type: ActionTypes.SetSelectedIndex, index })
- }
- },
- }),
- [dispatch, isControlled]
- )
+ let tabsActions = useMemo<_Actions>(() => ({ registerTab, registerPanel, change }), [])
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex })
diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md
index 3f5ab44a72..8d9c33173c 100644
--- a/packages/@headlessui-vue/CHANGELOG.md
+++ b/packages/@headlessui-vue/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-- Nothing yet!
+### Fixed
+
+- Reset form-like components when the parent `
+ `,
+ setup: () => ({
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose alice
+ await click(getComboboxOptions()[0])
+ expect(getComboboxButton()).toHaveTextContent('alice')
+ expect(getComboboxInput()).toHaveValue('alice')
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The combobox should be reset to bob
+ expect(getComboboxButton()).toHaveTextContent('bob')
+ expect(getComboboxInput()).toHaveValue('bob')
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertActiveComboboxOption(getComboboxOptions()[1])
+ })
+ )
+
+ it(
+ 'should be possible to reset to the default value if the form is reset (using objects)',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ let data = [
+ { id: 1, name: 'alice', label: 'Alice' },
+ { id: 2, name: 'bob', label: 'Bob' },
+ { id: 3, name: 'charlie', label: 'Charlie' },
+ ]
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => ({
+ data,
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose alice
+ await click(getComboboxOptions()[0])
+ expect(getComboboxButton()).toHaveTextContent('alice')
+ expect(getComboboxInput()).toHaveValue('alice')
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The combobox should be reset to bob
+ expect(getComboboxButton()).toHaveTextContent('bob')
+ expect(getComboboxInput()).toHaveValue('bob')
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertActiveComboboxOption(getComboboxOptions()[1])
+ })
+ )
+
it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts
index 479c9de98a..f6d32e9c74 100644
--- a/packages/@headlessui-vue/src/components/combobox/combobox.ts
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts
@@ -64,6 +64,7 @@ type StateDefinition = {
// State
comboboxState: Ref
value: ComputedRef
+ defaultValue: ComputedRef
mode: ComputedRef
nullable: ComputedRef
@@ -191,6 +192,7 @@ export let Combobox = defineComponent({
}
return props.by(a, z)
},
+ defaultValue: computed(() => props.defaultValue),
nullable,
inputRef,
labelRef,
@@ -406,6 +408,28 @@ export let Combobox = defineComponent({
: (options.value[api.activeOptionIndex.value].dataRef.value as any)
)
+ let form = computed(() => dom(inputRef)?.closest('form'))
+ onMounted(() => {
+ watch(
+ [form],
+ () => {
+ if (!form.value) return
+ if (props.defaultValue === undefined) return
+
+ function handle() {
+ api.change(props.defaultValue)
+ }
+
+ form.value.addEventListener('reset', handle)
+
+ return () => {
+ form.value?.removeEventListener('reset', handle)
+ }
+ },
+ { immediate: true }
+ )
+ })
+
return () => {
let { name, disabled, ...theirProps } = props
let slot = {
@@ -604,6 +628,7 @@ export let ComboboxInput = defineComponent({
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
displayValue: { type: Function as PropType<(item: unknown) => string> },
+ defaultValue: { type: String, default: undefined },
},
emits: {
change: (_value: Event & { target: HTMLInputElement }) => true,
@@ -789,6 +814,15 @@ export let ComboboxInput = defineComponent({
emit('change', event)
}
+ let defaultValue = computed(() => {
+ return (
+ props.defaultValue ??
+ props.displayValue?.(api.defaultValue.value) ??
+ api.defaultValue.value ??
+ ''
+ )
+ })
+
return () => {
let slot = { open: api.comboboxState.value === ComboboxStates.Open }
let ourProps = {
@@ -812,6 +846,7 @@ export let ComboboxInput = defineComponent({
type: attrs.type ?? 'text',
tabIndex: 0,
ref: api.inputRef,
+ defaultValue: defaultValue.value,
}
let theirProps = omit(props, ['displayValue'])
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index b9eb47d135..210019190f 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -1058,6 +1058,118 @@ describe('Rendering', () => {
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
})
+ it(
+ 'should be possible to reset to the default value if the form is reset',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => ({
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose alice
+ await click(getListboxOptions()[0])
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The listbox should be reset to bob
+ expect(getListboxButton()).toHaveTextContent('bob')
+
+ // Open listbox
+ await click(getListboxButton())
+ assertActiveListboxOption(getListboxOptions()[1])
+ })
+ )
+
+ it(
+ 'should be possible to reset to the default value if the form is reset (using objects)',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ let data = [
+ { id: 1, name: 'alice', label: 'Alice' },
+ { id: 2, name: 'bob', label: 'Bob' },
+ { id: 3, name: 'charlie', label: 'Charlie' },
+ ]
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => ({
+ data,
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose alice
+ await click(getListboxOptions()[0])
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // The listbox should be reset to bob
+ expect(getListboxButton()).toHaveTextContent('bob')
+
+ // Open listbox
+ await click(getListboxButton())
+ assertActiveListboxOption(getListboxOptions()[1])
+ })
+ )
+
it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index 70bacc15c2..8ba88190cd 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -327,6 +327,28 @@ export let Listbox = defineComponent({
)
)
+ let form = computed(() => dom(buttonRef)?.closest('form'))
+ onMounted(() => {
+ watch(
+ [form],
+ () => {
+ if (!form.value) return
+ if (props.defaultValue === undefined) return
+
+ function handle() {
+ api.select(props.defaultValue)
+ }
+
+ form.value.addEventListener('reset', handle)
+
+ return () => {
+ form.value?.removeEventListener('reset', handle)
+ }
+ },
+ { immediate: true }
+ )
+ })
+
return () => {
let { name, modelValue, disabled, ...theirProps } = props
diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts
index 0e97ddb42c..5bee9efd73 100644
--- a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts
+++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts
@@ -716,6 +716,121 @@ describe('Rendering', () => {
})
)
+ it(
+ 'should be possible to reset to the default value if the form is reset',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => ({
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+
+ // Bob is the defaultValue
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Choose alice
+ await click(getRadioGroupOptions()[0])
+
+ // Alice is now chosen
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // Bob should be submitted again
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+ })
+ )
+
+ it(
+ 'should be possible to reset to the default value if the form is reset (using objects)',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ let data = [
+ { id: 1, name: 'alice', label: 'Alice' },
+ { id: 2, name: 'bob', label: 'Bob' },
+ { id: 3, name: 'charlie', label: 'Charlie' },
+ ]
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => ({
+ data,
+ handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+
+ await click(document.getElementById('submit'))
+
+ // Bob is the defaultValue
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+
+ // Choose alice
+ await click(getRadioGroupOptions()[0])
+
+ // Alice is now chosen
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '1',
+ 'assignee[name]': 'alice',
+ 'assignee[label]': 'Alice',
+ })
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // Bob should be submitted again
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({
+ 'assignee[id]': '2',
+ 'assignee[name]': 'bob',
+ 'assignee[label]': 'Bob',
+ })
+ })
+ )
+
it(
'should still call the onChange listeners when choosing new values',
suppressConsoleLogs(async () => {
diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
index 1344209b9a..1a1c890cab 100644
--- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
+++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
@@ -9,6 +9,7 @@ import {
provide,
ref,
toRaw,
+ watch,
// Types
InjectionKey,
@@ -216,6 +217,28 @@ export let RadioGroup = defineComponent({
let id = `headlessui-radiogroup-${useId()}`
+ let form = computed(() => dom(radioGroupRef)?.closest('form'))
+ onMounted(() => {
+ watch(
+ [form],
+ () => {
+ if (!form.value) return
+ if (props.defaultValue === undefined) return
+
+ function handle() {
+ api.change(props.defaultValue)
+ }
+
+ form.value.addEventListener('reset', handle)
+
+ return () => {
+ form.value?.removeEventListener('reset', handle)
+ }
+ },
+ { immediate: true }
+ )
+ })
+
return () => {
let { disabled, name, ...theirProps } = props
diff --git a/packages/@headlessui-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx
index 7a67d0b3b6..462fe0e951 100644
--- a/packages/@headlessui-vue/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-vue/src/components/switch/switch.test.tsx
@@ -268,6 +268,43 @@ describe('Rendering', () => {
expect(handleSubmission).toHaveBeenLastCalledWith({})
})
+ it('should be possible to reset to the default value if the form is reset', async () => {
+ let handleSubmission = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => ({
+ handleSubmission(e: SubmitEvent) {
+ handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
+ },
+ }),
+ })
+
+ // Bob is the defaultValue
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+
+ // Toggle the switch
+ await click(getSwitch())
+
+ // Bob should not be active anymore
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+
+ // Reset
+ await click(document.getElementById('reset'))
+
+ // Bob should be submitted again
+ await click(document.getElementById('submit'))
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
+ })
+
it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()
diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts
index 34fb6353d9..b88858bbe4 100644
--- a/packages/@headlessui-vue/src/components/switch/switch.ts
+++ b/packages/@headlessui-vue/src/components/switch/switch.ts
@@ -6,10 +6,12 @@ import {
inject,
provide,
ref,
+ watch,
// Types
InjectionKey,
Ref,
+ onMounted,
} from 'vue'
import { render, compact, omit } from '../../utils/render'
@@ -21,6 +23,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit } from '../../utils/form'
import { useControllable } from '../../hooks/use-controllable'
+import { dom } from '../../utils/dom'
type StateDefinition = {
// State
@@ -69,7 +72,7 @@ export let Switch = defineComponent({
props: {
as: { type: [Object, String], default: 'button' },
modelValue: { type: Boolean, default: undefined },
- defaultChecked: { type: Boolean, default: false },
+ defaultChecked: { type: Boolean, optional: true },
name: { type: String, optional: true },
value: { type: String, optional: true },
},
@@ -88,7 +91,7 @@ export let Switch = defineComponent({
theirOnChange(!checked.value)
}
- let internalSwitchRef = ref(null)
+ let internalSwitchRef = ref(null)
let switchRef = api === null ? internalSwitchRef : api.switchRef
let type = useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
@@ -116,6 +119,27 @@ export let Switch = defineComponent({
event.preventDefault()
}
+ let form = computed(() => dom(switchRef)?.closest?.('form'))
+ onMounted(() => {
+ watch(
+ [form],
+ () => {
+ if (!form.value) return
+ if (props.defaultChecked === undefined) return
+
+ function handle() {
+ theirOnChange(props.defaultChecked)
+ }
+
+ form.value.addEventListener('reset', handle)
+ return () => {
+ form.value?.removeEventListener('reset', handle)
+ }
+ },
+ { immediate: true }
+ )
+ })
+
return () => {
let { name, value, ...theirProps } = props
let slot = { checked: checked.value }
diff --git a/packages/playground-react/pages/combinations/form.tsx b/packages/playground-react/pages/combinations/form.tsx
index 1b34c2e304..ce1e085b4a 100644
--- a/packages/playground-react/pages/combinations/form.tsx
+++ b/packages/playground-react/pages/combinations/form.tsx
@@ -335,9 +335,18 @@ export default function App() {
-
+
+
+
+
+
Form data (entries):
diff --git a/packages/playground-vue/src/components/combinations/form.vue b/packages/playground-vue/src/components/combinations/form.vue
new file mode 100644
index 0000000000..3cafaafb20
--- /dev/null
+++ b/packages/playground-vue/src/components/combinations/form.vue
@@ -0,0 +1,290 @@
+
+
+
+
+