@elementor/editor-controls 4.4.0-beta1 → 4.4.0-beta3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elementor/editor-controls",
3
3
  "description": "This package contains the controls model and utils for the Elementor editor",
4
- "version": "4.4.0-beta1",
4
+ "version": "4.4.0-beta3",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -40,23 +40,23 @@
40
40
  "dev": "tsup --config=../../tsup.dev.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@elementor/editor-current-user": "4.4.0-beta1",
44
- "@elementor/editor-elements": "4.4.0-beta1",
45
- "@elementor/editor-props": "4.4.0-beta1",
46
- "@elementor/editor-responsive": "4.4.0-beta1",
47
- "@elementor/editor-ui": "4.4.0-beta1",
48
- "@elementor/editor-v1-adapters": "4.4.0-beta1",
49
- "@elementor/env": "4.4.0-beta1",
50
- "@elementor/events": "4.4.0-beta1",
51
- "@elementor/http-client": "4.4.0-beta1",
43
+ "@elementor/editor-current-user": "4.4.0-beta3",
44
+ "@elementor/editor-elements": "4.4.0-beta3",
45
+ "@elementor/editor-props": "4.4.0-beta3",
46
+ "@elementor/editor-responsive": "4.4.0-beta3",
47
+ "@elementor/editor-ui": "4.4.0-beta3",
48
+ "@elementor/editor-v1-adapters": "4.4.0-beta3",
49
+ "@elementor/env": "4.4.0-beta3",
50
+ "@elementor/events": "4.4.0-beta3",
51
+ "@elementor/http-client": "4.4.0-beta3",
52
52
  "@elementor/icons": "~1.78.1",
53
- "@elementor/locations": "4.4.0-beta1",
54
- "@elementor/query": "4.4.0-beta1",
55
- "@elementor/schema": "4.4.0-beta1",
56
- "@elementor/session": "4.4.0-beta1",
53
+ "@elementor/locations": "4.4.0-beta3",
54
+ "@elementor/query": "4.4.0-beta3",
55
+ "@elementor/schema": "4.4.0-beta3",
56
+ "@elementor/session": "4.4.0-beta3",
57
57
  "@elementor/ui": "1.41.4",
58
- "@elementor/utils": "4.4.0-beta1",
59
- "@elementor/wp-media": "4.4.0-beta1",
58
+ "@elementor/utils": "4.4.0-beta3",
59
+ "@elementor/wp-media": "4.4.0-beta3",
60
60
  "@monaco-editor/react": "^4.7.0",
61
61
  "@tanstack/react-virtual": "^3.13.3",
62
62
  "@tiptap/extension-bold": "^3.11.1",
@@ -1,7 +1,8 @@
1
1
  import * as React from 'react';
2
2
  import { useLayoutEffect, useRef, useState } from 'react';
3
+ import { useRovingFocus } from '@elementor/editor-ui';
3
4
  import { Box, Tooltip, useTheme } from '@elementor/ui';
4
- import { useVirtualizer } from '@tanstack/react-virtual';
5
+ import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual';
5
6
  import { __ } from '@wordpress/i18n';
6
7
 
7
8
  import { type FontAwesome7Icon } from './font-awesome-7-catalog';
