@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.
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { useMemo, useRef } from 'react';
3
- import { Box, MenuList, MenuSubheader, styled } from '@elementor/ui';
3
+ import { Box, ListItem, MenuList, MenuSubheader, styled } from '@elementor/ui';
4
4
  import { useVirtualizer } from '@tanstack/react-virtual';
5
5
 
6
6
  import { useScrollTop, useScrollToSelected } from '../../hooks';
@@ -8,6 +8,7 @@ import { useScrollTop, useScrollToSelected } from '../../hooks';
8
8
  export type VirtualizedItem< T, V extends string > = {
9
9
  type: T;
10
10
  value: V;
11
+ disabled?: boolean;
11
12
  label?: string;
12
13
  icon?: React.ReactNode;
13
14
  secondaryText?: string;
@@ -105,6 +106,7 @@ export const PopoverMenuList = < T, V extends string >( {
105
106
  } );
106
107
 
107
108
  useScrollToSelected( { selectedValue, items, virtualizer } );
109
+ const virtualItems = virtualizer.getVirtualItems();
108
110
 
109
111
  return (
110
112
  <Box ref={ containerRef } sx={ { height: '100%', overflowY: 'auto' } }>
@@ -116,7 +118,7 @@ export const PopoverMenuList = < T, V extends string >( {
116
118
  style={ { height: `${ virtualizer.getTotalSize() }px` } }
117
119
  data-testid={ dataTestId }
118
120
  >
119
- { virtualizer.getVirtualItems().map( ( virtualRow ) => {
121
+ { virtualItems.map( ( virtualRow ) => {
120
122
  const item = items[ virtualRow.index ];
121
123
  const isLast = virtualRow.index === items.length - 1;
122
124
  const isFirst =
@@ -141,21 +143,26 @@ export const PopoverMenuList = < T, V extends string >( {
141
143
  </MenuSubheader>
142
144
  );
143
145
  }
144
-
146
+ const isDisabled = item.disabled;
145
147
  return (
146
- <li
148
+ <ListItem
147
149
  key={ virtualRow.key }
148
150
  role="option"
149
151
  aria-selected={ isSelected }
150
- onClick={ ( e ) => {
151
- if ( ( e.target as HTMLElement ).closest( 'button' ) ) {
152
- return;
153
- }
154
- onSelect( item.value );
155
- onClose();
156
- } }
157
- onKeyDown={ ( event ) => {
158
- if ( event.key === 'Enter' ) {
152
+ aria-disabled={ isDisabled }
153
+ onClick={
154
+ isDisabled
155
+ ? undefined
156
+ : ( e: React.MouseEvent< HTMLLIElement > ) => {
157
+ if ( ( e.target as HTMLElement ).closest( 'button' ) ) {
158
+ return;
159
+ }
160
+ onSelect( item.value );
161
+ onClose();
162
+ }
163
+ }
164
+ onKeyDown={ ( event: React.KeyboardEvent< HTMLLIElement > ) => {
165
+ if ( event.key === 'Enter' && ! isDisabled ) {
159
166
  onSelect( item.value );
160
167
  onClose();
161
168
  }
@@ -171,13 +178,13 @@ export const PopoverMenuList = < T, V extends string >( {
171
178
  }
172
179
  } }
173
180
  tabIndex={ isSelected ? 0 : tabIndexFallback }
174
- style={ {
181
+ sx={ {
175
182
  transform: `translateY(${ virtualRow.start + MENU_LIST_PADDING_TOP }px)`,
176
183
  ...( itemStyle ? itemStyle( item ) : {} ),
177
184
  } }
178
185
  >
179
186
  { menuItemContentTemplate ? menuItemContentTemplate( item ) : item.label || item.value }
180
- </li>
187
+ </ListItem>
181
188
  );
182
189
  } ) }
183
190
  </MenuListComponent>
@@ -203,6 +210,9 @@ export const StyledMenuList = styled( MenuList )( ( { theme } ) => ( {
203
210
  '&[aria-selected="true"]': {
204
211
  backgroundColor: theme.palette.action.selected,
205
212
  },
213
+ '&[aria-disabled="true"]': {
214
+ color: theme.palette.text.disabled,
215
+ },
206
216
  cursor: 'pointer',
207
217
  textOverflow: 'ellipsis',
208
218
  position: 'absolute',
@@ -0,0 +1,106 @@
1
+ import * as React from 'react';
2
+ import { useState } from 'react';
3
+ import { AlertTriangleFilledIcon, XIcon } from '@elementor/icons';
4
+ import {
5
+ Button,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogContentText,
10
+ type DialogContentTextProps,
11
+ type DialogProps,
12
+ DialogTitle,
13
+ IconButton,
14
+ Stack,
15
+ } from '@elementor/ui';
16
+
17
+ const TITLE_ID = 'save-changes-dialog';
18
+
19
+ export const SaveChangesDialog = ( { children, onClose }: Pick< DialogProps, 'children' | 'onClose' > ) => (
20
+ <Dialog open onClose={ onClose } aria-labelledby={ TITLE_ID } maxWidth="xs">
21
+ { children }
22
+ </Dialog>
23
+ );
24
+
25
+ const SaveChangesDialogTitle = ( { children, onClose }: React.PropsWithChildren & { onClose?: () => void } ) => (
26
+ <DialogTitle
27
+ id={ TITLE_ID }
28
+ display="flex"
29
+ alignItems="center"
30
+ gap={ 1 }
31
+ sx={ { lineHeight: 1, justifyContent: 'space-between' } }
32
+ >
33
+ <Stack direction="row" alignItems="center" gap={ 1 }>
34
+ <AlertTriangleFilledIcon color="secondary" />
35
+ { children }
36
+ </Stack>
37
+ { onClose && (
38
+ <IconButton onClick={ onClose } size="small">
39
+ <XIcon />
40
+ </IconButton>
41
+ ) }
42
+ </DialogTitle>
43
+ );
44
+
45
+ const SaveChangesDialogContent = ( { children }: React.PropsWithChildren ) => (
46
+ <DialogContent>{ children }</DialogContent>
47
+ );
48
+
49
+ const SaveChangesDialogContentText = ( props: DialogContentTextProps ) => (
50
+ <DialogContentText variant="body2" color="textPrimary" display="flex" flexDirection="column" { ...props } />
51
+ );
52
+
53
+ type Action = {
54
+ label: string;
55
+ action: () => void | Promise< void >;
56
+ };
57
+
58
+ type ConfirmationDialogActionsProps = {
59
+ actions: {
60
+ cancel?: Action;
61
+ confirm: Action;
62
+ discard?: Action;
63
+ };
64
+ };
65
+
66
+ const SaveChangesDialogActions = ( { actions }: ConfirmationDialogActionsProps ) => {
67
+ const [ isConfirming, setIsConfirming ] = useState( false );
68
+ const { cancel, confirm, discard } = actions;
69
+
70
+ const onConfirm = async () => {
71
+ setIsConfirming( true );
72
+ await confirm.action();
73
+ setIsConfirming( false );
74
+ };
75
+ return (
76
+ <DialogActions>
77
+ { cancel && (
78
+ <Button variant="text" color="secondary" onClick={ cancel.action }>
79
+ { cancel.label }
80
+ </Button>
81
+ ) }
82
+ { discard && (
83
+ <Button variant="text" color="secondary" onClick={ discard.action }>
84
+ { discard.label }
85
+ </Button>
86
+ ) }
87
+ <Button variant="contained" color="secondary" onClick={ onConfirm } loading={ isConfirming }>
88
+ { confirm.label }
89
+ </Button>
90
+ </DialogActions>
91
+ );
92
+ };
93
+
94
+ SaveChangesDialog.Title = SaveChangesDialogTitle;
95
+ SaveChangesDialog.Content = SaveChangesDialogContent;
96
+ SaveChangesDialog.ContentText = SaveChangesDialogContentText;
97
+ SaveChangesDialog.Actions = SaveChangesDialogActions;
98
+
99
+ export const useDialog = () => {
100
+ const [ isOpen, setIsOpen ] = useState( false );
101
+
102
+ const open = () => setIsOpen( true );
103
+ const close = () => setIsOpen( false );
104
+
105
+ return { isOpen, open, close };
106
+ };
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { useRef } from 'react';
3
3
  import { SearchIcon, XIcon } from '@elementor/icons';
4
- import { Box, IconButton, InputAdornment, TextField } from '@elementor/ui';
4
+ import { Box, type BoxProps, IconButton, InputAdornment, TextField } from '@elementor/ui';
5
5
  import { __ } from '@wordpress/i18n';
6
6
 
7
7
  const SIZE = 'tiny';
@@ -10,9 +10,10 @@ type Props = {
10
10
  value: string;
11
11
  onSearch: ( search: string ) => void;
12
12
  placeholder: string;
13
- };
13
+ id?: string;
14
+ } & BoxProps;
14
15
 
15
- export const PopoverSearch = ( { value, onSearch, placeholder }: Props ) => {
16
+ export const SearchField = ( { value, onSearch, placeholder, id, sx }: Props ) => {
16
17
  const inputRef = useRef< HTMLInputElement | null >( null );
17
18
 
18
19
  const handleClear = () => {
@@ -26,11 +27,12 @@ export const PopoverSearch = ( { value, onSearch, placeholder }: Props ) => {
26
27
  };
27
28
 
28
29
  return (
29
- <Box sx={ { px: 2, pb: 1.5 } }>
30
+ <Box sx={ { px: 2, pb: 1.5, ...sx } }>
30
31
  <TextField
31
32
  // eslint-disable-next-line jsx-a11y/no-autofocus
32
33
  autoFocus
33
34
  fullWidth
35
+ id={ id }
34
36
  size={ SIZE }
35
37
  value={ value }
36
38
  inputRef={ inputRef }
@@ -9,10 +9,14 @@ interface WarningInfotipProps extends PropsWithChildren {
9
9
  placement: InfotipProps[ 'placement' ];
10
10
  width?: string | number;
11
11
  offset?: number[];
12
+ hasError?: boolean;
12
13
  }
13
14
 
14
15
  export const WarningInfotip = forwardRef(
15
- ( { children, open, title, text, placement, width, offset }: WarningInfotipProps, ref: unknown ) => {
16
+ (
17
+ { children, open, title, text, placement, width, offset, hasError = true }: WarningInfotipProps,
18
+ ref: unknown
19
+ ) => {
16
20
  return (
17
21
  <Infotip
18
22
  ref={ ref }
@@ -27,7 +31,12 @@ export const WarningInfotip = forwardRef(
27
31
  } }
28
32
  arrow={ false }
29
33
  content={
30
- <Alert color="error" severity="warning" variant="standard" size="small">
34
+ <Alert
35
+ color={ hasError ? 'error' : 'secondary' }
36
+ severity="warning"
37
+ variant="standard"
38
+ size="small"
39
+ >
31
40
  { title ? <AlertTitle>{ title }</AlertTitle> : null }
32
41
  { text }
33
42
  </Alert>
@@ -0,0 +1,255 @@
1
+ import * as React from 'react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+
4
+ import { EditableField } from '../../components/editable-field';
5
+ import { useEditable } from '../use-editable';
6
+
7
+ // Test utility component that uses the hook with EditableField
8
+ const TestComponent = ( {
9
+ value,
10
+ onSubmit,
11
+ validation,
12
+ onClick,
13
+ onError,
14
+ }: {
15
+ value: string;
16
+ onSubmit: ( value: string ) => unknown;
17
+ validation?: ( value: string ) => string | null;
18
+ onClick?: ( event: React.MouseEvent< HTMLDivElement > ) => void;
19
+ onError?: ( error: string | null ) => void;
20
+ } ) => {
21
+ const { ref, isEditing, openEditMode, error, getProps } = useEditable( {
22
+ value,
23
+ onSubmit,
24
+ validation,
25
+ onClick,
26
+ onError,
27
+ } );
28
+
29
+ return (
30
+ <div>
31
+ <button onClick={ openEditMode } data-testid="open-edit">
32
+ Open Edit
33
+ </button>
34
+ <EditableField ref={ ref } { ...getProps() } data-testid="editable-field" />
35
+ <div data-testid="editing-state">{ isEditing ? 'editing' : 'not-editing' }</div>
36
+ <div data-testid="error-state">{ error || 'no-error' }</div>
37
+ </div>
38
+ );
39
+ };
40
+
41
+ describe( 'useEditable', () => {
42
+ it( 'should return editable content attributes and handlers', () => {
43
+ // Arrange & Act.
44
+ render( <TestComponent value="" onSubmit={ jest.fn() } /> );
45
+
46
+ // Assert.
47
+ const editableField = screen.getByRole( 'textbox' );
48
+
49
+ expect( editableField ).toHaveAttribute( 'role', 'textbox' );
50
+ expect( editableField ).toHaveAttribute( 'contentEditable', 'false' );
51
+ expect( editableField ).toHaveTextContent( '' );
52
+ } );
53
+
54
+ it( 'should set editable to true', () => {
55
+ // Arrange & Act.
56
+ render( <TestComponent value="" onSubmit={ jest.fn() } /> );
57
+
58
+ // Assert.
59
+ const editableField = screen.getByRole( 'textbox' );
60
+ const editingState = screen.getByText( 'not-editing' );
61
+ const openEditButton = screen.getByRole( 'button', { name: 'Open Edit' } );
62
+
63
+ expect( editingState ).toHaveTextContent( 'not-editing' );
64
+ expect( editableField ).toHaveAttribute( 'contentEditable', 'false' );
65
+
66
+ // Act.
67
+ fireEvent.click( openEditButton );
68
+
69
+ // Assert.
70
+ expect( screen.getByText( 'editing' ) ).toBeInTheDocument();
71
+ expect( editableField ).toHaveAttribute( 'contentEditable', 'true' );
72
+ } );
73
+
74
+ it( 'should call onSubmit with the new value on enter', async () => {
75
+ // Arrange.
76
+ const onSubmit = jest.fn();
77
+ const newValue = 'New value';
78
+ const validation = jest.fn().mockReturnValue( null );
79
+
80
+ render( <TestComponent value={ 'Some value' } onSubmit={ onSubmit } validation={ validation } /> );
81
+
82
+ const editableField = screen.getByRole( 'textbox' );
83
+ const openEditButton = screen.getByRole( 'button', { name: 'Open Edit' } );
84
+
85
+ // Mock the blur method to trigger onBlur event
86
+ mockBlur( editableField );
87
+
88
+ // Act.
89
+ fireEvent.click( openEditButton );
90
+
91
+ fireEvent.input( editableField, { target: { innerText: newValue } } );
92
+
93
+ // Assert.
94
+ expect( validation ).toHaveBeenCalledWith( newValue );
95
+
96
+ // Act.
97
+ fireEvent.keyDown( editableField, { key: 'Enter' } );
98
+
99
+ // Assert.
100
+ await waitFor( () => {
101
+ expect( onSubmit ).toHaveBeenCalledWith( newValue );
102
+ } );
103
+ } );
104
+
105
+ it( 'should remove the editable content attribute on blur', () => {
106
+ // Arrange & Act.
107
+ render( <TestComponent value="" onSubmit={ jest.fn() } /> );
108
+
109
+ fireEvent.click( screen.getByRole( 'button', { name: 'Open Edit' } ) );
110
+
111
+ // Assert.
112
+ expect( screen.getByText( 'editing' ) ).toBeInTheDocument();
113
+
114
+ // Act.
115
+ fireEvent.blur( screen.getByRole( 'textbox' ) );
116
+
117
+ // Assert.
118
+ expect( screen.getByText( 'not-editing' ) ).toBeInTheDocument();
119
+ } );
120
+
121
+ it( 'should call onSubmit with the new value on blur', () => {
122
+ // Arrange.
123
+ const onSubmit = jest.fn();
124
+ const newValue = 'New value';
125
+ const validation = jest.fn().mockReturnValue( null );
126
+
127
+ render( <TestComponent value={ 'Some value' } onSubmit={ onSubmit } validation={ validation } /> );
128
+
129
+ const editableField = screen.getByRole( 'textbox' );
130
+
131
+ // Act.
132
+ fireEvent.click( screen.getByRole( 'button', { name: 'Open Edit' } ) );
133
+
134
+ fireEvent.input( editableField, { target: { innerText: newValue } } );
135
+
136
+ // Assert.
137
+ expect( validation ).toHaveBeenCalledWith( newValue );
138
+
139
+ // Act.
140
+ fireEvent.blur( editableField );
141
+
142
+ // Assert.
143
+ expect( onSubmit ).toHaveBeenCalledWith( newValue );
144
+ } );
145
+
146
+ it( 'should set error message id validation fails on enter, and keep the edit mode open', () => {
147
+ // Arrange.
148
+ const newValue = 'invalid-value';
149
+ const onSubmit = jest.fn();
150
+
151
+ const validation = ( v: string ) => {
152
+ if ( v === newValue ) {
153
+ return 'Nope';
154
+ }
155
+
156
+ return null;
157
+ };
158
+
159
+ render( <TestComponent value={ 'Some value' } onSubmit={ onSubmit } validation={ validation } /> );
160
+
161
+ // Act.
162
+ fireEvent.click( screen.getByRole( 'button', { name: 'Open Edit' } ) );
163
+
164
+ // Assert.
165
+ expect( screen.getByText( 'no-error' ) ).toBeInTheDocument();
166
+
167
+ // Act.
168
+ const editableField = screen.getByRole( 'textbox' );
169
+ fireEvent.input( editableField, { target: { innerText: newValue } } );
170
+
171
+ // Assert.
172
+ expect( screen.getByText( 'Nope' ) ).toBeInTheDocument();
173
+
174
+ // Act.
175
+ fireEvent.keyDown( editableField, { key: 'Enter' } );
176
+
177
+ // Assert.
178
+ expect( onSubmit ).not.toHaveBeenCalled();
179
+ expect( editableField ).toHaveAttribute( 'contentEditable', 'true' );
180
+ } );
181
+
182
+ it( 'should not run validation & submit if the value has not changed', () => {
183
+ // Arrange.
184
+ const value = 'initial value';
185
+ const onSubmit = jest.fn();
186
+
187
+ const validation = () => {
188
+ return 'test-error';
189
+ };
190
+
191
+ render( <TestComponent value={ value } onSubmit={ onSubmit } validation={ validation } /> );
192
+
193
+ const editableField = screen.getByRole( 'textbox' );
194
+ const openEditButton = screen.getByRole( 'button', { name: 'Open Edit' } );
195
+
196
+ // Act.
197
+ fireEvent.click( openEditButton );
198
+
199
+ // Assert.
200
+ expect( screen.getByText( 'no-error' ) ).toBeInTheDocument();
201
+
202
+ // Act.
203
+ fireEvent.input( editableField, { target: { innerText: 'new value' } } );
204
+
205
+ // Assert.
206
+ expect( screen.getByText( 'test-error' ) ).toBeInTheDocument();
207
+
208
+ // Act.
209
+ fireEvent.input( editableField, { target: { innerText: value } } );
210
+
211
+ // Assert.
212
+ expect( screen.getByText( 'no-error' ) ).toBeInTheDocument();
213
+
214
+ // Act.
215
+ fireEvent.keyDown( editableField, { key: 'Enter' } );
216
+
217
+ // Assert.
218
+ expect( onSubmit ).not.toHaveBeenCalled();
219
+ } );
220
+
221
+ it( 'should not submit, and only close the edit mode on blur if there is an error', () => {
222
+ // Arrange.
223
+ const onSubmit = jest.fn();
224
+ const newValue = 'invalid-value';
225
+ const validation = jest.fn().mockReturnValue( 'Nope' );
226
+
227
+ render( <TestComponent value={ 'Some value' } onSubmit={ onSubmit } validation={ validation } /> );
228
+
229
+ const editableField = screen.getByRole( 'textbox' );
230
+ const openEditButton = screen.getByRole( 'button', { name: 'Open Edit' } );
231
+
232
+ // Act.
233
+ fireEvent.click( openEditButton );
234
+
235
+ fireEvent.input( editableField, { target: { innerText: newValue } } );
236
+
237
+ // Assert.
238
+ expect( validation ).toHaveBeenCalledWith( newValue );
239
+
240
+ // Act.
241
+ fireEvent.blur( editableField );
242
+
243
+ // Assert.
244
+ expect( onSubmit ).not.toHaveBeenCalled();
245
+ expect( screen.getByText( 'not-editing' ) ).toBeInTheDocument();
246
+ } );
247
+ } );
248
+
249
+ function mockBlur( element: HTMLElement ) {
250
+ const originalBlur = element.blur;
251
+ element.blur = jest.fn( () => {
252
+ originalBlur.call( element );
253
+ fireEvent.blur( element );
254
+ } );
255
+ }
@@ -21,8 +21,6 @@ export const useEditable = ( { value, onSubmit, validation, onClick, onError }:
21
21
  };
22
22
 
23
23
  const closeEditMode = () => {
24
- ref.current?.blur();
25
-
26
24
  setError( null );
27
25
  onError?.( null );
28
26
  setIsEditing( false );
@@ -30,6 +28,7 @@ export const useEditable = ( { value, onSubmit, validation, onClick, onError }:
30
28
 
31
29
  const submit = ( newValue: string ) => {
32
30
  if ( ! isDirty( newValue ) ) {
31
+ closeEditMode();
33
32
  return;
34
33
  }
35
34
 
@@ -62,7 +61,10 @@ export const useEditable = ( { value, onSubmit, validation, onClick, onError }:
62
61
 
63
62
  if ( [ 'Enter' ].includes( event.key ) ) {
64
63
  event.preventDefault();
65
- return submit( ( event.target as HTMLElement ).innerText );
64
+ // submission is invoked only on blur, to avoid issues with double-submission in certain cases
65
+ if ( ! error ) {
66
+ ref.current?.blur();
67
+ }
66
68
  }
67
69
  };
68
70
 
@@ -74,11 +76,20 @@ export const useEditable = ( { value, onSubmit, validation, onClick, onError }:
74
76
  onClick?.( event );
75
77
  };
76
78
 
79
+ const handleBlur = () => {
80
+ if ( error ) {
81
+ closeEditMode();
82
+ return;
83
+ }
84
+
85
+ submit( ( ref.current as HTMLElement ).innerText );
86
+ };
87
+
77
88
  const listeners = {
78
89
  onClick: handleClick,
79
90
  onKeyDown: handleKeyDown,
80
91
  onInput: onChange,
81
- onBlur: closeEditMode,
92
+ onBlur: handleBlur,
82
93
  } as const;
83
94
 
84
95
  const attributes = {
package/src/index.ts CHANGED
@@ -7,7 +7,12 @@ export { MenuListItem, MenuItemInfotip } from './components/menu-item';
7
7
  export { InfoTipCard } from './components/infotip-card';
8
8
  export { InfoAlert } from './components/info-alert';
9
9
  export { WarningInfotip } from './components/warning-infotip';
10
+ export { GlobalDialog, openDialog, closeDialog } from './components/global-dialog';
11
+ export { SearchField } from './components/search-field';
12
+ export { Form } from './components/form';
13
+
10
14
  export * from './components/popover';
15
+ export * from './components/save-changes-dialog';
11
16
 
12
17
  // hooks
13
18
  export { useEditable } from './hooks/use-editable';