@coopdigital/react 1.29.1 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coopdigital/react",
3
3
  "type": "module",
4
- "version": "1.29.1",
4
+ "version": "1.30.0",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -107,7 +107,7 @@
107
107
  "storybook": "$storybook"
108
108
  },
109
109
  "dependencies": {
110
- "@coopdigital/styles": "^1.20.0",
110
+ "@coopdigital/styles": "^1.21.0",
111
111
  "@radix-ui/react-popover": "^1.2.0",
112
112
  "clsx": "^2.1.1",
113
113
  "date-fns": "^4.4.0",
@@ -116,5 +116,5 @@
116
116
  "merge-refs": "^2.0.0",
117
117
  "react-day-picker": "^10.0.2"
118
118
  },
119
- "gitHead": "3072c248f8c6fd325dae0835245b13b74aceced5"
119
+ "gitHead": "3f7a257781401d8700ddc0e16feeaeab452bd79e"
120
120
  }
@@ -1,4 +1,6 @@
1
1
  "use client"
2
+ import type { UseComboboxReturnValue } from "downshift"
3
+
2
4
  import { createContext, useContext } from "react"
3
5
 
4
6
  import type { ComboboxItem } from "@/components/Combobox/combobox-utils"
@@ -6,22 +8,19 @@ import type { ComboboxItem } from "@/components/Combobox/combobox-utils"
6
8
  import { StandardSizes } from "@/types"
7
9
 
8
10
  interface ComboboxContextValue {
9
- disabled?: boolean
10
- error?: boolean
11
- items?: ComboboxItem[]
12
- props?: {
13
- getInputProps: Function
14
- getItemProps: Function
15
- getMenuProps: Function
16
- getToggleButtonProps: Function
17
- isOpen: boolean
18
- }
11
+ disabled: boolean
12
+ downshift: UseComboboxReturnValue<ComboboxItem>
13
+ error: boolean
14
+ // The options currently shown, after filtering and the maxResults cap
15
+ items: ComboboxItem[]
16
+ // The number of options that match the input, before the cap
17
+ matchCount: number
19
18
  size?: StandardSizes
20
19
  }
21
20
 
22
- export const ComboboxContext = createContext<ComboboxContextValue>({})
21
+ export const ComboboxContext = createContext<ComboboxContextValue | null>(null)
23
22
 
