@elementor/editor-controls 4.3.0-beta1 → 4.3.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.
@@ -0,0 +1,104 @@
1
+ import * as React from 'react';
2
+ import { useId } from 'react';
3
+ import { CheckIcon, ListIcon, WidgetsIcon } from '@elementor/icons';
4
+ import {
5
+ bindMenu,
6
+ bindToggle,
7
+ Menu,
8
+ MenuItem,
9
+ Stack,
10
+ ToggleButton,
11
+ Tooltip,
12
+ Typography,
13
+ usePopupState,
14
+ } from '@elementor/ui';
15
+ import { __ } from '@wordpress/i18n';
16
+
17
+ import { ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY } from './icon-library-tooltip';
18
+
19
+ export type IconLibraryView = 'grid' | 'list';
20
+
21
+ const VIEW_MENU_WIDTH = 122;
22
+
23
+ type IconLibraryViewToggleProps = {
24
+ value: IconLibraryView;
25
+ onChange: ( value: IconLibraryView ) => void;
26
+ };
27
+
28
+ export const IconLibraryViewToggle = ( { value, onChange }: IconLibraryViewToggleProps ) => {
29
+ const popupId = useId();
30
+ const popupState = usePopupState( {
31
+ variant: 'popover',
32
+ popupId,
33
+ } );
34
+ const isGrid = value === 'grid';
35
+ const ViewIcon = isGrid ? WidgetsIcon : ListIcon;
36
+ const viewButtonLabel = isGrid ? __( 'Grid view', 'elementor' ) : __( 'List view', 'elementor' );
37
+
38
+ return (
39
+ <>
40
+ <Tooltip
41
+ title={ viewButtonLabel }
42
+ placement="top"
43
+ enterDelay={ ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY }
44
+ enterNextDelay={ ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY }
45
+ disableInteractive
46
+ >
47
+ <ToggleButton
48
+ aria-label={ viewButtonLabel }
49
+ value="view"
50
+ size="tiny"
51
+ selected={ popupState.isOpen }
52
+ sx={ { flexShrink: 0 } }
53
+ { ...bindToggle( popupState ) }
54
+ aria-expanded={ popupState.isOpen }
55
+ >
56
+ <ViewIcon fontSize="tiny" />
57
+ </ToggleButton>
58
+ </Tooltip>
59
+ <Menu
60
+ { ...bindMenu( popupState ) }
61
+ MenuListProps={ {
62
+ dense: true,
63
+ autoFocusItem: true,
64
+ 'aria-label': __( 'View', 'elementor' ),
65
+ } }
66
+ sx={ { '& .MuiPaper-root': { minWidth: VIEW_MENU_WIDTH } } }
67
+ >
68
+ <ViewMenuItem
69
+ label={ __( 'List', 'elementor' ) }
70
+ selected={ value === 'list' }
71
+ onClick={ () => {
72
+ onChange( 'list' );
73
+ popupState.close();
74
+ } }
75
+ />
76
+ <ViewMenuItem
77
+ label={ __( 'Grid', 'elementor' ) }
78
+ selected={ value === 'grid' }
79
+ onClick={ () => {
80
+ onChange( 'grid' );
81
+ popupState.close();
82
+ } }
83
+ />
84
+ </Menu>
85
+ </>
86
+ );
87
+ };
88
+
89
+ type ViewMenuItemProps = {
90
+ label: string;
91
+ selected: boolean;
92
+ onClick: () => void;
93
+ };
94
+
95
+ const ViewMenuItem = ( { label, selected, onClick }: ViewMenuItemProps ) => (
96
+ <MenuItem role="menuitemradio" aria-checked={ selected } selected={ selected } onClick={ onClick }>
97
+ <Stack direction="row" alignItems="center" gap={ 1 } width="100%">
98
+ <Typography variant="caption" sx={ { flex: 1 } }>
99
+ { label }
100
+ </Typography>
101
+ { selected ? <CheckIcon fontSize="tiny" aria-hidden="true" /> : null }
102
+ </Stack>
103
+ </MenuItem>
104
+ );
@@ -0,0 +1,14 @@
1
+ import { useQuery } from '@elementor/query';
2
+
3
+ import { type FontAwesome7Icon, loadFontAwesome7Catalog } from './font-awesome-7-catalog';
4
+
5
+ const FONT_AWESOME_7_CATALOG_QUERY_KEY = [ 'font-awesome-7-catalog' ];
6
+
7
+ export function useFontAwesome7Catalog( enabled: boolean ) {
8
+ return useQuery< FontAwesome7Icon[] >( {
9
+ queryKey: FONT_AWESOME_7_CATALOG_QUERY_KEY,
10
+ queryFn: ( { signal } ) => loadFontAwesome7Catalog( signal ),
11
+ enabled,
12
+ staleTime: Infinity,
13
+ } );
14
+ }
@@ -0,0 +1,105 @@
1
+ import * as React from 'react';
2
+ import { useState } from 'react';
3
+ import { ajax } from '@elementor/editor-v1-adapters';
4
+ import { BulbIcon } from '@elementor/icons';
5
+ import { Alert, AlertAction, type AlertProps, AlertTitle, styled, Typography } from '@elementor/ui';
6
+
7
+ import { createControl } from '../create-control';
8
+
9
+ type NoticeType = 'info' | 'success' | 'warning' | 'danger';
10
+
11
+ // Elementor's `Alert` forces `.MuiAlertTitle-root { marginBottom: 0 }` via a selector scoped to the
12
+ // Alert's own class. Repeating the `.MuiAlertTitle-root` class here matches that selector's specificity
13
+ // so this override applies reliably regardless of stylesheet insertion order.
14
+ const NoticeTitle = styled( AlertTitle )( {
15
+ '&.MuiAlertTitle-root.MuiAlertTitle-root': {
16
+ marginBottom: 4,
17
+ fontStyle: 'italic',
18
+ },
19
+ } );
20
+
21
+ type NoticeControlProps = {
22
+ noticeType?: NoticeType;
23
+ heading?: string;
24
+ content?: string;
25
+ dismissible?: string;
26
+ buttonText?: string;
27
+ buttonUrl?: string;
28
+ };
29
+
30
+ type ExtendedWindow = Window & {
31
+ elementor?: {
32
+ config?: {
33
+ user?: {
34
+ dismissed_editor_notices?: string[];
35
+ };
36
+ };
37
+ };
38
+ };
39
+
40
+ const markNoticeAsDismissedInSession = ( dismissId: string ) => {
41
+ const dismissedNotices = ( window as ExtendedWindow ).elementor?.config?.user?.dismissed_editor_notices;
42
+
43
+ if ( dismissedNotices && ! dismissedNotices.includes( dismissId ) ) {
44
+ dismissedNotices.push( dismissId );
45
+ }
46
+ };
47
+
48
+ export const NoticeControl = createControl(
49
+ ( { noticeType = 'info', heading, content, dismissible, buttonText, buttonUrl }: NoticeControlProps ) => {
50
+ const [ isDismissed, setIsDismissed ] = useState( false );
51
+
52
+ if ( isDismissed || ! content ) {
53
+ return null;
54
+ }
55
+
56
+ const severity: AlertProps[ 'severity' ] = noticeType === 'danger' ? 'error' : noticeType;
57
+ const icon = noticeType === 'info' ? <BulbIcon fontSize="inherit" /> : undefined;
58
+
59
+ const handleDismiss = () => {
60
+ setIsDismissed( true );
61
+
62
+ if ( ! dismissible ) {
63
+ return;
64
+ }
65
+
66
+ markNoticeAsDismissedInSession( dismissible );
67
+
68
+ ajax.load( {
69
+ action: 'dismissed_editor_notices',
70
+ unique_id: `dismiss-editor-notice-${ dismissible }`,
71
+ data: { dismissId: dismissible },
72
+ } ).catch( () => {} );
73
+ };
74
+
75
+ const handleActionClick = () => {
76
+ setIsDismissed( true );
77
+
78
+ if ( dismissible ) {
79
+ markNoticeAsDismissedInSession( dismissible );
80
+ }
81
+ };
82
+
83
+ return (
84
+ <Alert variant="outlined" severity={ severity } icon={ icon } size="small" onClose={ handleDismiss }>
85
+ { heading && <NoticeTitle>{ heading }</NoticeTitle> }
86
+ <Typography variant="caption" color="textSecondary" sx={ { fontStyle: 'italic' } }>
87
+ { content }
88
+ </Typography>
89
+ { buttonText && buttonUrl && (
90
+ <AlertAction
91
+ variant="contained"
92
+ color={ severity }
93
+ target="_blank"
94
+ rel="noopener noreferrer"
95
+ href={ buttonUrl }
96
+ onClick={ handleActionClick }
97
+ sx={ { mt: 2 } }
98
+ >
99
+ { buttonText }
100
+ </AlertAction>
101
+ ) }
102
+ </Alert>
103
+ );
104
+ }
105
+ );
@@ -1,43 +1,29 @@
1
1
  import * as React from 'react';
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { useCurrentUserCapabilities } from '@elementor/editor-current-user';
4
4
  import { iconPropTypeUtil, svgSrcPropTypeUtil, urlPropTypeUtil } from '@elementor/editor-props';
