@primer/components 30.3.0-rc.2010c7d4 → 30.3.0-rc.9dbc85a9

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +4 -2
  2. package/dist/browser.esm.js +717 -718
  3. package/dist/browser.esm.js.map +1 -1
  4. package/dist/browser.umd.js +320 -321
  5. package/dist/browser.umd.js.map +1 -1
  6. package/docs/content/Autocomplete.mdx +627 -0
  7. package/docs/content/TextInputTokens.mdx +89 -0
  8. package/docs/src/@primer/gatsby-theme-doctocat/nav.yml +2 -0
  9. package/lib/AnchoredOverlay/AnchoredOverlay.d.ts +2 -1
  10. package/lib/AnchoredOverlay/AnchoredOverlay.js +11 -3
  11. package/lib/Autocomplete/Autocomplete.d.ts +304 -0
  12. package/lib/Autocomplete/Autocomplete.js +145 -0
  13. package/lib/Autocomplete/AutocompleteContext.d.ts +17 -0
  14. package/lib/Autocomplete/AutocompleteContext.js +11 -0
  15. package/lib/Autocomplete/AutocompleteInput.d.ts +292 -0
  16. package/lib/Autocomplete/AutocompleteInput.js +157 -0
  17. package/lib/Autocomplete/AutocompleteMenu.d.ts +72 -0
  18. package/lib/Autocomplete/AutocompleteMenu.js +224 -0
  19. package/lib/Autocomplete/AutocompleteOverlay.d.ts +20 -0
  20. package/lib/Autocomplete/AutocompleteOverlay.js +80 -0
  21. package/lib/Autocomplete/index.d.ts +2 -0
  22. package/lib/Autocomplete/index.js +15 -0
  23. package/lib/FilteredActionList/FilteredActionList.js +5 -31
  24. package/lib/Overlay.d.ts +1 -0
  25. package/lib/Overlay.js +3 -1
  26. package/lib/__tests__/Autocomplete.test.d.ts +1 -0
  27. package/lib/__tests__/Autocomplete.test.js +528 -0
  28. package/lib/__tests__/behaviors/scrollIntoViewingArea.test.d.ts +1 -0
  29. package/lib/__tests__/behaviors/scrollIntoViewingArea.test.js +226 -0
  30. package/lib/behaviors/scrollIntoViewingArea.d.ts +1 -0
  31. package/lib/behaviors/scrollIntoViewingArea.js +39 -0
  32. package/lib/hooks/useOpenAndCloseFocus.d.ts +2 -1
  33. package/lib/hooks/useOpenAndCloseFocus.js +7 -2
  34. package/lib/hooks/useOverlay.d.ts +2 -1
  35. package/lib/hooks/useOverlay.js +4 -2
  36. package/lib/index.d.ts +2 -0
  37. package/lib/index.js +8 -0
  38. package/lib/stories/Autocomplete.stories.js +608 -0
  39. package/lib/utils/types/MandateProps.d.ts +3 -0
  40. package/lib/utils/types/MandateProps.js +1 -0
  41. package/lib/utils/types/index.d.ts +1 -0
  42. package/lib/utils/types/index.js +13 -0
  43. package/lib-esm/AnchoredOverlay/AnchoredOverlay.d.ts +2 -1
  44. package/lib-esm/AnchoredOverlay/AnchoredOverlay.js +11 -3
  45. package/lib-esm/Autocomplete/Autocomplete.d.ts +304 -0
  46. package/lib-esm/Autocomplete/Autocomplete.js +123 -0
  47. package/lib-esm/Autocomplete/AutocompleteContext.d.ts +17 -0
  48. package/lib-esm/Autocomplete/AutocompleteContext.js +2 -0
  49. package/lib-esm/Autocomplete/AutocompleteInput.d.ts +292 -0
  50. package/lib-esm/Autocomplete/AutocompleteInput.js +138 -0
  51. package/lib-esm/Autocomplete/AutocompleteMenu.d.ts +72 -0
  52. package/lib-esm/Autocomplete/AutocompleteMenu.js +205 -0
  53. package/lib-esm/Autocomplete/AutocompleteOverlay.d.ts +20 -0
  54. package/lib-esm/Autocomplete/AutocompleteOverlay.js +62 -0
  55. package/lib-esm/Autocomplete/index.d.ts +2 -0
  56. package/lib-esm/Autocomplete/index.js +1 -0
  57. package/lib-esm/FilteredActionList/FilteredActionList.js +3 -31
  58. package/lib-esm/Overlay.d.ts +1 -0
  59. package/lib-esm/Overlay.js +3 -1
  60. package/lib-esm/__tests__/Autocomplete.test.d.ts +1 -0
  61. package/lib-esm/__tests__/Autocomplete.test.js +494 -0
  62. package/lib-esm/__tests__/behaviors/scrollIntoViewingArea.test.d.ts +1 -0
  63. package/lib-esm/__tests__/behaviors/scrollIntoViewingArea.test.js +224 -0
  64. package/lib-esm/behaviors/scrollIntoViewingArea.d.ts +1 -0
  65. package/lib-esm/behaviors/scrollIntoViewingArea.js +30 -0
  66. package/lib-esm/hooks/useOpenAndCloseFocus.d.ts +2 -1
  67. package/lib-esm/hooks/useOpenAndCloseFocus.js +7 -2
  68. package/lib-esm/hooks/useOverlay.d.ts +2 -1
  69. package/lib-esm/hooks/useOverlay.js +4 -2
  70. package/lib-esm/index.d.ts +2 -0
  71. package/lib-esm/index.js +1 -0
  72. package/lib-esm/stories/Autocomplete.stories.js +549 -0
  73. package/lib-esm/utils/types/MandateProps.d.ts +3 -0
  74. package/lib-esm/utils/types/MandateProps.js +1 -0
  75. package/lib-esm/utils/types/index.d.ts +1 -0
  76. package/lib-esm/utils/types/index.js +2 -1
  77. package/package.json +1 -1
  78. package/src/AnchoredOverlay/AnchoredOverlay.tsx +14 -3
  79. package/src/Autocomplete/Autocomplete.tsx +103 -0
  80. package/src/Autocomplete/AutocompleteContext.tsx +19 -0
  81. package/src/Autocomplete/AutocompleteInput.tsx +179 -0
  82. package/src/Autocomplete/AutocompleteMenu.tsx +341 -0
  83. package/src/Autocomplete/AutocompleteOverlay.tsx +68 -0
  84. package/src/Autocomplete/index.ts +2 -0
  85. package/src/FilteredActionList/FilteredActionList.tsx +10 -25
  86. package/src/Overlay.tsx +4 -1
  87. package/src/__tests__/Autocomplete.test.tsx +444 -0
  88. package/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap +3414 -0
  89. package/src/__tests__/behaviors/scrollIntoViewingArea.test.ts +195 -0
  90. package/src/behaviors/scrollIntoViewingArea.ts +27 -0
  91. package/src/hooks/useOpenAndCloseFocus.ts +7 -2
  92. package/src/hooks/useOverlay.tsx +4 -2
  93. package/src/index.ts +2 -0
  94. package/src/stories/Autocomplete.stories.tsx +572 -0
  95. package/src/utils/types/MandateProps.ts +19 -0
  96. package/src/utils/types/index.ts +1 -0
  97. package/stats.html +1 -1
