@wordpress/commands 1.41.1-next.v.202603161435.0 → 1.43.0

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 (35) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/build/components/command-menu.cjs +156 -154
  3. package/build/components/command-menu.cjs.map +3 -3
  4. package/build/components/use-recent-commands.cjs +125 -0
  5. package/build/components/use-recent-commands.cjs.map +7 -0
  6. package/build/store/index.cjs +2 -0
  7. package/build/store/index.cjs.map +2 -2
  8. package/build/store/private-actions.cjs +11 -2
  9. package/build/store/private-actions.cjs.map +2 -2
  10. package/build/store/private-selectors.cjs +32 -0
  11. package/build/store/private-selectors.cjs.map +7 -0
  12. package/build/store/reducer.cjs +12 -1
  13. package/build/store/reducer.cjs.map +2 -2
  14. package/build-module/components/command-menu.mjs +160 -154
  15. package/build-module/components/command-menu.mjs.map +2 -2
  16. package/build-module/components/use-recent-commands.mjs +104 -0
  17. package/build-module/components/use-recent-commands.mjs.map +7 -0
  18. package/build-module/store/index.mjs +2 -0
  19. package/build-module/store/index.mjs.map +2 -2
  20. package/build-module/store/private-actions.mjs +9 -1
  21. package/build-module/store/private-actions.mjs.map +2 -2
  22. package/build-module/store/private-selectors.mjs +8 -0
  23. package/build-module/store/private-selectors.mjs.map +7 -0
  24. package/build-module/store/reducer.mjs +12 -1
  25. package/build-module/store/reducer.mjs.map +2 -2
  26. package/build-style/style-rtl.css +25 -21
  27. package/build-style/style.css +25 -21
  28. package/package.json +12 -11
  29. package/src/components/command-menu.js +179 -161
  30. package/src/components/style.scss +32 -29
  31. package/src/components/use-recent-commands.js +131 -0
  32. package/src/store/index.js +2 -0
  33. package/src/store/private-actions.js +16 -0
  34. package/src/store/private-selectors.js +10 -0
  35. package/src/store/reducer.js +13 -0
@@ -12,8 +12,6 @@ import {
12
12
  useState,
13
13
  useEffect,
14
14
  useRef,
15
- useCallback,
16
- useMemo,
17
15
  isValidElement,
18
16
  Component,
19
17
  } from '@wordpress/element';
@@ -35,9 +33,16 @@ import { Icon, search as inputIcon, arrowRight } from '@wordpress/icons';
35
33
  */
36
34
  import { store as commandsStore } from '../store';
37
35
  import { unlock } from '../lock-unlock';
36
+ import {
37
+ recordUsage,
38
+ useLoaderCollector,
39
+ useRecentCommands,
40
+ } from './use-recent-commands';
38
41
 
39
42
  const { withIgnoreIMEEvents } = unlock( componentsPrivateApis );
40
43
 
44
+ // Namespaces item ids to avoid collisions with other elements on the page.
45
+ const ITEM_ID_PREFIX = 'command-palette-item-';
41
46
  const inputLabel = __( 'Search commands and settings' );
42
47
 
