From f3b65865d8a6339e5891906c7dd2d4d0cc5f1ee6 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 4 Nov 2022 11:35:16 +0100 Subject: [PATCH 01/11] add reset button to form example --- .../playground-react/pages/combinations/form.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/playground-react/pages/combinations/form.tsx b/packages/playground-react/pages/combinations/form.tsx index 1b34c2e30..ce1e085b4 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): From 4eafa39dd99043f765f16fedc0b2d5cea0e71947 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Nov 2022 10:46:09 +0100 Subject: [PATCH 02/11] 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. --- .../src/components/listbox/listbox.tsx | 595 ++++++++++-------- 1 file changed, 336 insertions(+), 259 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 6d9250daa..be3b62a57 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, useListboxContext) + 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, 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,192 @@ 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 } 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]) => ( + + ))} + {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })} + + + ) }) @@ -478,8 +570,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 +585,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 +614,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 +673,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 +722,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 +736,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 +756,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 +811,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 +886,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( From 7039d48dd3de1fc7c87d239f338827ef6c908d7c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Nov 2022 11:32:27 +0100 Subject: [PATCH 03/11] 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. --- .../src/components/tabs/tabs.tsx | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 0924972a1..04429b8da 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: _Actions = useMemo(() => ({ registerTab, registerPanel, change }), []) useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex }) From 4ed1dac3d625644937142958f90ea8559752271e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Nov 2022 12:58:44 +0100 Subject: [PATCH 04/11] type the actions abg similar to how we type the data bag --- packages/@headlessui-react/src/components/tabs/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 04429b8da..eb97906e7 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -233,7 +233,7 @@ let Tabs = forwardRefWithAs(function Tabs ({ registerTab, registerPanel, change }), []) + let tabsActions = useMemo<_Actions>(() => ({ registerTab, registerPanel, change }), []) useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex }) From 09c7f0292a29144cd24014ae47a13db422351de5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Nov 2022 12:59:08 +0100 Subject: [PATCH 05/11] refactor RadioGroup to use useData/useActions hooks --- .../components/radio-group/radio-group.tsx | 147 +++++++++--------- 1 file changed, 75 insertions(+), 72 deletions(-) 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 d26a3f07d..b087d266a 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -6,7 +6,6 @@ import React, { useRef, // Types - ContextType, ElementType, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, @@ -33,6 +32,7 @@ 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' interface Option { id: string @@ -79,26 +79,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 +281,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 = { @@ -289,32 +304,34 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< 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]) => ( + + ))} + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_RADIO_GROUP_TAG, + name: 'RadioGroup', + })} + + ) @@ -364,33 +381,19 @@ let Option = forwardRefWithAs(function Option< let { addFlag, removeFlag, hasFlag } = useFlags(OptionState.Empty) let { value, disabled = false, ...theirProps } = props - let propsRef = useRef({ value, disabled }) + let propsRef = useLatestValue({ value, disabled }) - useIsoMorphicEffect(() => { - propsRef.current.value = value - }, [value, propsRef]) - useIsoMorphicEffect(() => { - propsRef.current.disabled = disabled - }, [disabled, propsRef]) - - 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 +406,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 +421,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, From 4dc63fe18c637f660ea0b2cf49e06ea45361f08a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Nov 2022 15:48:05 +0100 Subject: [PATCH 06/11] reset Listbox to defaultValue on form reset --- .../src/components/listbox/listbox.test.tsx | 106 +++++++++++++++++ .../src/components/listbox/listbox.tsx | 20 +++- .../src/components/listbox/listbox.test.tsx | 112 ++++++++++++++++++ .../src/components/listbox/listbox.ts | 22 ++++ 4 files changed, 259 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 94742c0bd..84f415d3c 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( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {({ value }) => value ?? 'Trigger'} + + Alice + Bob + Charlie + + + + +
+ ) + + 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( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {({ value }) => value?.name ?? 'Trigger'} + + {data.map((person) => ( + + {person.label} + + ))} + + + + +
+ ) + + 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 be3b62a57..e7c52bc5a 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -515,6 +515,17 @@ let ListboxRoot = forwardRefWithAs(function Listbox< 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 ( @@ -526,9 +537,16 @@ let ListboxRoot = forwardRefWithAs(function Listbox< > {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', diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index b9eb47d13..210019190 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` +
+ + {{ value ?? 'Trigger' }} + + Alice + Bob + Charlie + + + + +
+ `, + 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` +
+ + {{ value ?? 'Trigger' }} + + + {{ person.label }} + + + + + +
+ `, + 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 70bacc15c..8ba88190c 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 From 5be25d5bd476f34273d9eea65a2bcbb44a1d5e33 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Nov 2022 15:53:19 +0100 Subject: [PATCH 07/11] reset Combobox to defaultValue on form reset --- .../src/components/combobox/combobox.test.tsx | 114 +++++++++++++++++ .../src/components/combobox/combobox.tsx | 29 ++++- .../src/components/combobox/combobox.test.ts | 121 ++++++++++++++++++ .../src/components/combobox/combobox.ts | 35 +++++ 4 files changed, 297 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 66c11c018..d7d5a559b 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -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( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {({ value }) => value ?? 'Trigger'} + value} /> + + Alice + Bob + Charlie + + + + +
+ ) + + 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( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {({ value }) => value?.name ?? 'Trigger'} + value.name} /> + + {data.map((person) => ( + + {person.label} + + ))} + + + + +
+ ) + + 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 9c72d9d08..8e463aa1b 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-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index a0fdf897f..3e8000c3e 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -1291,6 +1291,127 @@ 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` +
+ + {{ value ?? 'Trigger' }} + + + Alice + Bob + Charlie + + + + +
+ `, + 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` +
+ + {{ value ?? 'Trigger' }} + + + + {{ person.label }} + + + + + +
+ `, + 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 479c9de98..f6d32e9c7 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']) From 5854dcf0b968170bedabe2b3b87ceddd16c4ca2f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 9 Nov 2022 11:34:16 +0100 Subject: [PATCH 08/11] reset RadioGroup to defaultValue on form reset --- .../radio-group/radio-group.test.tsx | 110 +++++++++++++++++ .../components/radio-group/radio-group.tsx | 22 +++- .../radio-group/radio-group.test.ts | 115 ++++++++++++++++++ .../src/components/radio-group/radio-group.ts | 23 ++++ 4 files changed, 269 insertions(+), 1 deletion(-) 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 990f0a604..94bb93566 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( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + Alice + Bob + Charlie + + + +
+ ) + + // 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( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {data.map((person) => ( + + {person.label} + + ))} + + + +
+ ) + + 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 b087d266a..493d62bcc 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -12,6 +12,7 @@ import React, { MouseEvent as ReactMouseEvent, MutableRefObject, Ref, + useEffect, } from 'react' import { Props, Expand } from '../../types' @@ -33,6 +34,7 @@ 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 @@ -301,6 +303,17 @@ 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 ( @@ -308,9 +321,16 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< {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', 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 0e97ddb42..5bee9efd7 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` +
+ + Alice + Bob + Charlie + + + +
+ `, + 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` +
+ + + {{ person.label }} + + + + +
+ `, + 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 1344209b9..1a1c890ca 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 From cc5ee5441ec0b2a50efa9c3c533da95ee0662c5c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 9 Nov 2022 11:51:08 +0100 Subject: [PATCH 09/11] reset Switch to defaultChecked on form reset --- .../src/components/switch/switch.test.tsx | 37 +++++++++++++++++++ .../src/components/switch/switch.tsx | 13 +++++++ .../src/components/switch/switch.test.tsx | 37 +++++++++++++++++++ .../src/components/switch/switch.ts | 28 +++++++++++++- 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx index 811f0832e..542edd2e7 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( +
{ + 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' }) + + // 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 4a4110771..0ba1e3cb7 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-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx index 7a67d0b3b..462fe0e95 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 34fb6353d..b88858bbe 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 } From 7b31cf68deee08ad24a9738696c9961bfe551d65 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 9 Nov 2022 12:49:15 +0100 Subject: [PATCH 10/11] port combinations/form playground example to Vue --- .../src/components/combinations/form.vue | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 packages/playground-vue/src/components/combinations/form.vue 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 000000000..3cafaafb2 --- /dev/null +++ b/packages/playground-vue/src/components/combinations/form.vue @@ -0,0 +1,290 @@ + + + From 2005f1a85d07b1a0a45b42f9d848771dfef640ca Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 9 Nov 2022 23:33:03 +0100 Subject: [PATCH 11/11] update changelog --- packages/@headlessui-react/CHANGELOG.md | 4 +++- packages/@headlessui-vue/CHANGELOG.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index a1e302fbe..5180969a7 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 `
` resets ([#2004](https://github.com/tailwindlabs/headlessui/pull/2004)) ## [1.7.4] - 2022-11-03 diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 3f5ab44a7..8d9c33173 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 `` resets ([#2004](https://github.com/tailwindlabs/headlessui/pull/2004)) ## [1.7.4] - 2022-11-03