5
- import { UploadIcon } from '@elementor/icons';
6
- import {
7
- Box,
8
- Button,
9
- Card,
10
- CardMedia,
11
- CardOverlay,
12
- CircularProgress,
13
- Stack,
14
- styled,
15
- ThemeProvider,
16
- } from '@elementor/ui';
5
+ import { Box, Card, CardOverlay, Popover, Stack, styled, usePopupState } from '@elementor/ui';
17
6
  import { type OpenOptions, useWpMediaAttachment, useWpMediaFrame } from '@elementor/wp-media';
18
7
  import { __ } from '@wordpress/i18n';
19
8
 
20
9
  import { useBoundProp } from '../bound-prop-context';
21
- import { ConditionalControlInfotip } from '../components/conditional-control-infotip';
22
10
  import { EnableUnfilteredModal } from '../components/enable-unfiltered-modal';
23
11
  import ControlActions from '../control-actions/control-actions';
24
12
  import { createControl } from '../create-control';
25
13
  import { useUnfilteredFilesUpload } from '../hooks/use-unfiltered-files-upload';
26
- import {
27
- createIconPropValue,
28
- enqueueIconFonts,
29
- type IconLibrarySelection,
30
- isSvgLibrarySelection,
31
- openIconLibrary,
32
- } from './open-icon-library';
14
+ import { type IconLibraryAnchor, measureIconLibraryAnchor } from './icon-library/get-icon-library-anchor';
15
+ import { ICON_LIBRARY_POPOVER_WIDTH, IconLibraryPopover } from './icon-library/icon-library-popover';
16
+ import { createIconPropValue } from './open-icon-library';
17
+ import { SVG_MEDIA_CONTROL_CONTAINER_TEST_ID, SvgMediaOverlay } from './svg-media-overlay';
18
+ import { SvgMediaPreview } from './svg-media-preview';
33
19
 
