Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,44 @@
*/
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { useAsyncList } from '@wordpress/compose';
import { useRef, useLayoutEffect } from '@wordpress/element';
import { getScrollContainer } from '@wordpress/dom';

/**
* Internal dependencies
*/
import HtmlRenderer from '../../../utils/html-renderer';

export default function IconGrid( { icons, onChange, attributes } ) {
const INITIAL_RESULTS = 20;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The larger this value is, the faster icons load, but the more the UI tends to freeze.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rename this; I expected to see actual results, not number of results. RESULT_COUNT or RESULT_LIMIT or BATCH_SIZE maybe would be better. Also it's used for more than just "initial" so I feel like the current naming may be misleading

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find BATCH_SIZE to be the most intuitive. Fixed in 99e9dd1


export default function IconGrid( { icons, onChange, value } ) {
const shownIcons = useAsyncList( icons, {
step: INITIAL_RESULTS,
} );

// Scroll the selected icon into view, but wait until enough icons render
// below it so it can be centered rather than stuck at the bottom. Skip it
// if the user has already scrolled the list.
const selectedIconRef = useRef();
const selectedIndex =
icons?.findIndex( ( icon ) => icon.name === value ) ?? -1;
const isReadyToScroll =
selectedIndex >= 0 &&
( shownIcons.length >= selectedIndex + INITIAL_RESULTS ||
shownIcons.length === icons.length );

useLayoutEffect( () => {
const node = selectedIconRef.current;
if ( ! isReadyToScroll || ! node ) {
return;
}
if ( getScrollContainer( node )?.scrollTop ) {
return;
}
node.scrollIntoView( { block: 'center' } );
}, [ isReadyToScroll ] );

return (
<div className="wp-block-icon__inserter-grid">
{ ! icons?.length ? (
Expand All @@ -21,16 +52,19 @@ export default function IconGrid( { icons, onChange, attributes } ) {
className="wp-block-icon__inserter-grid-icons-list"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically a preexisting issue, but more noticeable now that we have search results that can include icons from different icon sets:

uneven icon tiles

The icon tiles can have different heights, and mess up the layout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, the current implementation only applies a 24px width to the icon, so if the SVG icon itself has a height attribute, its size will become taller than expected.

svg

I think we can fix this in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 16bd33c

All SVG elements now fit perfectly within a 24 x 24px area.

image

aria-label={ __( 'Icon library' ) }
>
{ icons.map( ( icon ) => {
{ shownIcons.map( ( icon ) => {
return (
<Button

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also preexisting, but I guess this should ideally use Composite for arrow key navigation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right. I would like to consider addressing this in a follow-up. Another idea is to completely refactor this icon list using DataViewsPicker. #71227 (comment)

key={ icon.name }
ref={
icon.name === value
? selectedIconRef
: undefined
}
className="wp-block-icon__inserter-grid-icons-list-item"
onClick={ () => onChange( icon.name ) }
variant={
icon.name === attributes?.icon
? 'primary'
: undefined
icon.name === value ? 'primary' : undefined
}
__next40pxDefaultSize
>
Expand Down
114 changes: 89 additions & 25 deletions packages/block-library/src/icon/components/custom-inserter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,68 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { Modal, SearchControl } from '@wordpress/components';
import { useState, useMemo, useCallback } from '@wordpress/element';
import { Modal, SearchControl, Spinner } from '@wordpress/components';
import { Stack, Tabs } from '@wordpress/ui';
import { useState, useMemo } from '@wordpress/element';
import { useDebounce } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';

/**
* Internal dependencies
*/
import IconGrid from './icon-grid';
import { normalizeSearchInput } from '../../../utils/search-patterns';

export default function CustomInserterModal( {
icons = [],
setInserterOpen,
attributes,
setAttributes,
} ) {
export default function CustomInserterModal( { onClose, value, onChange } ) {
const [ searchInput, setSearchInput ] = useState( '' );
const [ currentCollection, setCurrentCollection ] = useState( null );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we choose to keep the search within the current collection only, should we reset searchInput when currentCollection changes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added the "All" tab and made the search function operate globally.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the search behavior changes depending on the selected tab, a common pattern to make things clearer is to place the search field within the tabpanel.

Not a blocker for merge, and not sure if there are similar patterns within the app. But ideally, I think the search field placement should be clearer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the layout of the search button, I referred to the pattern modal.

image

One advantage of placing the search field outside the tab panel is that it allows users to change collections while preserving their query.

icon-picker.mp4


const debouncedSetSearchInput = useDebounce( setSearchInput, 300 );

const setIcon = useCallback(
( name ) => {
setAttributes( {
icon: name,
} );
setInserterOpen( false );
const collections = useSelect(
( select ) =>
select( coreDataStore ).getEntityRecords(
'root',
'iconCollection'
),
[]
);

// Default to the collection the selected icon belongs to, otherwise the
// first collection.
const selectedCollection = value?.split( '/' )[ 0 ];
const collectionSlug =
currentCollection ??
( collections?.some( ( { slug } ) => slug === selectedCollection )
? selectedCollection
: collections?.[ 0 ]?.slug ) ??
null;

const { icons, hasResolvedIcons } = useSelect(
( select ) => {
if ( ! collectionSlug ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WIll this ever happen?

@t-hamano t-hamano Jul 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This may be null until the collections are resolved.

return { icons: null, hasResolvedIcons: false };
}
const query = { namespace: collectionSlug };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, the fact that the search will now work only within the selected collection feels like a regression. Even in the inserter, search will work across all collections.

Is there a way for the search to work across collections? Or is there a good reason why we don't want it to work that way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added the "All" tab and made the search function operate globally.

const { getEntityRecords, hasFinishedResolution } =
select( coreDataStore );
return {
icons: getEntityRecords( 'root', 'icon', query ),
hasResolvedIcons: hasFinishedResolution( 'getEntityRecords', [
'root',
'icon',
query,
] ),
};
},
[ setAttributes, setInserterOpen ]
[ collectionSlug ]
);

const filteredIcons = useMemo( () => {
if ( ! icons ) {
return [];
}
if ( searchInput ) {
const input = normalizeSearchInput( searchInput );
return icons.filter( ( icon ) => {
Expand All @@ -52,22 +83,55 @@ export default function CustomInserterModal( {
<Modal
className="wp-block-icon__inserter-modal"
title={ __( 'Icon library' ) }
onRequestClose={ () => setInserterOpen( false ) }
onRequestClose={ onClose }
isFullScreen
>
<div className="wp-block-icon__inserter">
<div className="wp-block-icon__inserter-header">
<Tabs.Root
className="wp-block-icon__inserter"
orientation="vertical"
value={ collectionSlug }
onValueChange={ setCurrentCollection }
>
<Stack
direction="column"
gap="lg"
className="wp-block-icon__inserter-sidebar"
>
<SearchControl
value={ searchInput }
onChange={ debouncedSetSearchInput }
/>
</div>
<IconGrid
icons={ filteredIcons }
onChange={ setIcon }
attributes={ attributes }
/>
</div>
<Tabs.List className="wp-block-icon__inserter-collections">
Comment thread
t-hamano marked this conversation as resolved.
Resolved
{ collections?.map( ( collection ) => (
<Tabs.Tab
key={ collection.slug }
value={ collection.slug }
>
{ collection.label }
</Tabs.Tab>
) ) }
</Tabs.List>
</Stack>
{ collections?.map( ( collection ) => (
<Tabs.Panel
key={ collection.slug }
value={ collection.slug }
className="wp-block-icon__inserter-panel"
>
{ ! hasResolvedIcons ? (
<div className="wp-block-icon__inserter-loading">
<Spinner />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely needs an accessible label

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, fixed in 09e492b

</div>
) : (
<IconGrid
icons={ filteredIcons }
onChange={ onChange }
value={ value }
/>
) }
</Tabs.Panel>
) ) }
</Tabs.Root>
</Modal>
);
}
26 changes: 10 additions & 16 deletions packages/block-library/src/icon/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,12 @@ export function Edit( { attributes, setAttributes } ) {
const borderProps = useBorderProps( attributes );
const dimensionsProps = useDimensionsProps( attributes );

const { selectedIcon, allIcons = [] } = useSelect(
const selectedIcon = useSelect(
( select ) => {
const { getEntityRecord, getEntityRecords } =
select( coreDataStore );
return {
selectedIcon: icon
? getEntityRecord( 'root', 'icon', icon )
: null,
allIcons: isInserterOpen
? getEntityRecords( 'root', 'icon' )
: undefined,
};
const { getEntityRecord } = select( coreDataStore );
return icon ? getEntityRecord( 'root', 'icon', icon ) : null;
},
[ isInserterOpen, icon ]
[ icon ]
);

const iconToDisplay = selectedIcon?.content || '';
Expand Down Expand Up @@ -258,10 +250,12 @@ export function Edit( { attributes, setAttributes } ) {
</div>
{ isInserterOpen && (
<CustomInserterModal
icons={ allIcons }
setInserterOpen={ setInserterOpen }
attributes={ attributes }
setAttributes={ setAttributes }
onClose={ () => setInserterOpen( false ) }
value={ attributes.icon }
onChange={ ( name ) => {
setAttributes( { icon: name } );
setInserterOpen( false );
} }
/>
) }
</>
Expand Down
33 changes: 25 additions & 8 deletions packages/block-library/src/icon/editor.scss
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
@use "@wordpress/base-styles/variables" as *;
@use "@wordpress/base-styles/colors" as *;
@use "@wordpress/base-styles/mixins" as *;

.wp-block[data-align="center"] > .wp-block-icon {
display: flex;
justify-content: center;
}

// Style for the icon library modal.
.wp-block-icon__inserter {
padding: 0 $grid-unit-30;
margin: 0 (-1 * $grid-unit-30);
.wp-block-icon__inserter-sidebar {
padding-bottom: $grid-unit-30;

@include break-medium() {
overflow-x: visible;
overflow-y: auto;
position: absolute;
top: $header-height + $grid-unit-10;
left: 0;
bottom: 0;
width: $sidebar-width;
padding-top: $grid-unit-30;
padding-inline: $grid-unit-30;
}
}

.wp-block-icon__inserter-panel {
@include break-medium() {
margin-inline-start: $sidebar-width;
}
}

.wp-block-icon__inserter-header {
align-items: center;
.wp-block-icon__inserter-loading {
display: flex;
justify-content: space-between;
margin-bottom: $grid-unit-20;
justify-content: center;
padding: $grid-unit-30;
}

.wp-block-icon__inserter-grid-icons-list {
Expand Down Expand Up @@ -45,6 +61,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}

.wp-block-icon__toolbar-content {
Expand Down
Loading