@elementor/editor-controls 4.4.0-1095 → 4.4.0-1096

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-1095",
4
+ "version": "4.4.0-1096",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -40,24 +40,25 @@
40
40
  "dev": "tsup --config=../../tsup.dev.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@elementor/editor-current-user": "4.4.0-1095",
44
- "@elementor/editor-elements": "4.4.0-1095",
45
- "@elementor/editor-props": "4.4.0-1095",
46
- "@elementor/editor-responsive": "4.4.0-1095",
47
- "@elementor/editor-ui": "4.4.0-1095",
48
- "@elementor/editor-v1-adapters": "4.4.0-1095",
49
- "@elementor/env": "4.4.0-1095",
50
- "@elementor/events": "4.4.0-1095",
51
- "@elementor/http-client": "4.4.0-1095",
43
+ "@elementor/editor-current-user": "4.4.0-1096",
44
+ "@elementor/editor-elements": "4.4.0-1096",
45
+ "@elementor/editor-props": "4.4.0-1096",
46
+ "@elementor/editor-responsive": "4.4.0-1096",
47
+ "@elementor/editor-ui": "4.4.0-1096",
48
+ "@elementor/editor-v1-adapters": "4.4.0-1096",
49
+ "@elementor/env": "4.4.0-1096",
50
+ "@elementor/events": "4.4.0-1096",
51
+ "@elementor/http-client": "4.4.0-1096",
52
52
  "@elementor/icons": "~1.75.1",
53
- "@elementor/locations": "4.4.0-1095",
54
- "@elementor/query": "4.4.0-1095",
55
- "@elementor/schema": "4.4.0-1095",
56
- "@elementor/session": "4.4.0-1095",
53
+ "@elementor/locations": "4.4.0-1096",
54
+ "@elementor/query": "4.4.0-1096",
55
+ "@elementor/schema": "4.4.0-1096",
56
+ "@elementor/session": "4.4.0-1096",
57
57
  "@elementor/ui": "1.37.5",
58
- "@elementor/utils": "4.4.0-1095",
59
- "@elementor/wp-media": "4.4.0-1095",
58
+ "@elementor/utils": "4.4.0-1096",
59
+ "@elementor/wp-media": "4.4.0-1096",
60
60
  "@monaco-editor/react": "^4.7.0",
61
+ "@tanstack/react-virtual": "^3.13.3",
61
62
  "@tiptap/extension-bold": "^3.11.1",
62
63
  "@tiptap/extension-document": "^3.11.1",
63
64
  "@tiptap/extension-hard-break": "^3.11.1",
@@ -20,6 +20,7 @@ import {
20
20
  type FontAwesome7Library,
21
21
  type FontAwesome7LibraryFilter,
22
22
  } from './font-awesome-7-catalog';
23
+ import { ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY } from './icon-library-tooltip';
23
24
 
24
25
  const FILTER_MENU_WIDTH = 280;
25
26
  const FILTER_INDICATOR_SIZE = 6;
@@ -87,7 +88,13 @@ export const IconLibraryFilter = ( { value, onChange }: IconLibraryFilterProps )
87
88
 