34
20
  const TILE_SIZE = 8;
35
21
  const TILE_WHITE = 'transparent';
36
22
  const TILE_BLACK = '#c1c1c1';
37
- const ICON_PREVIEW_FONT_SIZE = 50;
38
23
  export const TILES_GRADIENT_FORMULA = `linear-gradient(45deg, ${ TILE_BLACK } 25%, ${ TILE_WHITE } 0, ${ TILE_WHITE } 75%, ${ TILE_BLACK } 0, ${ TILE_BLACK })`;
39
24
 
40
25
  const StyledCard = styled( Card )`
26
+ position: relative;
41
27
  background-color: white;
42
28
  background-image: ${ TILES_GRADIENT_FORMULA }, ${ TILES_GRADIENT_FORMULA };
43
29
  background-size: ${ TILE_SIZE }px ${ TILE_SIZE }px;
@@ -47,6 +33,8 @@ const StyledCard = styled( Card )`
47
33
  border: none;
48
34
  `;
49
35
 
36
+ const PREVIEW_ICON_COLOR = '#000000';
37
+
50
38
  const StyledCardMediaContainer = styled( Stack )`
51
39
  position: relative;
52
40
  height: 140px;
@@ -55,6 +43,7 @@ const StyledCardMediaContainer = styled( Stack )`
55
43
  justify-content: center;
56
44
  align-items: center;
57
45
  background-color: rgba( 255, 255, 255, 0.37 );
46
+ color: ${ PREVIEW_ICON_COLOR };
58
47
  `;
