1
- import { fireEvent , render , screen } from '@testing-library/react' ;
2
- import { beforeEach , describe , expect , it , vi } from 'vitest' ;
1
+ import { cleanup , fireEvent , render , screen } from '@testing-library/react' ;
2
+ import userEvent from '@testing-library/user-event' ;
3
+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
3
4
import { Popover } from './Popover' ;
4
5
5
- vi . mock ( 'react-dom' , ( ) => ( {
6
- createPortal : ( node : React . ReactNode ) => node ,
7
- } ) ) ;
8
-
9
6
describe ( 'Popover' , ( ) => {
10
- const onClose = vi . fn ( ) ;
11
7
let anchorEl : HTMLElement ;
12
8
13
9
beforeEach ( ( ) => {
14
- vi . clearAllMocks ( ) ;
10
+ Object . defineProperty ( window , 'matchMedia' , {
11
+ writable : true ,
12
+ value : vi . fn ( ) . mockImplementation ( ( query ) => ( {
13
+ matches : false ,
14
+ media : query ,
15
+ onchange : null ,
16
+ addListener : vi . fn ( ) ,
17
+ removeListener : vi . fn ( ) ,
18
+ addEventListener : vi . fn ( ) ,
19
+ removeEventListener : vi . fn ( ) ,
20
+ dispatchEvent : vi . fn ( ) ,
21
+ } ) ) ,
22
+ } ) ;
23
+
15
24
anchorEl = document . createElement ( 'button' ) ;
16
25
anchorEl . setAttribute ( 'data-testid' , 'anchor' ) ;
17
26
document . body . appendChild ( anchorEl ) ;
18
- vi . spyOn ( anchorEl , 'getBoundingClientRect' ) . mockReturnValue ( {
19
- x : 100 ,
20
- y : 100 ,
21
- top : 100 ,
22
- right : 200 ,
23
- bottom : 200 ,
24
- left : 100 ,
25
- width : 100 ,
26
- height : 100 ,
27
- toJSON : ( ) => { } ,
28
- } ) ;
27
+ } ) ;
28
+
29
+ afterEach ( ( ) => {
30
+ cleanup ( ) ;
31
+ document . body . innerHTML = '' ;
32
+ vi . clearAllMocks ( ) ;
29
33
} ) ;
30
34
31
35
describe ( 'rendering' , ( ) => {
32
- it ( 'renders nothing when isOpen is false' , ( ) => {
36
+ it ( 'should not render when isOpen is false' , ( ) => {
33
37
render (
34
- < Popover isOpen = { false } anchorEl = { anchorEl } onClose = { onClose } >
35
- < div > Content</ div >
38
+ < Popover anchorEl = { anchorEl } isOpen = { false } >
39
+ Content
36
40
</ Popover > ,
37
41
) ;
42
+
38
43
expect ( screen . queryByTestId ( 'ockPopover' ) ) . not . toBeInTheDocument ( ) ;
39
44
} ) ;
40
45
41
- it ( 'renders content when isOpen is true' , ( ) => {
46
+ it ( 'should render when isOpen is true' , ( ) => {
42
47
render (
43
- < Popover isOpen = { true } anchorEl = { anchorEl } onClose = { onClose } >
44
- < div data-testid = "content" > Content</ div >
48
+ < Popover anchorEl = { anchorEl } isOpen = { true } >
49
+ Content
45
50
</ Popover > ,
46
51
) ;
52
+
47
53
expect ( screen . getByTestId ( 'ockPopover' ) ) . toBeInTheDocument ( ) ;
48
- expect ( screen . getByTestId ( 'content ') ) . toBeInTheDocument ( ) ;
54
+ expect ( screen . getByText ( 'Content ') ) . toBeInTheDocument ( ) ;
49
55
} ) ;
50
- } ) ;
51
56
52
- describe ( 'accessibility' , ( ) => {
53
- it ( 'sets all ARIA attributes correctly' , ( ) => {
57
+ it ( 'should handle null anchorEl gracefully' , ( ) => {
54
58
render (
55
- < Popover
56
- isOpen = { true }
57
- anchorEl = { anchorEl }
58
- aria-label = "Test Popover"
59
- aria-describedby = "desc"
60
- aria-labelledby = "title"
61
- onClose = { onClose }
62
- >
63
- < div > Content</ div >
59
+ < Popover anchorEl = { null } isOpen = { true } >
60
+ Content
64
61
</ Popover > ,
65
62
) ;
66
63
67
- const popover = screen . getByTestId ( 'ockPopover' ) ;
68
- expect ( popover ) . toHaveAttribute ( 'role' , 'dialog' ) ;
69
- expect ( popover ) . toHaveAttribute ( 'aria-label' , 'Test Popover' ) ;
70
- expect ( popover ) . toHaveAttribute ( 'aria-describedby' , 'desc' ) ;
71
- expect ( popover ) . toHaveAttribute ( 'aria-labelledby' , 'title' ) ;
64
+ expect ( screen . getByTestId ( 'ockPopover' ) ) . toBeInTheDocument ( ) ;
72
65
} ) ;
73
66
} ) ;
74
67
75
68
describe ( 'positioning' , ( ) => {
76
- const testCases = [
77
- {
78
- position : 'top' ,
79
- align : 'start' ,
80
- expectedTop : - 8 ,
81
- expectedLeft : 100 ,
82
- } ,
83
- {
84
- position : 'bottom' ,
85
- align : 'center' ,
86
- expectedTop : 208 ,
87
- expectedLeft : 100 ,
88
- } ,
89
- {
90
- position : 'left' ,
91
- align : 'end' ,
92
- expectedTop : 200 ,
93
- expectedLeft : - 8 ,
94
- } ,
95
- {
96
- position : 'right' ,
97
- align : 'center' ,
98
- expectedTop : 150 ,
99
- expectedLeft : 208 ,
100
- } ,
101
- ] as const ;
102
-
103
- testCases . forEach ( ( { position, align, expectedTop, expectedLeft } ) => {
104
- it ( `positions correctly with position=${ position } and align=${ align } ` , ( ) => {
105
- render (
106
- < Popover
107
- isOpen = { true }
108
- anchorEl = { anchorEl }
109
- position = { position }
110
- align = { align }
111
- offset = { 8 }
112
- onClose = { onClose }
113
- >
114
- < div style = { { width : '100px' , height : '100px' } } > Content</ div >
115
- </ Popover > ,
116
- ) ;
117
-
118
- const popover = screen . getByTestId ( 'ockPopover' ) ;
119
- vi . spyOn ( popover , 'getBoundingClientRect' ) . mockReturnValue ( {
120
- width : 100 ,
121
- height : 100 ,
122
- } as DOMRect ) ;
123
-
124
- fireEvent ( window , new Event ( 'resize' ) ) ;
125
-
126
- expect ( popover . style . top ) . toBe ( `${ expectedTop } px` ) ;
127
- expect ( popover . style . left ) . toBe ( `${ expectedLeft } px` ) ;
128
- } ) ;
69
+ const positions = [ 'top' , 'right' , 'bottom' , 'left' ] as const ;
70
+ const alignments = [ 'start' , 'center' , 'end' ] as const ;
71
+
72
+ for ( const position of positions ) {
73
+ for ( const align of alignments ) {
74
+ it ( `should position correctly with position=${ position } and align=${ align } ` , ( ) => {
75
+ render (
76
+ < Popover
77
+ anchorEl = { anchorEl }
78
+ isOpen = { true }
79
+ position = { position }
80
+ align = { align }
81
+ offset = { 8 }
82
+ >
83
+ Content
84
+ </ Popover > ,
85
+ ) ;
86
+
87
+ const popover = screen . getByTestId ( 'ockPopover' ) ;
88
+ expect ( popover ) . toBeInTheDocument ( ) ;
89
+
90
+ expect ( popover . style . top ) . toBeDefined ( ) ;
91
+ expect ( popover . style . left ) . toBeDefined ( ) ;
92
+ } ) ;
93
+ }
94
+ }
95
+
96
+ it ( 'should update position on window resize' , async ( ) => {
97
+ render (
98
+ < Popover anchorEl = { anchorEl } isOpen = { true } >
99
+ Content
100
+ </ Popover > ,
101
+ ) ;
102
+
103
+ fireEvent ( window , new Event ( 'resize' ) ) ;
104
+
105
+ expect ( screen . getByTestId ( 'ockPopover' ) ) . toBeInTheDocument ( ) ;
129
106
} ) ;
130
107
131
- it ( 'updates position on scroll' , ( ) => {
108
+ it ( 'should update position on scroll' , async ( ) => {
132
109
render (
133
- < Popover isOpen = { true } anchorEl = { anchorEl } onClose = { onClose } >
134
- < div > Content</ div >
110
+ < Popover anchorEl = { anchorEl } isOpen = { true } >
111
+ Content
135
112
</ Popover > ,
136
113
) ;
137
114
138
115
fireEvent . scroll ( window ) ;
139
- expect ( anchorEl . getBoundingClientRect ) . toHaveBeenCalled ( ) ;
116
+
117
+ expect ( screen . getByTestId ( 'ockPopover' ) ) . toBeInTheDocument ( ) ;
140
118
} ) ;
141
- } ) ;
142
119
143
- describe ( 'dismissal behavior' , ( ) => {
144
- it ( 'calls onClose when clicking outside' , ( ) => {
120
+ it ( 'should handle missing getBoundingClientRect gracefully' , ( ) => {
121
+ const originalGetBoundingClientRect =
122
+ Element . prototype . getBoundingClientRect ;
123
+ Element . prototype . getBoundingClientRect = vi
124
+ . fn ( )
125
+ . mockReturnValue ( undefined ) ;
126
+
145
127
render (
146
- < Popover isOpen = { true } anchorEl = { anchorEl } onClose = { onClose } >
147
- < div > Content</ div >
128
+ < Popover anchorEl = { anchorEl } isOpen = { true } >
129
+ Content
148
130
</ Popover > ,
149
131
) ;
150
132
151
- fireEvent . pointerDown ( document . body ) ;
152
- expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
133
+ const popover = screen . getByTestId ( 'ockPopover' ) ;
134
+ expect ( popover ) . toBeInTheDocument ( ) ;
135
+
136
+ Element . prototype . getBoundingClientRect = originalGetBoundingClientRect ;
153
137
} ) ;
138
+ } ) ;
154
139
155
- it ( 'calls onClose when pressing Escape' , ( ) => {
140
+ describe ( 'interactions' , ( ) => {
141
+ it ( 'should not call onClose when clicking inside' , async ( ) => {
142
+ const onClose = vi . fn ( ) ;
156
143
render (
157
- < Popover isOpen = { true } anchorEl = { anchorEl } onClose = { onClose } >
158
- < div > Content</ div >
144
+ < Popover anchorEl = { anchorEl } isOpen = { true } onClose = { onClose } >
145
+ Content
159
146
</ Popover > ,
160
147
) ;
161
148
162
- fireEvent . keyDown ( document , { key : 'Escape' } ) ;
163
- expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
149
+ fireEvent . mouseDown ( screen . getByText ( 'Content' ) ) ;
150
+ expect ( onClose ) . not . toHaveBeenCalled ( ) ;
164
151
} ) ;
165
152
166
- it ( 'handles undefined onClose prop gracefully' , ( ) => {
153
+ it ( 'should call onClose when pressing Escape' , async ( ) => {
154
+ const onClose = vi . fn ( ) ;
167
155
render (
168
- < Popover isOpen = { true } anchorEl = { anchorEl } >
169
- < div > Content</ div >
156
+ < Popover anchorEl = { anchorEl } isOpen = { true } onClose = { onClose } >
157
+ Content
170
158
</ Popover > ,
171
159
) ;
172
160
173
- fireEvent . pointerDown ( document . body ) ;
174
- fireEvent . keyDown ( document , { key : 'Escape' } ) ;
161
+ fireEvent . keyDown ( document . body , { key : 'Escape' } ) ;
162
+ expect ( onClose ) . toHaveBeenCalled ( ) ;
175
163
} ) ;
176
164
} ) ;
177
165
178
- describe ( 'cleanup' , ( ) => {
179
- it ( 'removes event listeners on unmount' , ( ) => {
180
- const { unmount } = render (
181
- < Popover isOpen = { true } anchorEl = { anchorEl } onClose = { onClose } >
182
- < div > Content</ div >
166
+ describe ( 'accessibility' , ( ) => {
167
+ it ( 'should have correct ARIA attributes' , ( ) => {
168
+ render (
169
+ < Popover
170
+ anchorEl = { anchorEl }
171
+ isOpen = { true }
172
+ aria-label = "Test Label"
173
+ aria-labelledby = "labelId"
174
+ aria-describedby = "describeId"
175
+ >
176
+ Content
183
177
</ Popover > ,
184
178
) ;
185
179
186
- const removeEventListenerSpy = vi . spyOn ( window , 'removeEventListener' ) ;
187
- unmount ( ) ;
180
+ const popover = screen . getByTestId ( 'ockPopover' ) ;
181
+ expect ( popover ) . toHaveAttribute ( 'role' , 'dialog' ) ;
182
+ expect ( popover ) . toHaveAttribute ( 'aria-label' , 'Test Label' ) ;
183
+ expect ( popover ) . toHaveAttribute ( 'aria-labelledby' , 'labelId' ) ;
184
+ expect ( popover ) . toHaveAttribute ( 'aria-describedby' , 'describeId' ) ;
185
+ } ) ;
188
186
189
- expect ( removeEventListenerSpy ) . toHaveBeenCalledWith (
190
- 'resize' ,
191
- expect . any ( Function ) ,
192
- ) ;
193
- expect ( removeEventListenerSpy ) . toHaveBeenCalledWith (
194
- 'scroll' ,
195
- expect . any ( Function ) ,
187
+ it ( 'should trap focus when open' , async ( ) => {
188
+ const user = userEvent . setup ( ) ;
189
+ render (
190
+ < Popover anchorEl = { anchorEl } isOpen = { true } >
191
+ < button type = "button" > First </ button >
192
+ < button type = "button" > Second </ button >
193
+ </ Popover > ,
196
194
) ;
195
+
196
+ const firstButton = screen . getByText ( 'First' ) ;
197
+ const secondButton = screen . getByText ( 'Second' ) ;
198
+
199
+ firstButton . focus ( ) ;
200
+ expect ( document . activeElement ) . toBe ( firstButton ) ;
201
+
202
+ await user . tab ( ) ;
203
+ expect ( document . activeElement ) . toBe ( secondButton ) ;
204
+
205
+ await user . tab ( ) ;
206
+ expect ( document . activeElement ) . toBe ( firstButton ) ;
197
207
} ) ;
198
208
} ) ;
199
209
200
- describe ( 'portal rendering ' , ( ) => {
201
- it ( 'renders in portal ' , ( ) => {
202
- const { baseElement } = render (
203
- < Popover isOpen = { true } anchorEl = { anchorEl } onClose = { onClose } >
204
- < div > Content</ div >
210
+ describe ( 'cleanup ' , ( ) => {
211
+ it ( 'should remove event listeners on unmount ' , ( ) => {
212
+ const { unmount } = render (
213
+ < Popover anchorEl = { anchorEl } isOpen = { true } >
214
+ Content
205
215
</ Popover > ,
206
216
) ;
207
217
208
- expect ( baseElement . contains ( screen . getByTestId ( 'ockPopover' ) ) ) . toBe ( true ) ;
218
+ const removeEventListenerSpy = vi . spyOn ( window , 'removeEventListener' ) ;
219
+ unmount ( ) ;
220
+
221
+ expect ( removeEventListenerSpy ) . toHaveBeenCalledTimes ( 2 ) ;
209
222
} ) ;
210
223
} ) ;
211
- } ) ;
224
+ } ) ;
0 commit comments