@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.
@@ -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,2 @@
1
+ export { GlobalDialog } from './components/global-dialog';
2
+ export { openDialog, closeDialog } from './subscribers';
@@ -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 { Infotip, MenuItem, type MenuItemProps, MenuItemText } from '@elementor/ui';
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
- export const MenuListItem = ( { children, ...props }: MenuItemProps ) => {
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
- variant: 'caption',
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';