59
48
 
60
49
  const MODE_BROWSE: OpenOptions = { mode: 'browse' };
@@ -73,6 +62,10 @@ export const SvgMediaControl = createControl( ( { showIconLibrary = false }: Svg
73
62
  const src = attachment?.url ?? url?.value ?? null;
74
63
  const { data: allowSvgUpload } = useUnfilteredFilesUpload();
75
64
  const [ unfilteredModalOpenState, setUnfilteredModalOpenState ] = useState( false );
65
+ const iconLibraryPopoverState = usePopupState( { variant: 'popover' } );
66
+ const controlContainerRef = useRef< HTMLDivElement >( null );
67
+ const buttonGroupRef = useRef< HTMLDivElement >( null );
68
+ const [ iconLibraryAnchor, setIconLibraryAnchor ] = useState< IconLibraryAnchor | null >( null );
76
69
  const { isAdmin } = useCurrentUserCapabilities();
77
70
  const selectedIconClass =
78
71
  showIconLibrary && typeof iconValue?.value?.value === 'string' ? iconValue.value.value : null;
@@ -94,7 +87,7 @@ export const SvgMediaControl = createControl( ( { showIconLibrary = false }: Svg
94
87
  },
95
88
  } );
96
89
 
97
- const onCloseUnfilteredModal = ( enabled: boolean ) => {
90
+ const handleCloseUnfilteredModal = ( enabled: boolean ) => {
98
91
  setUnfilteredModalOpenState( false );
99
92
 
100
93
  if ( enabled ) {
@@ -110,152 +103,132 @@ export const SvgMediaControl = createControl( ( { showIconLibrary = false }: Svg
110
103
  }
111
104
  };
112
105
 
113
- const handleIconLibrarySelect = ( icon: IconLibrarySelection ) => {
114
- if ( ! showIconLibrary ) {
115
- return;
116
- }
117
- if ( isSvgLibrarySelection( icon ) ) {
118
- setSvgValue( {
119
- id: icon.value.id
120
- ? {
121
- $$type: 'image-attachment-id',
122
- value: icon.value.id,
123
- }
124
- : null,
125
- url: icon.value.url ? urlPropTypeUtil.create( icon.value.url ) : null,
126
- } );
127
- return;
128
- }
106
+ const handleSelectSvg = () => {
107
+ handleClick( MODE_BROWSE );
108
+ };
129
109
 
130
- if ( typeof icon.value === 'string' ) {
131
- setIconValue( createIconPropValue( icon.value, icon.library ) );
132
- }
110
+ const handleUpload = () => {
111
+ handleClick( MODE_UPLOAD );
133
112
  };
134
113
 
135
- const infotipProps = {
136
- title: __( "Sorry, you can't upload that file yet.", 'elementor' ),
137
- description: (
138
- <>
139
- { __( 'To upload them anyway, ask the site administrator to enable unfiltered', 'elementor' ) }
140
- <br />
141
- { __( 'file uploads.', 'elementor' ) }
142
- </>
143
- ),
144
- isEnabled: ! isAdmin,
114
+ const handleCloseIconLibrary = () => {
115
+ iconLibraryPopoverState.close();
116
+ setIconLibraryAnchor( null );
145
117
  };
146
118
 
147
- return (
148
- <Stack gap={ 1 } aria-label="SVG Control">
149
- <EnableUnfilteredModal open={ unfilteredModalOpenState } onClose={ onCloseUnfilteredModal } />
150
- <ControlActions>
151
- <StyledCard variant="outlined">
152
- <StyledCardMediaContainer>
153
- <SvgMediaPreview
154
- isFetching={ isFetching }
155
- src={ src }
156
- iconClassName={ selectedIconClass }
157
- iconLibrary={ selectedIconLibrary }
158
- />
159
- </StyledCardMediaContainer>
160
- <CardOverlay
161
- sx={ {
162
- '&:hover': {
163
- backgroundColor: 'rgba( 0, 0, 0, 0.75 )',
164
- },
165
- } }
166
- >
167
- <Stack gap={ 1 }>
168
- <Button
169
- size="tiny"
170
- color="inherit"
171
- variant="outlined"
172
- onClick={ () => handleClick( MODE_BROWSE ) }
173
- aria-label="Select SVG"
174
- >
175
- { __( 'Select SVG', 'elementor' ) }
176
- </Button>
177
- { showIconLibrary ? (
178
- <Button
179
- size="tiny"
180
- variant="text"
181
- color="inherit"
182
- onClick={ () =>
183
- openIconLibrary( {
184
- selected:
185
- selectedIconClass && selectedIconLibrary
186
- ? { value: selectedIconClass, library: selectedIconLibrary }
187
- : undefined,
188
- onSelect: handleIconLibrarySelect,
189
- } )
190
- }
191
- aria-label={ __( 'Icon library', 'elementor' ) }
192
- >
193
- { __( 'Icon library', 'elementor' ) }
194
- </Button>
195
- ) : null }
196
- <ConditionalControlInfotip { ...infotipProps }>
197
- <span>
198
- <ThemeProvider colorScheme={ isAdmin ? 'light' : 'dark' }>
199
- <Button
200
- size="tiny"
201
- variant="text"
202
- color="inherit"
203
- startIcon={ <UploadIcon /> }
204
- disabled={ ! isAdmin }
205
- onClick={ () => isAdmin && handleClick( MODE_UPLOAD ) }
206
- aria-label="Upload SVG"
207
- >
208
- { __( 'Upload', 'elementor' ) }
209
- </Button>
210
- </ThemeProvider>
211
- </span>
212
- </ConditionalControlInfotip>
213
- </Stack>
214
- </CardOverlay>
215
- </StyledCard>
216
- </ControlActions>
217
- </Stack>
218
- );
219
- } );
119
+ const handleIconLibrarySelect = ( icon: { value: string; library: string } ) => {
120
+ setIconValue( createIconPropValue( icon.value, icon.library ) );
121
+ };
122
+
123
+ const handleOpenIconLibrary = ( event: React.MouseEvent< HTMLElement > ) => {
124
+ const anchor = measureIconLibraryAnchor( controlContainerRef.current, buttonGroupRef.current );
125
+
126
+ if ( ! anchor ) {
127
+ return;
128
+ }
129
+
130
+ setIconLibraryAnchor( anchor );
131
+ iconLibraryPopoverState.open( event );
132
+ };
220
133
 
221
- function SvgMediaPreview( {
222
- isFetching,
223
- src,
224
- iconClassName,
225
- iconLibrary,
226
- }: {
227
- isFetching: boolean;
228
- src: string | null;
229
- iconClassName: string | null;
230
- iconLibrary: string | null;
231
- } ) {
232
134
  useEffect( () => {
233
- if ( iconLibrary ) {
234
- enqueueIconFonts( iconLibrary );
135
+ if ( ! iconLibraryPopoverState.isOpen ) {
136
+ return;
235
137
  }
236
- }, [ iconLibrary ] );
237
138
 
238
- if ( isFetching ) {
239
- return <CircularProgress role="progressbar" />;
240
- }
139
+ const handleResize = () => {
140
+ const nextAnchor = measureIconLibraryAnchor( controlContainerRef.current, buttonGroupRef.current );
241
141
 
242
- if ( iconClassName ) {
243
- return (
244
- <Box
245
- component="i"
246
- className={ iconClassName }
247
- aria-label={ __( 'Preview icon', 'elementor' ) }
248
- sx={ { fontSize: ICON_PREVIEW_FONT_SIZE } }
249
- />
250
- );
251
- }
142
+ if ( nextAnchor ) {
143
+ setIconLibraryAnchor( nextAnchor );
144
+ }
145
+ };
146
+
147
+ window.addEventListener( 'resize', handleResize );
148
+
149
+ return () => {
150
+ window.removeEventListener( 'resize', handleResize );
151
+ };
152
+ }, [ iconLibraryPopoverState.isOpen ] );
153
+
154
+ const iconLibraryWidth = iconLibraryAnchor?.width ?? ICON_LIBRARY_POPOVER_WIDTH;
252
155
 
253
156
  return (
254
- <CardMedia
255
- component="img"
256
- image={ src }
257
- alt={ __( 'Preview SVG', 'elementor' ) }
258
- sx={ { maxHeight: '140px', width: `${ ICON_PREVIEW_FONT_SIZE }px` } }
259
- />
157
+ <Stack gap={ 1 } aria-label={ __( 'SVG control', 'elementor' ) }>
158
+ <EnableUnfilteredModal open={ unfilteredModalOpenState } onClose={ handleCloseUnfilteredModal } />
159
+ { showIconLibrary && iconLibraryAnchor ? (
160
+ <Popover
161
+ disableScrollLock
162
+ open={ iconLibraryPopoverState.isOpen }
163
+ onClose={ handleCloseIconLibrary }
164
+ anchorReference="anchorPosition"
165
+ anchorPosition={ { top: iconLibraryAnchor.top, left: iconLibraryAnchor.left } }
166
+ anchorOrigin={ { vertical: 'top', horizontal: 'left' } }
167
+ transformOrigin={ { vertical: 'top', horizontal: 'left' } }
168
+ marginThreshold={ 0 }
169
+ PaperProps={ {
170
+ sx: {
171
+ width: iconLibraryWidth,
172
+ minWidth: iconLibraryWidth,
173
+ maxWidth: iconLibraryWidth,
174
+ m: 0,
175
+ },
176
+ } }
177
+ >
178
+ <IconLibraryPopover
179
+ open={ iconLibraryPopoverState.isOpen }
180
+ selectedIconClass={ selectedIconClass }
181
+ selectedIconLibrary={ selectedIconLibrary }
182
+ onSelect={ handleIconLibrarySelect }
183
+ onClose={ handleCloseIconLibrary }
184
+ width={ iconLibraryWidth }
185
+ />
186
+ </Popover>
187
+ ) : null }
188
+ <Box
189
+ ref={ controlContainerRef }
190
+ data-testid={ SVG_MEDIA_CONTROL_CONTAINER_TEST_ID }
191
+ sx={ { width: '100%' } }
192
+ >
193
+ <ControlActions>
194
+ <StyledCard variant="outlined">
195
+ <StyledCardMediaContainer>
196
+ <SvgMediaPreview
197
+ isFetching={ isFetching }
198
+ src={ src }
199
+ iconClassName={ selectedIconClass }
200
+ iconLibrary={ selectedIconLibrary }
201
+ />
202
+ </StyledCardMediaContainer>
203
+ <CardOverlay
204
+ sx={ {
205
+ '&:hover': {
206
+ backgroundColor: 'rgba( 0, 0, 0, 0.75 )',
207
+ },
208
+ } }
209
+ >
210
+ <SvgMediaOverlay
211
+ isAdmin={ isAdmin }
212
+ showIconLibrary={ showIconLibrary }
213
+ buttonGroupRef={ buttonGroupRef }
214
+ onSelectSvg={ handleSelectSvg }
215
+ onUpload={ handleUpload }
216
+ onOpenIconLibrary={ handleOpenIconLibrary }
217
+ infotipTitle={ __( "Sorry, you can't upload that file yet.", 'elementor' ) }
218
+ infotipDescription={ <UnfilteredUploadInfotipDescription /> }
219
+ />
220
+ </CardOverlay>
221
+ </StyledCard>
222
+ </ControlActions>
223
+ </Box>
224
+ </Stack>
260
225
  );
261
- }
226
+ } );
227
+
228
+ const UnfilteredUploadInfotipDescription = () => (
229
+ <>
230
+ { __( 'To upload them anyway, ask the site administrator to enable unfiltered', 'elementor' ) }
231
+ <br />
232
+ { __( 'file uploads.', 'elementor' ) }
233
+ </>
234
+ );