@@ -63,8 +64,6 @@ export const IconLibraryGrid = ( {
63
64
  }: IconLibraryGridProps ) => {
64
65
  const theme = useTheme();
65
66
  const containerRef = useRef< HTMLDivElement >( null );
66
- const cellRefs = useRef( new Map< string, HTMLButtonElement >() );
67
- const shouldRestoreFocusRef = useRef( false );
68
67
  const selectedIndex = items.findIndex( ( item ) => item.id === selectedValue );
69
68
  const [ focusedIndex, setFocusedIndex ] = useState( selectedIndex >= 0 ? selectedIndex : 0 );
70
69
  const [ { columnCount, cellSize }, setGridMetrics ] = useState( () => getGridMetrics( 0, 0, 0 ) );
@@ -79,6 +78,16 @@ export const IconLibraryGrid = ( {
79
78
  paddingStart: verticalPadding,
80
79
  paddingEnd: verticalPadding,
81
80
  overscan: GRID_OVERSCAN,
81
+ rangeExtractor: ( range ) => {
82
+ const rowIndexes = defaultRangeExtractor( range );
83
+ const focusedRow = Math.floor( focusedIndex / columnCount );
84
+
85
+ if ( items.length === 0 || rowIndexes.includes( focusedRow ) ) {
86
+ return rowIndexes;
87
+ }
88
+
89
+ return [ ...rowIndexes, focusedRow ].sort( ( a, b ) => a - b );
90
+ },
82
91
  } );
83
92
  const focusedItem = items[ focusedIndex ];
84
93
  const visibleRowIndexes = virtualizer.getVirtualIndexes().join( ',' );
@@ -103,20 +112,10 @@ export const IconLibraryGrid = ( {
103
112
  // eslint-disable-next-line react-hooks/exhaustive-deps
104
113
  }, [ cellSize, columnCount, items, selectedIndex, selectedValue ] );
105
114
 
106
- useLayoutEffect( () => {
107
- if ( ! shouldRestoreFocusRef.current || ! focusedItem ) {
108
- return;
109
- }
110
-
111
- const cell = cellRefs.current.get( focusedItem.id );
112
-
113
- if ( ! cell ) {
114
- return;
115
- }
116
-
117
- cell.focus();
118
- shouldRestoreFocusRef.current = false;
119
- }, [ focusedItem, visibleRowIndexes ] );
115
+ const { registerRef, requestFocus } = useRovingFocus< HTMLButtonElement >( {
116
+ tabbableKey: focusedItem?.id,
117
+ mountedSignature: visibleRowIndexes,
118
+ } );
120
119
 
121
120
  useLayoutEffect( () => {
122
121
  const container = containerRef.current;
@@ -152,7 +151,7 @@ export const IconLibraryGrid = ( {
152
151
  return;
153
152
  }
154
153
 
155
- shouldRestoreFocusRef.current = true;
154
+ requestFocus();
156
155
  setFocusedIndex( nextIndex );
157
156
  virtualizer.scrollToIndex( Math.floor( nextIndex / columnCount ) );
158
157
  };
@@ -281,13 +280,7 @@ export const IconLibraryGrid = ( {
281
280
  aria-label={ item.label }
282
281
  aria-selected={ isSelected }
283
282
  tabIndex={ tabIndex }
284
- ref={ ( node: HTMLButtonElement | null ) => {
285
- if ( node ) {
286
- cellRefs.current.set( item.id, node );
287
- } else {
288
- cellRefs.current.delete( item.id );
289
- }
290
- } }
283
+ ref={ registerRef( item.id ) }
291
284
  onClick={ () => {
292
285
  onSelect( item.id );
293
286
  onClose();
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { useMemo, useState } from 'react';
2
+ import { useMemo, useRef, useState } from 'react';
3
3
  import {
4
4
  PopoverBody,
5
5
  PopoverHeader,
@@ -103,6 +103,8 @@ export const IconLibraryPopover = ( {
103
103
  [ icons, selectedIconClass, selectedIconLibrary ]
104
104
  );
105
105
 
106
+ const contentRef = useRef< HTMLDivElement >( null );
107
+
106
108
  const handleClose = () => {
107
109
  setSearchValue( '' );
108
110
  setActiveLibraries( [] );
@@ -122,6 +124,19 @@ export const IconLibraryPopover = ( {
122
124
  } );
123
125
  };
124
126
 
127
+ const handleSearchKeyDown = ( event: React.KeyboardEvent ) => {
128
+ if ( event.key !== 'ArrowDown' || view !== 'list' ) {
129
+ return;
130
+ }
131
+
132
+ const option = contentRef.current?.querySelector< HTMLElement >( '[role="option"][tabindex="0"]' );
133
+
134
+ if ( option ) {
135
+ event.preventDefault();
136
+ option.focus();
137
+ }
138
+ };
139
+
125
140
  const handleClearSearch = () => {
126
141
  setSearchValue( '' );
127
142
  };
@@ -150,17 +165,19 @@ export const IconLibraryPopover = ( {
150
165
  gap={ 1 }
151
166
  sx={ { px: ICON_LIBRARY_INLINE_SPACING, pb: ICON_LIBRARY_SEARCH_PADDING_BOTTOM } }
152
167
  >
153
- <SearchField
154
- value={ searchInputValue }
155
- onSearch={ handleSearchChange }
156
- placeholder={ __( 'Search', 'elementor' ) }
157
- id="icon-library-search"
158
- sx={ { flex: 1, px: 0, pb: 0 } }
159
- />
168
+ <Box onKeyDown={ handleSearchKeyDown } sx={ { flex: 1, minWidth: 0 } }>
169
+ <SearchField
170
+ value={ searchInputValue }
171
+ onSearch={ handleSearchChange }
172
+ placeholder={ __( 'Search', 'elementor' ) }
173
+ id="icon-library-search"
174
+ sx={ { width: '100%', px: 0, pb: 0 } }
175
+ />
176
+ </Box>
160
177
  <IconLibraryFilter value={ activeLibraries } onChange={ setActiveLibraries } />
161
178
  </Stack>
162
179
  <Divider />
163
- <Box sx={ { flex: 1, overflow: 'hidden', minHeight: 0, minWidth: 0 } }>
180
+ <Box ref={ contentRef } sx={ { flex: 1, overflow: 'hidden', minHeight: 0, minWidth: 0 } }>
164
181
  <IconLibraryContent
165
182
  isLoading={ isLoading }
166
183
  items={ items }
@@ -235,6 +252,7 @@ const IconLibraryContent = ( {
235
252
  onClose={ onClose }
236
253
  itemHeight={ ICON_LIBRARY_ROW_HEIGHT }
237
254
  menuItemContentTemplate={ IconLibraryRow }
255
+ rovingFocus
238
256
  noResultsComponent={ emptyState }
239
257
  data-testid="icon-library-list"
240
258
  />
@@ -0,0 +1,104 @@
1
+ import * as React from 'react';
2
+ import { type SyntheticEvent, useMemo } from 'react';
3
+ import { numberPropTypeUtil, queryPropTypeUtil, stringPropTypeUtil } from '@elementor/editor-props';
4
+ import { Autocomplete, TextField } from '@elementor/ui';
5
+ import { __ } from '@wordpress/i18n';
6
+
7
+ import { useBoundProp } from '../bound-prop-context';
8
+ import { type FlatOption } from '../components/autocomplete';
9
+ import ControlActions from '../control-actions/control-actions';
10
+ import { createControl } from '../create-control';
11
+ import { extractFlatOptionFromQueryValue, useQueryAutocomplete } from '../hooks/use-query-autocomplete';
12
+
13
+ type QuerySelectControlProps = {
14
+ queryOptions: {
15
+ params: Record< string, unknown >;
16
+ url: string;
17
+ };
18
+ placeholder?: string;
19
+ minInputLength?: number;
20
+ ariaLabel?: string;
21
+ };
22
+
23
+ const SIZE = 'tiny';
24
+
25
+ export const QuerySelectControl = createControl( ( props: QuerySelectControlProps ) => {
26
+ const { queryOptions, placeholder, minInputLength = 0, ariaLabel } = props;
27
+ const { value, setValue, disabled } = useBoundProp( queryPropTypeUtil );
28
+
29
+ const selectedOption = useMemo( () => extractFlatOptionFromQueryValue( value ), [ value ] );
30
+
31
+ const { options, updateOptions } = useQueryAutocomplete( {
32
+ url: queryOptions.url,
33
+ params: queryOptions.params,
34
+ minInputLength,
35
+ initialQueryValue: value,
36
+ } );
37
+
38
+ const optionsWithSelected = useMemo(
39
+ () => withSelectedOption( options as FlatOption[], selectedOption ),
40
+ [ options, selectedOption ]
41
+ );
42
+
43
+ const handleChange = ( _: SyntheticEvent, newValue: FlatOption | null ) => {
44
+ if ( ! newValue ) {
45
+ setValue( null );
46
+
47
+ return;
48
+ }
49
+
50
+ setValue( {
51
+ id: numberPropTypeUtil.create( Number( newValue.id ) ),
52
+ label: stringPropTypeUtil.create( newValue.label ),
53
+ } );
54
+ };
55
+
56
+ const handleInputChange = ( _: SyntheticEvent, term: string, reason: string ) => {
57
+ if ( 'reset' === reason || 'clear' === reason ) {
58
+ updateOptions( null );
59
+ return;
60
+ }
61
+
62
+ if ( 'input' !== reason ) {
63
+ return;
64
+ }
65
+
66
+ updateOptions( term || null );
67
+ };
68
+
69
+ return (
70
+ <ControlActions>
71
+ <Autocomplete
72
+ fullWidth
73
+ openOnFocus
74
+ disabled={ disabled }
75
+ size={ SIZE }
76
+ value={ selectedOption }
77
+ options={ optionsWithSelected }
78
+ onChange={ handleChange }
79
+ onInputChange={ handleInputChange }
80
+ getOptionLabel={ ( option ) => option.label }
81
+ isOptionEqualToValue={ ( option, val ) => option.id === val.id }
82
+ filterOptions={ ( opts ) => opts }
83
+ renderInput={ ( params ) => (
84
+ <TextField
85
+ { ...params }
86
+ placeholder={ placeholder ?? __( 'Search', 'elementor' ) }
87
+ inputProps={ {
88
+ ...params.inputProps,
89
+ ...( ariaLabel ? { 'aria-label': ariaLabel } : {} ),
90
+ } }
91
+ />
92
+ ) }
93
+ />
94
+ </ControlActions>
95
+ );
96
+ } );
97
+
98
+ function withSelectedOption( options: FlatOption[], selectedOption: FlatOption | null ): FlatOption[] {
99
+ if ( ! selectedOption || options.some( ( option ) => option.id === selectedOption.id ) ) {
100
+ return options;
101
+ }
102
+
103
+ return [ selectedOption, ...options ];
104
+ }
@@ -4,7 +4,7 @@ import { type SizePropValue } from '@elementor/editor-props';
4
4
  import { useSyncExternalState } from '../../../hooks/use-sync-external-state';
5
5
  import { type SetSizeValue } from '../types';
6
6
  import { isExtendedUnit } from '../utils/is-extended-unit';
7
- import { createDefaultSizeValue, resolveSizeOnUnitChange, resolveSizeValue } from '../utils/resolve-size-value';
7
+ import { createEmptySizeValue, resolveSizeOnUnitChange, resolveSizeValue } from '../utils/resolve-size-value';
8
8
  import { useUnitSync } from './use-unit-sync';
9
9
 
10
10
  type SizeValue = SizePropValue[ 'value' ];
@@ -36,7 +36,7 @@ export const useSizeValue = < T extends SizeValue, U extends SizeValue[ 'unit' ]
36
36
  }
37
37
  },
38
38
  persistWhen: ( next ) => hasChanged( next, resolvedValue ),
39
- fallback: () => createDefaultSizeValue( units, defaultUnit ),
39
+ fallback: ( previousValue ) => createEmptySizeValue( previousValue, units, defaultUnit ),
40
40
  } );
41
41
 
42
42
  const [ unit, setUnit ] = useUnitSync( {
@@ -33,13 +33,16 @@ export const UnstableSizeControl = createControl(
33
33
  restoreValue,
34
34
  } = useBoundProp( sizePropTypeUtil );
35
35
  const lastNonAutoValue = useRef< SizePropValue[ 'value' ] | null >( null );
36
+ const clearedUnit = useRef< SizePropValue[ 'value' ][ 'unit' ] | null >( null );
36
37
 
37
- const { sizeValue, placeholder } = resolveBoundPropValue(
38
+ const { sizeValue: resolvedSizeValue, placeholder } = resolveBoundPropValue(
38
39
  value ?? lastNonAutoValue.current,
39
40
  boundPropPlaceholder,
40
41
  propPlaceholder
41
42
  );
42
43
 
44
+ const sizeValue = applyClearedUnit( resolvedSizeValue, value, clearedUnit.current );
45
+
43
46
  const units = getSizeUnits( propType, variant );
44
47
  const defaultUnit = getDefaultUnit( propType );
45
48
 
@@ -47,6 +50,7 @@ export const UnstableSizeControl = createControl(
47
50
  const isRequired = propType.settings.required;
48
51
 
49
52
  if ( shouldNullifyValue( value ) && ! isRequired ) {
53
+ clearedUnit.current = value?.unit ?? null;
50
54
  setValue( null );
51
55
  }
52
56
 
@@ -56,6 +60,8 @@ export const UnstableSizeControl = createControl(
56
60
  };
57
61
 
58
62
  const handleChange = ( newValue: SizePropValue[ 'value' ], options?: CreateOptions, meta?: SetValueMeta ) => {
63
+ clearedUnit.current = null;
64
+
59
65
  if ( isTransitioningFromExtendedUnit( newValue, value ) ) {
60
66
  lastNonAutoValue.current = newValue;
61
67
 
@@ -93,6 +99,18 @@ export const UnstableSizeControl = createControl(
93
99
  }
94
100
  );
95
101
 
102
+ const applyClearedUnit = (
103
+ resolvedValue: SizePropValue[ 'value' ] | null,
104
+ boundValue: SizePropValue[ 'value' ] | null,
105
+ clearedUnit: SizePropValue[ 'value' ][ 'unit' ] | null
106
+ ): SizePropValue[ 'value' ] | null => {
107
+ if ( boundValue || ! resolvedValue || ! clearedUnit ) {
108
+ return resolvedValue;
109
+ }
110
+
111
+ return { ...resolvedValue, unit: clearedUnit } as SizePropValue[ 'value' ];
112
+ };
113
+
96
114
  const isTransitioningFromExtendedUnit = (
97
115
  nextValue: SizePropValue[ 'value' ],
98
116
  previousValue: SizePropValue[ 'value' ]
@@ -60,6 +60,20 @@ export const createDefaultSizeValue = < T extends SizeValue >( units: SizeUnit[]
60
60
  return { size: DEFAULT_SIZE, unit } as T;
61
61
  };
62
62
 
63
+ export const createEmptySizeValue = < T extends SizeValue >(
64
+ previousValue: T | null,
65
+ units: SizeUnit[],
66
+ defaultUnit?: SizeUnit
67
+ ): T => {
68
+ const previousUnit = previousValue?.unit;
69
+
70
+ if ( ! previousUnit || isExtendedUnit( previousUnit ) || ! units.includes( previousUnit ) ) {
71
+ return createDefaultSizeValue< T >( units, defaultUnit );
72
+ }
73
+
74
+ return { size: DEFAULT_SIZE, unit: previousUnit } as T;
75
+ };
76
+
63
77
  const resolveFallbackUnit = < TUnit extends SizeUnit >(
64
78
  unit: TUnit,
65
79
  units: readonly TUnit[],
@@ -116,7 +116,7 @@ export const SizeControl = createControl(
116
116
  const popupState = usePopupState( { variant: 'popover' } );
117
117
 
118
118
  const memorizedExternalState = useMemo(
119
- () => createStateFromSizeProp( sizeValue, actualDefaultUnit ),
119
+ () => ( sizeValue ? createStateFromSizeProp( sizeValue, actualDefaultUnit ) : null ),
120
120
  [ sizeValue, actualDefaultUnit ]
121
121
  );
122
122
 
@@ -125,10 +125,10 @@ export const SizeControl = createControl(
125
125
  setExternal: ( newState: State | null, options, meta ) =>
126
126
  setSizeValue( extractValueFromState( newState ), options, meta ),
127
127
  persistWhen: ( newState ) => !! extractValueFromState( newState ),
128
- fallback: ( newState ) => ( {
129
- unit: newState?.unit ?? actualDefaultUnit,
130
- numeric: newState?.numeric ?? DEFAULT_SIZE,
131
- custom: newState?.custom ?? '',
128
+ fallback: ( prevState ) => ( {
129
+ unit: prevState?.unit && ! isUnitExtendedOption( prevState.unit ) ? prevState.unit : actualDefaultUnit,
130
+ numeric: DEFAULT_SIZE,
131
+ custom: '',
132
132
  } ),
133
133
  } );
134
134
 
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { numberPropTypeUtil, type QueryPropValue, stringPropTypeUtil } from '@elementor/editor-props';
3
3
  import { type HttpResponse, httpService } from '@elementor/http-client';
4
4
  import { debounce } from '@elementor/utils';
@@ -47,13 +47,17 @@ export function useQueryAutocomplete( {
47
47
  [ url, excludeIdSet ]
48
48
  );
49
49
 
50
+ const paramsKey = useMemo( () => JSON.stringify( params ), [ params ] );
51
+ const paramsRef = useRef( params );
52
+ paramsRef.current = params;
53
+
50
54
  useEffect( () => {
51
55
  if ( minInputLength === 0 && url ) {
52
- fetchOptions( url, { ...params, term: '' } ).then( ( newOptions ) => {
56
+ fetchOptions( url, { ...paramsRef.current, term: '' } ).then( ( newOptions ) => {
53
57
  setOptions( formatOptions( filterExcludedOptions( newOptions, excludeIdSet ) ) );
54
58
  } );
55
59
  }
56
- }, [ url, minInputLength, excludeIdSet, params ] );
60
+ }, [ url, minInputLength, excludeIdSet, paramsKey ] );
57
61
 
58
62
  const updateOptions = ( term: string | null ) => {
59
63
  const termStr = term ?? '';
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export { HtmlTagControl } from './controls/html-tag-control';
23
23
  export { QueryChipsControl } from './controls/query-chips-control';
24
24
  export { QueryControl } from './controls/query-control';
25
25
  export { QueryFilterRepeaterControl } from './controls/query-filter-repeater-control';
26
+ export { QuerySelectControl } from './controls/query-select-control';
26
27
  export { GapControl } from './controls/gap-control';
27
28
  export { AspectRatioControl } from './controls/aspect-ratio-control';
28
29
  export { SvgMediaControl } from './controls/svg-media-control';