@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/dist/components/Combobox/Combobox.context.js +1 -1
- package/dist/components/Combobox/Combobox.d.ts +79 -20
- package/dist/components/Combobox/Combobox.js +195 -102
- package/dist/components/Combobox/combobox-utils.js +9 -4
- package/dist/components/Combobox/index.d.ts +3 -2
- package/dist/components/Combobox/index.js +2 -2
- package/dist/components/ValidationSummary/validationsummary-utils.js +10 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/package.json +3 -3
- package/src/components/Combobox/Combobox.context.ts +11 -12
- package/src/components/Combobox/Combobox.tsx +324 -121
- package/src/components/Combobox/combobox-utils.ts +24 -3
- package/src/components/ValidationSummary/validationsummary-utils.ts +21 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coopdigital/react",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "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.
|
|
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": "
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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?:
|
|
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`
|
|
82
|
+
* Default: the `disabled` property of the option.
|
|
63
83
|
*/
|
|
64
84
|
isItemDisabled?: (item: ComboboxItem) => boolean
|
|
65
|
-
/** Custom function
|
|
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
|
|
100
|
+
/** Callback fired when the selection changes, including when it is cleared.
|
|
81
101
|
*
|
|
82
|
-
*
|
|
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:
|
|
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?: (
|
|
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?:
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
200
|
+
const Combobox = ({
|
|
138
201
|
allowCustom = false,
|
|
139
202
|
children,
|
|
140
203
|
className,
|
|
141
|
-
|
|
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
|
|
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
|
|
164
|
-
const
|
|
232
|
+
const filter = inputFilter ?? defaultFilter
|
|
233
|
+
const toString = itemToString ?? defaultItemToString
|
|
234
|
+
const isDisabled = isItemDisabled ?? defaultIsItemDisabled
|
|
165
235
|
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
320
|
+
if (event.type === useCombobox.stateChangeTypes.InputChange) setFilterText(event.inputValue)
|
|
190
321
|
onChange?.(event)
|
|
191
322
|
},
|
|
192
|
-
onIsOpenChange:
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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={{
|
|
370
|
+
value={{
|
|
371
|
+
disabled,
|
|
372
|
+
downshift,
|
|
373
|
+
error: hasError,
|
|
374
|
+
items: visibleItems,
|
|
375
|
+
matchCount: matchingItems.length,
|
|
376
|
+
size,
|
|
377
|
+
}}
|
|
225
378
|
>
|
|
226
|
-
<div
|
|
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
|
|
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
|
-
}:
|
|
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
|
|
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
|
-
|
|
262
|
-
{
|
|
263
|
-
|
|
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
|
|
431
|
+
const ComboboxButton = ({
|
|
432
|
+
"aria-label": ariaLabel = "Toggle menu",
|
|
268
433
|
children,
|
|
269
|
-
className,
|
|
270
434
|
...props
|
|
271
|
-
}:
|
|
272
|
-
const
|
|
273
|
-
|
|
435
|
+
}: ComboboxButtonProps): JSX.Element => {
|
|
436
|
+
const combobox = useComboboxContext()
|
|
437
|
+
|
|
438
|
+
const buttonProps = combobox.downshift.getToggleButtonProps({
|
|
274
439
|
...props,
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
285
|
-
const
|
|
458
|
+
}: ComboboxOptionsProps): JSX.Element => {
|
|
459
|
+
const { downshift, items, matchCount } = useComboboxContext()
|
|
460
|
+
const { getItemProps, getMenuProps, isOpen } = downshift
|
|
286
461
|
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
469
|
+
const message = getMessage()
|
|
470
|
+
const announcement = useDebounce(message, ANNOUNCEMENT_DELAY)
|
|
295
471
|
|
|
296
472
|
return (
|
|
297
|
-
|
|
298
|
-
{
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
303
|
-
{
|
|
304
|
-
{
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 }
|