@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,68 @@
1
+ import React, {useCallback, useContext} from 'react'
2
+ import {useAnchoredPosition} from '../hooks'
3
+ import Overlay, {OverlayProps} from '../Overlay'
4
+ import {ComponentProps} from '../utils/types'
5
+ import {AutocompleteContext} from './AutocompleteContext'
6
+ import {useCombinedRefs} from '../hooks/useCombinedRefs'
7
+
8
+ type AutocompleteOverlayInternalProps = {
9
+ /**
10
+ * The ref of the element that the position of the menu is based on. By default, the menu is positioned based on the text input
11
+ */
12
+ menuAnchorRef?: React.RefObject<HTMLElement>
13
+ /**
14
+ * Props to be spread on the internal `Overlay` component.
15
+ */
16
+ overlayProps?: Partial<OverlayProps>
17
+ children?: React.ReactNode
18
+ } & Pick<React.AriaAttributes, 'aria-labelledby'> // TODO: consider making 'aria-labelledby' required
19
+
20
+ function AutocompleteOverlay({menuAnchorRef, overlayProps, children}: AutocompleteOverlayInternalProps) {
21
+ const autocompleteContext = useContext(AutocompleteContext)
22
+ if (autocompleteContext === null) {
23
+ throw new Error('AutocompleteContext returned null values')
24
+ }
25
+ const {inputRef, scrollContainerRef, selectedItemLength, setShowMenu, showMenu = false} = autocompleteContext
26
+ const {floatingElementRef, position} = useAnchoredPosition(
27
+ {
28
+ side: 'outside-bottom',
29
+ align: 'start',
30
+ anchorElementRef: menuAnchorRef ? menuAnchorRef : inputRef
31
+ },
32
+ [showMenu, selectedItemLength]
33
+ )
34
+
35
+ const combinedOverlayRef = useCombinedRefs(scrollContainerRef, floatingElementRef)
36
+
37
+ const closeOptionList = useCallback(() => {
38
+ setShowMenu(false)
39
+ }, [setShowMenu])
40
+
41
+ if (typeof window === 'undefined') {
42
+ return null
43
+ }
44
+
45
+ return (
46
+ <Overlay
47
+ returnFocusRef={inputRef}
48
+ preventFocusOnOpen={true}
49
+ onClickOutside={closeOptionList}
50
+ onEscape={closeOptionList}
51
+ ref={combinedOverlayRef as React.RefObject<HTMLDivElement>}
52
+ top={position?.top}
53
+ left={position?.left}
54
+ visibility={showMenu ? 'visible' : 'hidden'}
55
+ sx={{
56
+ overflow: 'auto'
57
+ }}
58
+ {...overlayProps}
59
+ >
60
+ {children}
61
+ </Overlay>
62
+ )
63
+ }
64
+
65
+ AutocompleteOverlay.displayName = 'AutocompleteOverlay'
66
+
67
+ export type AutocompleteOverlayProps = ComponentProps<typeof AutocompleteOverlay>
68
+ export default AutocompleteOverlay
@@ -0,0 +1,2 @@
1
+ export {default} from './Autocomplete'
2
+ export type {AutocompleteMenuProps, AutocompleteInputProps, AutocompleteOverlayProps} from './Autocomplete'
@@ -1,4 +1,5 @@
1
1
  import React, {KeyboardEventHandler, useCallback, useEffect, useRef} from 'react'
2
+ import {useSSRSafeId} from '@react-aria/ssr'
2
3
  import {GroupedListProps, ListPropsBase} from '../ActionList/List'
3
4
  import TextInput, {TextInputProps} from '../TextInput'
4
5
  import Box from '../Box'
@@ -10,7 +11,7 @@ import styled from 'styled-components'
10
11
  import {get} from '../constants'
11
12
  import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
12
13
  import useScrollFlash from '../hooks/useScrollFlash'
13
- import {useSSRSafeId} from '@react-aria/ssr'
14
+ import {scrollIntoViewingArea} from '../behaviors/scrollIntoViewingArea'
14
15
  import {SxProp} from '../sx'
15
16
 
16
17
  export interface FilteredActionListProps
@@ -25,29 +26,6 @@ export interface FilteredActionListProps
25
26
  inputRef?: React.RefObject<HTMLInputElement>
26
27
  }
27
28
 
