diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a1d90655..8c61ae5987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow to override the `type` on the `ComboboxInput` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476)) - Ensure the the `` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477)) +### Added + +- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482)) + ## [Unreleased - @headlessui/react] ### Fixed @@ -19,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow to override the `type` on the `Combobox.Input` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476)) - Ensure the the `` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477)) +### Added + +- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482)) + ## [@headlessui/vue@v1.6.2] - 2022-05-19 ### Fixed diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index ac1fca084d..8284245a3f 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -170,6 +170,108 @@ describe('Rendering', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) + + describe('Equality', () => { + let options = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + + it( + 'should use object equality by default', + suppressConsoleLogs(async () => { + render( + + Trigger + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + + ) + + await click(getComboboxButton()) + + let bob = getComboboxOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a field', + suppressConsoleLogs(async () => { + render( + + Trigger + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + + ) + + await click(getComboboxButton()) + + let bob = getComboboxOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a comparator function', + suppressConsoleLogs(async () => { + render( + a.id === z.id} + > + Trigger + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + + ) + + await click(getComboboxButton()) + + let bob = getComboboxOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + }) }) describe('Combobox.Input', () => { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index b3a22edc5d..cb94fabef5 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -38,6 +38,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker' import { sortByDomNode } from '../../utils/focus-management' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' +import { useEvent } from '../../hooks/use-event' enum ComboboxStates { Open, @@ -69,6 +70,7 @@ interface StateDefinition { mode: ValueMode onChange(value: unknown): void nullable: boolean + compare(a: unknown, z: unknown): boolean __demoMode: boolean }> inputPropsRef: MutableRefObject<{ @@ -160,12 +162,13 @@ let reducers: { // Check if we have a selected value that we can make active let activeOptionIndex = state.activeOptionIndex - let { value, mode } = state.comboboxPropsRef.current + let { value, mode, compare } = state.comboboxPropsRef.current let optionIdx = state.options.findIndex((option) => { let optionValue = option.dataRef.current.value let selected = match(mode, { - [ValueMode.Multi]: () => (value as unknown[]).includes(optionValue), - [ValueMode.Single]: () => value === optionValue, + [ValueMode.Multi]: () => + (value as unknown[]).some((option) => compare(option, optionValue)), + [ValueMode.Single]: () => compare(value, optionValue), }) return selected @@ -226,11 +229,12 @@ let reducers: { // Check if we need to make the newly registered option active. if (state.activeOptionIndex === null) { - let { value, mode } = state.comboboxPropsRef.current + let { value, mode, compare } = state.comboboxPropsRef.current let optionValue = action.dataRef.current.value let selected = match(mode, { - [ValueMode.Multi]: () => (value as unknown[]).includes(optionValue), - [ValueMode.Single]: () => value === optionValue, + [ValueMode.Multi]: () => + (value as unknown[]).some((option) => compare(option, optionValue)), + [ValueMode.Single]: () => compare(value, optionValue), }) if (selected) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) @@ -340,10 +344,11 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< props: Props< TTag, ComboboxRenderPropArg, - 'value' | 'onChange' | 'disabled' | 'name' | 'nullable' | 'multiple' + 'value' | 'onChange' | 'disabled' | 'name' | 'nullable' | 'multiple' | 'by' > & { value: TType onChange(value: TType): void + by?: (keyof TType & string) | ((a: TType, z: TType) => boolean) disabled?: boolean __demoMode?: boolean name?: string @@ -356,6 +361,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< name, value, onChange, + by = (a, z) => a === z, disabled = false, __demoMode = false, nullable = false, @@ -367,6 +373,14 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< let comboboxPropsRef = useRef({ value, mode: multiple ? ValueMode.Multi : ValueMode.Single, + compare: useEvent( + typeof by === 'string' + ? (a: TType, z: TType) => { + let property = by as unknown as keyof TType + return a[property] === z[property] + } + : by + ), onChange, nullable, __demoMode, @@ -1093,9 +1107,13 @@ let Option = forwardRefWithAs(function Option< let id = `headlessui-combobox-option-${useId()}` let active = data.activeOptionIndex !== null ? state.options[data.activeOptionIndex].id === id : false + let selected = match(data.mode, { - [ValueMode.Multi]: () => (data.value as TType[]).includes(value), - [ValueMode.Single]: () => data.value === value, + [ValueMode.Multi]: () => + (data.value as TType[]).some((option) => + state.comboboxPropsRef.current.compare(option, value) + ), + [ValueMode.Single]: () => state.comboboxPropsRef.current.compare(data.value, value), }) let internalOptionRef = useRef(null) let bag = useRef({ disabled, value, domRef: internalOptionRef }) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index c20e005057..8fe7eb1f3c 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -162,6 +162,108 @@ describe('Rendering', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) + + describe('Equality', () => { + let options = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + + it( + 'should use object equality by default', + suppressConsoleLogs(async () => { + render( + + Trigger + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + + ) + + await click(getListboxButton()) + + let bob = getListboxOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a field', + suppressConsoleLogs(async () => { + render( + + Trigger + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + + ) + + await click(getListboxButton()) + + let bob = getListboxOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a comparator function', + suppressConsoleLogs(async () => { + render( + a.id === z.id} + > + Trigger + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + + ) + + await click(getListboxButton()) + + let bob = getListboxOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + }) }) describe('Listbox.Label', () => { diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 68c98f7063..d8dec57f77 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -37,6 +37,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' +import { useEvent } from '../../hooks/use-event' enum ListboxStates { Open, @@ -65,7 +66,12 @@ interface StateDefinition { orientation: 'horizontal' | 'vertical' - propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void; mode: ValueMode }> + propsRef: MutableRefObject<{ + value: unknown + onChange(value: unknown): void + mode: ValueMode + compare(a: unknown, z: unknown): boolean + }> labelRef: MutableRefObject buttonRef: MutableRefObject optionsRef: MutableRefObject @@ -154,12 +160,13 @@ let reducers: { // Check if we have a selected value that we can make active let activeOptionIndex = state.activeOptionIndex - let { value, mode } = state.propsRef.current + 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[]).includes(optionValue), - [ValueMode.Single]: () => value === optionValue, + [ValueMode.Multi]: () => + (value as unknown[]).some((option) => compare(option, optionValue)), + [ValueMode.Single]: () => compare(value, optionValue), }) return selected @@ -243,11 +250,12 @@ let reducers: { // Check if we need to make the newly registered option active. if (state.activeOptionIndex === null) { - let { value, mode } = state.propsRef.current + let { value, mode, compare } = state.propsRef.current let optionValue = action.dataRef.current.value let selected = match(mode, { - [ValueMode.Multi]: () => (value as unknown[]).includes(optionValue), - [ValueMode.Single]: () => value === optionValue, + [ValueMode.Multi]: () => + (value as unknown[]).some((option) => compare(option, optionValue)), + [ValueMode.Single]: () => compare(value, optionValue), }) if (selected) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) @@ -304,10 +312,11 @@ let ListboxRoot = forwardRefWithAs(function Listbox< props: Props< TTag, ListboxRenderPropArg, - 'value' | 'onChange' | 'disabled' | 'horizontal' | 'name' | 'multiple' + 'value' | 'onChange' | 'disabled' | 'horizontal' | 'name' | 'multiple' | 'by' > & { value: TType onChange(value: TType): void + by?: (keyof TType & string) | ((a: TType, z: TType) => boolean) disabled?: boolean horizontal?: boolean name?: string @@ -319,6 +328,7 @@ let ListboxRoot = forwardRefWithAs(function Listbox< value, name, onChange, + by = (a, z) => a === z, disabled = false, horizontal = false, multiple = false, @@ -330,7 +340,19 @@ let ListboxRoot = forwardRefWithAs(function Listbox< let reducerBag = useReducer(stateReducer, { listboxState: ListboxStates.Closed, propsRef: { - current: { value, onChange, mode: multiple ? ValueMode.Multi : ValueMode.Single }, + current: { + value, + onChange, + mode: multiple ? ValueMode.Multi : ValueMode.Single, + compare: useEvent( + typeof by === 'string' + ? (a: TType, z: TType) => { + let property = by as unknown as keyof TType + return a[property] === z[property] + } + : by + ), + }, }, labelRef: createRef(), buttonRef: createRef(), @@ -770,9 +792,12 @@ let Option = forwardRefWithAs(function 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]: () => (state.propsRef.current.value as TType[]).includes(value), - [ValueMode.Single]: () => state.propsRef.current.value === value, + [ValueMode.Multi]: () => (optionValue as TType[]).some((option) => compare(option, value)), + [ValueMode.Single]: () => compare(optionValue, value), }) let internalOptionRef = useRef(null) 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 47816492db..ca45345018 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 @@ -321,6 +321,93 @@ describe('Rendering', () => { assertActiveElement(getByText('Option 3')) }) ) + + describe('Equality', () => { + let options = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + + it( + 'should use object equality by default', + suppressConsoleLogs(async () => { + render( + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + ) + + let bob = getRadioGroupOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ checked: true, disabled: false, active: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a field', + suppressConsoleLogs(async () => { + render( + + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + ) + + let bob = getRadioGroupOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ checked: true, disabled: false, active: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a comparator function', + suppressConsoleLogs(async () => { + render( + a.id === z.id} + > + {options.map((option) => ( + JSON.stringify(info)} + > + {option.name} + + ))} + + ) + + let bob = getRadioGroupOptions()[1] + expect(bob).toHaveAttribute( + 'class', + JSON.stringify({ checked: true, disabled: false, active: false }) + ) + }) + ) + }) }) describe('Keyboard interactions', () => { 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 22622b8df7..0d061ed3aa 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -29,6 +29,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { attemptSubmit, objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' +import { useEvent } from '../../hooks/use-event' interface Option { id: string @@ -82,6 +83,7 @@ let RadioGroupContext = createContext<{ firstOption?: Option containsCheckedOption: boolean disabled: boolean + compare(a: unknown, z: unknown): boolean } | null>(null) RadioGroupContext.displayName = 'RadioGroupContext' @@ -112,16 +114,25 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< props: Props< TTag, RadioGroupRenderPropArg, - RadioGroupPropsWeControl | 'value' | 'onChange' | 'disabled' | 'name' + RadioGroupPropsWeControl | 'value' | 'onChange' | 'disabled' | 'name' | 'by' > & { value: TType onChange(value: TType): void + by?: (keyof TType & string) | ((a: TType, z: TType) => boolean) disabled?: boolean name?: string }, ref: Ref ) { - let { value, name, onChange, disabled = false, ...theirProps } = props + let { value, name, onChange, by = (a, z) => a === z, disabled = false, ...theirProps } = props + let compare = useEvent( + typeof by === 'string' + ? (a: TType, z: TType) => { + let property = by as unknown as keyof TType + return a[property] === z[property] + } + : by + ) let [{ options }, dispatch] = useReducer(stateReducer, { options: [], } as StateDefinition) @@ -140,16 +151,17 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< [options] ) let containsCheckedOption = useMemo( - () => options.some((option) => option.propsRef.current.value === value), + () => options.some((option) => compare(option.propsRef.current.value as TType, value)), [options, value] ) let triggerChange = useCallback( (nextValue) => { if (disabled) return false - if (nextValue === value) return false - let nextOption = options.find((option) => option.propsRef.current.value === nextValue) - ?.propsRef.current + if (compare(nextValue, value)) return false + let nextOption = options.find((option) => + compare(option.propsRef.current.value as TType, nextValue) + )?.propsRef.current if (nextOption?.disabled) return false onChange(nextValue) @@ -251,8 +263,9 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< change: triggerChange, disabled, value, + compare, }), - [registerOption, firstOption, containsCheckedOption, triggerChange, disabled, value] + [registerOption, firstOption, containsCheckedOption, triggerChange, disabled, value, compare] ) let ourProps = { @@ -357,6 +370,7 @@ let Option = forwardRefWithAs(function Option< firstOption, containsCheckedOption, value: radioGroupValue, + compare, } = useRadioGroupContext('RadioGroup.Option') useIsoMorphicEffect( @@ -377,7 +391,7 @@ let Option = forwardRefWithAs(function Option< let isFirstOption = firstOption?.id === id let isDisabled = radioGroupDisabled || disabled - let checked = radioGroupValue === value + let checked = compare(radioGroupValue as TType, value) let ourProps = { ref: optionRef, id, diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 447fe216be..c579fb01d6 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -225,6 +225,113 @@ describe('Rendering', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) + + describe('Equality', () => { + let options = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + + it( + 'should use object equality by default', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + {{ JSON.stringify(data) }} + + + `, + setup: () => { + let value = ref(options[1]) + return { options, value } + }, + }) + + await click(getComboboxButton()) + + let bob = getComboboxOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a field', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + {{ JSON.stringify(data) }} + + + `, + setup: () => { + let value = ref({ id: 2, name: 'Bob' }) + return { options, value } + }, + }) + + await click(getComboboxButton()) + + let bob = getComboboxOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a comparator function', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + {{ JSON.stringify(data) }} + + + `, + setup: () => { + let value = ref({ id: 2, name: 'Bob' }) + return { options, value, compare: (a: any, z: any) => a.id === z.id } + }, + }) + + await click(getComboboxButton()) + + let bob = getComboboxOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + }) }) describe('Combobox.Input', () => { diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index ca5854fc65..5e70ac2d52 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -35,6 +35,10 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' +function defaultComparator(a: T, z: T): boolean { + return a === z +} + enum ComboboxStates { Open, Closed, @@ -63,6 +67,8 @@ type StateDefinition = { mode: ComputedRef nullable: ComputedRef + compare: (a: unknown, z: unknown) => boolean + inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }> optionsPropsRef: Ref<{ static: boolean; hold: boolean }> @@ -110,6 +116,7 @@ export let Combobox = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: [Boolean], default: false }, + by: { type: [String, Function], default: () => defaultComparator }, modelValue: { type: [Object, String, Number, Boolean] }, name: { type: String }, nullable: { type: Boolean, default: false }, @@ -172,6 +179,13 @@ export let Combobox = defineComponent({ comboboxState, value, mode, + compare(a: any, z: any) { + if (typeof props.by === 'string') { + let property = props.by as unknown as any + return a[property] === z[property] + } + return props.by(a, z) + }, nullable, inputRef, labelRef, @@ -217,9 +231,11 @@ export let Combobox = defineComponent({ let optionIdx = options.value.findIndex((option) => { let optionValue = toRaw(option.dataRef.value) let selected = match(mode.value, { - [ValueMode.Single]: () => toRaw(api.value.value) === toRaw(optionValue), + [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(optionValue)), [ValueMode.Multi]: () => - (toRaw(api.value.value) as unknown[]).includes(toRaw(optionValue)), + (toRaw(api.value.value) as unknown[]).some((value) => + api.compare(toRaw(value), toRaw(optionValue)) + ), }) return selected @@ -350,9 +366,11 @@ export let Combobox = defineComponent({ if (activeOptionIndex.value === null) { let optionValue = (dataRef.value as any).value let selected = match(mode.value, { - [ValueMode.Single]: () => toRaw(api.value.value) === toRaw(optionValue), + [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(optionValue)), [ValueMode.Multi]: () => - (toRaw(api.value.value) as unknown[]).includes(toRaw(optionValue)), + (toRaw(api.value.value) as unknown[]).some((value) => + api.compare(toRaw(value), toRaw(optionValue)) + ), }) if (selected) { @@ -449,7 +467,7 @@ export let Combobox = defineComponent({ render({ props: { ...attrs, - ...omit(incomingProps, ['nullable', 'multiple', 'onUpdate:modelValue']), + ...omit(incomingProps, ['nullable', 'multiple', 'onUpdate:modelValue', 'by']), }, slot, slots, @@ -859,8 +877,11 @@ export let ComboboxOption = defineComponent({ let selected = computed(() => match(api.mode.value, { - [ValueMode.Single]: () => toRaw(api.value.value) === toRaw(props.value), - [ValueMode.Multi]: () => (toRaw(api.value.value) as unknown[]).includes(toRaw(props.value)), + [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(props.value)), + [ValueMode.Multi]: () => + (toRaw(api.value.value) as unknown[]).some((value) => + api.compare(toRaw(value), toRaw(props.value)) + ), }) ) diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index 433a078cd4..7fa2814158 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -198,6 +198,113 @@ describe('Rendering', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) + + describe('Equality', () => { + let options = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + + it( + 'should use object equality by default', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + {{ JSON.stringify(data) }} + + + `, + setup: () => { + let value = ref(options[1]) + return { options, value } + }, + }) + + await click(getListboxButton()) + + let bob = getListboxOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a field', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + {{ JSON.stringify(data) }} + + + `, + setup: () => { + let value = ref({ id: 2, name: 'Bob' }) + return { options, value } + }, + }) + + await click(getListboxButton()) + + let bob = getListboxOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a comparator function', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + {{ JSON.stringify(data) }} + + + `, + setup: () => { + let value = ref({ id: 2, name: 'Bob' }) + return { options, value, compare: (a: any, z: any) => a.id === z.id } + }, + }) + + await click(getListboxButton()) + + let bob = getListboxOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ active: true, selected: true, disabled: false }) + ) + }) + ) + }) }) describe('ListboxLabel', () => { diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 8c9b0a7f58..b3a123ab3d 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -33,6 +33,10 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' +function defaultComparator(a: T, z: T): boolean { + return a === z +} + enum ListboxStates { Open, Closed, @@ -67,6 +71,8 @@ type StateDefinition = { mode: ComputedRef + compare: (a: unknown, z: unknown) => boolean + labelRef: Ref buttonRef: Ref optionsRef: Ref @@ -110,6 +116,7 @@ export let Listbox = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: [Boolean], default: false }, + by: { type: [String, Function], default: () => defaultComparator }, horizontal: { type: [Boolean], default: false }, modelValue: { type: [Object, String, Number, Boolean] }, name: { type: String, optional: true }, @@ -164,6 +171,13 @@ export let Listbox = defineComponent({ listboxState, value, mode, + compare(a: any, z: any) { + if (typeof props.by === 'string') { + let property = props.by as unknown as any + return a[property] === z[property] + } + return props.by(a, z) + }, orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')), labelRef, buttonRef, @@ -269,7 +283,7 @@ export let Listbox = defineComponent({ let copy = toRaw(api.value.value as unknown[]).slice() let raw = toRaw(value) - let idx = copy.indexOf(raw) + let idx = copy.findIndex((value) => api.compare(raw, toRaw(value))) if (idx === -1) { copy.push(raw) } else { @@ -332,7 +346,7 @@ export let Listbox = defineComponent({ render({ props: { ...attrs, - ...omit(incomingProps, ['onUpdate:modelValue', 'horizontal', 'multiple']), + ...omit(incomingProps, ['onUpdate:modelValue', 'horizontal', 'multiple', 'by']), }, slot, slots, @@ -625,8 +639,11 @@ export let ListboxOption = defineComponent({ let selected = computed(() => match(api.mode.value, { - [ValueMode.Single]: () => toRaw(api.value.value) === toRaw(props.value), - [ValueMode.Multi]: () => (toRaw(api.value.value) as unknown[]).includes(toRaw(props.value)), + [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(props.value)), + [ValueMode.Multi]: () => + (toRaw(api.value.value) as unknown[]).some((value) => + api.compare(toRaw(value), toRaw(props.value)) + ), }) ) let isFirstSelected = computed(() => { @@ -635,8 +652,9 @@ export let ListboxOption = defineComponent({ let currentValues = toRaw(api.value.value) as unknown[] return ( - api.options.value.find((option) => currentValues.includes(option.dataRef.value))?.id === - id + api.options.value.find((option) => + currentValues.some((value) => api.compare(toRaw(value), toRaw(option.dataRef.value))) + )?.id === id ) }, [ValueMode.Single]: () => selected.value, 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 f0573610fa..e6aca27595 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 @@ -504,6 +504,101 @@ describe('Rendering', () => { // Verify that the third radio group option is active assertActiveElement(getByText('Option 3')) }) + + describe('Equality', () => { + let options = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + + it( + 'should use object equality by default', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + {{ JSON.stringify(data) }} + + `, + setup: () => { + let value = ref(options[1]) + return { options, value } + }, + }) + + let bob = getRadioGroupOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ checked: true, disabled: false, active: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a field', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + {{ JSON.stringify(data) }} + + `, + setup: () => { + let value = ref({ id: 2, name: 'Bob' }) + return { options, value } + }, + }) + + let bob = getRadioGroupOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ checked: true, disabled: false, active: false }) + ) + }) + ) + + it( + 'should be possible to compare objects by a comparator function', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + {{ JSON.stringify(data) }} + + `, + setup: () => { + let value = ref({ id: 2, name: 'Bob' }) + return { options, value, compare: (a: any, z: any) => a.id === z.id } + }, + }) + + let bob = getRadioGroupOptions()[1] + expect(bob).toHaveTextContent( + JSON.stringify({ checked: true, disabled: false, active: false }) + ) + }) + ) + }) }) describe('Keyboard interactions', () => { 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 a1dad48b9b..8b4204cfcd 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -27,6 +27,10 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { attemptSubmit, objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' +function defaultComparator(a: T, z: T): boolean { + return a === z +} + interface Option { id: string element: Ref @@ -41,6 +45,8 @@ interface StateDefinition { firstOption: Ref