diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index ed657c648b1a12..2c6342e58e18fd 100644
--- a/docs/reference-guides/core-blocks.md
+++ b/docs/reference-guides/core-blocks.md
@@ -431,6 +431,15 @@ Add custom HTML code and preview it as you edit. ([Source](https://github.com/Wo
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customClassName~~, ~~html~~
- **Attributes:** content
+## Icon
+
+Insert an SVG icon or graphic. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/icon))
+
+- **Name:** core/icon
+- **Category:** media
+- **Supports:** align, anchor, interactivity (clientNavigation), spacing (margin, padding), ~~html~~
+- **Attributes:** icon, iconName, label, title
+
## Image
Insert an image to make a visual statement. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/image))
@@ -521,7 +530,7 @@ A collection of blocks that allow visitors to get around your site. ([Source](ht
- **Name:** core/navigation
- **Category:** theme
-- **Allowed Blocks:** core/navigation-link, core/search, core/social-links, core/page-list, core/spacer, core/home-link, core/site-title, core/site-logo, core/navigation-submenu, core/loginout, core/buttons
+- **Allowed Blocks:** core/navigation-link, core/search, core/social-links, core/page-list, core/spacer, core/home-link, core/icon, core/site-title, core/site-logo, core/navigation-submenu, core/loginout, core/buttons
- **Supports:** align (full, wide), ariaLabel, contentRole, inserter, interactivity, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~, ~~renaming~~
- **Attributes:** __unstableLocation, backgroundColor, customBackgroundColor, customOverlayBackgroundColor, customOverlayTextColor, customTextColor, hasIcon, icon, maxNestingLevel, openSubmenusOnClick, overlayBackgroundColor, overlayMenu, overlayTextColor, ref, rgbBackgroundColor, rgbTextColor, showSubmenuIcon, templateLock, textColor
diff --git a/package-lock.json b/package-lock.json
index d21fdeec74fb4d..ec9dae66be2f3f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27004,6 +27004,114 @@
"resolved": "https://registry.npmjs.org/hpq/-/hpq-1.3.0.tgz",
"integrity": "sha512-fvYTvdCFOWQupGxqkahrkA+ERBuMdzkxwtUdKrxR6rmMd4Pfl+iZ1QiQYoaZ0B/v0y59MOMnz3XFUWbT50/NWA=="
},
+ "node_modules/html-dom-parser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.1.tgz",
+ "integrity": "sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "5.0.3",
+ "htmlparser2": "10.0.0"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/html-dom-parser/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/htmlparser2": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
+ "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.1",
+ "entities": "^6.0.0"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -27065,6 +27173,54 @@
"node": ">= 12"
}
},
+ "node_modules/html-react-parser": {
+ "version": "5.2.6",
+ "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.6.tgz",
+ "integrity": "sha512-qcpPWLaSvqXi+TndiHbCa+z8qt0tVzjMwFGFBAa41ggC+ZA5BHaMIeMJla9g3VSp4SmiZb9qyQbmbpHYpIfPOg==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "5.0.3",
+ "html-dom-parser": "5.1.1",
+ "react-property": "2.0.2",
+ "style-to-js": "1.1.17"
+ },
+ "peerDependencies": {
+ "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19",
+ "react": "0.14 || 15 || 16 || 17 || 18 || 19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/html-react-parser/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/html-react-parser/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
"node_modules/html-tags": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
@@ -27731,6 +27887,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/inline-style-parser": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
+ "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
+ "license": "MIT"
+ },
"node_modules/internal-slot": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz",
@@ -40031,6 +40193,12 @@
"async-limiter": "~1.0.0"
}
},
+ "node_modules/react-property": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz",
+ "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
@@ -43608,6 +43776,24 @@
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
"integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg=="
},
+ "node_modules/style-to-js": {
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz",
+ "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.9"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz",
+ "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.4"
+ }
+ },
"node_modules/stylehacks": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.0.0.tgz",
@@ -49773,6 +49959,7 @@
"escape-html": "^1.0.3",
"fast-average-color": "^9.1.1",
"fast-deep-equal": "^3.1.3",
+ "html-react-parser": "^5.2.6",
"memize": "^2.1.0",
"remove-accents": "^0.5.0",
"uuid": "^9.0.1"
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index fa311ce4ff7470..03ad6a8cf4d81b 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -81,6 +81,7 @@
"escape-html": "^1.0.3",
"fast-average-color": "^9.1.1",
"fast-deep-equal": "^3.1.3",
+ "html-react-parser": "^5.2.6",
"memize": "^2.1.0",
"remove-accents": "^0.5.0",
"uuid": "^9.0.1"
diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss
index feea74b786a4a3..6c6858620d2baf 100644
--- a/packages/block-library/src/editor.scss
+++ b/packages/block-library/src/editor.scss
@@ -21,6 +21,7 @@
@import "./gallery/editor.scss";
@import "./group/editor.scss";
@import "./html/editor.scss";
+@import "./icon/editor.scss";
@import "./image/editor.scss";
@import "./latest-posts/editor.scss";
@import "./media-text/editor.scss";
diff --git a/packages/block-library/src/icon/block.json b/packages/block-library/src/icon/block.json
new file mode 100644
index 00000000000000..187e15eabd88ff
--- /dev/null
+++ b/packages/block-library/src/icon/block.json
@@ -0,0 +1,61 @@
+{
+ "apiVersion": 3,
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "name": "core/icon",
+ "title": "Icon",
+ "category": "media",
+ "description": "Insert an SVG icon or graphic.",
+ "keywords": [ "icon", "svg" ],
+ "attributes": {
+ "icon": {
+ "type": "string",
+ "source": "html",
+ "selector": ".wp-block-icon",
+ "default": "",
+ "role": "content"
+ },
+ "iconName": {
+ "type": "string",
+ "role": "content"
+ },
+ "label": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "supports": {
+ "anchor": true,
+ "align": true,
+ "html": false,
+ "interactivity": {
+ "clientNavigation": true
+ },
+ "__experimentalBorder": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true,
+ "__experimentalSkipSerialization": true,
+ "__experimentalDefaultControls": {
+ "color": false,
+ "radius": false,
+ "style": false,
+ "width": false
+ }
+ },
+ "spacing": {
+ "padding": true,
+ "margin": true,
+ "__experimentalDefaultControls": {
+ "margin": false,
+ "padding": false
+ }
+ }
+ },
+ "textdomain": "icon-block",
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./editor.css",
+ "style": "file:./style.css"
+}
diff --git a/packages/block-library/src/icon/components/custom-inserter/index.js b/packages/block-library/src/icon/components/custom-inserter/index.js
new file mode 100644
index 00000000000000..5be19db1ae3a50
--- /dev/null
+++ b/packages/block-library/src/icon/components/custom-inserter/index.js
@@ -0,0 +1,170 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+ Button,
+ Modal,
+ Notice,
+ RangeControl,
+ TextareaControl,
+} from '@wordpress/components';
+import { useEffect, useState } from '@wordpress/element';
+import { Icon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import { bolt } from './../../icons/bolt';
+import { parseIcon } from './../../utils';
+
+export default function CustomInserterModal( props ) {
+ const {
+ isCustomInserterOpen,
+ setCustomInserterOpen,
+ attributes,
+ setAttributes,
+ } = props;
+ const { icon, iconName } = attributes;
+ const [ customIcon, setCustomIcon ] = useState( ! iconName ? icon : '' );
+ const [ iconSize, setIconSize ] = useState( 100 );
+
+ // Reset values when modal is closed.
+ useEffect( () => {
+ if ( ! isCustomInserterOpen ) {
+ setIconSize( 100 );
+ }
+ }, [ isCustomInserterOpen ] );
+
+ // If a SVG icon is inserted from the Media Library, we need to update
+ // the custom icon editor in the modal.
+ useEffect( () => setCustomIcon( icon ), [ icon ] );
+
+ if ( ! isCustomInserterOpen ) {
+ return null;
+ }
+
+ const insertCustomIcon = () => {
+ setAttributes( {
+ icon: customIcon,
+ iconName: '',
+ } );
+ setCustomInserterOpen( false );
+ };
+
+ const closeModal = () => {
+ setCustomInserterOpen( false );
+ };
+
+ let iconToRender = parseIcon( customIcon );
+ const isSVG =
+ iconToRender?.props && Object.keys( iconToRender.props ).length > 0;
+
+ // Render the default lightning bolt if the icon is not a valid SVG.
+ iconToRender = isSVG ? iconToRender : bolt;
+
+ return (
+
+
+
+
+
+
+
+ { customIcon && ! isSVG && (
+
+ { __(
+ 'The custom icon does not appear to be in a valid SVG format or contains non-SVG elements.'
+ ) }
+
+ ) }
+ setCustomIcon( '' ) }
+ onInsert={ insertCustomIcon }
+ />
+
+
+
+ );
+}
+
+function IconPreview( { iconToRender, iconSize, setIconSize, isSVG } ) {
+ return (
+
+
+
+
+
+
+ { __( 'Preview size' ) }
+
+
+
+
+ );
+}
+
+function IconInsertButtons( { customIcon, isSVG, onClear, onInsert } ) {
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/block-library/src/icon/components/icon-dropzone/index.js b/packages/block-library/src/icon/components/icon-dropzone/index.js
new file mode 100644
index 00000000000000..3de03ae45ae050
--- /dev/null
+++ b/packages/block-library/src/icon/components/icon-dropzone/index.js
@@ -0,0 +1,55 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { DropZone } from '@wordpress/components';
+import { isBlobURL } from '@wordpress/blob';
+
+/**
+ * Internal dependencies
+ */
+import {
+ parseUploadedMediaAndSetIcon,
+ parseDroppedMediaAndSetIcon,
+ displayMessages,
+} from './../../utils';
+
+export default function IconDropZone( props ) {
+ const { attributes, setAttributes, mediaUpload, isSVGUploadAllowed } =
+ props;
+
+ function onDropFiles( filesList ) {
+ if ( isSVGUploadAllowed ) {
+ mediaUpload( {
+ allowedTypes: [ 'image/svg+xml' ],
+ filesList,
+ onFileChange( [ image ] ) {
+ if ( isBlobURL( image?.url ) ) {
+ return;
+ }
+ parseUploadedMediaAndSetIcon(
+ image,
+ attributes,
+ setAttributes
+ );
+ },
+ onError( message ) {
+ createErrorNotice( message, { type: 'snackbar' } );
+ },
+ } );
+ } else {
+ const reader = new window.FileReader();
+ reader.readAsText( filesList[ 0 ] );
+ reader.onload = ( e ) => {
+ const fileContent = e?.target?.result ?? '';
+ parseDroppedMediaAndSetIcon( fileContent, setAttributes );
+ };
+ }
+ }
+
+ const label = isSVGUploadAllowed
+ ? __( 'Drop SVG file to upload' )
+ : __( 'Drop SVG file to insert' );
+
+ return ;
+}
diff --git a/packages/block-library/src/icon/components/icon-placeholder/index.js b/packages/block-library/src/icon/components/icon-placeholder/index.js
new file mode 100644
index 00000000000000..64032769e5b7a9
--- /dev/null
+++ b/packages/block-library/src/icon/components/icon-placeholder/index.js
@@ -0,0 +1,105 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, Placeholder } from '@wordpress/components';
+import { MediaUpload } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import { bolt } from './../../icons/bolt';
+import { parseUploadedMediaAndSetIcon } from '../../utils';
+import QuickInserterPopover from './../quick-inserter';
+
+export default function IconPlaceholder( props ) {
+ const {
+ setInserterOpen,
+ isQuickInserterOpen,
+ setQuickInserterOpen,
+ setCustomInserterOpen,
+ attributes,
+ setAttributes,
+ enableCustomIcons,
+ isSVGUploadAllowed,
+ } = props;
+
+ const instructions = () => {
+ const messages = {
+ default: __(
+ 'Choose an icon from the library, pick one from your media library, or insert a custom SVG.'
+ ),
+ noCustom: __(
+ 'Choose an icon from the library or pick one from your media library.'
+ ),
+ noMediaLibrary: __(
+ 'Choose an icon from the library or insert a custom SVG.'
+ ),
+ noCustomNoMediaLibrary: __(
+ 'Browse the icon library and choose one to insert.'
+ ),
+ };
+
+ if ( ! enableCustomIcons && ! isSVGUploadAllowed ) {
+ return messages.noCustomNoMediaLibrary;
+ } else if ( ! enableCustomIcons ) {
+ return messages.noCustom;
+ } else if ( ! isSVGUploadAllowed ) {
+ return messages.noMediaLibrary;
+ }
+
+ return messages.default;
+ };
+
+ return (
+
+
+ { isSVGUploadAllowed && (
+
+ parseUploadedMediaAndSetIcon(
+ media,
+ attributes,
+ setAttributes
+ )
+ }
+ allowedTypes={ [ 'image/svg+xml' ] }
+ render={ ( { open } ) => (
+
+ ) }
+ />
+ ) }
+ { enableCustomIcons && (
+
+ ) }
+
+
+ );
+}
diff --git a/packages/block-library/src/icon/components/index.js b/packages/block-library/src/icon/components/index.js
new file mode 100644
index 00000000000000..1233025eebf962
--- /dev/null
+++ b/packages/block-library/src/icon/components/index.js
@@ -0,0 +1,5 @@
+export { default as CustomInserterModal } from './custom-inserter';
+export { default as InserterModal } from './inserter';
+export { default as QuickInserterPopover } from './quick-inserter';
+export { default as IconPlaceholder } from './icon-placeholder';
+export { default as IconDropZone } from './icon-dropzone';
diff --git a/packages/block-library/src/icon/components/inserter/content-header.js b/packages/block-library/src/icon/components/inserter/content-header.js
new file mode 100644
index 00000000000000..bfafa81d915a5a
--- /dev/null
+++ b/packages/block-library/src/icon/components/inserter/content-header.js
@@ -0,0 +1,41 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf, _n } from '@wordpress/i18n';
+import { RangeControl } from '@wordpress/components';
+
+export default function ContentHeader( props ) {
+ const { searchInput, shownIconsCount, iconSize, setIconSize } = props;
+
+ return (
+
+
+ { searchInput &&
+ sprintf(
+ // translators: %1$s: Number of icons returned from search, %2$s: the search input
+ _n(
+ '%1$s search result for "%2$s"',
+ '%1$s search results for "%2$s"',
+ shownIconsCount
+ ),
+ shownIconsCount,
+ searchInput
+ ) }
+
+
+
+ { __( 'Preview size' ) }
+ setIconSize( value ) }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+
+
+
+ );
+}
diff --git a/packages/block-library/src/icon/components/inserter/icon-grid.js b/packages/block-library/src/icon/components/inserter/icon-grid.js
new file mode 100644
index 00000000000000..4b1626dc62ebd3
--- /dev/null
+++ b/packages/block-library/src/icon/components/inserter/icon-grid.js
@@ -0,0 +1,74 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Icon, blockDefault } from '@wordpress/icons';
+import { Button } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { parseIcon } from './../../utils';
+
+export default function IconGrid( props ) {
+ const { shownIcons, iconSize, updateIconAtts, attributes } = props;
+
+ const noResults = (
+
+
+
{ __( 'No results found.' ) }
+
+ );
+
+ const searchResults = (
+
+ { shownIcons.map( ( icon ) => {
+ let renderedIcon = icon.icon;
+
+ // Icons provided by third-parties are generally strings.
+ if ( typeof renderedIcon === 'string' ) {
+ renderedIcon = parseIcon( renderedIcon );
+ }
+
+ return (
+
+ );
+ } ) }
+
+ );
+
+ return (
+
+ { shownIcons.length === 0 ? noResults : searchResults }
+
+ );
+}
diff --git a/packages/block-library/src/icon/components/inserter/index.js b/packages/block-library/src/icon/components/inserter/index.js
new file mode 100644
index 00000000000000..6fd25f0cbcd7b1
--- /dev/null
+++ b/packages/block-library/src/icon/components/inserter/index.js
@@ -0,0 +1,142 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Modal } from '@wordpress/components';
+import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import getIcons from './../../icons';
+import { flattenIconsArray, getIconTypes } from './../../utils';
+import ContentHeader from './content-header';
+import IconGrid from './icon-grid';
+import Sidebar from './sidebar';
+export default function InserterModal( props ) {
+ const { isInserterOpen, setInserterOpen, attributes, setAttributes } =
+ props;
+ const iconsByType = getIcons();
+ const iconTypes = getIconTypes( iconsByType );
+
+ // Get the default type, and if there is none, get the first type.
+ const defaultType = useMemo( () => {
+ const defaultTypes = iconTypes.filter( ( type ) => type.isDefault );
+ return defaultTypes.length !== 0 ? defaultTypes : [ iconTypes[ 0 ] ];
+ }, [ iconTypes ] );
+
+ const [ searchInput, setSearchInput ] = useState( '' );
+ const [ currentCategory, setCurrentCategory ] = useState(
+ 'all__' + defaultType[ 0 ]?.type
+ );
+ const [ iconSize, setIconSize ] = useState( () => {
+ const storedSettings = window.localStorage.getItem( 'icon_block' );
+ return storedSettings
+ ? JSON.parse( storedSettings )?.preview_size || 24
+ : 24;
+ } );
+
+ useEffect( () => {
+ const settings = JSON.parse(
+ window.localStorage.getItem( 'icon_block' ) || '{}'
+ );
+ settings.preview_size = iconSize;
+ window.localStorage.setItem( 'icon_block', JSON.stringify( settings ) );
+ }, [ iconSize ] );
+
+ const iconsAll = useMemo(
+ () => flattenIconsArray( iconsByType ),
+ [ iconsByType ]
+ );
+
+ // Move the filtering logic to a separate function
+ const getFilteredIcons = useCallback( () => {
+ if ( searchInput ) {
+ return iconsAll.filter( ( icon ) => {
+ const input = searchInput.toLowerCase();
+ const iconName = icon.title.toLowerCase();
+
+ if ( iconName.includes( input ) ) {
+ return true;
+ }
+
+ return (
+ icon?.keywords?.some( ( keyword ) =>
+ keyword.includes( input )
+ ) || false
+ );
+ } );
+ }
+
+ if ( currentCategory.startsWith( 'all__' ) ) {
+ const categoryType = currentCategory.replace( 'all__', '' );
+ return (
+ iconsByType.find( ( type ) => type.type === categoryType )
+ ?.icons || []
+ );
+ }
+
+ return iconsAll.filter(
+ ( icon ) => icon?.categories?.includes( currentCategory ) || false
+ );
+ }, [ searchInput, currentCategory, iconsAll, iconsByType ] );
+
+ if ( ! isInserterOpen ) {
+ return null;
+ }
+
+ function updateIconAtts( name, hasNoIconFill ) {
+ setAttributes( {
+ icon: '',
+ iconName: name,
+ hasNoIconFill,
+ } );
+ setInserterOpen( false );
+ }
+
+ function onClickCategory( category ) {
+ setCurrentCategory( category );
+ }
+
+ return (
+ setInserterOpen( false ) }
+ isFullScreen
+ >
+
+
+ );
+}
diff --git a/packages/block-library/src/icon/components/inserter/sidebar.js b/packages/block-library/src/icon/components/inserter/sidebar.js
new file mode 100644
index 00000000000000..71b5fa99667fe9
--- /dev/null
+++ b/packages/block-library/src/icon/components/inserter/sidebar.js
@@ -0,0 +1,116 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { MenuGroup, MenuItem, SearchControl } from '@wordpress/components';
+import { useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { simplifyCategories } from '../../utils';
+
+const ALL_CATEGORY_PREFIX = 'all__';
+
+export default function Sidebar( props ) {
+ const {
+ iconsByType,
+ currentCategory,
+ onClickCategory,
+ searchInput,
+ setSearchInput,
+ } = props;
+
+ const preparedTypes = useMemo( () => {
+ return iconsByType.map( ( type ) => {
+ const title = type?.title ?? type.type;
+ const categoriesFull = type?.categories ?? [];
+ const categories = simplifyCategories( categoriesFull );
+ const allCategory = `${ ALL_CATEGORY_PREFIX }${ type.type }`;
+ const iconsOfType = type?.icons ?? [];
+
+ // Sort alphabetically and then add the "all" category.
+ if ( ! categories.includes( allCategory ) ) {
+ categories.sort().unshift( allCategory );
+ categoriesFull.unshift( {
+ name: allCategory,
+ title: __( 'All' ),
+ } );
+ }
+
+ return {
+ type: type.type,
+ title,
+ categoriesFull,
+ categories,
+ count: iconsOfType.length,
+ };
+ } );
+ }, [ iconsByType ] );
+
+ function renderIconTypeCategories( type ) {
+ return (
+
+ { type.categories.map( ( category ) => {
+ const isActive = currentCategory
+ ? category === currentCategory
+ : category === `${ ALL_CATEGORY_PREFIX }${ type.type }`;
+
+ const categoryIcons =
+ iconsByType
+ .find( ( t ) => t.type === type.type )
+ ?.icons.filter( ( icon ) => {
+ const iconCats = icon?.categories ?? [];
+ return iconCats.includes( category );
+ } ) ?? [];
+
+ const categoryTitle =
+ type.categoriesFull.find( ( c ) => c.name === category )
+ ?.title ?? category;
+
+ return (
+
+ );
+ } ) }
+
+ );
+ }
+
+ return (
+
+
+
+
+ { preparedTypes.map( ( type ) =>
+ renderIconTypeCategories( type )
+ ) }
+
+ );
+}
diff --git a/packages/block-library/src/icon/components/quick-inserter/index.js b/packages/block-library/src/icon/components/quick-inserter/index.js
new file mode 100644
index 00000000000000..c9149ad4f55a43
--- /dev/null
+++ b/packages/block-library/src/icon/components/quick-inserter/index.js
@@ -0,0 +1,181 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, Popover, SearchControl } from '@wordpress/components';
+import { useState } from '@wordpress/element';
+import { Icon, blockDefault } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import getIcons from './../../icons';
+import { flattenIconsArray, parseIcon } from './../../utils';
+
+export default function QuickInserterPopover( props ) {
+ const [ searchInput, setSearchInput ] = useState( '' );
+ const {
+ setInserterOpen,
+ isQuickInserterOpen,
+ setQuickInserterOpen,
+ setAttributes,
+ } = props;
+
+ if ( ! isQuickInserterOpen ) {
+ return null;
+ }
+
+ function updateIconAtts( name, hasNoIconFill ) {
+ setAttributes( {
+ icon: '',
+ iconName: name,
+ hasNoIconFill,
+ } );
+ setInserterOpen( false );
+ }
+
+ const iconsByType = getIcons();
+ const iconsAll = flattenIconsArray( iconsByType );
+
+ // Get the icons of the default type, if there is one. Otherwise, just pull
+ // from the first icon type.
+ const iconsOfDefaultType =
+ iconsByType.filter( ( t ) => t.isDefault )[ 0 ]?.icons ?? iconsAll;
+
+ let shownIcons = [];
+
+ if ( searchInput ) {
+ shownIcons = iconsAll.filter( ( icon ) => {
+ const input = searchInput.toLowerCase();
+ const iconName = icon.title.toLowerCase();
+
+ // First check if the name matches.
+ if ( iconName.includes( input ) ) {
+ return true;
+ }
+
+ // Then check if any keywords match.
+ if ( icon?.keywords && icon?.keywords.length !== 0 ) {
+ const keywordMatches = icon.keywords.filter( ( keyword ) =>
+ keyword.includes( input )
+ );
+
+ return keywordMatches.length > 0;
+ }
+
+ return false;
+ } );
+ }
+
+ if ( ! searchInput ) {
+ // See if there is a default icon(s) set.
+ const defaultIcons =
+ iconsOfDefaultType.filter( ( i ) => i.isDefault ) ?? [];
+
+ // Get the rest of the icons in the type excluding the default ones.
+ const nonDefaultIcons =
+ iconsOfDefaultType.filter( ( i ) => ! i.isDefault ) ?? [];
+
+ // First show the default icons, then the rest.
+ shownIcons = shownIcons.concat( defaultIcons, nonDefaultIcons );
+ }
+
+ // Only want to display 6 icons.
+ shownIcons = shownIcons.slice( 0, 6 );
+
+ const searchResults = (
+
+
+ { shownIcons.map( ( icon ) => {
+ let renderedIcon = icon.icon;
+
+ if ( typeof renderedIcon === 'string' ) {
+ renderedIcon = parseIcon( renderedIcon );
+ }
+
+ return (
+
+ );
+ } ) }
+
+
+ );
+
+ const noResults = (
+
+
+
{ __( 'No results found.' ) }
+
+ );
+
+ return (
+ setQuickInserterOpen( false ) }
+ position="bottom right"
+ offset={ 12 }
+ >
+
+
setSearchInput( value ) }
+ __nextHasNoMarginBottom
+ />
+
+ { [
+ shownIcons.length === 0 && noResults,
+ shownIcons.length > 0 && searchResults,
+ ] }
+
+
+
+
+ );
+}
diff --git a/packages/block-library/src/icon/edit.js b/packages/block-library/src/icon/edit.js
new file mode 100644
index 00000000000000..8ab78172a724d3
--- /dev/null
+++ b/packages/block-library/src/icon/edit.js
@@ -0,0 +1,393 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+ Dropdown,
+ DropdownMenu,
+ ExternalLink,
+ MenuGroup,
+ MenuItem,
+ NavigableMenu,
+ TextControl,
+ ToolbarButton,
+ ToolbarGroup,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
+import {
+ BlockControls,
+ InspectorControls,
+ MediaUpload,
+ useBlockProps,
+ useBlockEditingMode,
+ __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles,
+} from '@wordpress/block-editor';
+import { useState } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import { DOWN } from '@wordpress/keycodes';
+import { code, media as mediaIcon } from '@wordpress/icons';
+import { applyFilters } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import {
+ CustomInserterModal,
+ IconDropZone,
+ IconPlaceholder,
+ InserterModal,
+} from './components';
+import {
+ flattenIconsArray,
+ parseIcon,
+ parseUploadedMediaAndSetIcon,
+} from './utils';
+import { bolt as defaultIcon } from './icons/bolt';
+import getIcons from './icons';
+import { useToolsPanelDropdownMenuProps } from './utils/hooks';
+
+/**
+ * The edit function for the Icon Block.
+ *
+ * @param {Object} props All props passed to this function.
+ */
+export function Edit( props ) {
+ const { attributes, setAttributes } = props;
+ const { icon, iconName, label, title } = attributes;
+
+ // Allowed types for the current user.
+ const { allowedMimeTypes, mediaUpload } = useSelect( ( select ) => {
+ // Disabling this rule for the following line so as to not couple the block editor package to block-library.
+ // eslint-disable-next-line @wordpress/data-no-store-string-literals
+ const { getSettings } = select( 'core/block-editor' );
+
+ return {
+ allowedMimeTypes: getSettings().allowedMimeTypes,
+ mediaUpload: getSettings().mediaUpload,
+ };
+ }, [] );
+
+ const isSVGUploadAllowed = allowedMimeTypes
+ ? Object.values( allowedMimeTypes ).includes( 'image/svg+xml' )
+ : false;
+
+ const [ isInserterOpen, setInserterOpen ] = useState( false );
+ const [ isQuickInserterOpen, setQuickInserterOpen ] = useState( false );
+ const [ isCustomInserterOpen, setCustomInserterOpen ] = useState( false );
+
+ // Allow users to disable custom SVG icons.
+ const enableCustomIcons = applyFilters(
+ 'iconBlock.enableCustomIcons',
+ true
+ );
+
+ const isContentOnlyMode = useBlockEditingMode() === 'contentOnly';
+
+ const iconsAll = flattenIconsArray( getIcons() );
+ const namedIcon = iconsAll.filter( ( i ) => i.name === iconName );
+ const customIcon = defaultIcon;
+ let printedIcon = '';
+ if ( icon && namedIcon.length === 0 ) {
+ printedIcon = parseIcon( icon );
+
+ if (
+ customIcon.props &&
+ Object.keys( customIcon?.props ).length === 0
+ ) {
+ printedIcon = defaultIcon;
+ }
+ } else {
+ // Icon chosen from library.
+ if ( icon.length === 0 && namedIcon.length > 0 ) {
+ printedIcon = namedIcon[ 0 ]?.icon;
+ } else {
+ printedIcon = icon;
+ }
+
+ // Icons provided by third-parties are generally strings.
+ if ( typeof printedIcon === 'string' ) {
+ printedIcon = parseIcon( printedIcon );
+ }
+ }
+
+ function resetAll() {
+ setAttributes( {
+ label: undefined,
+ } );
+ }
+
+ const openOnArrowDown = ( event ) => {
+ if ( event.keyCode === DOWN ) {
+ event.preventDefault();
+ event.target.click();
+ }
+ };
+
+ const replaceText = icon || iconName ? __( 'Replace' ) : __( 'Add icon' );
+ const customIconText =
+ icon || iconName
+ ? __( 'Add/edit custom icon' )
+ : __( 'Add custom icon' );
+
+ const replaceDropdown = (
+ (
+
+ { replaceText }
+
+ ) }
+ renderContent={ ( { onClose } ) => (
+
+
+
+ { isSVGUploadAllowed && (
+ {
+ parseUploadedMediaAndSetIcon(
+ media,
+ attributes,
+ setAttributes
+ );
+ onClose( true );
+ } }
+ allowedTypes={ [ 'image/svg+xml' ] }
+ render={ ( { open } ) => (
+
+ ) }
+ />
+ ) }
+ { enableCustomIcons && (
+
+ ) }
+
+ { ( icon || iconName ) && (
+
+
+
+ ) }
+
+ ) }
+ />
+ );
+
+ const blockControls = (
+ <>
+ { ( icon || iconName ) && (
+
+
+
+ ) }
+
+ <>
+ { enableCustomIcons || isSVGUploadAllowed ? (
+ replaceDropdown
+ ) : (
+ {
+ setInserterOpen( true );
+ } }
+ >
+ { replaceText }
+
+ ) }
+ >
+
+ { isContentOnlyMode && ( icon || iconName ) && (
+ // Add some extra controls for content attributes when content only mode is active.
+ // With content only mode active, the inspector is hidden, so users need another way
+ // to edit these attributes.
+
+
+
+ { () => (
+
+ setAttributes( { label: value } )
+ }
+ help={ __(
+ 'Briefly describe the icon to help screen reader users.'
+ ) }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+ ) }
+
+
+
+ ) }
+ >
+ );
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+ const inspectorControls = ( icon || iconName ) && (
+ <>
+
+
+ !! label }
+ onDeselect={ () =>
+ setAttributes( { label: undefined } )
+ }
+ >
+
+ setAttributes( { label: value } )
+ }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+
+
+
+
+
+ setAttributes( { title: value } ) }
+ help={
+ <>
+ { __(
+ 'Describe the role of this icon on the page.'
+ ) }
+
+ { __(
+ 'Note: many devices and browsers do not display this text'
+ ) }
+
+ >
+ }
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ />
+
+ >
+ );
+
+ const borderProps = getBorderClassesAndStyles( attributes );
+
+ const iconMarkup = (
+ <>
+ { ! icon && ! iconName ? (
+
+ ) : (
+ <>{ printedIcon }>
+ ) }
+ >
+ );
+
+ return (
+ <>
+ { blockControls }
+ { inspectorControls }
+
+ { iconMarkup }
+
+
+
+ { enableCustomIcons && (
+
+ ) }
+ >
+ );
+}
+
+export default Edit;
diff --git a/packages/block-library/src/icon/editor.scss b/packages/block-library/src/icon/editor.scss
new file mode 100644
index 00000000000000..1c9d1a0fab379f
--- /dev/null
+++ b/packages/block-library/src/icon/editor.scss
@@ -0,0 +1,625 @@
+/**
+ * Editor only styles for the Icon Block.
+ */
+
+// Editor specific styles for the block itself.
+.wp-block-outermost-icon-block {
+
+ // Dropzone styles.
+ .components-drop-zone__content {
+ container-type: size;
+
+ .components-drop-zone__content-icon {
+ width: 24px;
+ margin-bottom: 10px;
+ }
+
+ // Only show the icon if the dropzone is too small.
+ @container (max-width: 150px) {
+ .components-drop-zone__content-icon {
+ margin-bottom: 0;
+ }
+
+ .components-drop-zone__content-text {
+ display: none;
+ }
+ }
+
+ @container (max-height: 120px) {
+ .components-drop-zone__content-icon {
+ margin-bottom: 0;
+ }
+
+ .components-drop-zone__content-text {
+ display: none;
+ }
+ }
+ }
+
+ // Placeholder styles.
+ .components-placeholder {
+
+ // Needed to match the Core styling while supporting icon rotation.
+ svg.components-placeholder__illustration {
+ transform: translate(-50%, -50%);
+ }
+
+ .components-placeholder__label {
+ svg {
+ max-width: 24px;
+ }
+ }
+ }
+
+ &.is-selected {
+
+ // Placeholder styles.
+ .components-placeholder {
+ background-color: #fff;
+ border: none;
+ border-radius: 2px;
+ box-shadow: inset 0 0 0 1px #1e1e1e;
+ color: #1e1e1e;
+ filter: none;
+
+ &::before {
+ opacity: 0;
+ }
+
+ .components-placeholder__illustration {
+ display: none;
+ }
+ }
+ }
+}
+
+.wp-block-outermost-icon-inserter__quick-inserter {
+ .icons-list {
+ .icons-list__item {
+ width: 33.33%;
+
+ // The quick inserter in core does not have border hover effects.
+ &:hover {
+ border-color: transparent;
+ }
+ }
+ }
+
+ // Temporary fix until updated in core.
+ .block-editor-inserter__no-results {
+ margin-top: 0;
+ }
+
+ // Fix minor gap between search field and icons.
+ .block-editor-inserter__search {
+ & > .components-base-control__field {
+ margin-bottom: 0;
+ }
+ }
+}
+
+// Style for the icon library modal.
+.wp-block-outermost-icon-inserter__modal {
+ .components-modal__content {
+ flex: 1 1 0%;
+ overflow: auto;
+ padding: 0;
+
+ &::before {
+ margin-bottom: 0;
+ }
+ }
+
+ .icon-inserter {
+ align-items: stretch;
+ display: flex;
+ height: 100%;
+ }
+
+ .icon-inserter__sidebar {
+ display: none;
+ flex-direction: column;
+ flex-shrink: 0;
+ overflow-y: scroll;
+ width: 280px;
+ padding: 24px 32px 32px;
+
+ @media (min-width: 900px) {
+ display: flex;
+ }
+
+ &__search {
+ margin-bottom: 16px;
+
+ .components-base-control__field {
+ margin-bottom: 0;
+ }
+ }
+
+ &__category-type {
+ // Override core component styles.
+ border: none;
+
+ .components-menu-group__label {
+ margin: 0;
+ padding: 16px 12px 16px;
+ }
+
+ .components-menu-item__item {
+ align-items: flex-start;
+ display: inline-flex;
+ justify-content: space-between;
+ white-space: normal;
+ width: 100%;
+ text-align: left;
+ text-transform: capitalize;
+
+ & > span {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ .icon-inserter__content {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ overflow: auto;
+ padding-top: 24px;
+ padding-right: 32px;
+ width: 100%;
+
+ @media (min-width: 900px) {
+ width: calc(100% - 281px);
+ }
+
+ .icon-inserter__content-header {
+ align-items: center;
+ display: flex;
+ height: 40px;
+ justify-content: space-between;
+ margin-bottom: 24px;
+
+ .search-results {
+ color: #757575;
+ }
+
+ .icon-controls__size {
+ display: flex;
+ align-items: center;
+ margin-right: 6px;
+
+ & > span {
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ text-transform: uppercase;
+ display: block;
+ margin-right: 18px;
+ }
+
+ .components-range-control {
+ width: 116px;
+
+ .components-base-control__field {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+
+ .icons-list {
+ padding: 4px 4px 72px 4px;
+ margin: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+
+ @media (min-width: 600px) {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ @media (min-width: 900px) {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ @media (min-width: 1100px) {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ @media (min-width: 1450px) {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ @media (min-width: 1600px) {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .icons-list__item {
+ &.is-active {
+ background-color: var(--wp-admin-theme-color);
+ color: rgb(255 255 255 / 80%);
+
+ &:hover {
+ color: #fff !important;
+ }
+
+ svg {
+ fill: #fff;
+ }
+
+ &.has-no-icon-fill {
+ svg {
+ color: #fff;
+ fill: none;
+ }
+ }
+ }
+ }
+ }
+
+ .block-editor-inserter__no-results {
+ height: calc(100% - 56px);
+ margin-top: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ svg {
+ margin: 0 auto;
+ }
+ }
+ }
+}
+
+// Styles for the custom SVG inserter modal.
+.wp-block-outermost-icon-custom-inserter__modal {
+ .components-modal__content {
+ flex: 1 1 0%;
+ overflow: auto;
+ padding: 0;
+
+ & > div:nth-child(2) {
+ height: 100%;
+ }
+
+ &::before {
+ margin-bottom: 0;
+ }
+ }
+
+ .icon-custom-inserter {
+ align-items: stretch;
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ height: 100%;
+ padding: 0 32px 32px;
+
+ @media (min-width: 900px) {
+ flex-direction: row;
+ }
+ }
+
+ .icon-custom-inserter__content {
+ min-height: 400px;
+
+ @media (min-width: 900px) {
+ width: calc(100% - 400px);
+ }
+
+ .components-base-control,
+ .components-base-control__field,
+ .components-textarea-control__input {
+ height: 100%;
+ }
+
+ textarea {
+ resize: none;
+ }
+ }
+
+ .icon-custom-inserter__sidebar {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ @media (min-width: 900px) {
+ width: 400px;
+ }
+
+ .icon-preview__window {
+ border: 1px solid #ddd;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+ min-height: 250px;
+ max-height: 400px;
+ overflow: scroll;
+
+ &.is-default {
+ svg {
+ opacity: 0.3;
+ }
+ }
+
+ svg {
+ max-width: 100%;
+ }
+ }
+
+ .icon-controls {
+ display: flex;
+ justify-content: center;
+ padding: 12px 24px;
+ }
+
+ .icon-controls__size {
+ display: flex;
+ align-items: center;
+
+ & > span {
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ text-transform: uppercase;
+ display: block;
+ margin-right: 18px;
+ }
+
+ .components-range-control {
+ width: 116px;
+
+ .components-base-control__field {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .components-notice {
+ margin: 12px 0 24px;
+
+ // Needed to fix color error in Notice component.
+ &.is-error {
+ background-color: #f8ebea;
+ }
+ }
+
+ .icon-insert-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 16px;
+ }
+ }
+}
+
+// Styles for the block's toolbar controls.
+.block-editor-block-toolbar {
+ .outermost-icon-block__rotate-button-90 {
+ svg {
+ transform: rotate(90deg);
+ }
+ }
+
+ .outermost-icon-block__rotate-button-180 {
+ svg {
+ transform: rotate(180deg);
+ }
+ }
+
+ .outermost-icon-block__rotate-button-270 {
+ svg {
+ transform: rotate(270deg);
+ }
+ }
+}
+
+// Styles for the block's inspector controls.
+.block-editor-block-inspector {
+
+ // Styling for the options panel used for the block settings.
+ .outermost-icon-block__main-settings {
+ &.options-panel {
+ border-top: 1px solid #ddd;
+ display: grid;
+ gap: 16px;
+ margin-top: -1px;
+ padding: 16px;
+
+ .options-panel-header {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: calc(8px);
+ grid-column: 1 / -1;
+ justify-content: space-between;
+ width: 100%;
+ -webkit-box-align: center;
+ -webkit-box-pack: justify;
+
+ h2 {
+ font-weight: 500;
+ margin: 0;
+ }
+
+ .options-panel-header__dropdown-menus {
+ line-height: 0;
+ margin: -4px 0;
+
+ .components-button {
+ padding: 0;
+ min-width: 24px;
+ }
+ }
+ }
+
+ .options-panel-container {
+ display: grid;
+ grid-gap: 16px;
+
+ .components-base-control {
+ margin: 0;
+ }
+
+ .components-base-control__help {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+
+ // Styles for color settings.
+ .color-block-support-panel,
+ .outermost-icon-block__color-settings {
+ .outermost-icon-block__color-settings__help {
+ font-size: 12px;
+ font-style: normal;
+ color: rgb(117, 117, 117);
+ grid-column: 1 / -1;
+ margin-top: 8px;
+ margin-bottom: 24px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ .outermost-icon-block__color-settings__apply-fill {
+ grid-column: 1 / -1;
+ margin: 0;
+ }
+
+ .components-base-control__help {
+ margin-bottom: 0;
+ }
+
+ .block-editor-contrast-checker {
+ margin-top: 24px;
+
+ .components-notice__content {
+ margin-right: 0;
+ }
+ }
+ }
+
+ // Styles for advanced settings.
+ .outermost-icon-block__title-control {
+ .components-external-link {
+ display: block;
+ margin-top: 8px;
+ }
+ }
+}
+
+// Styles for the icon list grid
+.wp-block-outermost-icon-inserter__modal,
+.wp-block-outermost-icon-inserter__quick-inserter {
+ .icons-list {
+ display: flex;
+ flex-wrap: wrap;
+
+ .icons-list__item {
+ align-items: stretch;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ color: #1e1e1e;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ font-size: 13px;
+ height: auto;
+ justify-content: center;
+ padding: 8px;
+ position: relative;
+ transition: all 0.05s ease-in-out;
+ word-break: break-word;
+
+ &:hover {
+ color: var(--wp-admin-theme-color) !important;
+ }
+
+ &.has-no-icon-fill {
+ svg {
+ fill: none;
+ }
+ }
+
+ .icons-list__item-icon {
+ border-radius: 2px;
+ color: #1e1e1e;
+ padding: 12px;
+ transition: all 0.05s ease-in-out;
+ }
+
+ .icons-list__item-title {
+ font-size: 12px;
+ padding: 4px 2px 8px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+}
+
+// Styles for the options dropdown popover.
+.options-panel__option-popover {
+
+ // Same size as core popovers.
+ .components-popover__content {
+ min-width: 240px;
+ }
+
+ .components-menu-item__button {
+ &[aria-disabled="true"]:not(.is-tertiary) {
+ color: #757575;
+ opacity: 1;
+
+ // Fix style bug in WordPress 6.1 and lower.
+ &:focus {
+ box-shadow: none;
+ outline: none;
+ }
+ }
+
+ &.has-reset {
+ .components-menu-item__item {
+ min-width: 100%;
+ }
+ }
+
+ .components-menu-item__item {
+ .menu-item-reset {
+ color: var(--wp-admin-theme-color-darker-10);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ margin-left: auto;
+ text-transform: uppercase;
+ }
+ }
+ }
+}
+
+// Minor style fix for the block toolbar. Not perfect, but better than nothing.
+.wp-block-outermost-icon-block__toolbar {
+ padding-left: 0;
+}
+
+.wp-block-outermost-icon-block__toolbar_content {
+ width: 250px;
+}
+
+.wp-block-outermost-icon-block__link-popover {
+
+ .block-editor-link-control__field {
+ margin: 8px;
+ }
+
+ .block-editor-link-control__search-item {
+ padding: 8px;
+ }
+}
diff --git a/packages/block-library/src/icon/icons/bolt.js b/packages/block-library/src/icon/icons/bolt.js
new file mode 100644
index 00000000000000..f327add7b8f2e8
--- /dev/null
+++ b/packages/block-library/src/icon/icons/bolt.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/components';
+
+export const bolt = (
+
+);
diff --git a/packages/block-library/src/icon/icons/index.js b/packages/block-library/src/icon/icons/index.js
new file mode 100644
index 00000000000000..d531660a0f6867
--- /dev/null
+++ b/packages/block-library/src/icon/icons/index.js
@@ -0,0 +1,464 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { applyFilters } from '@wordpress/hooks';
+import {
+ arrowDown,
+ arrowLeft,
+ arrowRight,
+ arrowUp,
+ atSymbol,
+ audio,
+ box,
+ brush,
+ calendar,
+ cancelCircleFilled,
+ capturePhoto,
+ captureVideo,
+ category,
+ cautionFilled,
+ chartBar,
+ check,
+ chevronDown,
+ chevronLeft,
+ chevronRight,
+ chevronUp,
+ chevronLeftSmall,
+ chevronRightSmall,
+ close,
+ closeSmall,
+ cloud,
+ cloudUpload,
+ code,
+ cog,
+ color,
+ commentContent,
+ commentAuthorAvatar,
+ desktop,
+ download,
+ external,
+ file,
+ gallery,
+ help,
+ helpFilled,
+ image,
+ info,
+ link,
+ lockOutline,
+ media,
+ megaphone,
+ mobile,
+ page,
+ people,
+ plugins,
+ plus,
+ quote,
+ redo,
+ resizeCornerNE,
+ rss,
+ search,
+ seen,
+ settings,
+ starEmpty,
+ starFilled,
+ store,
+ trash,
+ trendingDown,
+ trendingUp,
+ undo,
+ video,
+ wordpress,
+} from '@wordpress/icons';
+
+const wordpressIcons = [
+ {
+ name: 'arrowDown',
+ title: __( 'Arrow Down' ),
+ icon: arrowDown,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'arrowLeft',
+ title: __( 'Arrow Left' ),
+ icon: arrowLeft,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'arrowRight',
+ title: __( 'Arrow Right' ),
+ icon: arrowRight,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'arrowUp',
+ title: __( 'Arrow Up' ),
+ icon: arrowUp,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'atSymbol',
+ title: __( 'At Symbol' ),
+ icon: atSymbol,
+ },
+ {
+ name: 'audio',
+ title: __( 'Audio' ),
+ icon: audio,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'box',
+ title: __( 'Box' ),
+ icon: box,
+ },
+ {
+ name: 'brush',
+ title: __( 'Brush' ),
+ icon: brush,
+ },
+ {
+ name: 'calendar',
+ title: __( 'Calendar' ),
+ icon: calendar,
+ },
+ {
+ name: 'cancelCircleFilled',
+ title: __( 'Cancel - Circle Filled' ),
+ icon: cancelCircleFilled,
+ },
+ {
+ name: 'capturePhoto',
+ title: __( 'Capture Photo' ),
+ icon: capturePhoto,
+ categories: [ 'media' ],
+ },
+ {
+ name: 'captureVideo',
+ title: __( 'Capture Video' ),
+ icon: captureVideo,
+ categories: [ 'media' ],
+ },
+ {
+ name: 'category',
+ title: __( 'Category' ),
+ icon: category,
+ categories: [ 'admin' ],
+ },
+ {
+ name: 'chartBar',
+ title: __( 'Chart Bar' ),
+ icon: chartBar,
+ },
+ {
+ name: 'check',
+ title: __( 'Check' ),
+ icon: check,
+ },
+ {
+ name: 'chevronDown',
+ title: __( 'Chevron Down' ),
+ icon: chevronDown,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'chevronLeft',
+ title: __( 'Chevron Left' ),
+ icon: chevronLeft,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'chevronRight',
+ title: __( 'Chevron Right' ),
+ icon: chevronRight,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'chevronUp',
+ title: __( 'Chevron Up' ),
+ icon: chevronUp,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'chevronLeftSmall',
+ title: __( 'Chevron Left Small' ),
+ icon: chevronLeftSmall,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'chevronRightSmall',
+ title: __( 'Chevron Right Small' ),
+ icon: chevronRightSmall,
+ categories: [ 'arrows' ],
+ },
+ {
+ name: 'close',
+ title: __( 'Close' ),
+ icon: close,
+ },
+ {
+ name: 'closeSmall',
+ title: __( 'Close - Small' ),
+ icon: closeSmall,
+ },
+ {
+ name: 'cloud',
+ title: __( 'Cloud' ),
+ icon: cloud,
+ },
+ {
+ name: 'cloudUpload',
+ title: __( 'Cloud Upload' ),
+ icon: cloudUpload,
+ categories: [ 'media' ],
+ },
+ {
+ name: 'code',
+ title: __( 'Code' ),
+ icon: code,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'cog',
+ title: __( 'Cog' ),
+ icon: cog,
+ },
+ {
+ name: 'color',
+ title: __( 'Color' ),
+ icon: color,
+ },
+ {
+ name: 'commentContent',
+ title: __( 'Comment Content' ),
+ icon: commentContent,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'commentAuthorAvatar',
+ title: __( 'Comment Author Avatar' ),
+ icon: commentAuthorAvatar,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'desktop',
+ title: __( 'Desktop' ),
+ icon: desktop,
+ categories: [ 'design' ],
+ },
+ {
+ name: 'download',
+ title: __( 'Download' ),
+ icon: download,
+ categories: [ 'media' ],
+ },
+ {
+ name: 'external',
+ title: __( 'External' ),
+ icon: external,
+ },
+ {
+ name: 'file',
+ title: __( 'File' ),
+ icon: file,
+ },
+ {
+ name: 'gallery',
+ title: __( 'Gallery' ),
+ icon: gallery,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'help',
+ title: __( 'Help' ),
+ icon: help,
+ },
+ {
+ name: 'helpFilled',
+ title: __( 'Help - Filled' ),
+ icon: helpFilled,
+ },
+ {
+ name: 'image',
+ title: __( 'Image' ),
+ icon: image,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'info',
+ title: __( 'Info' ),
+ icon: info,
+ },
+ {
+ name: 'link',
+ title: __( 'Link' ),
+ icon: link,
+ },
+ {
+ name: 'lockOutline',
+ title: __( 'Lock - Outline' ),
+ icon: lockOutline,
+ },
+ {
+ name: 'media',
+ title: __( 'Media' ),
+ icon: media,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'megaphone',
+ title: __( 'Megaphone' ),
+ icon: megaphone,
+ },
+ {
+ name: 'mobile',
+ title: __( 'Mobile' ),
+ icon: mobile,
+ categories: [ 'design' ],
+ },
+ {
+ name: 'page',
+ title: __( 'Page' ),
+ icon: page,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'people',
+ title: __( 'People' ),
+ icon: people,
+ },
+ {
+ name: 'plugins',
+ title: __( 'Plugins' ),
+ icon: plugins,
+ },
+ {
+ name: 'plus',
+ title: __( 'Plus' ),
+ icon: plus,
+ },
+ {
+ name: 'quote',
+ title: __( 'Quote' ),
+ icon: quote,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'redo',
+ title: __( 'Redo' ),
+ icon: redo,
+ },
+ {
+ name: 'resizeCornerNE',
+ title: __( 'Resize Corner - Northeast' ),
+ icon: resizeCornerNE,
+ },
+ {
+ name: 'rss',
+ title: __( 'RSS' ),
+ icon: rss,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'search',
+ title: __( 'Search' ),
+ icon: search,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'seen',
+ title: __( 'Seen' ),
+ icon: seen,
+ },
+ {
+ name: 'settings',
+ title: __( 'Settings' ),
+ icon: settings,
+ },
+ {
+ name: 'starEmpty',
+ title: __( 'Star - Empty' ),
+ icon: starEmpty,
+ },
+ {
+ name: 'starFilled',
+ title: __( 'Star - Filled' ),
+ icon: starFilled,
+ },
+ {
+ name: 'store',
+ title: __( 'Store' ),
+ icon: store,
+ },
+ {
+ name: 'trash',
+ title: __( 'Trash' ),
+ icon: trash,
+ },
+ {
+ name: 'trendingDown',
+ title: __( 'Trending Down' ),
+ icon: trendingDown,
+ },
+ {
+ name: 'trendingUp',
+ title: __( 'Trending Up' ),
+ icon: trendingUp,
+ },
+ {
+ name: 'undo',
+ title: __( 'Undo' ),
+ icon: undo,
+ },
+ {
+ name: 'video',
+ title: __( 'Video' ),
+ icon: video,
+ categories: [ 'blocks' ],
+ },
+ {
+ name: 'cautionFilled',
+ title: __( 'Warning' ),
+ icon: cautionFilled,
+ },
+ {
+ name: 'wordpress',
+ title: __( 'WordPress' ),
+ icon: wordpress,
+ },
+];
+
+const icons = [
+ {
+ isDefault: false,
+ type: 'wordpress',
+ title: __( 'WordPress' ),
+ icons: [].concat( wordpressIcons ),
+ categories: [
+ {
+ name: 'arrows',
+ title: __( 'Arrows' ),
+ },
+ {
+ name: 'blocks',
+ title: __( 'Blocks' ),
+ },
+ {
+ name: 'admin',
+ title: __( 'Admin' ),
+ },
+ {
+ name: 'design',
+ title: __( 'Design' ),
+ },
+ {
+ name: 'media',
+ title: __( 'Media' ),
+ },
+ ],
+ },
+];
+
+export default function getIcons() {
+ return applyFilters( 'blocks.registerBlockType.icons', icons );
+}
diff --git a/packages/block-library/src/icon/index.js b/packages/block-library/src/icon/index.js
new file mode 100644
index 00000000000000..cda21d65941124
--- /dev/null
+++ b/packages/block-library/src/icon/index.js
@@ -0,0 +1,40 @@
+/**
+ * Internal dependencies
+ */
+import initBlock from '../utils/init-block';
+import edit from './edit';
+import metadata from './block.json';
+import save from './save';
+import { bolt as icon } from './icons/bolt';
+
+const { name } = metadata;
+export { metadata, name };
+export const settings = {
+ icon,
+ example: {
+ attributes: {
+ icon: '',
+ iconColorValue: '#ffffff',
+ iconBackgroundColorValue: '#0F172A',
+ itemsJustification: 'center',
+ width: '60px',
+ style: {
+ border: {
+ radius: '50px',
+ },
+ spacing: {
+ padding: {
+ top: '10px',
+ right: '10px',
+ bottom: '10px',
+ left: '10px',
+ },
+ },
+ },
+ },
+ },
+ edit,
+ save,
+};
+
+export const init = () => initBlock( { name, metadata, settings } );
diff --git a/packages/block-library/src/icon/save.js b/packages/block-library/src/icon/save.js
new file mode 100644
index 00000000000000..2d283bba507668
--- /dev/null
+++ b/packages/block-library/src/icon/save.js
@@ -0,0 +1,84 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ useBlockProps,
+ __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles,
+} from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import getIcons from './icons';
+import { flattenIconsArray, parseIcon } from './utils';
+
+/**
+ * The save function for the Icon Block.
+ *
+ * @param {Object} props All props passed to this function.
+ */
+export default function save( props ) {
+ const { icon, iconName, label, title } = props.attributes;
+
+ // If there is no icon and no iconName, don't save anything.
+ if ( ! icon && ! iconName ) {
+ return null;
+ }
+
+ const iconsAll = flattenIconsArray( getIcons() );
+ const namedIcon = iconsAll.filter( ( i ) => i.name === iconName );
+ let printedIcon = '';
+
+ // If there is an icon and the name is empty, then it's a custom icon.
+ if ( icon && namedIcon.length === 0 ) {
+ // Custom icons are strings and need to be parsed.
+ printedIcon = parseIcon( icon );
+
+ if (
+ printedIcon.props &&
+ Object.keys( printedIcon?.props ).length === 0
+ ) {
+ printedIcon = '';
+ }
+ } else {
+ // Icon chosen from library.
+ if ( icon.length === 0 && namedIcon.length > 0 ) {
+ printedIcon = namedIcon[ 0 ]?.icon;
+ } else {
+ printedIcon = icon;
+ }
+
+ // Icons provided by third-parties are generally strings.
+ if ( typeof printedIcon === 'string' ) {
+ printedIcon = parseIcon( printedIcon );
+ }
+ }
+
+ // If there is no valid SVG icon, don't save anything.
+ if ( ! printedIcon ) {
+ return null;
+ }
+
+ // If a label is set, add as aria-label. Will overwrite any aria-label in
+ // custom icons.
+ if ( label ) {
+ printedIcon = {
+ ...printedIcon,
+ props: { ...printedIcon.props, 'aria-label': label },
+ };
+ }
+
+ const borderProps = getBorderClassesAndStyles( props.attributes );
+
+ return (
+
+ { printedIcon }
+
+ );
+}
diff --git a/packages/block-library/src/icon/style.scss b/packages/block-library/src/icon/style.scss
new file mode 100644
index 00000000000000..f641b48fa6ffed
--- /dev/null
+++ b/packages/block-library/src/icon/style.scss
@@ -0,0 +1,17 @@
+/**
+ * Editor and frontend styles for the Icon Block.
+ */
+
+/* Icon Block styles. */
+.wp-block-icon {
+ display: flex;
+ line-height: 0;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ transition: transform 0.1s ease-in-out;
+ transform: rotate(var(--outermost--icon-block--transform-rotate, 0deg)) scaleX(var(--outermost--icon-block--transform-scale-x, 1)) scaleY(var(--outermost--icon-block--transform-scale-y, 1));
+ }
+
+}
diff --git a/packages/block-library/src/icon/utils/display-messages.js b/packages/block-library/src/icon/utils/display-messages.js
new file mode 100644
index 00000000000000..59587215c59cbe
--- /dev/null
+++ b/packages/block-library/src/icon/utils/display-messages.js
@@ -0,0 +1,33 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Display a snackbar message if there is an error when inserting an icon
+ * from the Media Library.
+ *
+ * @param {string} messageType The type of message to display.
+ */
+export function displayMessages( messageType ) {
+ const messages = {
+ fileTypeUploadError: __(
+ 'An error occurred while uploading. The file does not appear to be an SVG.'
+ ),
+ fileTypeSelectError: __(
+ 'An error occurred while inserting the icon. The media selected is not an SVG.'
+ ),
+ fileTypeError: __(
+ 'An error occurred while inserting the icon. Check that the file is valid SVG.'
+ ),
+ };
+
+ // Disabling as to not make core/notices a dependency of this package.
+ // eslint-disable-next-line @wordpress/data-no-store-string-literals
+ dispatch( 'core/notices' ).createNotice(
+ 'snackbar-notice',
+ messageType ? messages[ messageType ] : messages.fileType,
+ { type: 'snackbar', isDismissible: true }
+ );
+}
diff --git a/packages/block-library/src/icon/utils/hooks.js b/packages/block-library/src/icon/utils/hooks.js
new file mode 100644
index 00000000000000..3b1c57d45be46b
--- /dev/null
+++ b/packages/block-library/src/icon/utils/hooks.js
@@ -0,0 +1,18 @@
+/**
+ * WordPress dependencies
+ */
+import { useViewportMatch } from '@wordpress/compose';
+
+// A utility function that sets the popover props.
+export function useToolsPanelDropdownMenuProps() {
+ const isMobile = useViewportMatch( 'medium', '<' );
+ return ! isMobile
+ ? {
+ popoverProps: {
+ placement: 'left-start',
+ // For non-mobile, inner sidebar width (248px) - button width (24px) - border (1px) + padding (16px) + spacing (20px)
+ offset: 259,
+ },
+ }
+ : {};
+}
diff --git a/packages/block-library/src/icon/utils/icon-functions.js b/packages/block-library/src/icon/utils/icon-functions.js
new file mode 100644
index 00000000000000..acee9310b77fc2
--- /dev/null
+++ b/packages/block-library/src/icon/utils/icon-functions.js
@@ -0,0 +1,77 @@
+// Get all icon types.
+export function getIconTypes( icons ) {
+ const iconTypes = [];
+
+ icons.forEach( ( type ) => {
+ const iconType = type?.type;
+ const typeTitle = type?.title ?? type?.type;
+ const isDefault = type?.isDefault ?? false;
+
+ if ( iconType.length > 0 ) {
+ iconTypes.push( {
+ type: iconType,
+ title: typeTitle,
+ isDefault,
+ } );
+ }
+ } );
+
+ return iconTypes;
+}
+
+// Extracts all icons from all types and places them in a single array.
+export function flattenIconsArray( icons ) {
+ let allIcons = [];
+
+ icons.forEach( ( type ) => {
+ const iconType = type?.type;
+ const iconsOfType = type?.icons;
+
+ if ( iconsOfType.length > 0 ) {
+ // Append the type to the icon name and add the type parameter.
+ iconsOfType.forEach( ( icon ) => {
+ // This temporarily fixes a recursion error.
+ if ( ! icon.name.includes( iconType + '-' ) ) {
+ icon.name = iconType + '-' + icon.name;
+ }
+ icon.type = iconType;
+ } );
+
+ // Sort the icons alphabetically.
+ iconsOfType.sort( function ( a, b ) {
+ return a.name.localeCompare( b.name );
+ } );
+
+ allIcons = allIcons.concat( iconsOfType );
+ }
+ } );
+
+ return allIcons;
+}
+
+// Simplify the categories into a single array.
+export function simplifyCategories( categories ) {
+ const simplifiedCategories = [];
+
+ categories.forEach( ( category ) => {
+ if ( category?.name ) {
+ simplifiedCategories.push( category.name );
+ }
+ } );
+
+ // Make sure the array is alphabetized with any 'all_' categories first.
+ simplifiedCategories.sort( ( a, b ) => {
+ // Prioritize any string starting with 'all__'.
+ if ( a.startsWith( 'all__' ) ) {
+ return -1;
+ }
+ if ( b.startsWith( 'all__' ) ) {
+ return 1;
+ }
+
+ return a.localeCompare( b ); // Otherwise, sort alphabetically.
+ } );
+
+ // Return an alphabetized array of categories.
+ return simplifiedCategories;
+}
diff --git a/packages/block-library/src/icon/utils/index.js b/packages/block-library/src/icon/utils/index.js
new file mode 100644
index 00000000000000..50946041fbee03
--- /dev/null
+++ b/packages/block-library/src/icon/utils/index.js
@@ -0,0 +1,11 @@
+export {
+ getIconTypes,
+ flattenIconsArray,
+ simplifyCategories,
+} from './icon-functions';
+export { parseIcon } from './parse-icon';
+export {
+ parseUploadedMediaAndSetIcon,
+ parseDroppedMediaAndSetIcon,
+} from './parse-media';
+export { displayMessages } from './display-messages';
diff --git a/packages/block-library/src/icon/utils/parse-icon.js b/packages/block-library/src/icon/utils/parse-icon.js
new file mode 100644
index 00000000000000..07c0cff95135f5
--- /dev/null
+++ b/packages/block-library/src/icon/utils/parse-icon.js
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import parse, { attributesToProps, domToReact } from 'html-react-parser';
+
+/**
+ * Parse the icon sting into a React object.
+ *
+ * @param {string} icon The HTML icon.
+ * @return {Object} The icons as a React object.
+ */
+export function parseIcon( icon ) {
+ const newIcon = icon.trim();
+
+ const parseOptions = {
+ trim: true,
+ replace: ( { attribs, children, name, parent, type } ) => {
+ // Allow text but only within text elements.
+ if ( type === 'text' && parent && parent.name === 'text' ) {
+ return;
+ }
+
+ if (
+ ( type !== 'tag' && type !== 'style' ) || // Allow svg and style tags.
+ ( ! parent && name !== 'svg' ) || // The only root-level element can be an svg.
+ ! name
+ ) {
+ return <>>;
+ }
+
+ const Tag = `${ name }`;
+
+ // Handle style tags differently.
+ if ( type === 'style' && name === 'style' && children ) {
+ // Make sure it's not an empty style elements.
+ if ( children[ 0 ]?.data ) {
+ return (
+
+ { children[ 0 ].data }
+
+ );
+ }
+ return <>>;
+ }
+
+ // Hyphens or colons in attribute names are lost in the default
+ // process of html-react-parser. Spreading the attribs object as
+ // props avoids the loss. Style does need to be handled separately.
+ return (
+
+ { domToReact( children, parseOptions ) }
+
+ );
+ },
+ };
+
+ return parse( newIcon, parseOptions );
+}
+
+/**
+ * Parse the style attributes separately.
+ *
+ * @param {string} stylesString All styles in a string.
+ * @return {Object} All styles in object form.
+ */
+function parseStyles( stylesString ) {
+ let stylesObject = {};
+
+ if ( typeof stylesString === 'string' ) {
+ stylesObject = stylesString
+ .split( ';' )
+ .reduce( ( allStyles, style ) => {
+ const colonPosition = style.indexOf( ':' );
+
+ if ( colonPosition === -1 ) {
+ return allStyles;
+ }
+
+ const camelCaseProperty = style
+ .substr( 0, colonPosition )
+ .trim()
+ .replace( /^-ms-/, 'ms-' )
+ .replace( /-./g, ( c ) => c.substr( 1 ).toUpperCase() );
+ const styleValue = style.substr( colonPosition + 1 ).trim();
+
+ return styleValue
+ ? { ...allStyles, [ camelCaseProperty ]: styleValue }
+ : allStyles;
+ }, {} );
+ }
+
+ return stylesObject;
+}
diff --git a/packages/block-library/src/icon/utils/parse-media.js b/packages/block-library/src/icon/utils/parse-media.js
new file mode 100644
index 00000000000000..cdb333c4eb6253
--- /dev/null
+++ b/packages/block-library/src/icon/utils/parse-media.js
@@ -0,0 +1,90 @@
+/**
+ * Internal dependencies
+ */
+import { displayMessages } from './display-messages';
+
+/**
+ * Parse a saved SVG file in the Media Library as a string and
+ * set the icon attribute.
+ *
+ * @param {Object} media The media object for the selected SVG file.
+ * @param {Object} attributes All set block attributes.
+ * @param {Function} setAttributes Sets the block attributes.
+ */
+export function parseUploadedMediaAndSetIcon(
+ media,
+ attributes,
+ setAttributes
+) {
+ const { width } = attributes;
+
+ // TODO: Very basic file type validation, likely needs more refinement.
+ if ( ! media.url?.endsWith( '.svg' ) ) {
+ displayMessages( 'fileTypeSelect' );
+ return;
+ }
+
+ return fetch( media.url )
+ .then( ( response ) => response.text() )
+ .then( ( rawString ) => {
+ const svgString = sanitizeRawSVGString( rawString );
+
+ if ( ! svgString ) {
+ displayMessages( 'fileTypeError' );
+ return;
+ }
+
+ setAttributes( {
+ icon: svgString,
+ iconName: '',
+ width: width ? width : media?.width,
+ } );
+ } )
+ .catch( () => displayMessages( 'fileTypeError' ) );
+}
+
+/**
+ * Parse the SVG file dropped in the DropZone and set the icon if valid.
+ *
+ * @param {string} media The media object for the selected SVG file.
+ * @param {Function} setAttributes Sets the block attributes.
+ */
+export function parseDroppedMediaAndSetIcon( media, setAttributes ) {
+ const svgString = sanitizeRawSVGString( media );
+
+ if ( ! svgString ) {
+ displayMessages( 'fileTypeError' );
+ return;
+ }
+
+ setAttributes( {
+ icon: svgString,
+ iconName: '',
+ } );
+}
+
+/**
+ * Sanitize the raw string and make sure it's an SVG.
+ *
+ * @param {string} rawString The media object for the selected SVG file.
+ * @return { string } The sanitized svg string.
+ */
+function sanitizeRawSVGString( rawString ) {
+ const svgDoc = new window.DOMParser().parseFromString(
+ rawString,
+ 'image/svg+xml'
+ );
+ let svgString = '';
+
+ // TODO: Very basic SVG sanitization, likely needs more refinement.
+ if (
+ svgDoc.childNodes.length === 1 &&
+ svgDoc.firstChild.nodeName === 'svg'
+ ) {
+ svgString = new window.XMLSerializer().serializeToString(
+ svgDoc.documentElement
+ );
+ }
+
+ return svgString;
+}
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 590e3da0330882..f190d31657e7ed 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -68,6 +68,7 @@ import * as group from './group';
import * as heading from './heading';
import * as homeLink from './home-link';
import * as html from './html';
+import * as icon from './icon';
import * as image from './image';
import * as latestComments from './latest-comments';
import * as latestPosts from './latest-posts';
@@ -176,6 +177,7 @@ const getAllBlocks = () => {
file,
group,
html,
+ icon,
latestComments,
latestPosts,
mediaText,
@@ -379,7 +381,6 @@ export const registerCoreBlocks = (
setUnregisteredTypeHandlerName( missing.name );
setGroupingBlockName( group.name );
};
-
/**
* Function to register experimental core blocks depending on editor settings.
*
diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json
index 249193e1cc234a..24f17b4082cc40 100644
--- a/packages/block-library/src/navigation/block.json
+++ b/packages/block-library/src/navigation/block.json
@@ -11,6 +11,7 @@
"core/page-list",
"core/spacer",
"core/home-link",
+ "core/icon",
"core/site-title",
"core/site-logo",
"core/navigation-submenu",
diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss
index d94343a37b1977..ecc3b69b8dfa89 100644
--- a/packages/block-library/src/style.scss
+++ b/packages/block-library/src/style.scss
@@ -27,6 +27,7 @@
@import "./gallery/style.scss";
@import "./group/style.scss";
@import "./heading/style.scss";
+@import "./icon/style.scss";
@import "./image/style.scss";
@import "./latest-comments/style.scss";
@import "./latest-posts/style.scss";
diff --git a/test/integration/fixtures/blocks/core__icon.html b/test/integration/fixtures/blocks/core__icon.html
new file mode 100644
index 00000000000000..c6c2c59ef044b4
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__icon.html
@@ -0,0 +1 @@
+
diff --git a/test/integration/fixtures/blocks/core__icon.json b/test/integration/fixtures/blocks/core__icon.json
new file mode 100644
index 00000000000000..e6ac0ba6a62511
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__icon.json
@@ -0,0 +1,10 @@
+[
+ {
+ "name": "core/icon",
+ "isValid": true,
+ "attributes": {
+ "icon": ""
+ },
+ "innerBlocks": []
+ }
+]
diff --git a/test/integration/fixtures/blocks/core__icon.parsed.json b/test/integration/fixtures/blocks/core__icon.parsed.json
new file mode 100644
index 00000000000000..e02039a280361a
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__icon.parsed.json
@@ -0,0 +1,9 @@
+[
+ {
+ "blockName": "core/icon",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "",
+ "innerContent": []
+ }
+]
diff --git a/test/integration/fixtures/blocks/core__icon.serialized.html b/test/integration/fixtures/blocks/core__icon.serialized.html
new file mode 100644
index 00000000000000..c6c2c59ef044b4
--- /dev/null
+++ b/test/integration/fixtures/blocks/core__icon.serialized.html
@@ -0,0 +1 @@
+