@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
|
@@ -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
|
-
{
|
|
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
|
-
<
|
|
148
|
+
<ListItem
|
|
147
149
|
key={ virtualRow.key }
|
|
148
150
|
role="option"
|
|
149
151
|
aria-selected={ isSelected }
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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';
|