Add by prop for Listbox, Combobox and RadioGroup (#1482)

* Add `by` prop for `Listbox`, `Combobox` and `RadioGroup`

* update changelog
This commit is contained in:
Robin Malfait
2022-05-20 23:01:10 +02:00
committed by GitHub
parent cc6aaa234a
commit d200be5f6f
13 changed files with 766 additions and 46 deletions
+8
View File
@@ -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 `<PopoverPanel focus>` 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 `<Popover.Panel focus>` 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
@@ -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(
<Combobox value={options[1]} onChange={console.log}>
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
{options.map((option) => (
<Combobox.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
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(
<Combobox value={{ id: 2, name: 'Bob' }} onChange={console.log} by="id">
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
{options.map((option) => (
<Combobox.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
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(
<Combobox
value={{ id: 2, name: 'Bob' }}
onChange={console.log}
by={(a, z) => a.id === z.id}
>
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
{options.map((option) => (
<Combobox.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
await click(getComboboxButton())
let bob = getComboboxOptions()[1]
expect(bob).toHaveAttribute(
'class',
JSON.stringify({ active: true, selected: true, disabled: false })
)
})
)
})
})
describe('Combobox.Input', () => {
@@ -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<TType>,
'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<StateDefinition['comboboxPropsRef']['current']>({
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<HTMLLIElement | null>(null)
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
@@ -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(
<Listbox value={options[1]} onChange={console.log}>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
{options.map((option) => (
<Listbox.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
)
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(
<Listbox value={{ id: 2, name: 'Bob' }} onChange={console.log} by="id">
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
{options.map((option) => (
<Listbox.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
)
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(
<Listbox
value={{ id: 2, name: 'Bob' }}
onChange={console.log}
by={(a, z) => a.id === z.id}
>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
{options.map((option) => (
<Listbox.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
)
await click(getListboxButton())
let bob = getListboxOptions()[1]
expect(bob).toHaveAttribute(
'class',
JSON.stringify({ active: true, selected: true, disabled: false })
)
})
)
})
})
describe('Listbox.Label', () => {
@@ -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<HTMLLabelElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
@@ -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<HTMLLIElement | null>(null)
@@ -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(
<RadioGroup value={options[1]} onChange={console.log}>
{options.map((option) => (
<RadioGroup.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</RadioGroup.Option>
))}
</RadioGroup>
)
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(
<RadioGroup value={{ id: 2, name: 'Bob' }} onChange={console.log} by="id">
{options.map((option) => (
<RadioGroup.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</RadioGroup.Option>
))}
</RadioGroup>
)
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(
<RadioGroup
value={{ id: 2, name: 'Bob' }}
onChange={console.log}
by={(a, z) => a.id === z.id}
>
{options.map((option) => (
<RadioGroup.Option
key={option.id}
value={option}
className={(info) => JSON.stringify(info)}
>
{option.name}
</RadioGroup.Option>
))}
</RadioGroup>
)
let bob = getRadioGroupOptions()[1]
expect(bob).toHaveAttribute(
'class',
JSON.stringify({ checked: true, disabled: false, active: false })
)
})
)
})
})
describe('Keyboard interactions', () => {
@@ -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<HTMLElement>
) {
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,
@@ -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`
<Combobox v-model="value">
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</ComboboxOption
>
</ComboboxOptions>
</Combobox>
`,
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`
<Combobox v-model="value" by="id">
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</ComboboxOption
>
</ComboboxOptions>
</Combobox>
`,
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`
<Combobox v-model="value" :by="compare">
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</ComboboxOption
>
</ComboboxOptions>
</Combobox>
`,
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', () => {
@@ -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<T>(a: T, z: T): boolean {
return a === z
}
enum ComboboxStates {
Open,
Closed,
@@ -63,6 +67,8 @@ type StateDefinition = {
mode: ComputedRef<ValueMode>
nullable: ComputedRef<boolean>
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))
),
})
)
@@ -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`
<Listbox v-model="value">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</ListboxOption
>
</ListboxOptions>
</Listbox>
`,
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`
<Listbox v-model="value" by="id">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</ListboxOption
>
</ListboxOptions>
</Listbox>
`,
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`
<Listbox v-model="value" :by="compare">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</ListboxOption
>
</ListboxOptions>
</Listbox>
`,
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', () => {
@@ -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<T>(a: T, z: T): boolean {
return a === z
}
enum ListboxStates {
Open,
Closed,
@@ -67,6 +71,8 @@ type StateDefinition = {
mode: ComputedRef<ValueMode>
compare: (a: unknown, z: unknown) => boolean
labelRef: Ref<HTMLLabelElement | null>
buttonRef: Ref<HTMLButtonElement | null>
optionsRef: Ref<HTMLDivElement | null>
@@ -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,
@@ -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`
<RadioGroup v-model="value">
<RadioGroupButton>Trigger</RadioGroupButton>
<RadioGroupOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</RadioGroupOption
>
</RadioGroup>
`,
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`
<RadioGroup v-model="value" by="id">
<RadioGroupButton>Trigger</RadioGroupButton>
<RadioGroupOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</RadioGroupOption
>
</RadioGroup>
`,
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`
<RadioGroup v-model="value" :by="compare">
<RadioGroupButton>Trigger</RadioGroupButton>
<RadioGroupOption
v-for="option in options"
:key="option.id"
:value="option"
v-slot="data"
>{{ JSON.stringify(data) }}</RadioGroupOption
>
</RadioGroup>
`,
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', () => {
@@ -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<T>(a: T, z: T): boolean {
return a === z
}
interface Option {
id: string
element: Ref<HTMLElement | null>
@@ -41,6 +45,8 @@ interface StateDefinition {
firstOption: Ref<Option | undefined>
containsCheckedOption: Ref<boolean>
compare(a: unknown, z: unknown): boolean
// State mutators
change(nextValue: unknown): boolean
registerOption(action: Option): void
@@ -69,6 +75,7 @@ export let RadioGroup = defineComponent({
props: {
as: { type: [Object, String], default: 'div' },
disabled: { type: [Boolean], default: false },
by: { type: [String, Function], default: () => defaultComparator },
modelValue: { type: [Object, String, Number, Boolean] },
name: { type: String, optional: true },
},
@@ -94,13 +101,22 @@ export let RadioGroup = defineComponent({
})
),
containsCheckedOption: computed(() =>
options.value.some((option) => toRaw(option.propsRef.value) === toRaw(props.modelValue))
options.value.some((option) =>
api.compare(toRaw(option.propsRef.value), toRaw(props.modelValue))
)
),
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)
},
change(nextValue: unknown) {
if (props.disabled) return false
if (value.value === nextValue) return false
let nextOption = options.value.find(
(option) => toRaw(option.propsRef.value) === toRaw(nextValue)
if (api.compare(toRaw(value.value), toRaw(nextValue))) return false
let nextOption = options.value.find((option) =>
api.compare(toRaw(option.propsRef.value), toRaw(nextValue))
)?.propsRef
if (nextOption?.disabled) return false
emit('update:modelValue', nextValue)
@@ -267,7 +283,7 @@ export let RadioGroupOption = defineComponent({
let isFirstOption = computed(() => api.firstOption.value?.id === id)
let disabled = computed(() => api.disabled.value || props.disabled)
let checked = computed(() => toRaw(api.value.value) === toRaw(props.value))
let checked = computed(() => api.compare(toRaw(api.value.value), toRaw(props.value)))
let tabIndex = computed(() => {
if (disabled.value) return -1
if (checked.value) return 0