@elementor/editor-canvas 4.2.0-beta1 → 4.2.3

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,7 +1,11 @@
1
1
  import type { V1Element } from '@elementor/editor-elements';
2
2
 
3
+ import { parseXml } from '../../__tests__/parse-xml';
3
4
  import {
4
5
  clipboardRootsAreAtomicForms,
6
+ collectEmptyMessageErrors,
7
+ collectFormAncestorErrors,
8
+ collectSubmitButtonErrors,
5
9
  FORM_ELEMENT_TYPE,
6
10
  getClipboardElementType,
7
11
  movedContainersIncludeAtomicFormRoot,
@@ -67,4 +71,218 @@ describe( 'form-structure utils', () => {
67
71
  expect( clipboardRootsAreAtomicForms( [] ) ).toBe( false );
68
72
  } );
69
73
  } );
74
+
75
+ describe( 'collectFormAncestorErrors', () => {
76
+ it( 'returns no errors when form field is a direct child of e-form', () => {
77
+ const xml = parseXml( `
78
+ <e-form>
79
+ <e-form-input />
80
+ </e-form>
81
+ ` );
82
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
83
+ } );
84
+ it( 'returns no errors when form field is a deep descendant of e-form (valid)', () => {
85
+ const xml = parseXml( `
86
+ <e-form>
87
+ <e-flexbox>
88
+ <e-form-input />
89
+ </e-flexbox>
90
+ </e-form>
91
+ ` );
92
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
93
+ } );
94
+ it( 'returns no errors when e-form wraps multiple containers with fields (valid)', () => {
95
+ const xml = parseXml( `
96
+ <e-flexbox>
97
+ <e-form>
98
+ <e-form-input />
99
+ </e-form>
100
+ </e-flexbox>
101
+ ` );
102
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
103
+ } );
104
+ it( 'returns an error when form field has no e-form ancestor (invalid)', () => {
105
+ const xml = parseXml( `
106
+ <e-flexbox>
107
+ <e-flexbox>
108
+ <e-form-input />
109
+ </e-flexbox>
110
+ </e-flexbox>
111
+ ` );
112
+ const errors = collectFormAncestorErrors( xml );
113
+ expect( errors ).toHaveLength( 1 );
114
+ expect( errors[ 0 ] ).toContain( 'e-form-input' );
115
+ expect( errors[ 0 ] ).toContain( 'e-form' );
116
+ } );
117
+ it( 'returns an error for each orphaned form field', () => {
118
+ const xml = parseXml( `
119
+ <e-flexbox>
120
+ <e-form-input />
121
+ <e-form-label />
122
+ </e-flexbox>
123
+ ` );
124
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 2 );
125
+ } );
126
+ it( 'includes configuration-id in the error message when present', () => {
127
+ const xml = parseXml( `
128
+ <e-flexbox>
129
+ <e-form-input configuration-id="my-input" />
130
+ </e-flexbox>
131
+ ` );
132
+ const errors = collectFormAncestorErrors( xml );
133
+ expect( errors[ 0 ] ).toContain( 'configuration-id="my-input"' );
134
+ } );
135
+ it( 'returns no errors when there are no form fields at all', () => {
136
+ const xml = parseXml( `
137
+ <e-flexbox>
138
+ <e-heading />
139
+ </e-flexbox>
140
+ ` );
141
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
142
+ } );
143
+ } );
144
+
145
+ describe( 'collectSubmitButtonErrors', () => {
146
+ it( 'returns no errors when e-form has exactly one submit button', () => {
147
+ const xml = parseXml( `
148
+ <e-form>
149
+ <e-form-input />
150
+ <e-form-submit-button />
151
+ </e-form>
152
+ ` );
153
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
154
+ } );
155
+
156
+ it( 'returns no errors when submit button is nested inside a container within e-form', () => {
157
+ const xml = parseXml( `
158
+ <e-form>
159
+ <e-flexbox>
160
+ <e-form-submit-button />
161
+ </e-flexbox>
162
+ </e-form>
163
+ ` );
164
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
165
+ } );
166
+
167
+ it( 'returns an error when e-form has no submit button', () => {
168
+ const xml = parseXml( `
169
+ <e-form>
170
+ <e-form-input />
171
+ </e-form>
172
+ ` );
173
+ const errors = collectSubmitButtonErrors( xml );
174
+ expect( errors ).toHaveLength( 1 );
175
+ expect( errors[ 0 ] ).toContain( 'e-form-submit-button' );
176
+ } );
177
+
178
+ it( 'returns an error when e-form has more than one submit button', () => {
179
+ const xml = parseXml( `
180
+ <e-form>
181
+ <e-form-submit-button />
182
+ <e-form-submit-button />
183
+ </e-form>
184
+ ` );
185
+ const errors = collectSubmitButtonErrors( xml );
186
+ expect( errors ).toHaveLength( 1 );
187
+ expect( errors[ 0 ] ).toContain( '2' );
188
+ } );
189
+
190
+ it( 'returns one error per e-form that is missing a submit button', () => {
191
+ const xml = parseXml( `
192
+ <e-form>
193
+ <e-form-input />
194
+ </e-form>
195
+ <e-form>
196
+ <e-form-input />
197
+ </e-form>
198
+ ` );
199
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 2 );
200
+ } );
201
+
202
+ it( 'returns no errors when there are no e-form elements', () => {
203
+ const xml = parseXml( `
204
+ <e-flexbox>
205
+ <e-heading />
206
+ </e-flexbox>
207
+ ` );
208
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
209
+ } );
210
+ } );
211
+
212
+ describe( 'collectEmptyMessageErrors', () => {
213
+ it( 'returns no errors when success message has children', () => {
214
+ const xml = parseXml( `
215
+ <e-form>
216
+ <e-form-success-message>
217
+ <e-atomic-paragraph />
218
+ </e-form-success-message>
219
+ </e-form>
220
+ ` );
221
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
222
+ } );
223
+
224
+ it( 'returns no errors when error message has children', () => {
225
+ const xml = parseXml( `
226
+ <e-form>
227
+ <e-form-error-message>
228
+ <e-atomic-paragraph />
229
+ </e-form-error-message>
230
+ </e-form>
231
+ ` );
232
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
233
+ } );
234
+
235
+ it( 'returns an error when success message is empty', () => {
236
+ const xml = parseXml( `
237
+ <e-form>
238
+ <e-form-success-message />
239
+ </e-form>
240
+ ` );
241
+ const errors = collectEmptyMessageErrors( xml );
242
+ expect( errors ).toHaveLength( 1 );
243
+ expect( errors[ 0 ] ).toContain( 'e-form-success-message' );
244
+ } );
245
+
246
+ it( 'returns an error when error message is empty', () => {
247
+ const xml = parseXml( `
248
+ <e-form>
249
+ <e-form-error-message />
250
+ </e-form>
251
+ ` );
252
+ const errors = collectEmptyMessageErrors( xml );
253
+ expect( errors ).toHaveLength( 1 );
254
+ expect( errors[ 0 ] ).toContain( 'e-form-error-message' );
255
+ } );
256
+
257
+ it( 'returns two errors when both success and error messages are empty', () => {
258
+ const xml = parseXml( `
259
+ <e-form>
260
+ <e-form-success-message />
261
+ <e-form-error-message />
262
+ </e-form>
263
+ ` );
264
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 2 );
265
+ } );
266
+
267
+ it( 'returns no errors when there are no message elements', () => {
268
+ const xml = parseXml( `
269
+ <e-form>
270
+ <e-form-input />
271
+ <e-form-submit-button />
272
+ </e-form>
273
+ ` );
274
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
275
+ } );
276
+
277
+ it( 'returns no errors when message element is outside e-form but has children', () => {
278
+ const xml = parseXml( `
279
+ <e-flexbox>
280
+ <e-form-success-message>
281
+ <e-atomic-paragraph />
282
+ </e-form-success-message>
283
+ </e-flexbox>
284
+ ` );
285
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
286
+ } );
287
+ } );
70
288
  } );