28
- function scrollIntoViewingArea(
29
- child: HTMLElement,
30
- container: HTMLElement,
31
- margin = 8,
32
- behavior: ScrollBehavior = 'smooth'
33
- ) {
34
- const {top: childTop, bottom: childBottom} = child.getBoundingClientRect()
35
- const {top: containerTop, bottom: containerBottom} = container.getBoundingClientRect()
36
-
37
- const isChildTopAboveViewingArea = childTop < containerTop + margin
38
- const isChildBottomBelowViewingArea = childBottom > containerBottom - margin
39
-
40
- if (isChildTopAboveViewingArea) {
41
- const scrollHeightToChildTop = childTop - containerTop + container.scrollTop
42
- container.scrollTo({behavior, top: scrollHeightToChildTop - margin})
43
- } else if (isChildBottomBelowViewingArea) {
44
- const scrollHeightToChildBottom = childBottom - containerBottom + container.scrollTop
45
- container.scrollTo({behavior, top: scrollHeightToChildBottom + margin})
46
- }
47
-
48
- // either completely in view or outside viewing area on both ends, don't scroll
49
- }
50
-
51
29
  const StyledHeader = styled.div`
52
30
  box-shadow: 0 1px 0 ${get('colors.border.default')};
53
31
  z-index: 1;
@@ -118,7 +96,14 @@ export function FilteredActionList({
118
96
  useEffect(() => {
119
97
  // if items changed, we want to instantly move active descendant into view
120
98
  if (activeDescendantRef.current && scrollContainerRef.current) {
121
- scrollIntoViewingArea(activeDescendantRef.current, scrollContainerRef.current, undefined, 'auto')
99
+ scrollIntoViewingArea(
100
+ activeDescendantRef.current,
101
+ scrollContainerRef.current,
102
+ 'vertical',
103
+ undefined,
104
+ undefined,
105
+ 'auto'
106
+ )
122
107
  }
123
108
  }, [items])
124
109
 
package/src/Overlay.tsx CHANGED
@@ -90,6 +90,7 @@ export type OverlayProps = {
90
90
  top: number
91
91
  left: number
92
92
  portalContainerName?: string
93
+ preventFocusOnOpen?: boolean
93
94
  } & Omit<ComponentProps<typeof StyledOverlay>, 'visibility' | keyof SystemPositionProps>
94
95
 
95
96
  /**
@@ -124,6 +125,7 @@ const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
124
125
  left,
125
126
  anchorSide,
126
127
  portalContainerName,
128
+ preventFocusOnOpen,
127
129
  ...rest
128
130
  },
129
131
  forwardedRef
@@ -140,7 +142,8 @@ const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
140
142
  onEscape,
141
143
  ignoreClickRefs,
142
144
  onClickOutside,
143
- initialFocusRef
145
+ initialFocusRef,
146
+ preventFocusOnOpen
144
147
  })
145
148
 
146
149
  useEffect(() => {
@@ -0,0 +1,444 @@
1
+ import React from 'react'
2
+ import {render} from '../utils/testing'
3
+ import {render as HTMLRender, fireEvent} from '@testing-library/react'
4
+ import {toHaveNoViolations} from 'jest-axe'
5
+ import 'babel-polyfill'
6
+ import Autocomplete, {AutocompleteInputProps} from '../Autocomplete'
7
+ import {SSRProvider} from '../index'
8
+ import theme from '../theme'
9
+ import BaseStyles from '../BaseStyles'
10
+ import {ThemeProvider} from '../ThemeProvider'
11
+ import userEvent from '@testing-library/user-event'
12
+ import {AutocompleteMenuInternalProps} from '../Autocomplete/AutocompleteMenu'
13
+ import {ItemProps} from '../ActionList'
14
+ import {MandateProps} from '../utils/types'
15
+ expect.extend(toHaveNoViolations)
16
+
17
+ const mockItems = [
18
+ {text: 'zero', id: 0},
19
+ {text: 'one', id: 1},
20
+ {text: 'two', id: 2},
21
+ {text: 'three', id: 3},
22
+ {text: 'four', id: 4},
23
+ {text: 'five', id: 5},
24
+ {text: 'six', id: 6},
25
+ {text: 'seven', id: 7},
26
+ {text: 'twenty', id: 20},
27
+ {text: 'twentyone', id: 21}
28
+ ]
29
+
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ type AutocompleteItemProps<T = Record<string, any>> = MandateProps<ItemProps, 'id'> & {metadata?: T}
32
+
33
+ const AUTOCOMPLETE_LABEL = 'Autocomplete field'
34
+ const LabelledAutocomplete = <T extends AutocompleteItemProps>({
35
+ inputProps = {},
36
+ menuProps
37
+ }: {
38
+ inputProps?: AutocompleteInputProps
39
+ menuProps: AutocompleteMenuInternalProps<T>
40
+ }) => {
41
+ const {['aria-labelledby']: ariaLabelledBy = 'autocompleteLabel', ...menuPropsRest} = menuProps
42
+ const {id = 'autocompleteInput', ...inputPropsRest} = inputProps
43
+ return (
44
+ <ThemeProvider theme={theme}>
45
+ <SSRProvider>
46
+ <BaseStyles>
47
+ {/* eslint-disable-next-line jsx-a11y/label-has-for */}
48
+ <label htmlFor={id} id={ariaLabelledBy}>
49
+ Autocomplete field
50
+ </label>
51
+ <Autocomplete id="autocompleteId">
52
+ <Autocomplete.Input id={id} {...inputPropsRest} />
53
+ <Autocomplete.Overlay>
54
+ <Autocomplete.Menu aria-labelledby={ariaLabelledBy} {...menuPropsRest} />
55
+ </Autocomplete.Overlay>
56
+ </Autocomplete>
57
+ </BaseStyles>
58
+ </SSRProvider>
59
+ </ThemeProvider>
60
+ )
61
+ }
62
+
63
+ describe('Autocomplete', () => {
64
+ describe('snapshots', () => {
65
+ it('renders a single select input', () => {
66
+ expect(
67
+ render(
68
+ <SSRProvider>
69
+ <Autocomplete id="autocompleteId">
70
+ <Autocomplete.Input />
71
+ <Autocomplete.Menu aria-labelledby="labelId" items={mockItems} selectedItemIds={[]} />
72
+ </Autocomplete>
73
+ </SSRProvider>
74
+ )
75
+ ).toMatchSnapshot()
76
+ })
77
+
78
+ it('renders a multiselect input', () => {
79
+ expect(
80
+ render(
81
+ <SSRProvider>
82
+ <Autocomplete id="autocompleteId">
83
+ <Autocomplete.Input />
84
+ <Autocomplete.Menu
85
+ aria-labelledby="labelId"
86
+ items={mockItems}
87
+ selectedItemIds={[]}
88
+ selectionVariant="multiple"
89
+ />
90
+ </Autocomplete>
91
+ </SSRProvider>
92
+ )
93
+ ).toMatchSnapshot()
94
+ })
95
+
96
+ it('renders a multiselect input with selected menu items', () => {
97
+ expect(
98
+ render(
99
+ <SSRProvider>
100
+ <Autocomplete id="autocompleteId">
101
+ <Autocomplete.Input />
102
+ <Autocomplete.Menu
103
+ aria-labelledby="labelId"
104
+ items={mockItems}
105
+ selectedItemIds={[0, 1, 2]}
106
+ selectionVariant="multiple"
107
+ />
108
+ </Autocomplete>
109
+ </SSRProvider>
110
+ )
111
+ ).toMatchSnapshot()
112
+ })
113
+
114
+ it('renders a menu that contains an item to add to the menu', () => {
115
+ const handleAddItemMock = jest.fn()
116
+ expect(
117
+ render(
118
+ <SSRProvider>
119
+ <Autocomplete id="autocompleteId">
120
+ <Autocomplete.Input />
121
+ <Autocomplete.Menu
122
+ aria-labelledby="labelId"
123
+ items={mockItems}
124
+ selectionVariant="multiple"
125
+ selectedItemIds={[]}
126
+ addNewItem={{
127
+ text: 'Add new item',
128
+ handleAddItem: handleAddItemMock
129
+ }}
130
+ />
131
+ </Autocomplete>
132
+ </SSRProvider>
133
+ )
134
+ ).toMatchSnapshot()
135
+ })
136
+
137
+ it('renders a custom empty state message', () => {
138
+ expect(
139
+ render(
140
+ <SSRProvider>
141
+ <Autocomplete id="autocompleteId">
142
+ <Autocomplete.Input />
143
+ <Autocomplete.Menu
144
+ aria-labelledby="labelId"
145
+ items={[]}
146
+ selectedItemIds={[]}
147
+ emptyStateText="No results"
148
+ />
149
+ </Autocomplete>
150
+ </SSRProvider>
151
+ )
152
+ ).toMatchSnapshot()
153
+ })
154
+
155
+ it('renders a loading state', () => {
156
+ expect(
157
+ render(
158
+ <SSRProvider>
159
+ <Autocomplete id="autocompleteId">
160
+ <Autocomplete.Input />
161
+ <Autocomplete.Menu aria-labelledby="labelId" loading items={[]} selectedItemIds={[]} />
162
+ </Autocomplete>
163
+ </SSRProvider>
164
+ )
165
+ ).toMatchSnapshot()
166
+ })
167
+
168
+ it('renders with a custom text input component', () => {
169
+ expect(
170
+ render(
171
+ <SSRProvider>
172
+ <Autocomplete id="autocompleteId">
173
+ <Autocomplete.Input as={() => <input type="text" id="customInput" />} />
174
+ <Autocomplete.Menu aria-labelledby="labelId" items={mockItems} selectedItemIds={[]} />
175
+ </Autocomplete>
176
+ </SSRProvider>
177
+ )
178
+ ).toMatchSnapshot()
179
+ })
180
+
181
+ it('renders with an input value', () => {
182
+ expect(
183
+ render(
184
+ <SSRProvider>
185
+ <Autocomplete id="autocompleteId">
186
+ <Autocomplete.Input value="test" />
187
+ <Autocomplete.Menu aria-labelledby="labelId" items={mockItems} selectedItemIds={[]} />
188
+ </Autocomplete>
189
+ </SSRProvider>
190
+ )
191
+ ).toMatchSnapshot()
192
+ })
193
+ })
194
+
195
+ describe('Autocomplete.Input', () => {
196
+ it('calls onChange', () => {
197
+ const onChangeMock = jest.fn()
198
+ const {container} = HTMLRender(
199
+ <LabelledAutocomplete
200
+ inputProps={{onChange: onChangeMock}}
201
+ menuProps={{items: mockItems, selectedItemIds: []}}
202
+ />
203
+ )
204
+ const inputNode = container.querySelector('#autocompleteInput')
205
+
206
+ expect(onChangeMock).not.toHaveBeenCalled()
207
+ inputNode && userEvent.type(inputNode, 'z')
208
+ expect(onChangeMock).toHaveBeenCalled()
209
+ })
210
+
211
+ it('calls onFocus', () => {
212
+ const onFocusMock = jest.fn()
213
+ const {container} = HTMLRender(
214
+ <LabelledAutocomplete inputProps={{onFocus: onFocusMock}} menuProps={{items: mockItems, selectedItemIds: []}} />
215
+ )
216
+ const inputNode = container.querySelector('#autocompleteInput')
217
+
218
+ expect(onFocusMock).not.toHaveBeenCalled()
219
+ inputNode && fireEvent.focus(inputNode)
220
+ expect(onFocusMock).toHaveBeenCalled()
221
+ })
222
+
223
+ it('calls onKeyDown', () => {
224
+ const onKeyDownMock = jest.fn()
225
+ const {getByLabelText} = HTMLRender(
226
+ <LabelledAutocomplete inputProps={{onKeyDown: onKeyDownMock}} menuProps={{items: [], selectedItemIds: []}} />
227
+ )
228
+ const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)
229
+
230
+ expect(onKeyDownMock).not.toHaveBeenCalled()
231
+ fireEvent.keyDown(inputNode, {key: 'Shift'})
232
+ expect(onKeyDownMock).toHaveBeenCalled()
233
+ })
234
+
235
+ it('calls onKeyUp', () => {
236
+ const onKeyUpMock = jest.fn()
237
+ const {getByLabelText} = HTMLRender(
238
+ <LabelledAutocomplete inputProps={{onKeyUp: onKeyUpMock}} menuProps={{items: [], selectedItemIds: []}} />
239
+ )
240
+ const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)
241
+
242
+ expect(onKeyUpMock).not.toHaveBeenCalled()
243
+ fireEvent.keyUp(inputNode, {key: 'Shift'})
244
+ expect(onKeyUpMock).toHaveBeenCalled()
245
+ })
246
+
247
+ it('calls onKeyPress', () => {
248
+ const onKeyPressMock = jest.fn()
249
+ const {getByLabelText} = HTMLRender(
250
+ <LabelledAutocomplete inputProps={{onKeyPress: onKeyPressMock}} menuProps={{items: [], selectedItemIds: []}} />
251
+ )
252
+ const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)
253
+
254
+ expect(onKeyPressMock).not.toHaveBeenCalled()
255
+ userEvent.type(inputNode, '{enter}')
256
+ expect(onKeyPressMock).toHaveBeenCalled()
257
+ })
258
+
259
+ it('opens the menu when the input is focused', () => {
260
+ const {getByLabelText} = HTMLRender(<LabelledAutocomplete menuProps={{items: [], selectedItemIds: []}} />)
261
+ const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)
262
+
263
+ expect(inputNode.getAttribute('aria-expanded')).not.toBe('true')
264
+ fireEvent.focus(inputNode)
265
+ expect(inputNode.getAttribute('aria-expanded')).toBe('true')
266
+ })
267
+
268
+ it('closes the menu when the input is blurred', () => {
269
+ const {getByLabelText} = HTMLRender(<LabelledAutocomplete menuProps={{items: [], selectedItemIds: []}} />)
270
+ const inputNode = getByLabelText(AUTOCOMPLETE_LABEL)
271
+
272
+ expect(inputNode.getAttribute('aria-expanded')).not.toBe('true')
273
+ fireEvent.focus(inputNode)
274
+ expect(inputNode.getAttribute('aria-expanded')).toBe('true')
275
+ // eslint-disable-next-line github/no-blur
276
+ fireEvent.blur(inputNode)
277
+
278
+ // wait a tick for blur to finish
279
+ setTimeout(() => {
280
+ expect(inputNode.getAttribute('aria-expanded')).not.toBe('true')
281
+ }, 0)
282
+ })
283
+
284
+ it('sets the input value to the suggested item text and highlights the untyped part of the word', () => {
285
+ const {container, getByDisplayValue} = HTMLRender(
286
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: []}} />
287
+ )
288
+ const inputNode = container.querySelector('#autocompleteInput')
289
+
290
+ inputNode && userEvent.type(inputNode, 'ze')
291
+ expect(getByDisplayValue('zero')).toBeDefined()
292
+ })
293
+
294
+ it('does not show or highlight suggestion text after the user hits Backspace until they hit another key', () => {
295
+ const {container, getByDisplayValue} = HTMLRender(
296
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: []}} />
297
+ )
298
+ const inputNode = container.querySelector('#autocompleteInput')
299
+
300
+ expect((inputNode as HTMLInputElement).selectionStart).toBe(0)
301
+ inputNode && userEvent.type(inputNode, 'ze')
302
+ expect(getByDisplayValue('zero')).toBeDefined()
303
+ expect((inputNode as HTMLInputElement).selectionStart).toBe(2)
304
+ expect((inputNode as HTMLInputElement).selectionEnd).toBe(4)
305
+ inputNode && userEvent.type(inputNode, '{backspace}')
306
+ expect((inputNode as HTMLInputElement).selectionStart).toBe(2)
307
+ expect(getByDisplayValue('ze')).toBeDefined()
308
+ inputNode && userEvent.type(inputNode, 'r')
309
+ expect((inputNode as HTMLInputElement).selectionStart).toBe(3)
310
+ expect((inputNode as HTMLInputElement).selectionEnd).toBe(4)
311
+ expect(getByDisplayValue('zero')).toBeDefined()
312
+ })
313
+
314
+ it('clears the input value when when the user hits Escape', () => {
315
+ const {container} = HTMLRender(<LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: []}} />)
316
+ const inputNode = container.querySelector('#autocompleteInput')
317
+
318
+ expect(inputNode?.getAttribute('aria-expanded')).not.toBe('true')
319
+ inputNode && userEvent.type(inputNode, 'ze')
320
+ expect(inputNode?.getAttribute('aria-expanded')).toBe('true')
321
+ inputNode && userEvent.type(inputNode, '{esc}')
322
+ expect(inputNode?.getAttribute('aria-expanded')).not.toBe('true')
323
+ })
324
+ })
325
+
326
+ describe('Autocomplete.Menu', () => {
327
+ it('calls a custom filter function', () => {
328
+ const filterFnMock = jest.fn()
329
+ const {container} = HTMLRender(
330
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: [], filterFn: filterFnMock}} />
331
+ )
332
+ const inputNode = container.querySelector('#autocompleteInput')
333
+
334
+ inputNode && userEvent.type(inputNode, 'ze')
335
+ expect(filterFnMock).toHaveBeenCalled()
336
+ })
337
+
338
+ it('calls a custom sort function when the menu closes', () => {
339
+ const sortOnCloseFnMock = jest.fn()
340
+ const {container} = HTMLRender(
341
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: [], sortOnCloseFn: sortOnCloseFnMock}} />
342
+ )
343
+ const inputNode = container.querySelector('#autocompleteInput')
344
+
345
+ // `sortOnCloseFnMock` will be called in a `.sort()` on render to check if the
346
+ // current sort order matches the result of `sortOnCloseFnMock`
347
+ expect(sortOnCloseFnMock).toHaveBeenCalledTimes(mockItems.length - 1)
348
+ if (inputNode) {
349
+ userEvent.type(inputNode, 'ze')
350
+ // eslint-disable-next-line github/no-blur
351
+ fireEvent.blur(inputNode)
352
+ }
353
+
354
+ // wait a tick for blur to finish
355
+ setTimeout(() => {
356
+ expect(sortOnCloseFnMock).toHaveBeenCalledTimes(mockItems.length)
357
+ }, 0)
358
+ })
359
+
360
+ it("calls onOpenChange with the menu's open state", () => {
361
+ const onOpenChangeMock = jest.fn()
362
+ const {container} = HTMLRender(
363
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: [], onOpenChange: onOpenChangeMock}} />
364
+ )
365
+ const inputNode = container.querySelector('#autocompleteInput')
366
+
367
+ inputNode && userEvent.type(inputNode, 'ze')
368
+ expect(onOpenChangeMock).toHaveBeenCalled()
369
+ })
370
+
371
+ it('calls onSelectedChange with the data for the selected items', () => {
372
+ const onSelectedChangeMock = jest.fn()
373
+ const {container} = HTMLRender(
374
+ <LabelledAutocomplete
375
+ menuProps={{items: mockItems, selectedItemIds: [], onSelectedChange: onSelectedChangeMock}}
376
+ />
377
+ )
378
+ const inputNode = container.querySelector('#autocompleteInput')
379
+
380
+ expect(onSelectedChangeMock).not.toHaveBeenCalled()
381
+ if (inputNode) {
382
+ fireEvent.focus(inputNode)
383
+ userEvent.type(inputNode, '{enter}')
384
+ }
385
+
386
+ // wait a tick for the keyboard event to be dispatched to the menu item
387
+ setTimeout(() => {
388
+ expect(onSelectedChangeMock).toHaveBeenCalledWith([mockItems[0]])
389
+ }, 0)
390
+ })
391
+
392
+ it('does not close the menu when clicking an item in the menu if selectionVariant=multiple', () => {
393
+ const {getByText, container} = HTMLRender(
394
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: [], selectionVariant: 'multiple'}} />
395
+ )
396
+ const inputNode = container.querySelector('#autocompleteInput')
397
+ const itemToClickNode = getByText(mockItems[1].text)
398
+
399
+ expect(inputNode?.getAttribute('aria-expanded')).not.toBe('true')
400
+ inputNode && fireEvent.focus(inputNode)
401
+ expect(inputNode?.getAttribute('aria-expanded')).toBe('true')
402
+ fireEvent.click(itemToClickNode)
403
+ inputNode && userEvent.type(inputNode, '{enter}')
404
+ expect(inputNode?.getAttribute('aria-expanded')).toBe('true')
405
+ })
406
+
407
+ it('closes the menu when clicking an item in the menu if selectionVariant=single', () => {
408
+ const {getByText, container} = HTMLRender(
409
+ <LabelledAutocomplete menuProps={{items: mockItems, selectedItemIds: [], selectionVariant: 'single'}} />
410
+ )
411
+ const inputNode = container.querySelector('#autocompleteInput')
412
+ const itemToClickNode = getByText(mockItems[1].text)
413
+
414
+ expect(inputNode?.getAttribute('aria-expanded')).not.toBe('true')
415
+ inputNode && fireEvent.focus(inputNode)
416
+ expect(inputNode?.getAttribute('aria-expanded')).toBe('true')
417
+ fireEvent.click(itemToClickNode)
418
+ expect(inputNode?.getAttribute('aria-expanded')).not.toBe('true')
419
+ })
420
+
421
+ it('calls handleAddItem with new item data when passing addNewItem', () => {
422
+ const handleAddItemMock = jest.fn()
423
+ const {getByText} = HTMLRender(
424
+ <LabelledAutocomplete
425
+ menuProps={{
426
+ items: mockItems,
427
+ selectedItemIds: [],
428
+ selectionVariant: 'multiple',
429
+ addNewItem: {
430
+ text: 'Add new item',
431
+ handleAddItem: handleAddItemMock
432
+ }
433
+ }}
434
+ />
435
+ )
436
+
437
+ const addNewItemNode = getByText('Add new item')
438
+
439
+ expect(handleAddItemMock).not.toHaveBeenCalled()
440
+ fireEvent.click(addNewItemNode)
441
+ expect(handleAddItemMock).toHaveBeenCalled()
442
+ })
443
+ })
444
+ })