Skip to content

Commit d200be5

Browse files
authored
Add by prop for Listbox, Combobox and RadioGroup (#1482)
* Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` * update changelog
1 parent cc6aaa2 commit d200be5

File tree

13 files changed

+766
-46
lines changed

13 files changed

+766
-46
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Allow to override the `type` on the `ComboboxInput` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476))
1313
- Ensure the the `<PopoverPanel focus>` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477))
1414

15+
### Added
16+
17+
- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482))
18+
1519
## [Unreleased - @headlessui/react]
1620

1721
### Fixed
1822

1923
- Allow to override the `type` on the `Combobox.Input` ([#1476](https://github.com/tailwindlabs/headlessui/pull/1476))
2024
- Ensure the the `<Popover.Panel focus>` closes correctly ([#1477](https://github.com/tailwindlabs/headlessui/pull/1477))
2125

26+
### Added
27+
28+
- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482))
29+
2230
## [@headlessui/vue@v1.6.2] - 2022-05-19
2331

2432
### Fixed

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,108 @@ describe('Rendering', () => {
170170
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
171171
})
172172
)
173+
174+
describe('Equality', () => {
175+
let options = [
176+
{ id: 1, name: 'Alice' },
177+
{ id: 2, name: 'Bob' },
178+
{ id: 3, name: 'Charlie' },
179+
]
180+
181+
it(
182+
'should use object equality by default',
183+
suppressConsoleLogs(async () => {
184+
render(
185+
<Combobox value={options[1]} onChange={console.log}>
186+
<Combobox.Button>Trigger</Combobox.Button>
187+
<Combobox.Options>
188+
{options.map((option) => (
189+
<Combobox.Option
190+
key={option.id}
191+
value={option}
192+
className={(info) => JSON.stringify(info)}
193+
>
194+
{option.name}
195+
</Combobox.Option>
196+
))}
197+
</Combobox.Options>
198+
</Combobox>
199+
)
200+
201+
await click(getComboboxButton())
202+
203+
let bob = getComboboxOptions()[1]
204+
expect(bob).toHaveAttribute(
205+
'class',
206+
JSON.stringify({ active: true, selected: true, disabled: false })
207+
)
208+
})
209+
)
210+
211+
it(
212+
'should be possible to compare objects by a field',
213+
suppressConsoleLogs(async () => {
214+
render(
215+
<Combobox value={{ id: 2, name: 'Bob' }} onChange={console.log} by="id">
216+
<Combobox.Button>Trigger</Combobox.Button>
217+
<Combobox.Options>
218+
{options.map((option) => (
219+
<Combobox.Option
220+
key={option.id}
221+
value={option}
222+
className={(info) => JSON.stringify(info)}
223+
>
224+
{option.name}
225+
</Combobox.Option>
226+
))}
227+
</Combobox.Options>
228+
</Combobox>
229+
)
230+
231+
await click(getComboboxButton())
232+
233+
let bob = getComboboxOptions()[1]
234+
expect(bob).toHaveAttribute(
235+
'class',
236+
JSON.stringify({ active: true, selected: true, disabled: false })
237+
)
238+
})
239+
)
240+
241+
it(
242+
'should be possible to compare objects by a comparator function',
243+
suppressConsoleLogs(async () => {
244+
render(
245+
<Combobox
246+
value={{ id: 2, name: 'Bob' }}
247+
onChange={console.log}
248+
by={(a, z) => a.id === z.id}
249+
>
250+
<Combobox.Button>Trigger</Combobox.Button>
251+
<Combobox.Options>
252+
{options.map((option) => (
253+
<Combobox.Option
254+
key={option.id}
255+
value={option}
256+
className={(info) => JSON.stringify(info)}
257+
>
258+
{option.name}
259+
</Combobox.Option>
260+
))}
261+
</Combobox.Options>
262+
</Combobox>
263+
)
264+
265+
await click(getComboboxButton())
266+
267+
let bob = getComboboxOptions()[1]
268+
expect(bob).toHaveAttribute(
269+
'class',
270+
JSON.stringify({ active: true, selected: true, disabled: false })
271+
)
272+
})
273+
)
274+
})
173275
})
174276

175277
describe('Combobox.Input', () => {

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
3838
import { sortByDomNode } from '../../utils/focus-management'
3939
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
4040
import { objectToFormEntries } from '../../utils/form'
41+
import { useEvent } from '../../hooks/use-event'
4142

4243
enum ComboboxStates {
4344
Open,
@@ -69,6 +70,7 @@ interface StateDefinition {
6970
mode: ValueMode
7071
onChange(value: unknown): void
7172
nullable: boolean
73+
compare(a: unknown, z: unknown): boolean
7274
__demoMode: boolean
7375
}>
7476
inputPropsRef: MutableRefObject<{
@@ -160,12 +162,13 @@ let reducers: {
160162

161163
// Check if we have a selected value that we can make active
162164
let activeOptionIndex = state.activeOptionIndex
163-
let { value, mode } = state.comboboxPropsRef.current
165+
let { value, mode, compare } = state.comboboxPropsRef.current
164166
let optionIdx = state.options.findIndex((option) => {
165167
let optionValue = option.dataRef.current.value
166168
let selected = match(mode, {
167-
[ValueMode.Multi]: () => (value as unknown[]).includes(optionValue),
168-
[ValueMode.Single]: () => value === optionValue,
169+
[ValueMode.Multi]: () =>
170+
(value as unknown[]).some((option) => compare(option, optionValue)),
171+
[ValueMode.Single]: () => compare(value, optionValue),
169172
})
170173

171174
return selected
@@ -226,11 +229,12 @@ let reducers: {
226229

227230
// Check if we need to make the newly registered option active.
228231
if (state.activeOptionIndex === null) {
229-
let { value, mode } = state.comboboxPropsRef.current
232+
let { value, mode, compare } = state.comboboxPropsRef.current
230233
let optionValue = action.dataRef.current.value
231234
let selected = match(mode, {
232-
[ValueMode.Multi]: () => (value as unknown[]).includes(optionValue),
233-
[ValueMode.Single]: () => value === optionValue,
235+
[ValueMode.Multi]: () =>
236+
(value as unknown[]).some((option) => compare(option, optionValue)),
237+
[ValueMode.Single]: () => compare(value, optionValue),
234238
})
235239
if (selected) {
236240
adjustedState.activeOptionIndex = adjustedState.options.indexOf(option)
@@ -340,10 +344,11 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
340344
props: Props<
341345
TTag,
342346
ComboboxRenderPropArg<TType>,
343-
'value' | 'onChange' | 'disabled' | 'name' | 'nullable' | 'multiple'
347+
'value' | 'onChange' | 'disabled' | 'name' | 'nullable' | 'multiple' | 'by'
344348
> & {
345349
value: TType
346350
onChange(value: TType): void
351+
by?: (keyof TType & string) | ((a: TType, z: TType) => boolean)
347352
disabled?: boolean
348353
__demoMode?: boolean
349354
name?: string
@@ -356,6 +361,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
356361
name,
357362
value,
358363
onChange,
364+
by = (a, z) => a === z,
359365
disabled = false,
360366
__demoMode = false,
361367
nullable = false,
@@ -367,6 +373,14 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
367373
let comboboxPropsRef = useRef<StateDefinition['comboboxPropsRef']['current']>({
368374
value,
369375
mode: multiple ? ValueMode.Multi : ValueMode.Single,
376+
compare: useEvent(
377+
typeof by === 'string'
378+
? (a: TType, z: TType) => {
379+
let property = by as unknown as keyof TType
380+
return a[property] === z[property]
381+
}
382+
: by
383+
),
370384
onChange,
371385
nullable,
372386
__demoMode,
@@ -1093,9 +1107,13 @@ let Option = forwardRefWithAs(function Option<
10931107
let id = `headlessui-combobox-option-${useId()}`
10941108
let active =
10951109
data.activeOptionIndex !== null ? state.options[data.activeOptionIndex].id === id : false
1110+
10961111
let selected = match(data.mode, {
1097-
[ValueMode.Multi]: () => (data.value as TType[]).includes(value),
1098-
[ValueMode.Single]: () => data.value === value,
1112+
[ValueMode.Multi]: () =>
1113+
(data.value as TType[]).some((option) =>
1114+
state.comboboxPropsRef.current.compare(option, value)
1115+
),
1116+
[ValueMode.Single]: () => state.comboboxPropsRef.current.compare(data.value, value),
10991117
})
11001118
let internalOptionRef = useRef<HTMLLIElement | null>(null)
11011119
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,108 @@ describe('Rendering', () => {
162162
assertListbox({ state: ListboxState.InvisibleUnmounted })
163163
})
164164
)
165+
166+
describe('Equality', () => {
167+
let options = [
168+
{ id: 1, name: 'Alice' },
169+
{ id: 2, name: 'Bob' },
170+
{ id: 3, name: 'Charlie' },
171+
]
172+
173+
it(
174+
'should use object equality by default',
175+
suppressConsoleLogs(async () => {
176+
render(
177+
<Listbox value={options[1]} onChange={console.log}>
178+
<Listbox.Button>Trigger</Listbox.Button>
179+
<Listbox.Options>
180+
{options.map((option) => (
181+
<Listbox.Option
182+
key={option.id}
183+
value={option}
184+
className={(info) => JSON.stringify(info)}
185+
>
186+
{option.name}
187+
</Listbox.Option>
188+
))}
189+
</Listbox.Options>
190+
</Listbox>
191+
)
192+
193+
await click(getListboxButton())
194+
195+
let bob = getListboxOptions()[1]
196+
expect(bob).toHaveAttribute(
197+
'class',
198+
JSON.stringify({ active: true, selected: true, disabled: false })
199+
)
200+
})
201+
)
202+
203+
it(
204+
'should be possible to compare objects by a field',
205+
suppressConsoleLogs(async () => {
206+
render(
207+
<Listbox value={{ id: 2, name: 'Bob' }} onChange={console.log} by="id">
208+
<Listbox.Button>Trigger</Listbox.Button>
209+
<Listbox.Options>
210+
{options.map((option) => (
211+
<Listbox.Option
212+
key={option.id}
213+
value={option}
214+
className={(info) => JSON.stringify(info)}
215+
>
216+
{option.name}
217+
</Listbox.Option>
218+
))}
219+
</Listbox.Options>
220+
</Listbox>
221+
)
222+
223+
await click(getListboxButton())
224+
225+
let bob = getListboxOptions()[1]
226+
expect(bob).toHaveAttribute(
227+
'class',
228+
JSON.stringify({ active: true, selected: true, disabled: false })
229+
)
230+
})
231+
)
232+
233+
it(
234+
'should be possible to compare objects by a comparator function',
235+
suppressConsoleLogs(async () => {
236+
render(
237+
<Listbox
238+
value={{ id: 2, name: 'Bob' }}
239+
onChange={console.log}
240+
by={(a, z) => a.id === z.id}
241+
>
242+
<Listbox.Button>Trigger</Listbox.Button>
243+
<Listbox.Options>
244+
{options.map((option) => (
245+
<Listbox.Option
246+
key={option.id}
247+
value={option}
248+
className={(info) => JSON.stringify(info)}
249+
>
250+
{option.name}
251+
</Listbox.Option>
252+
))}
253+
</Listbox.Options>
254+
</Listbox>
255+
)
256+
257+
await click(getListboxButton())
258+
259+
let bob = getListboxOptions()[1]
260+
expect(bob).toHaveAttribute(
261+
'class',
262+
JSON.stringify({ active: true, selected: true, disabled: false })
263+
)
264+
})
265+
)
266+
})
165267
})
166268

167269
describe('Listbox.Label', () => {

0 commit comments

Comments
 (0)