43
48
  /**
@@ -80,18 +85,62 @@ export function isValidIcon( icon ) {
80
85
  );
81
86
  }
82
87
 
83
- function CommandMenuLoader( {
84
- name,
85
- search,
86
- hook,
87
- setLoader,
88
- close,
89
- category,
90
- } ) {
91
- const { isLoading, commands = [] } = hook( { search } ) ?? {};
88
+ function CommandItem( { command, search, category, valuePrefix } ) {
89
+ const { close } = useDispatch( commandsStore );
90
+ const commandCategory = category ?? command.category;
91
+ const label = command.searchLabel ?? command.label;
92
+ const value = valuePrefix ? `${ valuePrefix }${ command.name }` : label;
93
+ return (
94
+ <Command.Item
95
+ key={ command.name }
96
+ id={ `${ ITEM_ID_PREFIX }${ value.toLowerCase() }` }
97
+ value={ value }
98
+ keywords={
99
+ valuePrefix
100
+ ? [ ...( command.keywords ?? [] ), label ]
101
+ : command.keywords
102
+ }
103
+ onSelect={ () => {
104
+ recordUsage( command.name );
105
+ command.callback( { close } );
106
+ } }
107
+ >
108
+ <HStack
109
+ alignment="left"
110
+ className={ clsx( 'commands-command-menu__item', {
111
+ 'has-icon':
112
+ CATEGORY_ICONS[ commandCategory ] || command.icon,
113
+ } ) }
114
+ >
115
+ { CATEGORY_ICONS[ commandCategory ] ? (
116
+ <Icon icon={ CATEGORY_ICONS[ commandCategory ] } />
117
+ ) : (
118
+ isValidIcon( command.icon ) && (
119
+ <Icon icon={ command.icon } />
120
+ )
121
+ ) }
122
+ <span className="commands-command-menu__item-label">
123
+ <TextHighlight
124
+ text={ command.label }
125
+ highlight={ search }
126
+ />
127
+ </span>
128
+ { CATEGORY_LABELS[ commandCategory ] && (
129
+ <span className="commands-command-menu__item-category">
130
+ { CATEGORY_LABELS[ commandCategory ] }
131
+ </span>
132
+ ) }
133
+ </HStack>
134
+ </Command.Item>
135
+ );
136
+ }
137
+
138
+ function CommandMenuLoader( { name, search, hook, category, valuePrefix } ) {
139
+ const { setLoaderLoading } = unlock( useDispatch( commandsStore ) );
140
+ const { isLoading: loading, commands = [] } = hook( { search } ) ?? {};
92
141
  useEffect( () => {
93
- setLoader( name, isLoading );
94
- }, [ setLoader, name, isLoading ] );
142
+ setLoaderLoading( name, loading );
143
+ }, [ setLoaderLoading, name, loading ] );
95
144
 
96
145
  if ( ! commands.length ) {
97
146
  return null;
@@ -99,64 +148,24 @@ function CommandMenuLoader( {
99
148
 
100
149
  return (
101
150
  <>
102
- { commands.map( ( command ) => {
103
- const commandCategory = command.category ?? category;
104
- return (
105
- <Command.Item
106
- key={ command.name }
107
- value={ command.searchLabel ?? command.label }
108
- keywords={ command.keywords }
109
- onSelect={ () => command.callback( { close } ) }
110
- id={ command.name }
111
- >
112
- <HStack
113
- alignment="left"
114
- className={ clsx( 'commands-command-menu__item', {
115
- 'has-icon':
116
- CATEGORY_ICONS[ commandCategory ] ||
117
- command.icon,
118
- } ) }
119
- >
120
- { CATEGORY_ICONS[ commandCategory ] && (
121
- <Icon
122
- icon={ CATEGORY_ICONS[ commandCategory ] }
123
- />
124
- ) }
125
- { ! CATEGORY_ICONS[ commandCategory ] &&
126
- isValidIcon( command.icon ) && (
127
- <Icon icon={ command.icon } />
128
- ) }
129
- <span className="commands-command-menu__item-label">
130
- <TextHighlight
131
- text={ command.label }
132
- highlight={ search }
133
- />
134
- </span>
135
- { CATEGORY_LABELS[ commandCategory ] && (
136
- <span className="commands-command-menu__item-category">
137
- { CATEGORY_LABELS[ commandCategory ] }
138
- </span>
139
- ) }
140
- </HStack>
141
- </Command.Item>
142
- );
143
- } ) }
151
+ { commands.map( ( command ) => (
152
+ <CommandItem
153
+ key={ command.name }
154
+ command={ command }
155
+ search={ search }
156
+ category={ command.category ?? category }
157
+ valuePrefix={ valuePrefix }
158
+ />
159
+ ) ) }
144
160
  </>
145
161
  );
146
162
  }
147
163
 
148
- export function CommandMenuLoaderWrapper( {
149
- hook,
150
- search,
151
- setLoader,
152
- close,
153
- category,
154
- } ) {
164
+ function CommandMenuLoaderWrapper( { hook, ...props } ) {
155
165
  // The "hook" prop is actually a custom React hook
156
166
  // so to avoid breaking the rules of hooks
157
167
  // the CommandMenuLoaderWrapper component need to be
158
- // remounted on each hook prop change
159
- // We use the key state to make sure we do that properly.
168
+ // remounted on each hook prop change.
160
169
  const currentLoaderRef = useRef( hook );
161
170
  const [ key, setKey ] = useState( 0 );
162
171
  useEffect( () => {
@@ -170,96 +179,123 @@ export function CommandMenuLoaderWrapper( {
170
179
  <CommandMenuLoader
171
180
  key={ key }
172
181
  hook={ currentLoaderRef.current }
173
- search={ search }
174
- setLoader={ setLoader }
175
- close={ close }
176
- category={ category }
182
+ { ...props }
177
183
  />
178
184
  );
179
185
  }
180
186
 
181
- export function CommandMenuGroup( { isContextual, search, setLoader, close } ) {
182
- const { commands, loaders } = useSelect(
183
- ( select ) => {
184
- const { getCommands, getCommandLoaders } = select( commandsStore );
185
- return {
186
- commands: getCommands( isContextual ),
187
- loaders: getCommandLoaders( isContextual ),
188
- };
189
- },
190
- [ isContextual ]
187
+ function CommandList( { search, commands, loaders, valuePrefix } ) {
188
+ return (
189
+ <>
190
+ { commands.map( ( command ) => (
191
+ <CommandItem
192
+ key={ command.name }
193
+ command={ command }
194
+ search={ search }
195
+ valuePrefix={ valuePrefix }
196
+ />
197
+ ) ) }
198
+ { loaders.map( ( loader ) => (
199
+ <CommandMenuLoaderWrapper
200
+ key={ loader.name }
201
+ name={ loader.name }
202
+ search={ search }
203
+ hook={ loader.hook }
204
+ category={ loader.category }
205
+ valuePrefix={ valuePrefix }
206
+ />
207
+ ) ) }
208
+ </>
191
209
  );
210
+ }
211
+
212
+ function RecentLoaderRunner( { hook, name, filterNames, onResolved } ) {
213
+ useLoaderCollector( hook, name, filterNames, onResolved );
214
+ return null;
215
+ }
216
+
217
+ function RecentGroup() {
218
+ const { commands, loaders, recentSet, onResolved } = useRecentCommands();
192
219
 
193
220
  if ( ! commands.length && ! loaders.length ) {
194
221
  return null;
195
222
  }
196
223
 
197
224
  return (
198
- <Command.Group>
199
- { commands.map( ( command ) => (
200
- <Command.Item
201
- key={ command.name }
202
- value={ command.searchLabel ?? command.label }
203
- keywords={ command.keywords }
204
- onSelect={ () => command.callback( { close } ) }
205
- id={ command.name }
206
- >
207
- <HStack
208
- alignment="left"
209
- className={ clsx( 'commands-command-menu__item', {
210
- 'has-icon':
211
- CATEGORY_ICONS[ command.category ] ||
212
- command.icon,
213
- } ) }
214
- >
215
- { CATEGORY_ICONS[ command.category ] ? (
216
- <Icon icon={ CATEGORY_ICONS[ command.category ] } />
217
- ) : (
218
- command.icon && <Icon icon={ command.icon } />
219
- ) }
220
- <span>
221
- <TextHighlight
222
- text={ command.label }
223
- highlight={ search }
224
- />
225
- </span>
226
- { CATEGORY_LABELS[ command.category ] && (
227
- <span className="commands-command-menu__item-category">
228
- { CATEGORY_LABELS[ command.category ] }
229
- </span>
230
- ) }
231
- </HStack>
232
- </Command.Item>
233
- ) ) }
225
+ <Command.Group heading={ __( 'Recent' ) }>
234
226
  { loaders.map( ( loader ) => (
235
- <CommandMenuLoaderWrapper
227
+ <RecentLoaderRunner
236
228
  key={ loader.name }
229
+ name={ loader.name }
237
230
  hook={ loader.hook }
238
- search={ search }
239
- setLoader={ setLoader }
240
- close={ close }
241
- category={ loader.category }
231
+ filterNames={ recentSet }
232
+ onResolved={ onResolved }
233
+ />
234
+ ) ) }
235
+ { commands.map( ( command ) => (
236
+ <CommandItem
237
+ key={ command.name }
238
+ command={ command }
239
+ search=""
240
+ valuePrefix="recent-"
242
241
  />
243
242
  ) ) }
244
243
  </Command.Group>
245
244
  );
246
245
  }
247
246
 
248
- function CommandInput( { isOpen, search, setSearch } ) {
247
+ function SuggestionsGroup() {
248
+ const { commands, loaders } = useSelect( ( select ) => {
249
+ const { getCommands, getCommandLoaders } = select( commandsStore );
250
+ return {
251
+ commands: getCommands( true ),
252
+ loaders: getCommandLoaders( true ),
253
+ };
254
+ }, [] );
255
+
256
+ return (
257
+ <Command.Group heading={ __( 'Suggestions' ) }>
258
+ <CommandList search="" commands={ commands } loaders={ loaders } />
259
+ </Command.Group>
260
+ );
261
+ }
262
+
263
+ function ResultsGroup( { search } ) {
264
+ const { commands, contextualCommands, loaders, contextualLoaders } =
265
+ useSelect( ( select ) => {
266
+ const { getCommands, getCommandLoaders } = select( commandsStore );
267
+ return {
268
+ commands: getCommands( false ),
269
+ contextualCommands: getCommands( true ),
270
+ loaders: getCommandLoaders( false ),
271
+ contextualLoaders: getCommandLoaders( true ),
272
+ };
273
+ }, [] );
274
+
275
+ return (
276
+ <Command.Group heading={ __( 'Results' ) }>
277
+ <CommandList
278
+ search={ search }
279
+ commands={ commands }
280
+ loaders={ loaders }
281
+ />
282
+ <CommandList
283
+ search={ search }
284
+ commands={ contextualCommands }
285
+ loaders={ contextualLoaders }
286
+ />
287
+ </Command.Group>
288
+ );
289
+ }
290
+
291
+ function CommandInput( { search, setSearch } ) {
249
292
  const commandMenuInput = useRef();
250
293
  const _value = useCommandState( ( state ) => state.value );
251
- const selectedItemId = useMemo( () => {
252
- const item = document.querySelector(
253
- `[cmdk-item=""][data-value="${ _value }"]`
254
- );
255
- return item?.getAttribute( 'id' );
256
- }, [ _value ] );
294
+ const selectedItemId = _value ? `${ ITEM_ID_PREFIX }${ _value }` : null;
257
295
  useEffect( () => {
258
296
  // Focus the command palette input when mounting the modal.
259
- if ( isOpen ) {
260
- commandMenuInput.current.focus();
261
- }
262
- }, [ isOpen ] );
297
+ commandMenuInput.current.focus();
298
+ }, [] );
263
299
  return (
264
300
  <Command.Input
265
301
  ref={ commandMenuInput }
@@ -277,12 +313,14 @@ function CommandInput( { isOpen, search, setSearch } ) {
277
313
  export function CommandMenu() {
278
314
  const { registerShortcut } = useDispatch( keyboardShortcutsStore );
279
315
  const [ search, setSearch ] = useState( '' );
280
- const isOpen = useSelect(
281
- ( select ) => select( commandsStore ).isOpen(),
316
+ const { isOpen: paletteIsOpen, loadersLoading } = useSelect(
317
+ ( select ) => ( {
318
+ isOpen: select( commandsStore ).isOpen(),
319
+ loadersLoading: unlock( select( commandsStore ) ).isLoading(),
320
+ } ),
282
321
  []
283
322
  );
284
323
  const { open, close } = useDispatch( commandsStore );
285
- const [ loaders, setLoaders ] = useState( {} );
286
324
 
287
325
  useEffect( () => {
288
326
  registerShortcut( {
@@ -306,7 +344,7 @@ export function CommandMenu() {
306
344
  }
307
345
 
308
346
  event.preventDefault();
309
- if ( isOpen ) {
347
+ if ( paletteIsOpen ) {
310
348
  close();
311
349
  } else {
312
350
  open();
@@ -317,35 +355,26 @@ export function CommandMenu() {
317
355
  }
318
356
  );
319
357
 
320
- const setLoader = useCallback(
321
- ( name, value ) =>
322
- setLoaders( ( current ) => ( {
323
- ...current,
324
- [ name ]: value,
325
- } ) ),
326
- []
327
- );
328
358
  const closeAndReset = () => {
329
359
  setSearch( '' );
330
360
  close();
331
361
  };
332
362
 
333
- if ( ! isOpen ) {
363
+ if ( ! paletteIsOpen ) {
334
364
  return false;
335
365
  }
336
366
 
337
- const isLoading = Object.values( loaders ).some( Boolean );
338
-
339
367
  return (
340
368
  <Modal
341
369
  className="commands-command-menu"
342
370
  overlayClassName="commands-command-menu__overlay"
343
371
  onRequestClose={ closeAndReset }
344
372
  __experimentalHideHeader
373
+ size="medium"
345
374
  contentLabel={ __( 'Command palette' ) }
346
375
  >
347
376
  <div className="commands-command-menu__container">
348
- <Command label={ inputLabel }>
377
+ <Command label={ inputLabel } loop>
349
378
  <div className="commands-command-menu__header">
350
379
  <Icon
351
380
  className="commands-command-menu__header-search-icon"
@@ -354,28 +383,17 @@ export function CommandMenu() {
354
383
  <CommandInput
355
384
  search={ search }
356
385
  setSearch={ setSearch }
357
- isOpen={ isOpen }
358
386
  />
359
387
  </div>
360
388
  <Command.List label={ __( 'Command suggestions' ) }>
361
- { search && ! isLoading && (
389
+ { search && ! loadersLoading && (
362
390
  <Command.Empty>
363
391
  { __( 'No results found.' ) }
364
392
  </Command.Empty>
365
393
  ) }
366
- <CommandMenuGroup
367
- search={ search }
368
- setLoader={ setLoader }
369
- close={ closeAndReset }
370
- isContextual
371
- />
372
- { search && (
373
- <CommandMenuGroup
374
- search={ search }
375
- setLoader={ setLoader }
376
- close={ closeAndReset }
377
- />
378
- ) }
394
+ { ! search && <RecentGroup /> }
395
+ { ! search && <SuggestionsGroup /> }
396
+ { search && <ResultsGroup search={ search } /> }
379
397
  </Command.List>
380
398
  </Command>
381
399
  </div>
@@ -2,12 +2,12 @@
2
2
  @use "@wordpress/base-styles/variables" as *;
3
3
  @use "@wordpress/base-styles/colors" as *;
4
4
 
5
+ $palette-max-height: 376px;
6
+ $palette-header-height: 48px;
7
+
5
8
  // Here we extend the modal styles to be tighter, and to the center. Because the palette uses the modal as a container.
6
9
  .commands-command-menu {
7
- border-radius: $grid-unit-05;
8
- width: calc(100% - #{$grid-unit-40});
9
10
  margin: auto;
10
- max-width: 400px;
11
11
  position: relative;
12
12
  top: calc(5% + #{$header-height});
13
13
 
@@ -61,7 +61,7 @@
61
61
  [cmdk-input] {
62
62
  border: none;
63
63
  width: 100%;
64
- padding: $grid-unit-20 $grid-unit-05;
64
+ padding: $grid-unit-15 $grid-unit-05;
65
65
  outline: none;
66
66
  color: $gray-900;
67
67
  margin: 0;
@@ -80,23 +80,13 @@
80
80
  }
81
81
 
82
82
  [cmdk-item] {
83
- border-radius: $radius-small;
84
83
  cursor: pointer;
85
84
  display: flex;
86
85
  align-items: center;
86
+ padding: $grid-unit-05 0;
87
87
  color: $gray-900;
88
88
  font-size: $default-font-size;
89
89
 
90
- &[aria-selected="true"],
91
- &:active {
92
- background: var(--wp-admin-theme-color);
93
- color: $white;
94
-
95
- svg {
96
- fill: $white;
97
- }
98
- }
99
-
100
90
  &[aria-disabled="true"] {
101
91
  color: $gray-600;
102
92
  cursor: not-allowed;
@@ -107,19 +97,34 @@
107
97
  }
108
98
 
109
99
  > div {
110
- min-height: $button-size-next-default-40px;
100
+ border-radius: $radius-small;
101
+ min-height: $grid-unit-40;
111
102
  padding: $grid-unit-05;
112
103
  padding-left: $grid-unit-50; // Account for commands without icons.
113
104
  padding-right: $grid-unit-20; // Accounts for the command category.
114
105
  }
115
106
 
107
+ &[aria-selected="true"] > div,
108
+ &:active > div {
109
+ background: var(--wp-admin-theme-color);
110
+ color: $white;
111
+
112
+ svg {
113
+ fill: $white;
114
+ }
115
+ }
116
+
116
117
  > .has-icon {
117
118
  padding-left: $grid-unit;
118
119
  }
119
120
  }
120
121
 
122
+ [cmdk-group]:has([cmdk-group-items]:empty) {
123
+ display: none;
124
+ }
125
+
121
126
  [cmdk-root] > [cmdk-list] {
122
- max-height: $palette-max-height; // Specific to not have commands overflow oddly.
127
+ max-height: min($palette-max-height - $palette-header-height, calc(70vh - $palette-header-height));
123
128
  overflow: auto;
124
129
  scroll-padding-top: $grid-unit-10;
125
130
  scroll-padding-bottom: $grid-unit-10;
@@ -130,20 +135,18 @@
130
135
  border-top: $border-width solid $gray-300;
131
136
  }
132
137
 
133
- // Only add vertical padding when there are commands to show.
134
- &:has([cmdk-group-items]:not(:empty)) {
135
- padding-top: $grid-unit-10;
136
- }
137
-
138
- // Ensures there is always padding bottom on the last group, when there are commands.
139
- &
140
- [cmdk-list-sizer] > [cmdk-group]:last-child
141
- [cmdk-group-items]:not(:empty) {
142
- padding-bottom: $grid-unit-10;
138
+ & [cmdk-list-sizer] > [cmdk-group] > [cmdk-group-items]:not(:empty) {
139
+ padding: 0 $grid-unit-10 $grid-unit-05;
143
140
  }
144
141
 
145
- & [cmdk-list-sizer] > [cmdk-group] > [cmdk-group-items]:not(:empty) {
146
- padding: 0 $grid-unit-10;
142
+ [cmdk-group-heading] {
143
+ margin-top: $grid-unit-10;
144
+ padding: $grid-unit-10 $grid-unit-20;
145
+ line-height: $grid-unit-20;
146
+ font-size: $font-size-x-small;
147
+ font-weight: $font-weight-medium;
148
+ text-transform: uppercase;
149
+ color: $gray-900;
147
150
  }
148
151
  }
149
152
 
@@ -0,0 +1,131 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import {
5
+ useSelect,
6
+ useDispatch,
7
+ select as globalSelect,
8
+ dispatch,
9
+ } from '@wordpress/data';
10
+ import { store as preferencesStore } from '@wordpress/preferences';
11
+ import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
12
+
13
+ /**
14
+ * Internal dependencies
15
+ */
16
+ import { store as commandsStore } from '../store';
17
+ import { unlock } from '../lock-unlock';
18
+
19
+ const MAX_RECENTLY_SAVED = 30;
20
+ const MAX_RECENTLY_DISPLAYED = 5;
21
+ const EMPTY_ARRAY = [];
22
+ const EMPTY_SET = new Set();
23
+
24
+ export function recordUsage( name ) {
25
+ const current =
26
+ globalSelect( preferencesStore ).get(
27
+ 'core/commands',
28
+ 'recentlyUsed'
29
+ ) ?? [];
30
+ const next = [ name, ...current.filter( ( n ) => n !== name ) ].slice(
31
+ 0,
32
+ MAX_RECENTLY_SAVED
33
+ );
34
+ dispatch( preferencesStore ).set( 'core/commands', 'recentlyUsed', next );
35
+ }
36
+
37
+ export function useLoaderCollector( hook, name, filterNames, onResolved ) {
38
+ const { setLoaderLoading } = unlock( useDispatch( commandsStore ) );
39
+ const { isLoading: loading, commands = [] } = hook( { search: '' } ) ?? {};
40
+
41
+ useEffect( () => {
42
+ setLoaderLoading( name, loading );
43
+ }, [ setLoaderLoading, name, loading ] );
44
+
45
+ const filtered = filterNames
46
+ ? commands.filter( ( c ) => filterNames.has( c.name ) )
47
+ : commands;
48
+
49
+ useEffect( () => {
50
+ onResolved( name, filtered );
51
+ }, [ onResolved, name, filtered ] );
52
+
53
+ // Clear this loader's entries when it unmounts.
54
+ useEffect( () => {
55
+ return () => onResolved( name, [] );
56
+ }, [ onResolved, name ] );
57
+ }
58
+
59
+ export function useRecentCommands() {
60
+ const {
61
+ contextualCommands,
62
+ staticCommands,
63
+ contextualLoaders,
64
+ staticLoaders,
65
+ recentlyUsedNames = EMPTY_ARRAY,
66
+ } = useSelect( ( select ) => {
67
+ const { getCommands, getCommandLoaders } = select( commandsStore );
68
+ return {
69
+ contextualCommands: getCommands( true ),
70
+ staticCommands: getCommands( false ),
71
+ contextualLoaders: getCommandLoaders( true ),
72
+ staticLoaders: getCommandLoaders( false ),
73
+ recentlyUsedNames: select( preferencesStore ).get(
74
+ 'core/commands',
75
+ 'recentlyUsed'
76
+ ),
77
+ };
78
+ }, [] );
79
+
80
+ const [ resolvedMap, setResolvedMap ] = useState( () => new Map() );
81
+
82
+ const onResolved = useCallback( ( loaderName, cmds ) => {
83
+ setResolvedMap( ( prev ) => {
84
+ const prevCmds = prev.get( loaderName );
85
+ if (
86
+ prevCmds &&
87
+ prevCmds.length === cmds.length &&
88
+ prevCmds.every( ( c, i ) => c.name === cmds[ i ].name )
89
+ ) {
90
+ return prev;
91
+ }
92
+ const next = new Map( prev );
93
+ next.set( loaderName, cmds );
94
+ return next;
95
+ } );
96
+ }, [] );
97
+
98
+ const { recentNames, recentSet } = useMemo( () => {
99
+ const names = recentlyUsedNames.slice( 0, MAX_RECENTLY_DISPLAYED );
100
+ return { recentNames: names, recentSet: new Set( names ) };
101
+ }, [ recentlyUsedNames ] );
102
+
103
+ if ( ! recentlyUsedNames.length ) {
104
+ return {
105
+ commands: [],
106
+ loaders: [],
107
+ recentSet: EMPTY_SET,
108
+ onResolved,
109
+ };
110
+ }
111
+
112
+ const allStaticCommands = [ ...contextualCommands, ...staticCommands ];
113
+ const loaders = [ ...contextualLoaders, ...staticLoaders ];
114
+
115
+ // Merge static commands with loader-resolved commands.
116
+ const allByName = new Map();
117
+ allStaticCommands.forEach( ( c ) => allByName.set( c.name, c ) );
118
+ for ( const cmds of resolvedMap.values() ) {
119
+ cmds.forEach( ( c ) => {
120
+ if ( ! allByName.has( c.name ) ) {
121
+ allByName.set( c.name, c );
122
+ }
123
+ } );
124
+ }
125
+ // Return in recency order.
126
+ const commands = recentNames
127
+ .map( ( n ) => allByName.get( n ) )
128
+ .filter( Boolean );
129
+
130
+ return { commands, loaders, recentSet, onResolved };
131
+ }