@@ -5,9 +5,11 @@ import { __ } from '@wordpress/i18n';
5
5
  import {
6
6
  clipboardRootsAreAtomicForms,
7
7
  type CreateArgs,
8
+ FORM_ELEMENT_TYPE,
8
9
  FORM_FIELD_ELEMENT_TYPES,
9
10
  getArgsElementType,
10
11
  hasClipboardElementTypes,
12
+ hasElementType,
11
13
  hasElementTypes,
12
14
  isWithinForm,
13
15
  type MoveArgs,
@@ -46,7 +48,9 @@ function blockFormFieldCreate( args: CreateArgs ): boolean {
46
48
  return false;
47
49
  }
48
50
 
49
- if ( ! isWithinForm( args.container ) ) {
51
+ const containers = args.containers ?? [ args.container ];
52
+
53
+ if ( containers.some( ( container ) => ! isWithinForm( container ) ) ) {
50
54
  handleBlockedFormField();
51
55
 
52
56
  return true;
@@ -58,11 +62,14 @@ function blockFormFieldCreate( args: CreateArgs ): boolean {
58
62
  function blockFormFieldMove( args: MoveArgs ): boolean {
59
63
  const { containers = [ args.container ], target } = args;
60
64
 
61
- const hasFormFieldElement = containers.some( ( container ) =>
62
- container ? hasElementTypes( container, FORM_FIELD_ELEMENT_TYPES ) : false
65
+ // Form fields must not be outside a form, but can be moved while inside a form or the container IS a form [ED-23858]
66
+ const hasLooseFormFields = containers.some( ( container ) =>
67
+ container
68
+ ? ! hasElementType( container, FORM_ELEMENT_TYPE ) && hasElementTypes( container, FORM_FIELD_ELEMENT_TYPES )
69
+ : false
63
70
  );
64
71
 
65
- if ( hasFormFieldElement && ! isWithinForm( target ) && ! movedContainersIncludeAtomicFormRoot( containers ) ) {
72
+ if ( hasLooseFormFields && ! isWithinForm( target ) && ! movedContainersIncludeAtomicFormRoot( containers ) ) {
66
73
  handleBlockedFormField();
67
74
 
68
75
  return true;
@@ -7,6 +7,7 @@ type Model = {
7
7
 
8
8
  export type CreateArgs = {
9
9
  container?: V1Element;
10
+ containers?: V1Element[];
10
11
  model?: Model;
11
12
  };
12
13
 
@@ -114,3 +115,49 @@ export function clipboardRootsAreAtomicForms( elements: ClipboardElement[] ): bo
114
115
 
115
116
  return elements.every( ( el ) => getClipboardElementType( el ) === FORM_ELEMENT_TYPE );
116
117
  }
118
+
119
+ export function hasFormAncestor( node: Element ): boolean {
120
+ return node.closest( FORM_ELEMENT_TYPE ) !== null;
121
+ }
122
+
123
+ export function collectFormAncestorErrors( xml: Document ): string[] {
124
+ const errors: string[] = [];
125
+ for ( const node of xml.querySelectorAll( '*' ) ) {
126
+ if ( ! FORM_FIELD_ELEMENT_TYPES.has( node.tagName.toLowerCase() ) ) {
127
+ continue;
128
+ }
129
+ if ( hasFormAncestor( node ) ) {
130
+ continue;
131
+ }
132
+ const id = node.getAttribute( 'configuration-id' );
133
+ errors.push(
134
+ `<${ node.tagName }${
135
+ id ? ` configuration-id="${ id }"` : ''
136
+ }> must be nested inside <e-form> (any ancestor depth is allowed).`
137
+ );
138
+ }
139
+ return errors;
140
+ }
141
+
142
+ export function collectSubmitButtonErrors( xml: Document ): string[] {
143
+ const errors: string[] = [];
144
+ for ( const form of xml.querySelectorAll( 'e-form' ) ) {
145
+ const submitButtons = form.querySelectorAll( 'e-form-submit-button' );
146
+ if ( submitButtons.length === 0 ) {
147
+ errors.push( `<e-form> has no <e-form-submit-button>.` );
148
+ } else if ( submitButtons.length > 1 ) {
149
+ errors.push( `<e-form> has ${ submitButtons.length } submit buttons — only 1 is allowed.` );
150
+ }
151
+ }
152
+ return errors;
153
+ }
154
+
155
+ export function collectEmptyMessageErrors( xml: Document ): string[] {
156
+ const errors: string[] = [];
157
+ for ( const node of xml.querySelectorAll( 'e-form-success-message, e-form-error-message' ) ) {
158
+ if ( node.children.length === 0 ) {
159
+ errors.push( `<${ node.tagName }> must have at least one child element (e.g. <e-atomic-paragraph>).` );
160
+ }
161
+ }
162
+ return errors;
163
+ }
@@ -80,14 +80,14 @@ export function initStyleTransformers() {
80
80
  )
81
81
  .register( 'flex', flexTransformer )
82
82
  .register(
83
- 'border-width',
83
+ 'border-width-v2',
84
84
  createMultiPropsTransformer(
85
85
  [ 'block-start', 'block-end', 'inline-start', 'inline-end' ],
86
86
  ( { key } ) => `border-${ key }-width`
87
87
  )
88
88
  )
89
89
  .register(
90
- 'border-radius',
90
+ 'border-radius-v2',
91
91
  createMultiPropsTransformer(
92
92
  [ 'start-start', 'start-end', 'end-start', 'end-end' ],
93
93
  ( { key } ) => `border-${ key }-radius`
@@ -84,8 +84,11 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
84
84
  compositionBuilder.setStylesConfig( stylesConfig );
85
85
  compositionBuilder.setCustomCSS( customCSS );
86
86
 
87
- const { configErrors, rootContainers: generatedRootContainers } =
88
- await compositionBuilder.build( targetContainer );
87
+ const {
88
+ configErrors,
89
+ formErrors,
90
+ rootContainers: generatedRootContainers,
91
+ } = await compositionBuilder.build( targetContainer );
89
92
 
90
93
  rootContainers.push( ...generatedRootContainers );
91
94
  generatedXML = new XMLSerializer().serializeToString( compositionBuilder.getXML() );
@@ -105,6 +108,10 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
105
108
  if ( configErrors.length ) {
106
109
  errors.push( ...configErrors.map( ( msg ) => new Error( msg ) ) );
107
110
  }
111
+
112
+ if ( formErrors.length ) {
113
+ errors.push( ...formErrors.map( ( msg ) => new Error( msg ) ) );
114
+ }
108
115
  } catch ( error ) {
109
116
  errors.push( error as Error );
110
117
  }
@@ -5,12 +5,14 @@ export type ElementOverlayProps = {
5
5
  id: string;
6
6
  isSelected: boolean;
7
7
  isGlobal?: boolean;
8
+ widgetType?: string;
8
9
  };
9
10
 
10
11
  export type OverlayFilterArgs = {
11
12
  id: string;
12
13
  element: HTMLElement;
13
14
  isSelected: boolean;
15
+ widgetType?: string;
14
16
  };
15
17
 
16
18
  export type ElementOverlayConfig = {
@@ -3,6 +3,7 @@ import {
3
3
  computeCellRects,
4
4
  computeGridLines,
5
5
  parseTrackList,
6
+ resolveGapPx,
6
7
  snapToHalfPixel,
7
8
  toGridTracks,
8
9
  toPx,
@@ -215,11 +216,29 @@ describe( 'toPx', () => {
215
216
  } );
216
217
  } );
217
218
 
219
+ describe( 'resolveGapPx', () => {
220
+ it( 'resolves a percentage gap against the reference size', () => {
221
+ expect( resolveGapPx( '20%', '1000px' ) ).toBe( 200 );
222
+ expect( resolveGapPx( '10%', '500px' ) ).toBe( 50 );
223
+ } );
224
+
225
+ it( 'falls back to px parsing for non-percentage gaps', () => {
226
+ expect( resolveGapPx( '16px', '1000px' ) ).toBe( 16 );
227
+ expect( resolveGapPx( 'normal', '1000px' ) ).toBe( 0 );
228
+ } );
229
+
230
+ it( 'returns 0 when the reference size is not a finite number', () => {
231
+ expect( resolveGapPx( '20%', 'auto' ) ).toBe( 0 );
232
+ } );
233
+ } );
234
+
218
235
  type ComputedStyleParts = {
219
236
  gridTemplateColumns: string;
220
237
  gridTemplateRows: string;
221
238
  columnGap: string;
222
239
  rowGap: string;
240
+ width: string;
241
+ height: string;
223
242
  paddingTop: string;
224
243
  paddingRight: string;
225
244
  paddingBottom: string;
@@ -233,6 +252,8 @@ function mockComputedStyle( parts: Partial< ComputedStyleParts > = {} ): CSSStyl
233
252
  gridTemplateRows: 'none',
234
253
  columnGap: 'normal',
235
254
  rowGap: 'normal',
255
+ width: '1000px',
256
+ height: '500px',
236
257
  paddingTop: '0px',
237
258
  paddingRight: '0px',
238
259
  paddingBottom: '0px',
@@ -246,6 +267,8 @@ function mockComputedStyle( parts: Partial< ComputedStyleParts > = {} ): CSSStyl
246
267
  gridTemplateRows: resolved.gridTemplateRows,
247
268
  columnGap: resolved.columnGap,
248
269
  rowGap: resolved.rowGap,
270
+ width: resolved.width,
271
+ height: resolved.height,
249
272
  paddingTop: resolved.paddingTop,
250
273
  paddingRight: resolved.paddingRight,
251
274
  paddingBottom: resolved.paddingBottom,
@@ -286,6 +309,22 @@ describe( 'toGridTracks', () => {
286
309
  expect( tracks.rows ).toEqual( [] );
287
310
  } );
288
311
 
312
+ it( 'resolves percentage gaps against the content-box width and height', () => {
313
+ const tracks = toGridTracks(
314
+ mockComputedStyle( {
315
+ gridTemplateColumns: '100px 100px',
316
+ gridTemplateRows: '80px 80px',
317
+ columnGap: '20%',
318
+ rowGap: '10%',
319
+ width: '1000px',
320
+ height: '500px',
321
+ } )
322
+ );
323
+
324
+ expect( tracks.columnGap ).toBe( 200 );
325
+ expect( tracks.rowGap ).toBe( 50 );
326
+ } );
327
+
289
328
  it( 'reports gap as 0 when computed style returns "normal"', () => {
290
329
  const tracks = toGridTracks(
291
330
  mockComputedStyle( { gridTemplateColumns: '100px 100px', columnGap: 'normal', rowGap: 'normal' } )
@@ -0,0 +1,39 @@
1
+ import { shouldUseSmallerOutlineOffset, THIN_ELEMENT_MAX_HEIGHT_PX } from '../outline-offset-utils';
2
+
3
+ describe( 'shouldUseSmallerOutlineOffset', () => {
4
+ it( 'returns true for thin elements like dividers', () => {
5
+ // Arrange.
6
+ const element = document.createElement( 'hr' );
7
+ Object.defineProperty( element, 'offsetHeight', { value: THIN_ELEMENT_MAX_HEIGHT_PX } );
8
+
9
+ // Act.
10
+ const result = shouldUseSmallerOutlineOffset( element );
11
+
12
+ // Assert.
13
+ expect( result ).toBe( true );
14
+ } );
15
+
16
+ it( 'returns true for atomic form input widgets', () => {
17
+ // Arrange.
18
+ const element = document.createElement( 'input' );
19
+ Object.defineProperty( element, 'offsetHeight', { value: 36 } );
20
+
21
+ // Act.
22
+ const result = shouldUseSmallerOutlineOffset( element, 'e-form-input' );
23
+
24
+ // Assert.
25
+ expect( result ).toBe( true );
26
+ } );
27
+
28
+ it( 'returns false for other atomic widgets', () => {
29
+ // Arrange.
30
+ const element = document.createElement( 'div' );
31
+ Object.defineProperty( element, 'offsetHeight', { value: 100 } );
32
+
33
+ // Act.
34
+ const result = shouldUseSmallerOutlineOffset( element, 'e-heading' );
35
+
36
+ // Assert.
37
+ expect( result ).toBe( false );
38
+ } );
39
+ } );
@@ -28,8 +28,8 @@ export function toGridTracks( computedStyle: CSSStyleDeclaration ): GridTracks {
28
28
  return {
29
29
  columns: parseTrackList( computedStyle.gridTemplateColumns ),
30
30
  rows: parseTrackList( computedStyle.gridTemplateRows ),
31
- columnGap: toPx( computedStyle.columnGap ),
32
- rowGap: toPx( computedStyle.rowGap ),
31
+ columnGap: resolveGapPx( computedStyle.columnGap, computedStyle.width ),
32
+ rowGap: resolveGapPx( computedStyle.rowGap, computedStyle.height ),
33
33
  padding: {
34
34
  top: toPx( computedStyle.paddingTop ),
35
35
  right: toPx( computedStyle.paddingRight ),
@@ -142,3 +142,14 @@ export function toPx( value: string ): number {
142
142
 
143
143
  return Number.isFinite( parsed ) ? parsed : 0;
144
144
  }
145
+
146
+ export function resolveGapPx( value: string, referenceSize: string ): number {
147
+ if ( value.trim().endsWith( '%' ) ) {
148
+ const percent = parseFloat( value );
149
+ const reference = parseFloat( referenceSize );
150
+
151
+ return Number.isFinite( percent ) && Number.isFinite( reference ) ? ( percent / 100 ) * reference : 0;
152
+ }
153
+
154
+ return toPx( value );
155
+ }
@@ -0,0 +1,11 @@
1
+ export const THIN_ELEMENT_MAX_HEIGHT_PX = 1;
2
+
3
+ export const SMALLER_OUTLINE_OFFSET_WIDGET_TYPES = new Set( [ 'e-form-input' ] );
4
+
5
+ export function shouldUseSmallerOutlineOffset( element: HTMLElement, widgetType?: string ): boolean {
6
+ if ( element.offsetHeight <= THIN_ELEMENT_MAX_HEIGHT_PX ) {
7
+ return true;
8
+ }
9
+
10
+ return widgetType !== undefined && SMALLER_OUTLINE_OFFSET_WIDGET_TYPES.has( widgetType );
11
+ }