Skip to content

Commit

Permalink
Add by prop for Listbox, Combobox and RadioGroup (#1482)
Browse files Browse the repository at this point in the history
* Add `by` prop for `Listbox`, `Combobox` and `RadioGroup`

* update changelog
  • Loading branch information
RobinMalfait committed May 20, 2022
1 parent cc6aaa2 commit d200be5
Show file tree
Hide file tree
Showing 13 changed files with 766 additions and 46 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Expand Up @@ -12,13 +12,21 @@ 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

- 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
Expand Down
102 changes: 102 additions & 0 deletions packages/@headlessui-react/src/components/combobox/combobox.test.tsx
Expand Up @@ -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', () => {
Expand Down
36 changes: 27 additions & 9 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +70,7 @@ interface StateDefinition {
mode: ValueMode
onChange(value: unknown): void
nullable: boolean
compare(a: unknown, z: unknown): boolean
__demoMode: boolean
}>
inputPropsRef: MutableRefObject<{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -356,6 +361,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
name,
value,
onChange,
by = (a, z) => a === z,
disabled = false,
__demoMode = false,
nullable = false,
Expand All @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
102 changes: 102 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Expand Up @@ -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', () => {
Expand Down

0 comments on commit d200be5

Please sign in to comment.