Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reset form-like components when the parent <form> resets #2004

Merged
merged 11 commits into from Nov 9, 2022
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Expand Up @@ -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 `<form>` resets ([#2004](https://github.com/tailwindlabs/headlessui/pull/2004))

## [1.7.4] - 2022-11-03

Expand Down
114 changes: 114 additions & 0 deletions packages/@headlessui-react/src/components/combobox/combobox.test.tsx
Expand Up @@ -1202,6 +1202,120 @@ 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(
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
}}
>
<Combobox name="assignee" defaultValue="bob">
<Combobox.Button>{({ value }) => value ?? 'Trigger'}</Combobox.Button>
<Combobox.Input onChange={NOOP} displayValue={(value: string) => value} />
<Combobox.Options>
<Combobox.Option value="alice">Alice</Combobox.Option>
<Combobox.Option value="bob">Bob</Combobox.Option>
<Combobox.Option value="charlie">Charlie</Combobox.Option>
</Combobox.Options>
</Combobox>
<button id="submit">submit</button>
<button type="reset" id="reset">
reset
</button>
</form>
)

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(
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
}}
>
<Combobox name="assignee" defaultValue={{ id: 2, name: 'bob', label: 'Bob' }} by="id">
<Combobox.Button>{({ value }) => value?.name ?? 'Trigger'}</Combobox.Button>
<Combobox.Input onChange={NOOP} displayValue={(value: typeof data[0]) => value.name} />
<Combobox.Options>
{data.map((person) => (
<Combobox.Option key={person.id} value={person}>
{person.label}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
<button id="submit">submit</button>
<button type="reset" id="reset">
reset
</button>
</form>
)

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()

Expand Down
29 changes: 27 additions & 2 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Expand Up @@ -14,6 +14,7 @@ import React, {
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
useEffect,
} from 'react'
import { ByComparator, EnsureArray, Expand, Props } from '../../types'

Expand Down Expand Up @@ -266,6 +267,7 @@ type _Actions = ReturnType<typeof useActions>
let ComboboxDataContext = createContext<
| ({
value: unknown
defaultValue: unknown
disabled: boolean
mode: ValueMode
activeOptionIndex: number | null
Expand Down Expand Up @@ -453,6 +455,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
buttonRef,
optionsRef,
value,
defaultValue,
disabled,
mode: multiple ? ValueMode.Multi : ValueMode.Single,
get activeOptionIndex() {
Expand All @@ -477,7 +480,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
nullable,
__demoMode,
}),
[value, disabled, multiple, nullable, __demoMode, state]
[value, defaultValue, disabled, multiple, nullable, __demoMode, state]
)

useIsoMorphicEffect(() => {
Expand Down Expand Up @@ -589,6 +592,17 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T

let ourProps = ref === null ? {} : { ref }

let form = useRef<HTMLFormElement | null>(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 (
<ComboboxActionsContext.Provider value={actions}>
<ComboboxDataContext.Provider value={data}>
Expand All @@ -600,9 +614,16 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
>
{name != null &&
value != null &&
objectToFormEntries({ [name]: value }).map(([name, value]) => (
objectToFormEntries({ [name]: value }).map(([name, value], idx) => (
<Hidden
features={HiddenFeatures.Hidden}
ref={
idx === 0
? (element: HTMLInputElement | null) => {
form.current = element?.closest('form') ?? null
}
: undefined
}
{...compact({
key: name,
as: 'input',
Expand Down Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Expand Up @@ -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(
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
}}
>
<Listbox name="assignee" defaultValue="bob">
<Listbox.Button>{({ value }) => value ?? 'Trigger'}</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="alice">Alice</Listbox.Option>
<Listbox.Option value="bob">Bob</Listbox.Option>
<Listbox.Option value="charlie">Charlie</Listbox.Option>
</Listbox.Options>
</Listbox>
<button id="submit">submit</button>
<button type="reset" id="reset">
reset
</button>
</form>
)

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(
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
}}
>
<Listbox name="assignee" defaultValue={{ id: 2, name: 'bob', label: 'Bob' }} by="id">
<Listbox.Button>{({ value }) => value?.name ?? 'Trigger'}</Listbox.Button>
<Listbox.Options>
{data.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.label}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
<button id="submit">submit</button>
<button type="reset" id="reset">
reset
</button>
</form>
)

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()

Expand Down