@elementor/editor-controls 3.33.0-99 → 3.35.0-325

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.
Files changed (94) hide show
  1. package/dist/index.d.mts +276 -85
  2. package/dist/index.d.ts +276 -85
  3. package/dist/index.js +2491 -1783
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2304 -1592
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +31 -17
  8. package/src/bound-prop-context/prop-context.tsx +7 -1
  9. package/src/bound-prop-context/use-bound-prop.ts +19 -5
  10. package/src/components/autocomplete.tsx +34 -3
  11. package/src/components/conditional-control-infotip.tsx +64 -0
  12. package/src/components/{unstable-repeater → control-repeater}/actions/disable-item-action.tsx +2 -2
  13. package/src/components/{unstable-repeater → control-repeater}/actions/duplicate-item-action.tsx +10 -4
  14. package/src/components/{unstable-repeater → control-repeater}/actions/remove-item-action.tsx +2 -2
  15. package/src/components/control-repeater/context/item-context.tsx +8 -0
  16. package/src/components/{unstable-repeater → control-repeater}/context/repeater-context.tsx +24 -15
  17. package/src/components/control-repeater/control-repeater.tsx +29 -0
  18. package/src/components/{unstable-repeater → control-repeater}/index.ts +1 -2
  19. package/src/components/{unstable-repeater → control-repeater}/items/edit-item-popover.tsx +6 -20
  20. package/src/components/control-repeater/items/item.tsx +75 -0
  21. package/src/components/{unstable-repeater → control-repeater}/items/items-container.tsx +8 -13
  22. package/src/components/{unstable-repeater → control-repeater}/locations.ts +0 -4
  23. package/src/components/{unstable-repeater → control-repeater}/types.ts +1 -2
  24. package/src/components/control-toggle-button-group.tsx +79 -69
  25. package/src/components/enable-unfiltered-modal.tsx +1 -26
  26. package/src/components/icon-buttons/clear-icon-button.tsx +23 -0
  27. package/src/components/inline-editor-toolbar.tsx +137 -0
  28. package/src/components/inline-editor.tsx +111 -0
  29. package/src/components/item-selector.tsx +10 -4
  30. package/src/components/{unstable-repeater/header/header.tsx → repeater/repeater-header.tsx} +4 -12
  31. package/src/components/repeater/repeater-popover.tsx +19 -0
  32. package/src/components/repeater/repeater-tag.tsx +16 -0
  33. package/src/components/repeater/repeater.tsx +405 -0
  34. package/src/components/{sortable.tsx → repeater/sortable.tsx} +1 -1
  35. package/src/components/size-control/size-input.tsx +20 -14
  36. package/src/components/size-control/text-field-inner-selection.tsx +15 -2
  37. package/src/control-adornments/control-adornments-context.tsx +5 -4
  38. package/src/control-replacements.tsx +12 -47
  39. package/src/controls/background-control/background-control.tsx +43 -12
  40. package/src/controls/background-control/background-gradient-color-control.tsx +5 -8
  41. package/src/controls/background-control/background-overlay/background-image-overlay/background-image-overlay-position.tsx +18 -13
  42. package/src/controls/background-control/background-overlay/background-overlay-repeater-control.tsx +25 -16
  43. package/src/controls/box-shadow-repeater-control.tsx +38 -21
  44. package/src/controls/color-control.tsx +3 -1
  45. package/src/controls/date-time-control.tsx +108 -0
  46. package/src/controls/filter-control/drop-shadow/drop-shadow-item-content.tsx +1 -0
  47. package/src/controls/filter-control/drop-shadow/drop-shadow-item-label.tsx +10 -6
  48. package/src/controls/filter-control/filter-content.tsx +1 -1
  49. package/src/controls/filter-control/filter-repeater-control.tsx +24 -21
  50. package/src/controls/filter-control/single-size/single-size-item-content.tsx +1 -1
  51. package/src/controls/filter-control/single-size/single-size-item-label.tsx +2 -1
  52. package/src/controls/font-family-control/font-family-control.tsx +66 -55
  53. package/src/controls/html-tag-control.tsx +90 -0
  54. package/src/controls/image-media-control.tsx +2 -2
  55. package/src/controls/inline-editing-control.tsx +18 -0
  56. package/src/controls/key-value-control.tsx +8 -2
  57. package/src/controls/link-control.tsx +23 -123
  58. package/src/controls/query-control.tsx +168 -0
  59. package/src/controls/repeatable-control.tsx +62 -27
  60. package/src/controls/select-control-wrapper.tsx +57 -0
  61. package/src/controls/select-control.tsx +9 -5
  62. package/src/controls/selection-size-control.tsx +13 -2
  63. package/src/controls/size-control.tsx +43 -25
  64. package/src/controls/svg-media-control.tsx +33 -10
  65. package/src/controls/text-area-control.tsx +5 -1
  66. package/src/controls/text-control.tsx +5 -0
  67. package/src/controls/toggle-control.tsx +11 -2
  68. package/src/controls/transform-control/functions/axis-row.tsx +1 -0
  69. package/src/controls/transform-control/transform-icon.tsx +2 -2
  70. package/src/controls/transform-control/transform-label.tsx +15 -32
  71. package/src/controls/transform-control/transform-repeater-control.tsx +42 -36
  72. package/src/controls/transform-control/{transform-base-control.tsx → transform-settings-control.tsx} +2 -2
  73. package/src/controls/transform-control/use-transform-tabs-history.tsx +1 -1
  74. package/src/controls/transition-control/data.ts +16 -1
  75. package/src/controls/transition-control/trainsition-events.ts +2 -2
  76. package/src/controls/transition-control/transition-repeater-control.tsx +137 -13
  77. package/src/controls/transition-control/transition-selector.tsx +37 -14
  78. package/src/controls/url-control.tsx +21 -16
  79. package/src/create-control.tsx +3 -2
  80. package/src/hooks/use-filtered-items-list.ts +3 -2
  81. package/src/hooks/use-repeatable-control-context.ts +3 -0
  82. package/src/hooks/use-sync-external-state.tsx +0 -1
  83. package/src/index.ts +21 -5
  84. package/src/utils/convert-toggle-options-to-atomic.tsx +33 -0
  85. package/src/utils/escape-html-attr.ts +11 -0
  86. package/src/components/css-code-editor/css-editor.styles.ts +0 -52
  87. package/src/components/css-code-editor/css-editor.tsx +0 -142
  88. package/src/components/css-code-editor/css-validation.ts +0 -75
  89. package/src/components/css-code-editor/resize-handle.tsx +0 -55
  90. package/src/components/css-code-editor/visual-content-change-protection.ts +0 -69
  91. package/src/components/repeater.tsx +0 -343
  92. package/src/components/unstable-repeater/items/item.tsx +0 -77
  93. package/src/components/unstable-repeater/unstable-repeater.tsx +0 -26
  94. /package/src/components/{unstable-repeater → control-repeater}/actions/tooltip-add-item-action.tsx +0 -0
