@elementor/editor-ui 3.33.0-99 → 3.34.2
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/index.d.mts +57 -10
- package/dist/index.d.ts +57 -10
- package/dist/index.js +223 -76
- package/dist/index.mjs +227 -73
- package/package.json +4 -4
- package/src/components/form.tsx +30 -0
- package/src/components/global-dialog/__tests__/global-dialog.test.tsx +272 -0
- package/src/components/global-dialog/__tests__/subscribers.test.ts +90 -0
- package/src/components/global-dialog/components/global-dialog.tsx +30 -0
- package/src/components/global-dialog/index.ts +2 -0
- package/src/components/global-dialog/subscribers.ts +36 -0
- package/src/components/menu-item.tsx +20 -6
- package/src/components/popover/__tests__/menu-list.test.tsx +106 -0
- package/src/components/popover/body.tsx +3 -1
- package/src/components/popover/index.ts +0 -1
- package/src/components/popover/menu-list.tsx +25 -15
- package/src/components/save-changes-dialog.tsx +106 -0
- package/src/components/{popover/search.tsx → search-field.tsx} +6 -4
- package/src/components/warning-infotip.tsx +11 -2
- package/src/hooks/__tests__/use-editable.test.tsx +255 -0
- package/src/hooks/use-editable.ts +15 -4
- package/src/index.ts +5 -0
- package/src/hooks/__tests__/use-editable.test.ts +0 -209
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { GlobalDialog } from '../components/global-dialog';
|
|
5
|
+
import { closeDialog, type DialogStateCallback, openDialog } from '../subscribers';
|
|
6
|
+
|
|
7
|
+
// Get mock functions for cleanup
|
|
8
|
+
const mockEventBus = jest.requireMock( '../subscribers' );
|
|
9
|
+
|
|
10
|
+
jest.mock( '../subscribers', () => {
|
|
11
|
+
let currentState: { component: React.ReactElement } | null = null;
|
|
12
|
+
const subscribers = new Set< DialogStateCallback >();
|
|
13
|
+
|
|
14
|
+
const notifySubscribers = () => {
|
|
15
|
+
subscribers.forEach( ( callback ) => callback( currentState ) );
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
subscribeToDialogState: jest.fn( ( callback: DialogStateCallback ) => {
|
|
20
|
+
subscribers.add( callback );
|
|
21
|
+
// Call callback immediately with current state (matches real implementation)
|
|
22
|
+
callback( currentState );
|
|
23
|
+
return () => subscribers.delete( callback );
|
|
24
|
+
} ),
|
|
25
|
+
openDialog: jest.fn( ( { component }: { component: React.ReactElement } ) => {
|
|
26
|
+
currentState = { component };
|
|
27
|
+
// Notify synchronously (matches real implementation)
|
|
28
|
+
notifySubscribers();
|
|
29
|
+
} ),
|
|
30
|
+
closeDialog: jest.fn( () => {
|
|
31
|
+
currentState = null;
|
|
32
|
+
// Notify synchronously (matches real implementation)
|
|
33
|
+
notifySubscribers();
|
|
34
|
+
} ),
|
|
35
|
+
// For manual control in tests
|
|
36
|
+
__notifySubscribers: notifySubscribers,
|
|
37
|
+
__getCurrentState: () => currentState,
|
|
38
|
+
__getSubscribers: () => subscribers,
|
|
39
|
+
__reset: () => {
|
|
40
|
+
currentState = null;
|
|
41
|
+
subscribers.clear();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
// Helper function to render GlobalDialog
|
|
47
|
+
const renderGlobalDialog = () => {
|
|
48
|
+
return render( <GlobalDialog /> );
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe( 'GlobalDialog', () => {
|
|
52
|
+
// Reset dialog state before each test to avoid test interference
|
|
53
|
+
beforeEach( () => {
|
|
54
|
+
mockEventBus.__reset();
|
|
55
|
+
} );
|
|
56
|
+
|
|
57
|
+
afterEach( () => {
|
|
58
|
+
// Clean up any open dialogs after each test
|
|
59
|
+
mockEventBus.__reset();
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'should not render anything when no dialog is open', () => {
|
|
63
|
+
// Act
|
|
64
|
+
renderGlobalDialog();
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
|
|
68
|
+
} );
|
|
69
|
+
|
|
70
|
+
it( 'should render dialog when openDialog is called', async () => {
|
|
71
|
+
// Arrange
|
|
72
|
+
const TestDialogContent = () => <div>Test Dialog Content</div>;
|
|
73
|
+
|
|
74
|
+
renderGlobalDialog();
|
|
75
|
+
|
|
76
|
+
// Act
|
|
77
|
+
act( () => {
|
|
78
|
+
openDialog( { component: <TestDialogContent /> } );
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
// Assert - Wait for the dialog to appear
|
|
82
|
+
await waitFor( () => {
|
|
83
|
+
expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 2 ); // MUI creates 2 dialog elements
|
|
84
|
+
} );
|
|
85
|
+
expect( screen.getByText( 'Test Dialog Content' ) ).toBeInTheDocument();
|
|
86
|
+
} );
|
|
87
|
+
|
|
88
|
+
it( 'should close dialog when closeDialog is called', async () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
const TestDialogContent = () => <div>Test Dialog Content</div>;
|
|
91
|
+
|
|
92
|
+
renderGlobalDialog();
|
|
93
|
+
|
|
94
|
+
// Open dialog first
|
|
95
|
+
act( () => {
|
|
96
|
+
openDialog( { component: <TestDialogContent /> } );
|
|
97
|
+
} );
|
|
98
|
+
|
|
99
|
+
await waitFor( () => {
|
|
100
|
+
expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 2 );
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
act( () => {
|
|
105
|
+
closeDialog();
|
|
106
|
+
} );
|
|
107
|
+
|
|
108
|
+
// Assert
|
|
109
|
+
await waitFor( () => {
|
|
110
|
+
expect( screen.queryAllByRole( 'dialog' ) ).toHaveLength( 0 );
|
|
111
|
+
} );
|
|
112
|
+
expect( screen.queryByText( 'Test Dialog Content' ) ).not.toBeInTheDocument();
|
|
113
|
+
} );
|
|
114
|
+
|
|
115
|
+
it( 'should close dialog when Dialog onClose is triggered', async () => {
|
|
116
|
+
// Arrange
|
|
117
|
+
const TestDialogContent = () => <div>Test Dialog Content</div>;
|
|
118
|
+
|
|
119
|
+
renderGlobalDialog();
|
|
120
|
+
|
|
121
|
+
// Open dialog first
|
|
122
|
+
act( () => {
|
|
123
|
+
openDialog( { component: <TestDialogContent /> } );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
await waitFor( () => {
|
|
127
|
+
expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 2 );
|
|
128
|
+
} );
|
|
129
|
+
expect( screen.getByText( 'Test Dialog Content' ) ).toBeInTheDocument();
|
|
130
|
+
|
|
131
|
+
// Act - Simulate Dialog's onClose being called (like when user clicks close button)
|
|
132
|
+
act( () => {
|
|
133
|
+
closeDialog();
|
|
134
|
+
} );
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
await waitFor( () => {
|
|
138
|
+
expect( screen.queryAllByRole( 'dialog' ) ).toHaveLength( 0 );
|
|
139
|
+
} );
|
|
140
|
+
expect( screen.queryByText( 'Test Dialog Content' ) ).not.toBeInTheDocument();
|
|
141
|
+
} );
|
|
142
|
+
|
|
143
|
+
it( 'should handle null/undefined dialog content gracefully', () => {
|
|
144
|
+
// This test ensures the component doesn't crash with invalid content
|
|
145
|
+
renderGlobalDialog();
|
|
146
|
+
|
|
147
|
+
// No dialog should be rendered when content is null
|
|
148
|
+
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
|
|
149
|
+
} );
|
|
150
|
+
it( 'should replace dialog content when a new dialog is opened', async () => {
|
|
151
|
+
// Arrange
|
|
152
|
+
const FirstDialogContent = () => <div>First Dialog</div>;
|
|
153
|
+
const SecondDialogContent = () => <div>Second Dialog</div>;
|
|
154
|
+
|
|
155
|
+
renderGlobalDialog();
|
|
156
|
+
|
|
157
|
+
// Open first dialog
|
|
158
|
+
act( () => {
|
|
159
|
+
openDialog( { component: <FirstDialogContent /> } );
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
await waitFor( () => {
|
|
163
|
+
expect( screen.getByText( 'First Dialog' ) ).toBeInTheDocument();
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
// Act - Open second dialog
|
|
167
|
+
act( () => {
|
|
168
|
+
openDialog( { component: <SecondDialogContent /> } );
|
|
169
|
+
} );
|
|
170
|
+
|
|
171
|
+
// Assert
|
|
172
|
+
await waitFor( () => {
|
|
173
|
+
expect( screen.queryByText( 'First Dialog' ) ).not.toBeInTheDocument();
|
|
174
|
+
} );
|
|
175
|
+
expect( screen.getByText( 'Second Dialog' ) ).toBeInTheDocument();
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
it( 'should subscribe to dialog state on mount', async () => {
|
|
179
|
+
// Arrange
|
|
180
|
+
const TestDialogContent = () => <div>Test Content</div>;
|
|
181
|
+
|
|
182
|
+
// Act
|
|
183
|
+
renderGlobalDialog();
|
|
184
|
+
|
|
185
|
+
// The component should be subscribed, so opening a dialog should work
|
|
186
|
+
act( () => {
|
|
187
|
+
openDialog( { component: <TestDialogContent /> } );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
191
|
+
await waitFor( () => {
|
|
192
|
+
expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 2 );
|
|
193
|
+
} );
|
|
194
|
+
} );
|
|
195
|
+
|
|
196
|
+
it( 'should clean up subscription when component unmounts', async () => {
|
|
197
|
+
// Arrange
|
|
198
|
+
const TestDialogContent = () => <div>Test Dialog</div>;
|
|
199
|
+
|
|
200
|
+
const view = render( <GlobalDialog /> );
|
|
201
|
+
const unmount = view.unmount;
|
|
202
|
+
|
|
203
|
+
// Open dialog to verify subscription is working
|
|
204
|
+
act( () => {
|
|
205
|
+
openDialog( { component: <TestDialogContent /> } );
|
|
206
|
+
} );
|
|
207
|
+
|
|
208
|
+
await waitFor( () => {
|
|
209
|
+
expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 2 );
|
|
210
|
+
} );
|
|
211
|
+
|
|
212
|
+
// Act - Unmount component
|
|
213
|
+
unmount();
|
|
214
|
+
|
|
215
|
+
// Try to open dialog again - should not affect the unmounted component
|
|
216
|
+
act( () => {
|
|
217
|
+
openDialog( { component: <TestDialogContent /> } );
|
|
218
|
+
} );
|
|
219
|
+
|
|
220
|
+
// Assert - No dialog should be rendered since component is unmounted
|
|
221
|
+
expect( screen.queryAllByRole( 'dialog' ) ).toHaveLength( 0 );
|
|
222
|
+
} );
|
|
223
|
+
it( 'should work with store dispatch patterns like error dialogs', async () => {
|
|
224
|
+
// Arrange - Simulate error dialog pattern from editor-global-classes
|
|
225
|
+
const ErrorDialogContent = ( {
|
|
226
|
+
modifiedLabels,
|
|
227
|
+
}: {
|
|
228
|
+
modifiedLabels: Array< { original: string; modified: string; id: string } >;
|
|
229
|
+
} ) => (
|
|
230
|
+
<div>
|
|
231
|
+
<h3>Duplicate Labels Found</h3>
|
|
232
|
+
<ul>
|
|
233
|
+
{ modifiedLabels.map( ( label ) => (
|
|
234
|
+
<li key={ label.id }>
|
|
235
|
+
{ label.original } → { label.modified }
|
|
236
|
+
</li>
|
|
237
|
+
) ) }
|
|
238
|
+
</ul>
|
|
239
|
+
<button onClick={ () => closeDialog() }>Close</button>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const mockModifiedLabels = [
|
|
244
|
+
{ original: 'MyClass', modified: 'DUP_MyClass', id: 'class-1' },
|
|
245
|
+
{ original: 'Button', modified: 'DUP_Button', id: 'class-2' },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
renderGlobalDialog();
|
|
249
|
+
|
|
250
|
+
// Act - Simulate error dialog opening (like in show-error-dialog.tsx)
|
|
251
|
+
act( () => {
|
|
252
|
+
openDialog( {
|
|
253
|
+
component: <ErrorDialogContent modifiedLabels={ mockModifiedLabels } />,
|
|
254
|
+
} );
|
|
255
|
+
} );
|
|
256
|
+
|
|
257
|
+
// Assert
|
|
258
|
+
await waitFor( () => {
|
|
259
|
+
expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 2 );
|
|
260
|
+
} );
|
|
261
|
+
expect( screen.getByText( 'Duplicate Labels Found' ) ).toBeInTheDocument();
|
|
262
|
+
expect( screen.getByText( 'MyClass → DUP_MyClass' ) ).toBeInTheDocument();
|
|
263
|
+
expect( screen.getByText( 'Button → DUP_Button' ) ).toBeInTheDocument();
|
|
264
|
+
|
|
265
|
+
// Test closing
|
|
266
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Close' } ) );
|
|
267
|
+
|
|
268
|
+
await waitFor( () => {
|
|
269
|
+
expect( screen.queryAllByRole( 'dialog' ) ).toHaveLength( 0 );
|
|
270
|
+
} );
|
|
271
|
+
} );
|
|
272
|
+
} );
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { waitFor } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { closeDialog, type DialogContent, openDialog, subscribeToDialogState } from '../subscribers';
|
|
5
|
+
|
|
6
|
+
describe( 'subscribers', () => {
|
|
7
|
+
// Clean up state after each test
|
|
8
|
+
afterEach( () => {
|
|
9
|
+
closeDialog();
|
|
10
|
+
} );
|
|
11
|
+
|
|
12
|
+
it( 'should update state when openDialog is called', async () => {
|
|
13
|
+
// Arrange
|
|
14
|
+
const callback = jest.fn();
|
|
15
|
+
const testComponent = React.createElement( 'div', { children: 'Test' } );
|
|
16
|
+
const dialogContent: DialogContent = { component: testComponent };
|
|
17
|
+
|
|
18
|
+
subscribeToDialogState( callback );
|
|
19
|
+
callback.mockClear(); // Clear the initial call
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
openDialog( dialogContent );
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
await waitFor( () => {
|
|
26
|
+
expect( callback ).toHaveBeenCalledWith( { component: testComponent } );
|
|
27
|
+
} );
|
|
28
|
+
await waitFor( () => {
|
|
29
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
30
|
+
} );
|
|
31
|
+
} );
|
|
32
|
+
|
|
33
|
+
it( 'should reset state to null when closeDialog is called', async () => {
|
|
34
|
+
// Arrange
|
|
35
|
+
const callback = jest.fn();
|
|
36
|
+
const testComponent = React.createElement( 'div', { children: 'Test' } );
|
|
37
|
+
|
|
38
|
+
subscribeToDialogState( callback );
|
|
39
|
+
openDialog( { component: testComponent } );
|
|
40
|
+
|
|
41
|
+
// Wait for the openDialog to complete
|
|
42
|
+
await waitFor( () => {
|
|
43
|
+
expect( callback ).toHaveBeenCalledWith( { component: testComponent } );
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
callback.mockClear(); // Clear previous calls
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
closeDialog();
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
52
|
+
await waitFor( () => {
|
|
53
|
+
expect( callback ).toHaveBeenCalledWith( null );
|
|
54
|
+
} );
|
|
55
|
+
await waitFor( () => {
|
|
56
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
57
|
+
} );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
it( 'should call callback immediately with current state on subscription', () => {
|
|
61
|
+
// Arrange
|
|
62
|
+
const testComponent = React.createElement( 'div', { children: 'Test' } );
|
|
63
|
+
openDialog( { component: testComponent } );
|
|
64
|
+
|
|
65
|
+
const callback = jest.fn();
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
subscribeToDialogState( callback );
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect( callback ).toHaveBeenCalledWith( { component: testComponent } );
|
|
72
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
it( 'should unsubscribe callback when unsubscribe function is called', () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
const callback = jest.fn();
|
|
78
|
+
const testComponent = React.createElement( 'div', { children: 'Test' } );
|
|
79
|
+
|
|
80
|
+
const unsubscribe = subscribeToDialogState( callback );
|
|
81
|
+
callback.mockClear(); // Clear initial call
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
unsubscribe();
|
|
85
|
+
openDialog( { component: testComponent } );
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
88
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
89
|
+
} );
|
|
90
|
+
} );
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { Dialog } from '@elementor/ui';
|
|
4
|
+
|
|
5
|
+
import ThemeProvider from '../../theme-provider';
|
|
6
|
+
import { closeDialog, subscribeToDialogState } from '../subscribers';
|
|
7
|
+
import { type DialogContent } from '../subscribers';
|
|
8
|
+
|
|
9
|
+
export const GlobalDialog = () => {
|
|
10
|
+
const [ content, setContent ] = useState< DialogContent | null >( null );
|
|
11
|
+
|
|
12
|
+
useEffect( () => {
|
|
13
|
+
const unsubscribe = subscribeToDialogState( setContent );
|
|
14
|
+
return () => {
|
|
15
|
+
unsubscribe();
|
|
16
|
+
};
|
|
17
|
+
}, [] );
|
|
18
|
+
|
|
19
|
+
if ( ! content ) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ThemeProvider>
|
|
25
|
+
<Dialog role="dialog" open onClose={ closeDialog } maxWidth="sm" fullWidth>
|
|
26
|
+
{ content.component }
|
|
27
|
+
</Dialog>
|
|
28
|
+
</ThemeProvider>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
type DialogState = {
|
|
4
|
+
component: ReactElement;
|
|
5
|
+
} | null;
|
|
6
|
+
|
|
7
|
+
export type DialogStateCallback = ( state: DialogState ) => void;
|
|
8
|
+
|
|
9
|
+
let currentDialogState: DialogState = null;
|
|
10
|
+
|
|
11
|
+
const stateSubscribers = new Set< DialogStateCallback >();
|
|
12
|
+
|
|
13
|
+
export const subscribeToDialogState = ( callback: DialogStateCallback ) => {
|
|
14
|
+
stateSubscribers.add( callback );
|
|
15
|
+
|
|
16
|
+
callback( currentDialogState );
|
|
17
|
+
return () => stateSubscribers.delete( callback );
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const notifySubscribers = () => {
|
|
21
|
+
stateSubscribers.forEach( ( callback ) => callback( currentDialogState ) );
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DialogContent = {
|
|
25
|
+
component: ReactElement;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const openDialog = ( { component }: DialogContent ) => {
|
|
29
|
+
currentDialogState = { component };
|
|
30
|
+
notifySubscribers();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const closeDialog = () => {
|
|
34
|
+
currentDialogState = null;
|
|
35
|
+
notifySubscribers();
|
|
36
|
+
};
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { forwardRef } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Infotip,
|
|
5
|
+
MenuItem,
|
|
6
|
+
type MenuItemProps,
|
|
7
|
+
MenuItemText,
|
|
8
|
+
type MenuItemTextProps,
|
|
9
|
+
type TypographyProps,
|
|
10
|
+
} from '@elementor/ui';
|
|
4
11
|
|
|
5
12
|
import { InfoAlert } from './info-alert';
|
|
6
|
-
|
|
7
|
-
|
|
13
|
+
type MenuListItemProps = MenuItemProps & {
|
|
14
|
+
menuItemTextProps?: MenuItemTextProps;
|
|
15
|
+
primaryTypographyProps?: TypographyProps;
|
|
16
|
+
};
|
|
17
|
+
export const MenuListItem = ( {
|
|
18
|
+
children,
|
|
19
|
+
menuItemTextProps,
|
|
20
|
+
primaryTypographyProps = { variant: 'caption' },
|
|
21
|
+
...props
|
|
22
|
+
}: MenuListItemProps ) => {
|
|
8
23
|
return (
|
|
9
24
|
<MenuItem
|
|
10
25
|
dense
|
|
@@ -15,9 +30,8 @@ export const MenuListItem = ( { children, ...props }: MenuItemProps ) => {
|
|
|
15
30
|
>
|
|
16
31
|
<MenuItemText
|
|
17
32
|
primary={ children }
|
|
18
|
-
primaryTypographyProps={
|
|
19
|
-
|
|
20
|
-
} }
|
|
33
|
+
primaryTypographyProps={ primaryTypographyProps }
|
|
34
|
+
{ ...menuItemTextProps }
|
|
21
35
|
/>
|
|
22
36
|
</MenuItem>
|
|
23
37
|
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { PopoverMenuList } from '../menu-list';
|
|
5
|
+
|
|
6
|
+
const mockItems = [
|
|
7
|
+
{ value: 'Item 1', type: 'item', disabled: false },
|
|
8
|
+
{ value: 'Item 2', type: 'item', disabled: true },
|
|
9
|
+
{ value: 'Item 3', type: 'item', disabled: false },
|
|
10
|
+
{ value: 'Item 4', type: 'item', disabled: false },
|
|
11
|
+
{ value: 'Item 5', type: 'item', disabled: true },
|
|
12
|
+
{ value: 'Item 6', type: 'item', disabled: false },
|
|
13
|
+
{ value: 'Item 7', type: 'item', disabled: false },
|
|
14
|
+
{ value: 'Item 8', type: 'item', disabled: false },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
jest.mock( '@tanstack/react-virtual', () => ( {
|
|
18
|
+
useVirtualizer: jest.fn().mockImplementation( () => ( {
|
|
19
|
+
getVirtualItems: jest
|
|
20
|
+
.fn()
|
|
21
|
+
.mockReturnValue( mockItems.map( ( item, index ) => ( { key: item.value, index, start: index * 10 } ) ) ),
|
|
22
|
+
getTotalSize: jest.fn().mockReturnValue( mockItems.length ),
|
|
23
|
+
scrollToIndex: jest.fn(),
|
|
24
|
+
} ) ),
|
|
25
|
+
} ) );
|
|
26
|
+
|
|
27
|
+
describe( 'PopoverMenuList', () => {
|
|
28
|
+
it( 'should render an empty list', async () => {
|
|
29
|
+
// Arrange.
|
|
30
|
+
render(
|
|
31
|
+
<PopoverMenuList
|
|
32
|
+
items={ [] }
|
|
33
|
+
onSelect={ jest.fn() }
|
|
34
|
+
onClose={ jest.fn() }
|
|
35
|
+
noResultsComponent={ <div>No results</div> }
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Assert.
|
|
40
|
+
expect( screen.getByText( 'No results' ) ).toBeInTheDocument();
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
it( 'should render a list with items', async () => {
|
|
44
|
+
const onSelect = jest.fn();
|
|
45
|
+
// Arrange.
|
|
46
|
+
const enabledMockItems = mockItems.filter( ( item ) => ! item.disabled );
|
|
47
|
+
render( <PopoverMenuList items={ mockItems } onSelect={ onSelect } onClose={ jest.fn() } /> );
|
|
48
|
+
|
|
49
|
+
// Assert.
|
|
50
|
+
enabledMockItems.forEach( ( item ) => {
|
|
51
|
+
const itemElement = screen.getByText( item.value );
|
|
52
|
+
expect( itemElement ).toBeInTheDocument();
|
|
53
|
+
fireEvent.click( itemElement );
|
|
54
|
+
expect( onSelect ).toHaveBeenCalledWith( item.value );
|
|
55
|
+
} );
|
|
56
|
+
|
|
57
|
+
expect( onSelect ).toHaveBeenCalledTimes( enabledMockItems.length );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
it( 'should render a list with items and a selected item', async () => {
|
|
61
|
+
// Arrange.
|
|
62
|
+
const selectedValue = 'Item 5';
|
|
63
|
+
render(
|
|
64
|
+
<PopoverMenuList
|
|
65
|
+
items={ mockItems }
|
|
66
|
+
onSelect={ jest.fn() }
|
|
67
|
+
onClose={ jest.fn() }
|
|
68
|
+
selectedValue={ selectedValue }
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Assert.
|
|
73
|
+
mockItems.forEach( ( item ) => {
|
|
74
|
+
const itemElement = screen.getByText( item.value );
|
|
75
|
+
expect( itemElement ).toBeInTheDocument();
|
|
76
|
+
} );
|
|
77
|
+
|
|
78
|
+
const item5 = screen.getByText( selectedValue );
|
|
79
|
+
expect( item5 ).toHaveAttribute( 'aria-selected', 'true' );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'should render a list with items and disabled items', async () => {
|
|
83
|
+
// Arrange.
|
|
84
|
+
const onSelect = jest.fn();
|
|
85
|
+
render( <PopoverMenuList items={ mockItems } onSelect={ onSelect } onClose={ jest.fn() } /> );
|
|
86
|
+
|
|
87
|
+
// Assert.
|
|
88
|
+
mockItems.forEach( ( item ) => {
|
|
89
|
+
const itemElement = screen.getByText( item.value );
|
|
90
|
+
expect( itemElement ).toBeInTheDocument();
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
let disabledItem = screen.getByText( mockItems[ 1 ].value );
|
|
94
|
+
expect( disabledItem ).toHaveAttribute( 'aria-disabled', 'true' );
|
|
95
|
+
fireEvent.click( disabledItem );
|
|
96
|
+
expect( onSelect ).not.toHaveBeenCalled();
|
|
97
|
+
disabledItem = screen.getByText( mockItems[ 4 ].value );
|
|
98
|
+
expect( disabledItem ).toHaveAttribute( 'aria-disabled', 'true' );
|
|
99
|
+
fireEvent.click( disabledItem );
|
|
100
|
+
expect( onSelect ).not.toHaveBeenCalled();
|
|
101
|
+
const item1 = screen.getByText( mockItems[ 0 ].value );
|
|
102
|
+
expect( item1 ).toHaveAttribute( 'aria-disabled', 'false' );
|
|
103
|
+
fireEvent.click( item1 );
|
|
104
|
+
expect( onSelect ).toHaveBeenCalledWith( mockItems[ 0 ].value );
|
|
105
|
+
} );
|
|
106
|
+
} );
|
|
@@ -9,9 +9,10 @@ const FALLBACK_POPOVER_WIDTH = 220;
|
|
|
9
9
|
type PopoverBodyProps = PropsWithChildren< {
|
|
10
10
|
height?: number | 'auto';
|
|
11
11
|
width?: number;
|
|
12
|
+
id?: string;
|
|
12
13
|
} >;
|
|
13
14
|
|
|
14
|
-
export const PopoverBody = ( { children, height = DEFAULT_POPOVER_HEIGHT, width }: PopoverBodyProps ) => {
|
|
15
|
+
export const PopoverBody = ( { children, height = DEFAULT_POPOVER_HEIGHT, width, id }: PopoverBodyProps ) => {
|
|
15
16
|
return (
|
|
16
17
|
<Box
|
|
17
18
|
display="flex"
|
|
@@ -22,6 +23,7 @@ export const PopoverBody = ( { children, height = DEFAULT_POPOVER_HEIGHT, width
|
|
|
22
23
|
width: `${ width ? width - SECTION_PADDING_INLINE : FALLBACK_POPOVER_WIDTH }px`,
|
|
23
24
|
maxWidth: 496,
|
|
24
25
|
} }
|
|
26
|
+
id={ id }
|
|
25
27
|
>
|
|
26
28
|
{ children }
|
|
27
29
|
</Box>
|
|
@@ -2,4 +2,3 @@ export { PopoverBody } from './body';
|
|
|
2
2
|
export { PopoverHeader } from './header';
|
|
3
3
|
export { ITEM_HEIGHT, PopoverMenuList, StyledMenuList } from './menu-list';
|
|
4
4
|
export type { PopoverMenuListProps, VirtualizedItem } from './menu-list';
|
|
5
|
-
export { PopoverSearch } from './search';
|