Skip to content

Commit

Permalink
Reset form-like components when the parent <form> resets (#2004)
Browse files Browse the repository at this point in the history
* add reset button to form example

* refactor React Listbox

This splitsup the raw `[state, dispatch]` to separate `useActions` and `useData` hooks.

This allows us to make the actions themselves simpler and include logic
that doesn't really belong in the reducer itself.

This also allows us to expose data via the `useData` hook that doesn't
belong in the state exposed from the `useReducer` hook.

E.g.: we used to store a `propsRef` from the root `Listbox`, and update
the ref with the new props in a `useEffect`. Now, we will just expose
that information directly via the `useData` hook. This simplifies the
code, removes useEffect's and so on.

* refactor Tabs, ensure function reference stays the same

If the `isControlled` value changes, then the references to all the
functions changed. Now they won't because of the `useEvent` hooks.

* type the actions abg similar to how we type the data bag

* refactor RadioGroup to use useData/useActions hooks

* reset Listbox to defaultValue on form reset

* reset Combobox to defaultValue on form reset

* reset RadioGroup to defaultValue on form reset

* reset Switch to defaultChecked on form reset

* port combinations/form playground example to Vue

* update changelog
  • Loading branch information
RobinMalfait committed Nov 9, 2022
1 parent 74e7b43 commit c0f0d43
Show file tree
Hide file tree
Showing 21 changed files with 1,676 additions and 362 deletions.
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

0 comments on commit c0f0d43

Please sign in to comment.