@@ -19,64 +19,75 @@ export type FontCategory = {
19
19
  type FontFamilyControlProps = {
20
20
  fontFamilies: FontCategory[];
21
21
  sectionWidth: number;
22
+ ariaLabel?: string;
22
23
  };
23
24
 
24
- export const FontFamilyControl = createControl( ( { fontFamilies, sectionWidth }: FontFamilyControlProps ) => {
25
- const { value: fontFamily, setValue: setFontFamily, disabled, placeholder } = useBoundProp( stringPropTypeUtil );
25
+ export const FontFamilyControl = createControl(
26
+ ( { fontFamilies, sectionWidth, ariaLabel }: FontFamilyControlProps ) => {
27
+ const {
28
+ value: fontFamily,
29
+ setValue: setFontFamily,
30
+ disabled,
31
+ placeholder,
32
+ } = useBoundProp( stringPropTypeUtil );
26
33
 
27
- const popoverState = usePopupState( { variant: 'popover' } );
28
- const isShowingPlaceholder = ! fontFamily && placeholder;
34
+ const popoverState = usePopupState( { variant: 'popover' } );
35
+ const isShowingPlaceholder = ! fontFamily && placeholder;
29
36
 
30
- const mapFontSubs = React.useMemo< Category[] >( () => {
31
- return fontFamilies.map( ( { label, fonts } ) => ( {
32
- label,
33
- items: fonts,
34
- } ) );
35
- }, [ fontFamilies ] );
37
+ const mapFontSubs = React.useMemo< Category[] >( () => {
38
+ return fontFamilies.map( ( { label, fonts } ) => ( {
39
+ label,
40
+ items: fonts,
41
+ } ) );
42
+ }, [ fontFamilies ] );
36
43
 
37
- return (
38
- <>
39
- <ControlActions>
40
- <UnstableTag
41
- variant="outlined"
42
- label={ fontFamily || placeholder }
43
- endIcon={ <ChevronDownIcon fontSize="tiny" /> }
44
- { ...bindTrigger( popoverState ) }
45
- fullWidth
46
- disabled={ disabled }
47
- sx={
48
- isShowingPlaceholder
49
- ? {
50
- '& .MuiTag-label': {
51
- color: ( theme ) => theme.palette.text.tertiary,
52
- },
53
- textTransform: 'capitalize',
54
- }
55
- : undefined
56
- }
57
- />
58
- </ControlActions>
44
+ return (
45
+ <>
46
+ <ControlActions>
47
+ <UnstableTag
48
+ id="font-family-control"
49
+ variant="outlined"
50
+ label={ fontFamily || placeholder }
51
+ endIcon={ <ChevronDownIcon fontSize="tiny" /> }
52
+ { ...bindTrigger( popoverState ) }
53
+ fullWidth
54
+ disabled={ disabled }
55
+ aria-label={ ariaLabel }
56
+ sx={
57
+ isShowingPlaceholder
58
+ ? {
59
+ '& .MuiTag-label': {
60
+ color: ( theme ) => theme.palette.text.tertiary,
61
+ },
62
+ textTransform: 'capitalize',
63
+ }
64
+ : undefined
65
+ }
66
+ />
67
+ </ControlActions>
59
68
 
60
- <Popover
61
- disablePortal
62
- disableScrollLock
63
- anchorOrigin={ { vertical: 'bottom', horizontal: 'right' } }
64
- transformOrigin={ { vertical: 'top', horizontal: 'right' } }
65
- sx={ { my: 1.5 } }
66
- { ...bindPopover( popoverState ) }
67
- >
68
- <ItemSelector
69
- itemsList={ mapFontSubs }
70
- selectedItem={ fontFamily }
71
- onItemChange={ setFontFamily }
72
- onClose={ popoverState.close }
73
- sectionWidth={ sectionWidth }
74
- title={ __( 'Font Family', 'elementor' ) }
75
- itemStyle={ ( item ) => ( { fontFamily: item.value } ) }
76
- onDebounce={ enqueueFont }
77
- icon={ TextIcon as React.ElementType< { fontSize: string } > }
78
- />
79
- </Popover>
80
- </>
81
- );
82
- } );
69
+ <Popover
70
+ disablePortal
71
+ disableScrollLock
72
+ anchorOrigin={ { vertical: 'bottom', horizontal: 'right' } }
73
+ transformOrigin={ { vertical: 'top', horizontal: 'right' } }
74
+ sx={ { my: 1.5 } }
75
+ { ...bindPopover( popoverState ) }
76
+ >
77
+ <ItemSelector
78
+ id="font-family-selector"
79
+ itemsList={ mapFontSubs }
80
+ selectedItem={ fontFamily }
81
+ onItemChange={ setFontFamily }
82
+ onClose={ popoverState.close }
83
+ sectionWidth={ sectionWidth }
84
+ title={ __( 'Font family', 'elementor' ) }
85
+ itemStyle={ ( item ) => ( { fontFamily: item.value } ) }
86
+ onDebounce={ enqueueFont }
87
+ icon={ TextIcon as React.ElementType< { fontSize: string } > }
88
+ />
89
+ </Popover>
90
+ </>
91
+ );
92
+ }
93
+ );
@@ -0,0 +1,90 @@
1
+ import * as React from 'react';
2
+ import { getElementLabel } from '@elementor/editor-elements';
3
+ import { stringPropTypeUtil, type StringPropValue } from '@elementor/editor-props';
4
+ import { MenuListItem } from '@elementor/editor-ui';
5
+ import { Select, type SelectChangeEvent, styled, Typography } from '@elementor/ui';
6
+ import { __ } from '@wordpress/i18n';
7
+
8
+ import { useBoundProp } from '../bound-prop-context';
9
+ import { ConditionalControlInfotip } from '../components/conditional-control-infotip';
10
+ import ControlActions from '../control-actions/control-actions';
11
+ import { createControl } from '../create-control';
12
+
13
+ export type SelectOption = {
14
+ label: string;
15
+ value: StringPropValue[ 'value' ];
16
+ disabled?: boolean;
17
+ };
18
+
19
+ type Props = {
20
+ options: SelectOption[];
21
+ onChange?: ( newValue: string | null, previousValue: string | null | undefined ) => void;
22
+ fallbackLabels?: Record< string, string >;
23
+ };
24
+
25
+ const StyledSelect = styled( Select )( () => ( { '.MuiSelect-select.Mui-disabled': { cursor: 'not-allowed' } } ) );
26
+
27
+ export const HtmlTagControl = createControl( ( { options, onChange, fallbackLabels = {} }: Props ) => {
28
+ const { value, setValue, disabled, placeholder } = useBoundProp( stringPropTypeUtil );
29
+ const handleChange = ( event: SelectChangeEvent< StringPropValue[ 'value' ] > ) => {
30
+ const newValue = event.target.value || null;
31
+
32
+ onChange?.( newValue, value );
33
+ setValue( newValue );
34
+ };
35
+
36
+ const elementLabel = getElementLabel() ?? 'element';
37
+ const infoTipProps = {
38
+ title: __( 'HTML Tag', 'elementor' ),
39
+ /* translators: %s is the element name. */
40
+ description: __(
41
+ `The tag is locked to 'a' tag because this %s has a link. To pick a different tag, remove the link first.`,
42
+ 'elementor'
43
+ ).replace( '%s', elementLabel ),
44
+ isEnabled: !! disabled,
45
+ };
46
+
47
+ const renderValue = ( selectedValue: string | null ) => {
48
+ if ( selectedValue ) {
49
+ return findOptionByValue( selectedValue )?.label || fallbackLabels[ selectedValue ] || selectedValue;
50
+ }
51
+
52
+ if ( ! placeholder ) {
53
+ return '';
54
+ }
55
+
56
+ const placeholderOption = findOptionByValue( placeholder );
57
+ const displayText = placeholderOption?.label || placeholder;
58
+
59
+ return (
60
+ <Typography component="span" variant="caption" color="text.tertiary">
61
+ { displayText }
62
+ </Typography>
63
+ );
64
+ };
65
+
66
+ const findOptionByValue = ( searchValue: string | null ) => options.find( ( opt ) => opt.value === searchValue );
67
+
68
+ return (
69
+ <ControlActions>
70
+ <ConditionalControlInfotip { ...infoTipProps }>
71
+ <StyledSelect
72
+ sx={ { overflow: 'hidden', cursor: disabled ? 'not-allowed' : undefined } }
73
+ displayEmpty
74
+ size="tiny"
75
+ renderValue={ renderValue }
76
+ value={ value ?? '' }
77
+ onChange={ handleChange }
78
+ disabled={ disabled }
79
+ fullWidth
80
+ >
81
+ { options.map( ( { label, ...props } ) => (
82
+ <MenuListItem key={ props.value } { ...props } value={ props.value ?? '' }>
83
+ { label }
84
+ </MenuListItem>
85
+ ) ) }
86
+ </StyledSelect>
87
+ </ConditionalControlInfotip>
88
+ </ControlActions>
89
+ );
90
+ } );
@@ -14,7 +14,7 @@ type ImageMediaControlProps = {
14
14
  };
15
15
 
16
16
  export const ImageMediaControl = createControl( ( { mediaTypes = [ 'image' ] }: ImageMediaControlProps ) => {
17
- const { value, setValue } = useBoundProp( imageSrcPropTypeUtil );
17
+ const { value, setValue, propType } = useBoundProp( imageSrcPropTypeUtil );
18
18
  const { id, url } = value ?? {};
19
19
 
20
20
  const { data: attachment, isFetching } = useWpMediaAttachment( id?.value || null );
@@ -38,7 +38,7 @@ export const ImageMediaControl = createControl( ( { mediaTypes = [ 'image' ] }:
38
38
  return (
39
39
  <ControlActions>
40
40
  <Card variant="outlined">
41
- <CardMedia image={ src } sx={ { height: 150 } }>
41
+ <CardMedia image={ src } sx={ { height: propType.meta.isDynamic ? 134 : 150 } }>
42
42
  { isFetching ? (
43
43
  <Stack justifyContent="center" alignItems="center" width="100%" height="100%">
44
44
  <CircularProgress />
@@ -0,0 +1,18 @@
1
+ import * as React from 'react';
2
+ import { htmlPropTypeUtil } from '@elementor/editor-props';
3
+
4
+ import { useBoundProp } from '../bound-prop-context';
5
+ import { InlineEditor } from '../components/inline-editor';
6
+ import ControlActions from '../control-actions/control-actions';
7
+ import { createControl } from '../create-control';
8
+
9
+ export const InlineEditingControl = createControl( () => {
10
+ const { value, setValue } = useBoundProp( htmlPropTypeUtil );
11
+ const handleChange = ( newValue: unknown ) => setValue( newValue as string );
12
+
13
+ return (
14
+ <ControlActions>
15
+ <InlineEditor value={ value || '' } setValue={ handleChange } />
16
+ </ControlActions>
17
+ );
18
+ } );
@@ -13,6 +13,7 @@ import { __ } from '@wordpress/i18n';
13
13
 
14
14
  import { PropKeyProvider, PropProvider, useBoundProp } from '../bound-prop-context';
15
15
  import { createControl } from '../create-control';
16
+ import { escapeHtmlAttr } from '../utils/escape-html-attr';
16
17
  import { TextControl } from './text-control';
17
18
 
18
19
  type KeyValueControlProps = {
@@ -21,6 +22,7 @@ type KeyValueControlProps = {
21
22
  regexKey?: string;
22
23
  regexValue?: string;
23
24
  validationErrorMessage?: string;
25
+ escapeHtml?: boolean;
24
26
  getHelperText?: ( key: string, value: string ) => { keyHelper?: string; valueHelper?: string };
25
27
  };
26
28
 
@@ -115,7 +117,11 @@ export const KeyValueControl = createControl( ( props: KeyValueControlProps = {}
115
117
  { keyLabel }
116
118
  </FormLabel>
117
119
  <PropKeyProvider bind={ 'key' }>
118
- <TextControl inputValue={ sessionState.key } error={ !! keyError } helperText={ keyHelper } />
120
+ <TextControl
121
+ inputValue={ props.escapeHtml ? escapeHtmlAttr( sessionState.key ) : sessionState.key }
122
+ error={ !! keyError }
123
+ helperText={ keyHelper }
124
+ />
119
125
  </PropKeyProvider>
120
126
  { !! keyError && <FormHelperText error>{ keyError }</FormHelperText> }
121
127
  </Grid>
@@ -125,7 +131,7 @@ export const KeyValueControl = createControl( ( props: KeyValueControlProps = {}
125
131
  </FormLabel>
126
132
  <PropKeyProvider bind={ 'value' }>
127
133
  <TextControl
128
- inputValue={ sessionState.value }
134
+ inputValue={ props.escapeHtml ? escapeHtmlAttr( sessionState.value ) : sessionState.value }
129
135
  error={ !! valueError }
130
136
  inputDisabled={ !! keyError }
131
137
  helperText={ valueHelper }
@@ -1,44 +1,31 @@
1
1
  import * as React from 'react';
2
- import { useMemo, useState } from 'react';
2
+ import { useState } from 'react';
3
3
  import { getLinkInLinkRestriction } from '@elementor/editor-elements';
4
- import {
5
- linkPropTypeUtil,
6
- type LinkPropValue,
7
- numberPropTypeUtil,
8
- stringPropTypeUtil,
9
- urlPropTypeUtil,
10
- } from '@elementor/editor-props';
11
- import { type HttpResponse, httpService } from '@elementor/http-client';
4
+ import { linkPropTypeUtil, type LinkPropValue } from '@elementor/editor-props';
12
5
  import { MinusIcon, PlusIcon } from '@elementor/icons';
13
6
  import { useSessionStorage } from '@elementor/session';
14
7
  import { Collapse, Grid, IconButton, Stack } from '@elementor/ui';
15
- import { debounce } from '@elementor/utils';
16
8
  import { __ } from '@wordpress/i18n';
17
9
 
18
10
  import { PropKeyProvider, PropProvider, useBoundProp } from '../bound-prop-context';
19
- import {
20
- Autocomplete,
21
- type CategorizedOption,
22
- findMatchingOption,
23
- type FlatOption,
24
- isCategorizedOptionPool,
25
- } from '../components/autocomplete';
26
11
  import { ControlFormLabel } from '../components/control-form-label';
12
+ import { ControlLabel } from '../components/control-label';
27
13
  import { RestrictedLinkInfotip } from '../components/restricted-link-infotip';
28
- import ControlActions from '../control-actions/control-actions';
29
14
  import { createControl } from '../create-control';
30
15
  import { type ControlProps } from '../utils/types';
16
+ import { QueryControl } from './query-control';
31
17
  import { SwitchControl } from './switch-control';
32
18
 
33
19
  type Props = ControlProps< {
34
20
  queryOptions: {
35
- requestParams: Record< string, unknown >;
36
- endpoint: string;
21
+ params: Record< string, unknown >;
22
+ url: string;
37
23
  };
38
24
  allowCustomValues?: boolean;
39
25
  minInputLength?: number;
40
26
  placeholder?: string;
41
27
  label?: string;
28
+ ariaLabel?: string;
42
29
  } >;
43
30
 
44
31
  type LinkSessionValue = {
@@ -48,7 +35,7 @@ type LinkSessionValue = {
48
35
  };
49
36
  };
50
37
 
51
- type Response = HttpResponse< { value: FlatOption[] | CategorizedOption[] } >;
38
+ export type DestinationProp = LinkPropValue[ 'value' ][ 'destination' ];
52
39
 
53
40
  const SIZE = 'tiny';
54
41
 
@@ -58,18 +45,16 @@ export const LinkControl = createControl( ( props: Props ) => {
58
45
  const [ isActive, setIsActive ] = useState( !! value );
59
46
 
60
47
  const {
61
- allowCustomValues,
62
- queryOptions: { endpoint = '', requestParams = {} },
48
+ allowCustomValues = true,
49
+ queryOptions,
63
50
  placeholder,
64
51
  minInputLength = 2,
65
52
  context: { elementId },
66
53
  label = __( 'Link', 'elementor' ),
54
+ ariaLabel,
67
55
  } = props || {};
68
56
 
69
57
  const [ linkInLinkRestriction, setLinkInLinkRestriction ] = useState( getLinkInLinkRestriction( elementId ) );
70
- const [ options, setOptions ] = useState< FlatOption[] | CategorizedOption[] >(
71
- generateFirstLoadedOption( value )
72
- );
73
58
  const shouldDisableAddingLink = ! isActive && linkInLinkRestriction.shouldRestrict;
74
59
 
75
60
  const onEnabledChange = () => {
@@ -96,60 +81,17 @@ export const LinkControl = createControl( ( props: Props ) => {
96
81
  } );
97
82
  };
98
83
 
99
- const onOptionChange = ( newValue: number | null ) => {
84
+ const onSaveValueToSession = ( newValue: DestinationProp[ 'value' ] | null ) => {
100
85
  const valueToSave: LinkPropValue[ 'value' ] | null = newValue
101
86
  ? {
102
87
  ...value,
103
- destination: numberPropTypeUtil.create( newValue ),
104
- label: stringPropTypeUtil.create( findMatchingOption( options, newValue )?.label || null ),
88
+ destination: newValue,
105
89
  }
106
90
  : null;
107
91
 
108
- onSaveNewValue( valueToSave );
92
+ setLinkSessionValue( { ...linkSessionValue, value: valueToSave } );
109
93
  };
110
94
 
111
- const onTextChange = ( newValue: string | null ) => {
112
- newValue = newValue?.trim() || '';
113
-
114
- const valueToSave: LinkPropValue[ 'value' ] | null = newValue
115
- ? {
116
- ...value,
117
- destination: urlPropTypeUtil.create( newValue ),
118
- label: stringPropTypeUtil.create( '' ),
119
- }
120
- : null;
121
-
122
- onSaveNewValue( valueToSave );
123
- updateOptions( newValue );
124
- };
125
-
126
- const onSaveNewValue = ( newValue: LinkPropValue[ 'value' ] | null ) => {
127
- setValue( newValue );
128
- setLinkSessionValue( { ...linkSessionValue, value: newValue } );
129
- };
130
-
131
- const updateOptions = ( newValue: string | null ) => {
132
- setOptions( [] );
133
-
134
- if ( ! newValue || ! endpoint || newValue.length < minInputLength ) {
135
- return;
136
- }
137
-
138
- debounceFetch( { ...requestParams, term: newValue } );
139
- };
140
-
141
- const debounceFetch = useMemo(
142
- () =>
143
- debounce(
144
- ( params: FetchOptionsParams ) =>
145
- fetchOptions( endpoint, params ).then( ( newOptions ) => {
146
- setOptions( formatOptions( newOptions ) );
147
- } ),
148
- 400
149
- ),
150
- [ endpoint ]
151
- );
152
-
153
95
  return (
154
96
  <PropProvider { ...propContext } value={ value } setValue={ setValue }>
155
97
  <Stack gap={ 1.5 }>
@@ -161,7 +103,7 @@ export const LinkControl = createControl( ( props: Props ) => {
161
103
  marginInlineEnd: -0.75,
162
104
  } }
163
105
  >
164
- <ControlFormLabel>{ label }</ControlFormLabel>
106
+ <ControlLabel>{ label }</ControlLabel>
165
107
  <RestrictedLinkInfotip isVisible={ ! isActive } linkInLinkRestriction={ linkInLinkRestriction }>
166
108
  <ToggleIconControl
167
109
  disabled={ shouldDisableAddingLink }
@@ -174,17 +116,14 @@ export const LinkControl = createControl( ( props: Props ) => {
174
116
  <Collapse in={ isActive } timeout="auto" unmountOnExit>
175
117
  <Stack gap={ 1.5 }>
176
118
  <PropKeyProvider bind={ 'destination' }>
177
- <ControlActions>
178
- <Autocomplete
179
- options={ options }
180
- allowCustomValues={ allowCustomValues }
181
- placeholder={ placeholder }
182
- value={ value?.destination?.value?.settings?.label || value?.destination?.value }
183
- onOptionChange={ onOptionChange }
184
- onTextChange={ onTextChange }
185
- minInputLength={ minInputLength }
186
- />
187
- </ControlActions>
119
+ <QueryControl
120
+ queryOptions={ queryOptions }
121
+ allowCustomValues={ allowCustomValues }
122
+ minInputLength={ minInputLength }
123
+ placeholder={ placeholder }
124
+ onSetValue={ onSaveValueToSession }
125
+ ariaLabel={ ariaLabel || label }
126
+ />
188
127
  </PropKeyProvider>
189
128
  <PropKeyProvider bind={ 'isTargetBlank' }>
190
129
  <Grid container alignItems="center" flexWrap="nowrap" justifyContent="space-between">
@@ -217,42 +156,3 @@ const ToggleIconControl = ( { disabled, active, onIconClick, label }: ToggleIcon
217
156
  </IconButton>
218
157
  );
219
158
  };
220
-
221
- type FetchOptionsParams = Record< string, unknown > & { term: string };
222
-
223
- async function fetchOptions( ajaxUrl: string, params: FetchOptionsParams ) {
224
- if ( ! params || ! ajaxUrl ) {
225
- return [];
226
- }
227
-
228
- try {
229
- const { data: response } = await httpService().get< Response >( ajaxUrl, { params } );
230
-
231
- return response.data.value;
232
- } catch {
233
- return [];
234
- }
235
- }
236
-
237
- function formatOptions( options: FlatOption[] | CategorizedOption[] ): FlatOption[] | CategorizedOption[] {
238
- const compareKey = isCategorizedOptionPool( options ) ? 'groupLabel' : 'label';
239
-
240
- return options.sort( ( a, b ) =>
241
- a[ compareKey ] && b[ compareKey ] ? a[ compareKey ].localeCompare( b[ compareKey ] ) : 0
242
- );
243
- }
244
-
245
- function generateFirstLoadedOption( unionValue: LinkPropValue[ 'value' ] | null ): FlatOption[] {
246
- const value = unionValue?.destination?.value;
247
- const label = unionValue?.label?.value;
248
- const type = unionValue?.destination?.$$type || 'url';
249
-
250
- return value && label && type === 'number'
251
- ? [
252
- {
253
- id: value.toString(),
254
- label,
255
- },
256
- ]
257
- : [];
258
- }
@@ -0,0 +1,168 @@
1
+ import * as React from 'react';
2
+ import { useMemo, useState } from 'react';
3
+ import { numberPropTypeUtil, stringPropTypeUtil, urlPropTypeUtil } from '@elementor/editor-props';
4
+ import { type HttpResponse, httpService } from '@elementor/http-client';
5
+ import { SearchIcon } from '@elementor/icons';
6
+ import { debounce } from '@elementor/utils';
7
+ import { __ } from '@wordpress/i18n';
8
+
9
+ import { useBoundProp } from '../bound-prop-context';
10
+ import {
11
+ Autocomplete,
12
+ type CategorizedOption,
13
+ findMatchingOption,
14
+ type FlatOption,
15
+ isCategorizedOptionPool,
16
+ } from '../components/autocomplete';
17
+ import ControlActions from '../control-actions/control-actions';
18
+ import { createControl } from '../create-control';
19
+ import { type DestinationProp } from './link-control';
20
+
21
+ type Props = {
22
+ queryOptions: {
23
+ params: Record< string, unknown >;
24
+ url: string;
25
+ };
26
+ allowCustomValues?: boolean;
27
+ minInputLength?: number;
28
+ placeholder?: string;
29
+ onSetValue?: ( value: DestinationProp | null ) => void;
30
+ ariaLabel?: string;
31
+ };
32
+
33
+ type Response = HttpResponse< { value: FlatOption[] | CategorizedOption[] } >;
34
+
35
+ type FetchOptionsParams = Record< string, unknown > & { term: string };
36
+
37
+ export const QueryControl = createControl( ( props: Props ) => {
38
+ const { value, setValue } = useBoundProp< DestinationProp >();
39
+
40
+ const {
41
+ allowCustomValues = true,
42
+ queryOptions: { url, params = {} },
43
+ placeholder,
44
+ minInputLength = 2,
45
+ onSetValue,
46
+ ariaLabel,
47
+ } = props || {};
48
+
49
+ const normalizedPlaceholder = placeholder || __( 'Search', 'elementor' );
50
+
51
+ const [ options, setOptions ] = useState< FlatOption[] | CategorizedOption[] >(
52
+ generateFirstLoadedOption( value?.value )
53
+ );
54
+
55
+ const onOptionChange = ( newValue: number | null ) => {
56
+ if ( newValue === null ) {
57
+ setValue( null );
58
+ onSetValue?.( null );
59
+
60
+ return;
61
+ }
62
+
63
+ const valueToSave = {
64
+ $$type: 'query',
65
+ value: {
66
+ id: numberPropTypeUtil.create( newValue ),
67
+ label: stringPropTypeUtil.create( findMatchingOption( options, newValue )?.label || null ),
68
+ },
69
+ };
70
+
71
+ setValue( valueToSave );
72
+ onSetValue?.( valueToSave );
73
+ };
74
+
75
+ const onTextChange = ( newValue: string | null ) => {
76
+ if ( ! newValue ) {
77
+ setValue( null );
78
+ onSetValue?.( null );
79
+
80
+ return;
81
+ }
82
+
83
+ const newLinkValue = newValue?.trim() || '';
84
+ const valueToSave = newLinkValue ? urlPropTypeUtil.create( newLinkValue ) : null;
85
+
86
+ setValue( valueToSave );
87
+ onSetValue?.( valueToSave );
88
+ updateOptions( newValue );
89
+ };
90
+
91
+ const updateOptions = ( newValue: string | null ) => {
92
+ setOptions( [] );
93
+
94
+ if ( ! newValue || ! url || newValue.length < minInputLength ) {
95
+ return;
96
+ }
97
+
98
+ debounceFetch( { ...params, term: newValue } );
99
+ };
100
+
101
+ const debounceFetch = useMemo(
102
+ () =>
103
+ debounce(
104
+ ( queryParams: FetchOptionsParams ) =>
105
+ fetchOptions( url, queryParams ).then( ( newOptions ) => {
106
+ setOptions( formatOptions( newOptions ) );
107
+ } ),
108
+ 400
109
+ ),
110
+ [ url ]
111
+ );
112
+
113
+ return (
114
+ <ControlActions>
115
+ <Autocomplete
116
+ options={ options }
117
+ allowCustomValues={ allowCustomValues }
118
+ placeholder={ normalizedPlaceholder }
119
+ startAdornment={ <SearchIcon fontSize="tiny" /> }
120
+ value={ value?.value?.id?.value || value?.value }
121
+ onOptionChange={ onOptionChange }
122
+ onTextChange={ onTextChange }
123
+ minInputLength={ minInputLength }
124
+ disablePortal={ false }
125
+ inputProps={ {
126
+ ...( ariaLabel ? { 'aria-label': ariaLabel } : {} ),
127
+ } }
128
+ />
129
+ </ControlActions>
130
+ );
131
+ } );
132
+
133
+ async function fetchOptions( ajaxUrl: string, params: FetchOptionsParams ) {
134
+ if ( ! params || ! ajaxUrl ) {
135
+ return [];
136
+ }
137
+
138
+ try {
139
+ const { data: response } = await httpService().get< Response >( ajaxUrl, { params } );
140
+
141
+ return response.data.value;
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
147
+ function formatOptions( options: FlatOption[] | CategorizedOption[] ): FlatOption[] | CategorizedOption[] {
148
+ const compareKey = isCategorizedOptionPool( options ) ? 'groupLabel' : 'label';
149
+
150
+ return options.sort( ( a, b ) =>
151
+ a[ compareKey ] && b[ compareKey ] ? a[ compareKey ].localeCompare( b[ compareKey ] ) : 0
152
+ );
153
+ }
154
+
155
+ function generateFirstLoadedOption( unionValue: DestinationProp | null ): FlatOption[] {
156
+ const value = unionValue?.id?.value;
157
+ const label = unionValue?.label?.value;
158
+ const type = unionValue?.id?.$$type || 'url';
159
+
160
+ return value && label && type === 'number'
161
+ ? [
162
+ {
163
+ id: value.toString(),
164
+ label,
165
+ },
166
+ ]
167
+ : [];
168
+ }