88
89
  return (
89
90
  <>
90
- <Tooltip title={ __( 'Filter by library', 'elementor' ) } placement="top">
91
+ <Tooltip
92
+ title={ __( 'Filter by library', 'elementor' ) }
93
+ placement="top"
94
+ enterDelay={ ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY }
95
+ enterNextDelay={ ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY }
96
+ disableInteractive
97
+ >
91
98
  <ToggleButton
92
99
  aria-label={ filterButtonLabel }
93
100
  value="filter"
@@ -154,6 +161,6 @@ const renderFilterMenuItemContent = ( label: string, Icon: typeof ListIcon, sele
154
161
  <Typography variant="caption" sx={ { flex: 1 } }>
155
162
  { label }
156
163
  </Typography>
157
- { selected ? <CheckIcon fontSize="tiny" /> : null }
164
+ { selected ? <CheckIcon fontSize="tiny" aria-hidden="true" /> : null }
158
165
  </Stack>
159
166
  );
@@ -0,0 +1,344 @@
1
+ import * as React from 'react';
2
+ import { useLayoutEffect, useRef, useState } from 'react';
3
+ import { Box, Tooltip, useTheme } from '@elementor/ui';
4
+ import { useVirtualizer } from '@tanstack/react-virtual';
5
+ import { __ } from '@wordpress/i18n';
6
+
7
+ import { type FontAwesome7Icon } from './font-awesome-7-catalog';
8
+ import { FontAwesomeGlyph } from './font-awesome-glyph';
9
+
10
+ export const ICON_LIBRARY_GRID_FALLBACK_COLUMN_COUNT = 4;
11
+ export const ICON_LIBRARY_GRID_MIN_CELL_SIZE = 52;
12
+ export const ICON_LIBRARY_GRID_TOOLTIP_ENTER_DELAY = 1000;
13
+
14
+ const ICON_GLYPH_SIZE = 20;
15
+ const GRID_OVERSCAN = 6;
16
+ const GRID_COLUMN_GAP = 1;
17
+ const GRID_HORIZONTAL_PADDING = 1;
18
+ const HOME_END_KEYS = new Set( [ 'Home', 'End' ] );
19
+
20
+ const getGridMetrics = ( containerWidth: number, columnGap: number, inlinePadding: number ) => {
21
+ const availableWidth = containerWidth - inlinePadding;
22
+
23
+ if ( availableWidth <= 0 ) {
24
+ return {
25
+ columnCount: ICON_LIBRARY_GRID_FALLBACK_COLUMN_COUNT,
26
+ cellSize: ICON_LIBRARY_GRID_MIN_CELL_SIZE,
27
+ };
28
+ }
29
+
30
+ const columnCount = Math.max(
31
+ 1,
32
+ Math.floor( ( availableWidth + columnGap ) / ( ICON_LIBRARY_GRID_MIN_CELL_SIZE + columnGap ) )
33
+ );
34
+ const cellSize = Math.floor( ( availableWidth - columnGap * ( columnCount - 1 ) ) / columnCount );
35
+
36
+ return {
37
+ columnCount,
38
+ cellSize: Math.max( cellSize, 1 ),
39
+ };
40
+ };
41
+
42
+ type IconLibraryGridItem = FontAwesome7Icon & {
43
+ id: string;
44
+ value: string;
45
+ };
46
+
47
+ type IconLibraryGridProps = {
48
+ items: IconLibraryGridItem[];
49
+ selectedValue?: string;
50
+ onSelect: ( id: string ) => void;
51
+ onClose: () => void;
52
+ noResultsComponent?: React.ReactNode;
53
+ };
54
+
55
+ export const IconLibraryGrid = ( {
56
+ items,
57
+ selectedValue,
58
+ onSelect,
59
+ onClose,
60
+ noResultsComponent,
61
+ }: IconLibraryGridProps ) => {
62
+ const theme = useTheme();
63
+ const containerRef = useRef< HTMLDivElement >( null );
64
+ const cellRefs = useRef( new Map< string, HTMLButtonElement >() );
65
+ const shouldRestoreFocusRef = useRef( false );
66
+ const selectedIndex = items.findIndex( ( item ) => item.id === selectedValue );
67
+ const [ focusedIndex, setFocusedIndex ] = useState( selectedIndex >= 0 ? selectedIndex : 0 );
68
+ const [ { columnCount, cellSize }, setGridMetrics ] = useState( () => getGridMetrics( 0, 0, 0 ) );
69
+ const rowCount = Math.ceil( items.length / columnCount );
70
+ const virtualizer = useVirtualizer( {
71
+ count: rowCount,
72
+ getScrollElement: () => containerRef.current,
73
+ estimateSize: () => cellSize,
74
+ overscan: GRID_OVERSCAN,
75
+ } );
76
+ const focusedItem = items[ focusedIndex ];
77
+ const visibleRowIndexes = virtualizer.getVirtualIndexes().join( ',' );
78
+
79
+ useLayoutEffect( () => {
80
+ setFocusedIndex( ( current ) => {
81
+ if ( items.length === 0 ) {
82
+ return 0;
83
+ }
84
+
85
+ if ( selectedIndex >= 0 ) {
86
+ return selectedIndex;
87
+ }
88
+
89
+ return Math.min( current, items.length - 1 );
90
+ } );
91
+
92
+ if ( selectedIndex >= 0 ) {
93
+ virtualizer.scrollToIndex( Math.floor( selectedIndex / columnCount ) );
94
+ }
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [ columnCount, items, selectedIndex, selectedValue ] );
97
+
98
+ useLayoutEffect( () => {
99
+ if ( ! shouldRestoreFocusRef.current || ! focusedItem ) {
100
+ return;
101
+ }
102
+
103
+ const cell = cellRefs.current.get( focusedItem.id );
104
+
105
+ if ( ! cell ) {
106
+ return;
107
+ }
108
+
109
+ cell.focus();
110
+ shouldRestoreFocusRef.current = false;
111
+ }, [ focusedItem, visibleRowIndexes ] );
112
+
113
+ useLayoutEffect( () => {
114
+ const container = containerRef.current;
115
+
116
+ if ( ! container ) {
117
+ return;
118
+ }
119
+
120
+ const measureGrid = () => {
121
+ const columnGap = Number.parseFloat( theme.spacing( GRID_COLUMN_GAP ) );
122
+ const inlinePadding = Number.parseFloat( theme.spacing( GRID_HORIZONTAL_PADDING ) ) * 2;
123
+ setGridMetrics( getGridMetrics( container.clientWidth, columnGap, inlinePadding ) );
124
+ };
125
+
126
+ measureGrid();
127
+ const resizeObserver = new ResizeObserver( measureGrid );
128
+ resizeObserver.observe( container );
129
+
130
+ return () => {
131
+ resizeObserver.disconnect();
132
+ };
133
+ }, [ theme, items.length ] );
134
+
135
+ useLayoutEffect( () => {
136
+ virtualizer.measure();
137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
138
+ }, [ cellSize, columnCount ] );
139
+
140
+ const isRtl = theme.direction === 'rtl' || document.documentElement.dir === 'rtl';
141
+ const horizontalOffset = isRtl ? -1 : 1;
142
+
143
+ const moveFocus = ( nextIndex: number ) => {
144
+ if ( nextIndex < 0 || nextIndex >= items.length ) {
145
+ return;
146
+ }
147
+
148
+ shouldRestoreFocusRef.current = true;
149
+ setFocusedIndex( nextIndex );
150
+ virtualizer.scrollToIndex( Math.floor( nextIndex / columnCount ) );
151
+ };
152
+
153
+ const handleKeyDown = ( event: React.KeyboardEvent< HTMLButtonElement >, index: number ) => {
154
+ if ( event.key === 'Enter' || event.key === ' ' ) {
155
+ event.preventDefault();
156
+ onSelect( items[ index ].id );
157
+ onClose();
158
+ return;
159
+ }
160
+
161
+ if ( event.key === 'ArrowRight' ) {
162
+ event.preventDefault();
163
+ moveFocus( index + horizontalOffset );
164
+ return;
165
+ }
166
+
167
+ if ( event.key === 'ArrowLeft' ) {
168
+ event.preventDefault();
169
+ moveFocus( index - horizontalOffset );
170
+ return;
171
+ }
172
+
173
+ if ( event.key === 'ArrowDown' ) {
174
+ event.preventDefault();
175
+ moveFocus( index + columnCount );
176
+ return;
177
+ }
178
+
179
+ if ( event.key === 'ArrowUp' ) {
180
+ event.preventDefault();
181
+ moveFocus( index - columnCount );
182
+ return;
183
+ }
184
+
185
+ if ( HOME_END_KEYS.has( event.key ) ) {
186
+ event.preventDefault();
187
+
188
+ if ( event.ctrlKey ) {
189
+ moveFocus( event.key === 'Home' ? 0 : items.length - 1 );
190
+ return;
191
+ }
192
+
193
+ const rowStart = Math.floor( index / columnCount ) * columnCount;
194
+ const rowEnd = Math.min( rowStart + columnCount - 1, items.length - 1 );
195
+
196
+ moveFocus( event.key === 'Home' ? rowStart : rowEnd );
197
+ }
198
+ };
199
+
200
+ return (
201
+ <Box
202
+ ref={ containerRef }
203
+ sx={ {
204
+ width: '100%',
205
+ height: '100%',
206
+ minWidth: 0,
207
+ overflowX: 'hidden',
208
+ overflowY: 'auto',
209
+ } }
210
+ >
211
+ { items.length === 0 && noResultsComponent ? (
212
+ noResultsComponent
213
+ ) : (
214
+ <Box
215
+ role="grid"
216
+ aria-label={ __( 'Icons', 'elementor' ) }
217
+ aria-rowcount={ rowCount }
218
+ aria-colcount={ columnCount }
219
+ data-testid="icon-library-grid"
220
+ sx={ {
221
+ width: '100%',
222
+ minWidth: 0,
223
+ height: virtualizer.getTotalSize(),
224
+ position: 'relative',
225
+ } }
226
+ >
227
+ { virtualizer.getVirtualItems().map( ( virtualRow ) => {
228
+ const startIndex = virtualRow.index * columnCount;
229
+ const rowItems = items.slice( startIndex, startIndex + columnCount );
230
+
231
+ return (
232
+ <Box
233
+ key={ virtualRow.key }
234
+ role="row"
235
+ aria-rowindex={ virtualRow.index + 1 }
236
+ sx={ {
237
+ position: 'absolute',
238
+ top: 0,
239
+ left: 0,
240
+ width: '100%',
241
+ height: virtualRow.size,
242
+ transform: `translateY(${ virtualRow.start }px)`,
243
+ display: 'grid',
244
+ gridTemplateColumns: `repeat(${ columnCount }, minmax(0, 1fr))`,
245
+ gap: GRID_COLUMN_GAP,
246
+ px: GRID_HORIZONTAL_PADDING,
247
+ boxSizing: 'border-box',
248
+ } }
249
+ >
250
+ { rowItems.map( ( item, columnIndex ) => {
251
+ const index = startIndex + columnIndex;
252
+ const isSelected = selectedValue === item.id;
253
+ const tabIndex = focusedIndex === index ? 0 : -1;
254
+
255
+ return (
256
+ <Box
257
+ key={ item.id }
258
+ role="presentation"
259
+ sx={ { minWidth: 0, minHeight: 0, width: '100%', height: '100%' } }
260
+ >
261
+ <Tooltip
262
+ title={ item.label }
263
+ placement="top"
264
+ enterDelay={ ICON_LIBRARY_GRID_TOOLTIP_ENTER_DELAY }
265
+ enterNextDelay={ ICON_LIBRARY_GRID_TOOLTIP_ENTER_DELAY }
266
+ disableInteractive
267
+ disableFocusListener
268
+ >
269
+ <Box
270
+ component="button"
271
+ type="button"
272
+ role="gridcell"
273
+ aria-colindex={ columnIndex + 1 }
274
+ aria-label={ item.label }
275
+ aria-selected={ isSelected }
276
+ tabIndex={ tabIndex }
277
+ ref={ ( node: HTMLButtonElement | null ) => {
278
+ if ( node ) {
279
+ cellRefs.current.set( item.id, node );
280
+ } else {
281
+ cellRefs.current.delete( item.id );
282
+ }
283
+ } }
284
+ onClick={ () => {
285
+ onSelect( item.id );
286
+ onClose();
287
+ } }
288
+ onFocus={ () => setFocusedIndex( index ) }
289
+ onKeyDown={ ( event: React.KeyboardEvent< HTMLButtonElement > ) =>
290
+ handleKeyDown( event, index )
291
+ }
292
+ sx={ {
293
+ boxSizing: 'border-box',
294
+ appearance: 'none',
295
+ m: 0,
296
+ width: '100%',
297
+ height: '100%',
298
+ minWidth: 0,
299
+ minHeight: 0,
300
+ display: 'flex',
301
+ alignItems: 'center',
302
+ justifyContent: 'center',
303
+ border: '1px solid',
304
+ borderColor: 'divider',
305
+ borderRadius: 1,
306
+ color: 'text.tertiary',
307
+ bgcolor: 'transparent',
308
+ p: 0,
309
+ cursor: 'pointer',
310
+ font: 'inherit',
311
+ lineHeight: 0,
312
+ overflow: 'hidden',
313
+ '&:hover, &:focus': {
314
+ bgcolor: 'action.hover',
315
+ },
316
+ '&[aria-selected="true"]': {
317
+ bgcolor: 'action.selected',
318
+ },
319
+ '&[aria-selected="true"]:hover, &[aria-selected="true"]:focus':
320
+ {
321
+ bgcolor: 'action.selected',
322
+ },
323
+ } }
324
+ >
325
+ { item.paths.length > 0 ? (
326
+ <FontAwesomeGlyph
327
+ icon={ item }
328
+ size={ ICON_GLYPH_SIZE }
329
+ color="currentColor"
330
+ />
331
+ ) : null }
332
+ </Box>
333
+ </Tooltip>
334
+ </Box>
335
+ );
336
+ } ) }
337
+ </Box>
338
+ );
339
+ } ) }
340
+ </Box>
341
+ ) }
342
+ </Box>
343
+ );
344
+ };
@@ -9,6 +9,7 @@ import {
9
9
  type VirtualizedItem,
10
10
  } from '@elementor/editor-ui';
11
11
  import { ComponentsIcon } from '@elementor/icons';
12
+ import { useSessionStorage } from '@elementor/session';
12
13
  import { Box, CircularProgress, Divider, Link, Stack, styled, Typography } from '@elementor/ui';
13
14
  import { useDebounceState } from '@elementor/utils';
14
15
  import { __ } from '@wordpress/i18n';
@@ -22,11 +23,20 @@ import {
22
23
  } from './font-awesome-7-catalog';
23
24
  import { FontAwesomeGlyph } from './font-awesome-glyph';
24
25
  import { IconLibraryFilter } from './icon-library-filter';
26
+ import { IconLibraryGrid } from './icon-library-grid';
27
+ import { type IconLibraryView, IconLibraryViewToggle } from './icon-library-view-toggle';
25
28
  import { useFontAwesome7Catalog } from './use-font-awesome-7-catalog';
26
29
 
27
30
  export const ICON_LIBRARY_POPOVER_WIDTH = 300;
28
31
  export const ICON_LIBRARY_ROW_HEIGHT = 48;
29
32
  export const ICON_LIBRARY_SEARCH_DEBOUNCE_DELAY = 300;
33
+ export const ICON_LIBRARY_VIEW_STORAGE_KEY = 'icon-library-view';
34
+ export const ICON_LIBRARY_VIEW_STORAGE_PREFIX = 'editor-controls';
35
+ const DEFAULT_ICON_LIBRARY_VIEW: IconLibraryView = 'list';
36
+
37
+ const isIconLibraryView = ( value: unknown ): value is IconLibraryView => {
38
+ return value === 'grid' || value === 'list';
39
+ };
30
40
  const ICON_TILE_SIZE = 40;
31
41
  const ICON_GLYPH_SIZE = 20;
32
42
  const ICON_LIBRARY_INLINE_SPACING = 1;
@@ -63,6 +73,11 @@ export const IconLibraryPopover = ( {
63
73
  setImmediateValue: setSearchValue,
64
74
  } = useDebounceState( { delay: ICON_LIBRARY_SEARCH_DEBOUNCE_DELAY } );
65
75
  const [ activeLibraries, setActiveLibraries ] = useState< FontAwesome7LibraryFilter >( [] );
76
+ const [ storedView, setStoredView ] = useSessionStorage< IconLibraryView >(
77
+ ICON_LIBRARY_VIEW_STORAGE_KEY,
78
+ ICON_LIBRARY_VIEW_STORAGE_PREFIX
79
+ );
80
+ const view = isIconLibraryView( storedView ) ? storedView : DEFAULT_ICON_LIBRARY_VIEW;
66
81
  const { data: icons = [], isLoading } = useFontAwesome7Catalog( open );
67
82
 
68
83
  const items = useMemo(
@@ -103,6 +118,7 @@ export const IconLibraryPopover = ( {
103
118
  title={ __( 'Icon library', 'elementor' ) }
104
119
  onClose={ handleClose }
105
120
  icon={ <ComponentsIcon fontSize="tiny" /> }
121
+ actions={ [ <IconLibraryViewToggle key="view" value={ view } onChange={ setStoredView } /> ] }
106
122
  sx={ { pl: ICON_LIBRARY_INLINE_SPACING, pr: 0.5 } }
107
123
  />
108
124
  <Stack direction="row" alignItems="center" gap={ 1 } sx={ { px: ICON_LIBRARY_INLINE_SPACING, pb: 1 } }>
@@ -116,13 +132,14 @@ export const IconLibraryPopover = ( {
116
132
  <IconLibraryFilter value={ activeLibraries } onChange={ setActiveLibraries } />
117
133
  </Stack>
118
134
  <Divider />
119
- <Box sx={ { flex: 1, overflow: 'auto', minHeight: 0 } }>
135
+ <Box sx={ { flex: 1, overflow: 'hidden', minHeight: 0, minWidth: 0 } }>
120
136
  <IconLibraryContent
121
137
  isLoading={ isLoading }
122
138
  items={ items }
123
139
  selectedValue={ selectedValue }
124
140
  searchValue={ searchValue }
125
141
  isCatalogAvailable={ icons.length > 0 }
142
+ view={ view }
126
143
  onSelect={ handleSelect }
127
144
  onClose={ handleClose }
128
145
  onClearSearch={ handleClearSearch }
@@ -138,6 +155,7 @@ type IconLibraryContentProps = {
138
155
  selectedValue: string | undefined;
139
156
  searchValue: string;
140
157
  isCatalogAvailable: boolean;
158
+ view: IconLibraryView;
141
159
  onSelect: ( id: string ) => void;
142
160
  onClose: () => void;
143
161
  onClearSearch: () => void;
@@ -149,6 +167,7 @@ const IconLibraryContent = ( {
149
167
  selectedValue,
150
168
  searchValue,
151
169
  isCatalogAvailable,
170
+ view,
152
171
  onSelect,
153
172
  onClose,
154
173
  onClearSearch,
@@ -157,6 +176,28 @@ const IconLibraryContent = ( {
157
176
  return <IconLibraryLoadingState />;
158
177
  }
159
178
 
179
+ const emptyState = (
180
+ <IconLibraryEmptyState
181
+ searchValue={ searchValue }
182
+ isCatalogAvailable={ isCatalogAvailable }
183
+ onClear={ onClearSearch }
184
+ />
185
+ );
186
+
187
+ if ( view === 'grid' ) {
188
+ return (
189
+ <Box sx={ { width: '100%', height: '100%', minWidth: 0, minHeight: 0 } }>
190
+ <IconLibraryGrid
191
+ items={ items }
192
+ selectedValue={ selectedValue }
193
+ onSelect={ onSelect }
194
+ onClose={ onClose }
195
+ noResultsComponent={ emptyState }
196
+ />
197
+ </Box>
198
+ );
199
+ }
200
+
160
201
  return (
161
202
  <PopoverMenuList
162
203
  items={ items }
@@ -166,13 +207,7 @@ const IconLibraryContent = ( {
166
207
  onClose={ onClose }
167
208
  itemHeight={ ICON_LIBRARY_ROW_HEIGHT }
168
209
  menuItemContentTemplate={ IconLibraryRow }
169
- noResultsComponent={
170
- <IconLibraryEmptyState
171
- searchValue={ searchValue }
172
- isCatalogAvailable={ isCatalogAvailable }
173
- onClear={ onClearSearch }
174
- />
175
- }
210
+ noResultsComponent={ emptyState }
176
211
  data-testid="icon-library-list"
177
212
  />
178
213
  );
@@ -0,0 +1 @@
1
+ export const ICON_LIBRARY_ACTION_TOOLTIP_ENTER_DELAY = 0;
@@ -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
+ );