@@ -0,0 +1,103 @@
1
+ import React, {useCallback, useReducer, useRef} from 'react'
2
+ import {useSSRSafeId} from '@react-aria/ssr'
3
+ import {ComponentProps} from '../utils/types'
4
+ import {AutocompleteContext} from './AutocompleteContext'
5
+ import AutocompleteInput from './AutocompleteInput'
6
+ import AutocompleteMenu from './AutocompleteMenu'
7
+ import AutocompleteOverlay from './AutocompleteOverlay'
8
+
9
+ type Action =
10
+ | {type: 'showMenu' | 'isMenuDirectlyActivated'; payload: boolean}
11
+ | {type: 'autocompleteSuggestion' | 'inputValue'; payload: string}
12
+ | {type: 'selectedItemLength'; payload: number}
13
+
14
+ interface State {
15
+ inputValue: string
16
+ showMenu: boolean
17
+ isMenuDirectlyActivated: boolean
18
+ autocompleteSuggestion: string
19
+ selectedItemLength: number
20
+ }
21
+
22
+ const initialState = {
23
+ inputValue: '',
24
+ showMenu: false,
25
+ isMenuDirectlyActivated: false,
26
+ autocompleteSuggestion: '',
27
+ selectedItemLength: 0
28
+ }
29
+
30
+ const reducer = (state: State, action: Action) => {
31
+ const {type, payload} = action
32
+ switch (type) {
33
+ case 'inputValue':
34
+ return {...state, inputValue: payload as State['inputValue']}
35
+ case 'showMenu':
36
+ return {...state, showMenu: payload as State['showMenu']}
37
+ case 'isMenuDirectlyActivated':
38
+ return {...state, isMenuDirectlyActivated: payload as State['isMenuDirectlyActivated']}
39
+ case 'autocompleteSuggestion':
40
+ return {...state, autocompleteSuggestion: payload as State['autocompleteSuggestion']}
41
+ case 'selectedItemLength':
42
+ return {...state, selectedItemLength: payload as State['selectedItemLength']}
43
+ default:
44
+ return state
45
+ }
46
+ }
47
+
48
+ const Autocomplete: React.FC<{id?: string}> = ({children, id: idProp}) => {
49
+ const activeDescendantRef = useRef<HTMLElement>(null)
50
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
51
+ const inputRef = useRef<HTMLInputElement>(null)
52
+ const [state, dispatch] = useReducer(reducer, initialState)
53
+ const {inputValue, showMenu, autocompleteSuggestion, isMenuDirectlyActivated, selectedItemLength} = state
54
+ const setInputValue = useCallback((value: State['inputValue']) => {
55
+ dispatch({type: 'inputValue', payload: value})
56
+ }, [])
57
+ const setShowMenu = useCallback((value: State['showMenu']) => {
58
+ dispatch({type: 'showMenu', payload: value})
59
+ }, [])
60
+ const setAutocompleteSuggestion = useCallback((value: State['autocompleteSuggestion']) => {
61
+ dispatch({type: 'autocompleteSuggestion', payload: value})
62
+ }, [])
63
+ const setIsMenuDirectlyActivated = useCallback((value: State['isMenuDirectlyActivated']) => {
64
+ dispatch({type: 'isMenuDirectlyActivated', payload: value})
65
+ }, [])
66
+ const setSelectedItemLength = useCallback((value: State['selectedItemLength']) => {
67
+ dispatch({type: 'selectedItemLength', payload: value})
68
+ }, [])
69
+ const id = useSSRSafeId(idProp)
70
+
71
+ return (
72
+ <AutocompleteContext.Provider
73
+ value={{
74
+ activeDescendantRef,
75
+ autocompleteSuggestion,
76
+ id,
77
+ inputRef,
78
+ inputValue,
79
+ isMenuDirectlyActivated,
80
+ scrollContainerRef,
81
+ selectedItemLength,
82
+ setAutocompleteSuggestion,
83
+ setInputValue,
84
+ setIsMenuDirectlyActivated,
85
+ setShowMenu,
86
+ setSelectedItemLength,
87
+ showMenu
88
+ }}
89
+ >
90
+ {children}
91
+ </AutocompleteContext.Provider>
92
+ )
93
+ }
94
+
95
+ export type AutocompleteProps = ComponentProps<typeof Autocomplete>
96
+ export type {AutocompleteInputProps} from './AutocompleteInput'
97
+ export type {AutocompleteMenuProps} from './AutocompleteMenu'
98
+ export type {AutocompleteOverlayProps} from './AutocompleteOverlay'
99
+ export default Object.assign(Autocomplete, {
100
+ Input: AutocompleteInput,
101
+ Menu: AutocompleteMenu,
102
+ Overlay: AutocompleteOverlay
103
+ })
@@ -0,0 +1,19 @@
1
+ import {createContext} from 'react'
2
+
3
+ export const AutocompleteContext = createContext<{
4
+ activeDescendantRef: React.MutableRefObject<HTMLElement | null>
5
+ autocompleteSuggestion: string
6
+ // TODO: consider changing `id` to `listboxId` because we're just using it to associate the input and combobox with the listbox
7
+ id: string
8
+ inputRef: React.MutableRefObject<HTMLInputElement | null>
9
+ inputValue: string
10
+ isMenuDirectlyActivated: boolean
11
+ scrollContainerRef: React.MutableRefObject<HTMLElement | null>
12
+ selectedItemLength: number
13
+ setAutocompleteSuggestion: (value: string) => void
14
+ setInputValue: (value: string) => void
15
+ setIsMenuDirectlyActivated: (value: boolean) => void
16
+ setSelectedItemLength: (value: number) => void
17
+ setShowMenu: (value: boolean) => void
18
+ showMenu: boolean
19
+ } | null>(null)
@@ -0,0 +1,179 @@
1
+ import React, {
2
+ ChangeEventHandler,
3
+ FocusEventHandler,
4
+ KeyboardEventHandler,
5
+ MutableRefObject,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useState
10
+ } from 'react'
11
+ import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic'
12
+ import {AutocompleteContext} from './AutocompleteContext'
13
+ import TextInput from '../TextInput'
14
+ import {useCombinedRefs} from '../hooks/useCombinedRefs'
15
+ import {ComponentProps} from '../utils/types'
16
+
17
+ type InternalAutocompleteInputProps = {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ as?: React.ComponentType<any>
20
+ }
21
+
22
+ const AutocompleteInput = React.forwardRef(
23
+ (
24
+ {as: Component = TextInput, onFocus, onBlur, onChange, onKeyDown, onKeyUp, onKeyPress, value, ...props},
25
+ forwardedRef
26
+ ) => {
27
+ const autocompleteContext = useContext(AutocompleteContext)
28
+ if (autocompleteContext === null) {
29
+ throw new Error('AutocompleteContext returned null values')
30
+ }
31
+ const {
32
+ activeDescendantRef,
33
+ autocompleteSuggestion = '',
34
+ id,
35
+ inputRef,
36
+ inputValue = '',
37
+ isMenuDirectlyActivated,
38
+ setInputValue,
39
+ setShowMenu,
40
+ showMenu
41
+ } = autocompleteContext
42
+ const combinedInputRef = useCombinedRefs(inputRef, forwardedRef)
43
+ const [highlightRemainingText, setHighlightRemainingText] = useState<boolean>(true)
44
+
45
+ const handleInputFocus: FocusEventHandler<HTMLInputElement> = useCallback(
46
+ event => {
47
+ onFocus && onFocus(event)
48
+ setShowMenu(true)
49
+ },
50
+ [onFocus, setShowMenu]
51
+ )
52
+
53
+ const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(
54
+ event => {
55
+ onBlur && onBlur(event)
56
+
57
+ // HACK: wait a tick and check the focused element before hiding the autocomplete menu
58
+ // this prevents the menu from hiding when the user is clicking an option in the Autoselect.Menu,
59
+ // but still hides the menu when the user blurs the input by tabbing out or clicking somewhere else on the page
60
+ setTimeout(() => {
61
+ if (document.activeElement !== combinedInputRef.current) {
62
+ setShowMenu(false)
63
+ }
64
+ }, 0)
65
+ },
66
+ [onBlur, setShowMenu, combinedInputRef]
67
+ )
68
+
69
+ const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
70
+ event => {
71
+ onChange && onChange(event)
72
+ setInputValue(event.currentTarget.value)
73
+ if (!showMenu) {
74
+ setShowMenu(true)
75
+ }
76
+ },
77
+ [onChange, setInputValue, setShowMenu, showMenu]
78
+ )
79
+
80
+ const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
81
+ event => {
82
+ onKeyDown && onKeyDown(event)
83
+
84
+ if (event.key === 'Backspace') {
85
+ setHighlightRemainingText(false)
86
+ }
87
+
88
+ if (event.key === 'Escape' && inputRef.current?.value) {
89
+ setInputValue('')
90
+ inputRef.current.value = ''
91
+ }
92
+ },
93
+ [inputRef, setInputValue, setHighlightRemainingText, onKeyDown]
94
+ )
95
+
96
+ const handleInputKeyUp: KeyboardEventHandler<HTMLInputElement> = useCallback(
97
+ event => {
98
+ onKeyUp && onKeyUp(event)
99
+
100
+ if (event.key === 'Backspace') {
101
+ setHighlightRemainingText(true)
102
+ }
103
+ },
104
+ [setHighlightRemainingText, onKeyUp]
105
+ )
106
+
107
+ const onInputKeyPress: KeyboardEventHandler<HTMLInputElement> = useCallback(
108
+ event => {
109
+ onKeyPress && onKeyPress(event)
110
+
111
+ if (showMenu && event.key === 'Enter' && activeDescendantRef.current) {
112
+ event.preventDefault()
113
+ event.nativeEvent.stopImmediatePropagation()
114
+
115
+ // Forward Enter key press to active descendant so that item gets activated
116
+ const activeDescendantEvent = new KeyboardEvent(event.type, event.nativeEvent)
117
+ activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
118
+ }
119
+ },
120
+ [activeDescendantRef, showMenu, onKeyPress]
121
+ )
122
+
123
+ useEffect(() => {
124
+ if (!inputRef.current) {
125
+ return
126
+ }
127
+
128
+ // resets input value to being empty after a selection has been made
129
+ if (!autocompleteSuggestion) {
130
+ inputRef.current.value = inputValue
131
+ }
132
+
133
+ // TODO: fix bug where this function prevents `onChange` from being triggered if the highlighted item text
134
+ // is the same as what I'm typing
135
+ // e.g.: typing 'tw' highights 'two', but when I 'two', the text input change does not get triggered
136
+ if (highlightRemainingText && autocompleteSuggestion && (inputValue || isMenuDirectlyActivated)) {
137
+ inputRef.current.value = autocompleteSuggestion
138
+
139
+ if (autocompleteSuggestion.toLowerCase().indexOf(inputValue.toLowerCase()) === 0) {
140
+ inputRef.current.setSelectionRange(inputValue.length, autocompleteSuggestion.length)
141
+ }
142
+ }
143
+
144
+ // calling this useEffeect when `highlightRemainingText` changes breaks backspace functionality
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [autocompleteSuggestion, inputValue, inputRef, isMenuDirectlyActivated])
147
+
148
+ useEffect(() => {
149
+ if (value) {
150
+ setInputValue(value.toString())
151
+ }
152
+ }, [value, setInputValue])
153
+
154
+ return (
155
+ <Component
156
+ onFocus={handleInputFocus}
157
+ onBlur={handleInputBlur}
158
+ onChange={handleInputChange}
159
+ onKeyDown={handleInputKeyDown}
160
+ onKeyPress={onInputKeyPress}
161
+ onKeyUp={handleInputKeyUp}
162
+ ref={combinedInputRef as MutableRefObject<HTMLInputElement>}
163
+ aria-controls={`${id}-listbox`}
164
+ aria-autocomplete="both"
165
+ role="combobox"
166
+ aria-expanded={showMenu}
167
+ aria-haspopup="listbox"
168
+ aria-owns={`${id}-listbox`}
169
+ autocomplete="off"
170
+ {...props}
171
+ />
172
+ )
173
+ }
174
+ ) as PolymorphicForwardRefComponent<typeof TextInput, InternalAutocompleteInputProps>
175
+
176
+ AutocompleteInput.displayName = 'AutocompleteInput'
177
+
178
+ export type AutocompleteInputProps = ComponentProps<typeof AutocompleteInput>
179
+ export default AutocompleteInput
@@ -0,0 +1,341 @@
1
+ import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'
2
+ import {ActionList, ItemProps} from '../ActionList'
3
+ import {useFocusZone} from '../hooks/useFocusZone'
4
+ import {ComponentProps, MandateProps} from '../utils/types'
5
+ import {Box, Spinner} from '../'
6
+ import {AutocompleteContext} from './AutocompleteContext'
7
+ import {PlusIcon} from '@primer/octicons-react'
8
+ import {uniqueId} from '../utils/uniqueId'
9
+ import {scrollIntoViewingArea} from '../behaviors/scrollIntoViewingArea'
10
+
11
+ type OnSelectedChange<T> = (item: T | T[]) => void
12
+ type AutocompleteMenuItem = MandateProps<ItemProps, 'id'>
13
+
14
+ const getDefaultSortFn =
15
+ (isItemSelectedFn: (itemId: string | number) => boolean) => (itemIdA: string | number, itemIdB: string | number) =>
16
+ isItemSelectedFn(itemIdA) === isItemSelectedFn(itemIdB) ? 0 : isItemSelectedFn(itemIdA) ? -1 : 1
17
+
18
+ function getDefaultItemFilter<T extends AutocompleteMenuItem>(filterValue: string) {
19
+ return function (item: T, _i: number) {
20
+ return Boolean(item.text?.toLowerCase().startsWith(filterValue.toLowerCase()))
21
+ }
22
+ }
23
+
24
+ function getDefaultOnSelectionChange<T extends AutocompleteMenuItem>(
25
+ setInputValueFn: (value: string) => void
26
+ ): OnSelectedChange<T> {
27
+ return function (itemOrItems) {
28
+ const {text = ''} = Array.isArray(itemOrItems) ? itemOrItems.slice(-1)[0] : itemOrItems
29
+ setInputValueFn(text)
30
+ }
31
+ }
32
+
33
+ const isItemSelected = (itemId: string | number, selectedItemIds: Array<string | number>) =>
34
+ selectedItemIds.includes(itemId)
35
+
36
+ function getItemById<T extends AutocompleteMenuItem>(itemId: string | number, items: T[]) {
37
+ return items.find(item => item.id === itemId)
38
+ }
39
+
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ type AutocompleteItemProps<T = Record<string, any>> = AutocompleteMenuItem & {metadata?: T}
42
+
43
+ export type AutocompleteMenuInternalProps<T extends AutocompleteItemProps> = {
44
+ /**
45
+ * A menu item that is used to allow users make a selection that is not available in the array passed to the `items` prop.
46
+ * This menu item gets appended to the end of the list of options.
47
+ */
48
+ // TODO: rethink this part of the component API. this is kind of weird and confusing to use
49
+ // TODO: rethink `addNewItem` prop name
50
+ addNewItem?: Omit<T, 'onAction' | 'leadingVisual' | 'id'> & {
51
+ handleAddItem: (item: Omit<T, 'onAction' | 'leadingVisual'>) => void
52
+ }
53
+
54
+ /**
55
+ * The text that appears in the menu when there are no options in the array passed to the `items` prop.
56
+ */
57
+ emptyStateText?: React.ReactNode | false
58
+
59
+ /**
60
+ * A custom function used to filter the options in the array passed to the `items` prop.
61
+ * By default, we filter out items that don't match the value of the autocomplete text input. The default filter is not case-sensitive.
62
+ */
63
+ filterFn?: (item: T, i: number) => boolean
64
+
65
+ /**
66
+ * The options for field values that are displayed in the dropdown menu.
67
+ * One or more may be selected depending on the value of the `selectionVariant` prop.
68
+ */
69
+ items: T[]
70
+
71
+ /**
72
+ * Whether the data is loaded for the menu items
73
+ */
74
+ loading?: boolean
75
+
76
+ /**
77
+ * The IDs of the selected items
78
+ */
79
+ // NOTE: this diverges from the SelectPanel component API, where we pass an array of objects to the `selected` prop
80
+ selectedItemIds: Array<string | number>
81
+
82
+ /**
83
+ * The sort function that is applied to the options in the array passed to the `items` prop after the user closes the menu.
84
+ * By default, selected items are sorted to the top after the user closes the menu.
85
+ */
86
+ sortOnCloseFn?: (itemIdA: string | number, itemIdB: string | number) => number
87
+
88
+ /**
89
+ * Whether there can be one item selected from the menu or multiple items selected from the menu
90
+ */
91
+ selectionVariant?: 'single' | 'multiple'
92
+
93
+ /**
94
+ * Function that gets called when the menu is opened or closed
95
+ */
96
+ onOpenChange?: (open: boolean) => void
97
+
98
+ /**
99
+ * The function that is called when an item in the list is selected or deselected
100
+ */
101
+ onSelectedChange?: OnSelectedChange<T>
102
+
103
+ /**
104
+ * If the menu is rendered in a scrolling element other than the `Autocomplete.Overlay` component,
105
+ * pass the ref of that element to `customScrollContainerRef` to ensure the container automatically
106
+ * scrolls when the user highlights an item in the menu that is outside the scroll container
107
+ */
108
+ customScrollContainerRef?: React.MutableRefObject<HTMLElement | null>
109
+ } & Pick<React.AriaAttributes, 'aria-labelledby'>
110
+
111
+ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMenuInternalProps<T>) {
112
+ const autocompleteContext = useContext(AutocompleteContext)
113
+ if (autocompleteContext === null) {
114
+ throw new Error('AutocompleteContext returned null values')
115
+ }
116
+ const {
117
+ activeDescendantRef,
118
+ id,
119
+ inputRef,
120
+ inputValue = '',
121
+ scrollContainerRef,
122
+ setAutocompleteSuggestion,
123
+ setShowMenu,
124
+ setInputValue,
125
+ setIsMenuDirectlyActivated,
126
+ setSelectedItemLength,
127
+ showMenu
128
+ } = autocompleteContext
129
+ const {
130
+ items,
131
+ selectedItemIds,
132
+ sortOnCloseFn,
133
+ emptyStateText,
134
+ addNewItem,
135
+ loading,
136
+ selectionVariant,
137
+ filterFn,
138
+ 'aria-labelledby': ariaLabelledBy,
139
+ onOpenChange,
140
+ onSelectedChange,
141
+ customScrollContainerRef
142
+ } = props
143
+ const listContainerRef = useRef<HTMLDivElement>(null)
144
+ const [highlightedItem, setHighlightedItem] = useState<T>()
145
+ const [sortedItemIds, setSortedItemIds] = useState<Array<number | string>>(items.map(({id: itemId}) => itemId))
146
+
147
+ const selectableItems = useMemo(
148
+ () =>
149
+ items.map(selectableItem => {
150
+ return {
151
+ ...selectableItem,
152
+ role: 'option',
153
+ id: selectableItem.id,
154
+ selected: selectionVariant === 'multiple' ? selectedItemIds.includes(selectableItem.id) : undefined,
155
+ onAction: (item: T) => {
156
+ const otherSelectedItemIds = selectedItemIds.filter(selectedItemId => selectedItemId !== item.id)
157
+ const newSelectedItemIds = selectedItemIds.includes(item.id)
158
+ ? otherSelectedItemIds
159
+ : [...otherSelectedItemIds, item.id]
160
+ const onSelectedChangeFn = onSelectedChange ? onSelectedChange : getDefaultOnSelectionChange(setInputValue)
161
+
162
+ onSelectedChangeFn(
163
+ newSelectedItemIds.map(newSelectedItemId => getItemById(newSelectedItemId, items)) as T[]
164
+ )
165
+
166
+ if (selectionVariant === 'multiple') {
167
+ setInputValue('')
168
+ setAutocompleteSuggestion('')
169
+ } else {
170
+ setShowMenu(false)
171
+ inputRef.current?.setSelectionRange(inputRef.current.value.length, inputRef.current.value.length)
172
+ }
173
+ }
174
+ }
175
+ }),
176
+ [
177
+ items,
178
+ selectedItemIds,
179
+ inputRef,
180
+ onSelectedChange,
181
+ selectionVariant,
182
+ setAutocompleteSuggestion,
183
+ setInputValue,
184
+ setShowMenu
185
+ ]
186
+ )
187
+
188
+ const itemSortOrderData = useMemo(
189
+ () =>
190
+ sortedItemIds.reduce<Record<string | number, number>>((acc, curr, i) => {
191
+ acc[curr] = i
192
+
193
+ return acc
194
+ }, {}),
195
+ [sortedItemIds]
196
+ )
197
+
198
+ const sortedAndFilteredItemsToRender = useMemo(
199
+ () =>
200
+ selectableItems
201
+ .filter(filterFn ? filterFn : getDefaultItemFilter(inputValue))
202
+ .sort((a, b) => itemSortOrderData[a.id] - itemSortOrderData[b.id]),
203
+ [selectableItems, itemSortOrderData, filterFn, inputValue]
204
+ )
205
+
206
+ const allItemsToRender = useMemo(
207
+ () => [
208
+ // sorted and filtered selectable items
209
+ ...sortedAndFilteredItemsToRender,
210
+
211
+ // menu item used for creating a token from whatever is in the text input
212
+ ...(addNewItem
213
+ ? [
214
+ {
215
+ ...addNewItem,
216
+ leadingVisual: () => <PlusIcon />,
217
+ onAction: (item: T) => {
218
+ // TODO: make it possible to pass a leadingVisual when using `addNewItem`
219
+ addNewItem.handleAddItem({...item, id: item.id || uniqueId(), leadingVisual: undefined})
220
+
221
+ if (selectionVariant === 'multiple') {
222
+ setInputValue('')
223
+ setAutocompleteSuggestion('')
224
+ }
225
+ }
226
+ }
227
+ ]
228
+ : [])
229
+ ],
230
+ [sortedAndFilteredItemsToRender, addNewItem, setAutocompleteSuggestion, selectionVariant, setInputValue]
231
+ )
232
+
233
+ useFocusZone(
234
+ {
235
+ containerRef: listContainerRef,
236
+ focusOutBehavior: 'wrap',
237
+ focusableElementFilter: element => {
238
+ return !(element instanceof HTMLInputElement)
239
+ },
240
+ activeDescendantFocus: inputRef,
241
+ onActiveDescendantChanged: (current, _previous, directlyActivated) => {
242
+ activeDescendantRef.current = current || null
243
+ if (current) {
244
+ const selectedItem = selectableItems.find(item => item.id.toString() === current.getAttribute('data-id'))
245
+
246
+ setHighlightedItem(selectedItem)
247
+ setIsMenuDirectlyActivated(directlyActivated)
248
+ }
249
+
250
+ if (current && customScrollContainerRef && customScrollContainerRef.current && directlyActivated) {
251
+ scrollIntoViewingArea(current, customScrollContainerRef.current)
252
+ } else if (current && scrollContainerRef.current && directlyActivated) {
253
+ scrollIntoViewingArea(current, scrollContainerRef.current)
254
+ }
255
+ }
256
+ },
257
+ [loading]
258
+ )
259
+
260
+ useEffect(() => {
261
+ if (highlightedItem?.text?.startsWith(inputValue) && !selectedItemIds.includes(highlightedItem.id)) {
262
+ setAutocompleteSuggestion(highlightedItem.text)
263
+ } else {
264
+ setAutocompleteSuggestion('')
265
+ }
266
+ }, [highlightedItem, inputValue, selectedItemIds, setAutocompleteSuggestion])
267
+
268
+ useEffect(() => {
269
+ const itemIdSortResult = [...sortedItemIds].sort(
270
+ sortOnCloseFn ? sortOnCloseFn : getDefaultSortFn(itemId => isItemSelected(itemId, selectedItemIds))
271
+ )
272
+ const sortResultMatchesState =
273
+ itemIdSortResult.length === sortedItemIds.length &&
274
+ itemIdSortResult.every((element, index) => element === sortedItemIds[index])
275
+
276
+ if (showMenu === false && !sortResultMatchesState) {
277
+ setSortedItemIds(itemIdSortResult)
278
+ }
279
+
280
+ onOpenChange && onOpenChange(Boolean(showMenu))
281
+ }, [showMenu, onOpenChange, selectedItemIds, sortOnCloseFn, sortedItemIds])
282
+
283
+ useEffect(() => {
284
+ if (selectedItemIds.length) {
285
+ setSelectedItemLength(selectedItemIds.length)
286
+ }
287
+ }, [selectedItemIds, setSelectedItemLength])
288
+
289
+ return (
290
+ <Box
291
+ sx={
292
+ !showMenu
293
+ ? {
294
+ // visually hides this label for sighted users
295
+ position: 'absolute',
296
+ width: '1px',
297
+ height: '1px',
298
+ padding: '0',
299
+ margin: '-1px',
300
+ overflow: 'hidden',
301
+ clip: 'rect(0, 0, 0, 0)',
302
+ whiteSpace: 'nowrap',
303
+ borderWidth: '0'
304
+ }
305
+ : {}
306
+ }
307
+ >
308
+ {loading ? (
309
+ <Box p={3} display="flex" justifyContent="center">
310
+ <Spinner />
311
+ </Box>
312
+ ) : (
313
+ <div ref={listContainerRef}>
314
+ {allItemsToRender.length ? (
315
+ <ActionList
316
+ selectionVariant="multiple"
317
+ // have to typecast to `ItemProps` because we have an extra property
318
+ // on `items` for Autocomplete: `metadata`
319
+ items={allItemsToRender as ItemProps[]}
320
+ role="listbox"
321
+ id={`${id}-listbox`}
322
+ aria-labelledby={ariaLabelledBy}
323
+ />
324
+ ) : (
325
+ <Box p={3}>{emptyStateText}</Box>
326
+ )}
327
+ </div>
328
+ )}
329
+ </Box>
330
+ )
331
+ }
332
+
333
+ AutocompleteMenu.defaultProps = {
334
+ emptyStateText: 'No selectable options',
335
+ selectionVariant: 'single'
336
+ }
337
+
338
+ AutocompleteMenu.displayName = 'AutocompleteMenu'
339
+
340
+ export type AutocompleteMenuProps = ComponentProps<typeof AutocompleteMenu>
341
+ export default AutocompleteMenu