24
- export const useComboboxContext = () => {
23
+ export const useComboboxContext = (): ComboboxContextValue => {
25
24
  const context = useContext(ComboboxContext)
26
25
  if (!context) throw new Error("Combobox components must be used inside <Combobox>")
27
26
  return context
@@ -4,13 +4,13 @@ import type { ButtonHTMLAttributes, HTMLAttributes, JSX, ReactNode, Ref } from "
4
4
  import clsx from "clsx"
5
5
  import {
6
6
  useCombobox,
7
- UseComboboxHighlightedIndexChange,
8
- UseComboboxInputValueChange,
9
- UseComboboxIsOpenChange,
10
- UseComboboxSelectedItemChange,
11
- UseComboboxStateChange,
7
+ type UseComboboxHighlightedIndexChange,
8
+ type UseComboboxInputValueChange,
9
+ type UseComboboxIsOpenChange,
10
+ type UseComboboxState,
11
+ type UseComboboxStateChange,
12
12
  } from "downshift"
13
- import { useState } from "react"
13
+ import { useEffect, useMemo, useRef, useState } from "react"
14
14
 
15
15
  import {
16
16
  type ComboboxItem,
@@ -18,19 +18,34 @@ import {
18
18
  defaultFilter,
19
19
  defaultIsItemDisabled,
20
20
  defaultItemToString,
21
+ isMatchingText,
21
22
  normalizeOptions,
23
+ resolveOption,
22
24
  } from "@/components/Combobox/combobox-utils"
23
25
  import { ComboboxContext, useComboboxContext } from "@/components/Combobox/Combobox.context"
24
26
  import { getFieldError, useField } from "@/components/Field/Field.context"
25
27
  import { ChevronDownIcon } from "@/components/icons"
26
- import { TextInput, TextInputProps } from "@/components/TextInput"
27
- import { useSlots } from "@/hooks"
28
+ import { TextInput, type TextInputProps } from "@/components/TextInput"
29
+ import { useDebounce, useId, useSlots } from "@/hooks"
28
30
  import { StandardSizes } from "@/types"
29
31
  import { getSlotProp } from "@/utils"
30
32
 
33
+ export type { ComboboxItem, ComboboxOption } from "@/components/Combobox/combobox-utils"
34
+
35
+ export interface ComboboxSelectEvent {
36
+ /** Whether the selection is a custom value, entered by users when `allowCustom` is set,
37
+ * rather than one of the options.
38
+ */
39
+ custom: boolean
40
+ /** The selected item, or `null` when the selection is cleared. For a custom value, this is a new item with that value. */
41
+ selectedItem: ComboboxItem | null
42
+ /** The value of the selected option, or `null` when the selection is cleared. */
43
+ value: string | null
44
+ }
45
+
31
46
  export interface ComboboxProps extends Omit<
32
47
  HTMLAttributes<HTMLDivElement>,
33
- "onChange" | "onSelect" | "onToggle"
48
+ "defaultValue" | "onChange" | "onSelect" | "onToggle"
34
49
  > {
35
50
  /** Specify whether custom values are accepted.
36
51
  *
@@ -41,9 +56,14 @@ export interface ComboboxProps extends Omit<
41
56
  *
42
57
  * This should be a set of `<Combobox.Controls>` and `<Combobox.Options>`.
43
58
  */
44
- children?: string | ReactNode
59
+ children?: ReactNode
45
60
  /** Specify additional CSS classes. */
46
61
  className?: string
62
+ /** Specify the initial selected option, when the selection is not controlled with `selected`.
63
+ *
64
+ * Accepts a `ComboboxOption`, matched to the options by its value.
65
+ */
66
+ defaultSelected?: ComboboxOption | null
47
67
  /** Specify the disabled state. */
48
68
  disabled?: boolean
49
69
  /** Specify the component error state. */
@@ -59,10 +79,10 @@ export interface ComboboxProps extends Omit<
59
79
  inputFilter?: (items: ComboboxItem[], value: string) => ComboboxItem[]
60
80
  /** Custom function to mark item as disabled.
61
81
  *
62
- * Default: `disabled` prop on `.ComboboxOption`
82
+ * Default: the `disabled` property of the option.
63
83
  */
64
84
  isItemDisabled?: (item: ComboboxItem) => boolean
65
- /** Custom function to render text in the input field after selecting a value.
85
+ /** Custom function that gives the text shown in the input for an item. Typed text is matched against this text.
66
86
  *
67
87
  * Default: Uses item label or value: `item.label ?? item.value`.
68
88
  */
@@ -77,11 +97,11 @@ export interface ComboboxProps extends Omit<
77
97
  * This is a `downshift` event.
78
98
  */
79
99
  onHighlight?: (event: UseComboboxHighlightedIndexChange<ComboboxItem>) => void
80
- /** Callback fired when users select an option from the list.
100
+ /** Callback fired when the selection changes, including when it is cleared.
81
101
  *
82
- * This is a `downshift` event.
102
+ * The event contains the full option object in `selectedItem`, and its value in `value`. Both are `null` when the selection is cleared.
83
103
  */
84
- onSelect?: (event: UseComboboxSelectedItemChange<ComboboxItem>) => void
104
+ onSelect?: (event: ComboboxSelectEvent) => void
85
105
  /** Callback fired whenever the state changes.
86
106
  *
87
107
  * This is a `downshift` event.
@@ -91,16 +111,19 @@ export interface ComboboxProps extends Omit<
91
111
  *
92
112
  * This is a `downshift` event.
93
113
  */
94
- onToggle?: (changes: UseComboboxIsOpenChange<ComboboxItem>) => void
114
+ onToggle?: (event: UseComboboxIsOpenChange<ComboboxItem>) => void
95
115
  /** Specify whether the menu should appear immediately when clicking the input field.
96
116
  *
97
117
  * Default: `true`.
98
118
  */
99
119
  openOnClick?: boolean
100
- /** Specify placeholder text. Do not use in place of a form label. */
101
- placeholder?: string
102
120
  /** Specify a custom React ref for this component. */
103
121
  ref?: Ref<HTMLDivElement>
122
+ /** Specify the selected option, when the selection is controlled. Update it from `onSelect`.
123
+ *
124
+ * Accepts a `ComboboxOption`, matched to the options by its value, or `null` for no selection.
125
+ */
126
+ selected?: ComboboxOption | null
104
127
  /** Specify the component size. */
105
128
  size?: StandardSizes
106
129
  }
@@ -110,35 +133,76 @@ export interface ComboboxControlsProps extends HTMLAttributes<HTMLDivElement> {
110
133
  *
111
134
  * This should be a set of `<Combobox.Input>` and `<Combobox.Button>`.
112
135
  */
113
- children?: React.ReactNode
136
+ children?: ReactNode
114
137
  /** Specify additional CSS classes. */
115
138
  className?: string
139
+ /** Specify a custom React ref for this component. */
140
+ ref?: Ref<HTMLDivElement>
141
+ }
142
+
143
+ // Prefixes and suffixes are not supported on ComboboxInput
144
+ export type ComboboxInputProps = Omit<
145
+ TextInputProps,
146
+ "defaultValue" | "prefix" | "prefixInline" | "suffix" | "suffixInline" | "value"
147
+ >
148
+
149
+ export interface ComboboxButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
150
+ /** Specify the accessible label for the button.
151
+ *
152
+ * Default: `Toggle menu`
153
+ */
154
+ "aria-label"?: string
155
+ /** Content inside the component.
156
+ *
157
+ * Default: `ChevronIconDown`. */
158
+ children?: ReactNode
159
+ /** Specify a custom React ref for this component. */
160
+ ref?: Ref<HTMLButtonElement>
116
161
  }
117
162
 
118
163
  export interface ComboboxOptionsProps extends HTMLAttributes<HTMLUListElement> {
119
164
  /** Specify additional CSS classes. */
120
165
  className?: string
166
+ /** Specify the maximum number of options to show in the list. Set to `false` to show all matching options.
167
+ *
168
+ * Default: `50`
169
+ */
170
+ maxResults?: false | number
171
+ /** Specify the message shown when more options match than `maxResults`.
172
+ *
173
+ * Default: `Showing X of Y results.`
174
+ */
175
+ maxResultsText?: (shown: number, total: number) => string
121
176
  /** Specify text to display when there are no matching options. */
122
177
  noResultsText?: string
123
178
  /** Specify array of options to choose from.
124
179
  *
125
- * Can be either a simple string array or a complex `ComboboxOption` array.
180
+ * Accepts a `ComboboxOption` array: strings, or `ComboboxItem` objects with a `value` and an optional `label`.
126
181
  */
127
182
  options: ComboboxOption[]
183
+ /** Specify a custom React ref for this component. */
184
+ ref?: Ref<HTMLUListElement>
128
185
  }
129
186
 
130
- export type ComboboxInputProps = TextInputProps
187
+ type ComboboxState = UseComboboxState<ComboboxItem>
188
+
189
+ const DEFAULT_MAX_RESULTS = 50
190
+ const ANNOUNCEMENT_DELAY = 750
191
+
192
+ const defaultMaxResultsText = (shown: number, total: number): string =>
193
+ `Showing ${shown} of ${total} results.`
131
194
 
132
195
  const componentSlots = {
133
196
  ComboboxControls: null,
134
197
  ComboboxOptions: null,
135
198
  }
136
199
 
137
- export const Combobox = ({
200
+ const Combobox = ({
138
201
  allowCustom = false,
139
202
  children,
140
203
  className,
141
- disabled,
204
+ defaultSelected,
205
+ disabled = false,
142
206
  error,
143
207
  id,
144
208
  inputFilter,
@@ -151,79 +215,175 @@ export const Combobox = ({
151
215
  onToggle,
152
216
  openOnClick = true,
153
217
  ref,
218
+ selected,
154
219
  size,
155
220
  ...props
156
- }: ComboboxProps): JSX.Element | null => {
221
+ }: ComboboxProps): JSX.Element => {
157
222
  const field = useField()
158
- const uid = id ?? field?.id
223
+ const uid = useId(id ?? field?.id)
159
224
  const hasError = getFieldError(error, field)
160
225
 
161
226
  const slots = useSlots(componentSlots, children)
227
+ const options = getSlotProp<ComboboxOption[]>(slots.ComboboxOptions, "options")
228
+ const maxResults =
229
+ getSlotProp<false | number>(slots.ComboboxOptions, "maxResults") ?? DEFAULT_MAX_RESULTS
230
+ const items = useMemo(() => normalizeOptions(options ?? []), [options])
162
231
 
163
- const userOptions = getSlotProp<ComboboxOption[]>(slots.ComboboxOptions, "options") ?? []
164
- const items = normalizeOptions(userOptions)
232
+ const filter = inputFilter ?? defaultFilter
233
+ const toString = itemToString ?? defaultItemToString
234
+ const isDisabled = isItemDisabled ?? defaultIsItemDisabled
165
235
 
166
- const [inputItems, setInputItems] = useState(items)
236
+ // Typing filters the list, and the input is cleared when the
237
+ // menu closes, so reopening after a selection shows every option
238
+ const [filterText, setFilterText] = useState("")
239
+ const matchingItems = useMemo(() => filter(items, filterText), [filter, items, filterText])
167
240
 
168
- const componentProps = {
169
- className: clsx("coop-combobox", className),
170
- "data-field": "",
171
- "data-size": size ?? undefined,
172
- id: uid,
173
- ...props,
241
+ // Rendering a long list is slow, as every option renders again when the
242
+ // highlight moves, so only the first matches are shown. Typed text is
243
+ // still checked against every option.
244
+ const visibleItems = useMemo(
245
+ () => (maxResults === false ? matchingItems : matchingItems.slice(0, maxResults)),
246
+ [matchingItems, maxResults]
247
+ )
248
+
249
+ const [initialSelectedItem] = useState(() => resolveOption(defaultSelected, items))
250
+ const selectedItem = selected === undefined ? undefined : resolveOption(selected, items)
251
+
252
+ // Work out the selection from the text in the input, so the two always
253
+ // agree. Returns null when the text does not match and custom values are
254
+ // not allowed.
255
+ const commit = (
256
+ changes: Partial<ComboboxState>,
257
+ closeMenu: boolean
258
+ ): Partial<ComboboxState> | null => {
259
+ const text = (changes.inputValue ?? "").trim()
260
+ if (text === "") return { ...changes, inputValue: "", selectedItem: null }
261
+
262
+ // Prefer the option downshift picked, such as the highlighted one, then
263
+ // look for an option with the same text
264
+ const picked = changes.selectedItem
265
+ const match =
266
+ picked && isMatchingText(toString(picked), text)
267
+ ? picked
268
+ : items.find((item) => !isDisabled(item) && isMatchingText(toString(item), text))
269
+
270
+ const closed = closeMenu ? { isOpen: false } : {}
271
+
272
+ if (match) return { ...changes, ...closed, inputValue: toString(match), selectedItem: match }
273
+ if (allowCustom)
274
+ return { ...changes, ...closed, inputValue: text, selectedItem: { value: text } }
275
+ return null
174
276
  }
175
277
 
176
- const inputFilterActual = inputFilter ?? defaultFilter
177
- const itemToStringActual = itemToString ?? defaultItemToString
278
+ const getChanges = (
279
+ state: ComboboxState,
280
+ type: string,
281
+ changes: Partial<ComboboxState>
282
+ ): Partial<ComboboxState> => {
283
+ const { InputBlur, InputChange, InputClick, InputKeyDownEnter, InputKeyDownEscape } =
284
+ useCombobox.stateChangeTypes
178
285
 
179
- const comboboxProps = useCombobox<ComboboxItem>({
286
+ switch (type) {
287
+ // downshift keeps the selection when the input is emptied
288
+ case InputChange:
289
+ return changes.inputValue === "" ? { ...changes, selectedItem: null } : changes
290
+ case InputClick:
291
+ return openOnClick ? changes : { ...changes, isOpen: false }
292
+ // Closing the menu puts back the text of the selection, as the input
293
+ // is only checked against the options while the menu is open
294
+ case InputKeyDownEscape:
295
+ return state.isOpen ? { ...changes, inputValue: toString(state.selectedItem) } : changes
296
+ case InputBlur:
297
+ return commit(changes, false) ?? { ...changes, inputValue: "", selectedItem: null }
298
+ // Without a match, keep the menu open so users can pick an option
299
+ case InputKeyDownEnter:
300
+ return commit(changes, true) ?? { ...changes, isOpen: true }
301
+ default:
302
+ return changes
303
+ }
304
+ }
305
+
306
+ const downshift = useCombobox<ComboboxItem>({
307
+ getItemId: (index) => visibleItems[index]?.id ?? `${uid}_item_${index}`,
180
308
  id: uid,
181
- inputId: uid + "_input",
182
- isItemDisabled: isItemDisabled ?? defaultIsItemDisabled,
183
- items: inputItems,
184
- itemToString: itemToStringActual,
185
- labelId: uid + "_label",
186
- menuId: uid + "_menu",
309
+ initialSelectedItem,
310
+ inputId: `${uid}_input`,
311
+ isItemDisabled: isDisabled,
312
+ items: visibleItems,
313
+ // Compare options by value, as they are recreated when options change
314
+ itemToKey: (item) => item?.value,
315
+ itemToString: toString,
316
+ labelId: `${uid}_label`,
317
+ menuId: `${uid}_menu`,
187
318
  onHighlightedIndexChange: onHighlight,
188
319
  onInputValueChange: (event) => {
189
- setInputItems(inputFilterActual(items, event.inputValue))
320
+ if (event.type === useCombobox.stateChangeTypes.InputChange) setFilterText(event.inputValue)
190
321
  onChange?.(event)
191
322
  },
192
- onIsOpenChange: onToggle,
193
- onSelectedItemChange: (event) => {
194
- onSelect?.(event) // This might need to broadcast just the value to work with RHF - need to check
323
+ onIsOpenChange: (event) => {
324
+ if (!event.isOpen) setFilterText("")
325
+ onToggle?.(event)
195
326
  },
327
+ onSelectedItemChange: ({ selectedItem }) =>
328
+ onSelect?.({
329
+ custom: !!selectedItem && !items.some(({ value }) => value === selectedItem.value),
330
+ selectedItem,
331
+ value: selectedItem?.value ?? null,
332
+ }),
196
333
  onStateChange,
197
- stateReducer(_, actionAndChanges) {
198
- const { InputBlur, InputClick, InputKeyDownEnter } = useCombobox.stateChangeTypes
199
-
200
- const { changes: state, type } = actionAndChanges
201
- const currentInput = state.inputValue ?? ""
202
- const hasMatch = items.some((item) => itemToStringActual(item) === currentInput)
203
-
204
- switch (type) {
205
- case openOnClick === false && InputClick:
206
- return { ...state, isOpen: false }
207
- case allowCustom === false && InputBlur:
208
- return hasMatch ? state : { ...state, inputValue: "" }
209
- case allowCustom === false && InputKeyDownEnter:
210
- return hasMatch ? state : { ...state, isOpen: true }
211
- default:
212
- return state
213
- }
334
+ selectedItem,
335
+ stateReducer(state, { changes, type }) {
336
+ const next = getChanges(state, type, changes)
337
+
338
+ // downshift reports a change when the selected object changes, so
339
+ // keep the current one when the value is the same
340
+ return next.selectedItem && next.selectedItem.value === state.selectedItem?.value
341
+ ? { ...next, selectedItem: state.selectedItem }
342
+ : next
214
343
  },
215
344
  })
216
345
 
217
- // if (!slots.ComboboxControls || !slots.ComboboxOptions) {
218
- // console.warn("Combobox must contain Combobox.Controls and Combobox.Options")
219
- // return null
220
- // }
346
+ // Options can arrive after the selection, such as when they are fetched,
347
+ // so show the option text once it is known. Text that users are typing is
348
+ // left alone.
349
+ const selection = resolveOption(downshift.selectedItem, items)
350
+ const selectionText = toString(selection)
351
+ const selectionValue = selection?.value
352
+ const previousSelection = useRef({ text: selectionText, value: selectionValue })
353
+ const { inputValue, setInputValue } = downshift
354
+
355
+ useEffect(() => {
356
+ const previous = previousSelection.current
357
+ previousSelection.current = { text: selectionText, value: selectionValue }
358
+
359
+ if (
360
+ selectionValue === previous.value &&
361
+ selectionText !== previous.text &&
362
+ inputValue === previous.text
363
+ ) {
364
+ setInputValue(selectionText)
365
+ }
366
+ }, [inputValue, selectionText, selectionValue, setInputValue])
221
367
 
222
368
  return (
223
369
  <ComboboxContext.Provider
224
- value={{ disabled, error: hasError, items: inputItems, props: comboboxProps, size }}
370
+ value={{
371
+ disabled,
372
+ downshift,
373
+ error: hasError,
374
+ items: visibleItems,
375
+ matchCount: matchingItems.length,
376
+ size,
377
+ }}
225
378
  >
226
- <div {...componentProps} ref={ref}>
379
+ <div
380
+ {...props}
381
+ className={clsx("coop-combobox", className)}
382
+ data-field=""
383
+ data-size={size ?? undefined}
384
+ id={uid}
385
+ ref={ref}
386
+ >
227
387
  {slots.ComboboxControls}
228
388
  {slots.ComboboxOptions}
229
389
  </div>
@@ -231,83 +391,125 @@ export const Combobox = ({
231
391
  )
232
392
  }
233
393
 
234
- const ComboboxInput = ({ className, placeholder, ...props }: ComboboxInputProps): JSX.Element => {
235
- const combobox = useComboboxContext()
236
- const componentProps = {
237
- className,
238
- disabled: combobox.disabled,
239
- error: combobox.error,
240
- placeholder,
241
- size: combobox.size,
242
- ...props,
243
- }
244
- return <TextInput {...componentProps} {...combobox.props?.getInputProps({})} />
245
- }
246
-
247
- const ComboboxButton = ({
394
+ const ComboboxControls = ({
248
395
  children,
249
396
  className,
250
397
  ...props
251
- }: ButtonHTMLAttributes<HTMLButtonElement>): JSX.Element => {
398
+ }: ComboboxControlsProps): JSX.Element => (
399
+ <div {...props} className={clsx("coop-combobox-controls", className)}>
400
+ {children}
401
+ </div>
402
+ )
403
+
404
+ const ComboboxInput = ({ error, name, size, ...props }: ComboboxInputProps): JSX.Element => {
252
405
  const combobox = useComboboxContext()
253
- const componentProps = {
254
- "aria-label": props["aria-label"] ?? "Toggle menu",
255
- className,
256
- disabled: combobox.disabled,
257
- ...props,
258
- }
406
+ const disabled = combobox.disabled || props.disabled
259
407
 
408
+ // Pass the props through downshift, so it combines its handlers and ref
409
+ // with those given here
410
+ const inputProps = combobox.downshift.getInputProps({ ...props, disabled })
411
+
412
+ // The visible input shows the option text, which can be a label, so it has
413
+ // no name. The form submits the selected value from a hidden input
414
+ // instead. The hidden input comes first, so the button still follows the
415
+ // visible input.
260
416
  return (
261
- <button type="button" {...componentProps} {...combobox.props?.getToggleButtonProps()}>
262
- {children ?? <ChevronDownIcon stroke="black" strokeWidth={1} width={"1.25rem"} />}
263
- </button>
417
+ <>
418
+ {name && (
419
+ <input
420
+ disabled={disabled}
421
+ name={name}
422
+ type="hidden"
423
+ value={combobox.downshift.selectedItem?.value ?? ""}
424
+ />
425
+ )}
426
+ <TextInput {...inputProps} error={error ?? combobox.error} size={size ?? combobox.size} />
427
+ </>
264
428
  )
265
429
  }
266
430
 
267
- const ComboboxControls = ({
431
+ const ComboboxButton = ({
432
+ "aria-label": ariaLabel = "Toggle menu",
268
433
  children,
269
- className,
270
434
  ...props
271
- }: ComboboxControlsProps): JSX.Element => {
272
- const componentProps = {
273
- className: clsx("coop-combobox-controls", className),
435
+ }: ComboboxButtonProps): JSX.Element => {
436
+ const combobox = useComboboxContext()
437
+
438
+ const buttonProps = combobox.downshift.getToggleButtonProps({
274
439
  ...props,
275
- }
276
- return <div {...componentProps}>{children}</div>
440
+ "aria-label": ariaLabel,
441
+ disabled: combobox.disabled || props.disabled,
442
+ })
443
+
444
+ return (
445
+ <button type="button" {...buttonProps}>
446
+ {children ?? <ChevronDownIcon stroke="black" strokeWidth={1} width="1.25rem" />}
447
+ </button>
448
+ )
277
449
  }
278
450
 
279
451
  const ComboboxOptions = ({
280
452
  className,
453
+ maxResults: _maxResults,
454
+ maxResultsText = defaultMaxResultsText,
281
455
  noResultsText = "No results",
282
- options,
456
+ options: _options,
283
457
  ...props
284
- }: ComboboxOptionsProps): JSX.Element | null => {
285
- const combobox = useComboboxContext()
458
+ }: ComboboxOptionsProps): JSX.Element => {
459
+ const { downshift, items, matchCount } = useComboboxContext()
460
+ const { getItemProps, getMenuProps, isOpen } = downshift
286
461
 
287
- const componentProps = {
288
- className: clsx("coop-combobox-options", className),
289
- ...props,
462
+ const getMessage = (): string => {
463
+ if (!isOpen) return ""
464
+ if (items.length === 0) return noResultsText
465
+ if (matchCount > items.length) return maxResultsText(items.length, matchCount)
466
+ return ""
290
467
  }
291
468
 
292
- if (!options) {
293
- return null
294
- }
469
+ const message = getMessage()
470
+ const announcement = useDebounce(message, ANNOUNCEMENT_DELAY)
295
471
 
296
472
  return (
297
- <ul {...componentProps} {...combobox.props?.getMenuProps()}>
298
- {combobox.props?.isOpen && combobox.items?.length
299
- ? combobox.items.map((item, index) => {
300
- const itemProps = { ...combobox.props?.getItemProps({ index, item }) }
473
+ <>
474
+ <ul {...getMenuProps({ ...props, className: clsx("coop-combobox-options", className) })}>
475
+ {isOpen &&
476
+ items.map((item, index) => {
477
+ // Keep the option fields off the element, and pass its handlers
478
+ // through downshift so they are combined with its own
479
+ const {
480
+ children,
481
+ disabled: _disabled,
482
+ icon,
483
+ label,
484
+ onClick,
485
+ onMouseDown,
486
+ onMouseMove,
487
+ value,
488
+ ...attributes
489
+ } = item
490
+
301
491
  return (
302
- <li key={item.value} {...itemProps} {...item}>
303
- {item?.icon}
304
- {item.children ?? item.label ?? item.value}
492
+ <li
493
+ key={value}
494
+ {...attributes}
495
+ {...getItemProps({ index, item, onClick, onMouseDown, onMouseMove })}
496
+ >
497
+ {icon}
498
+ {children ?? label ?? value}
305
499
  </li>
306
500
  )
307
- })
308
- : null}
309
- {combobox.props?.isOpen && !combobox.items?.length && <li>{noResultsText}</li>}
310
- </ul>
501
+ })}
502
+
503
+ {message && (
504
+ <li aria-hidden className="coop-combobox-message">
505
+ {message}
506
+ </li>
507
+ )}
508
+ </ul>
509
+ <span aria-live="polite" className="sr-only">
510
+ {announcement}
511
+ </span>
512
+ </>
311
513
  )
312
514
  }
313
515
 
@@ -326,3 +528,4 @@ Combobox.Button = ComboboxButton
326
528
  Combobox.Options = ComboboxOptions
327
529
 
328
530
  export default Combobox
531
+ export { Combobox, ComboboxButton, ComboboxControls, ComboboxInput, ComboboxOptions }