From 973e4ad94e0bf3b76b51713f3bdb7421fcc5f483 Mon Sep 17 00:00:00 2001 From: Colin Swinney Date: Tue, 5 May 2026 23:07:41 +0200 Subject: [PATCH 1/3] Add useMaxInnerBlocks hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces an upper bound on a block's direct innerBlocks. Diffs clientIds against a snapshot to remove only the newest extras — existing children are preserved even when a duplicate lands between them. Fires a configurable notice (snackbar by default) when extras are removed. - hooks/use-max-inner-blocks: new hook + readme - example/src/blocks/max-inner-blocks-example: demo block with inspector toggles for max, notice type, icon, dismissibility, explicit dismiss, and action button --- .../max-inner-blocks-example/block.json | 37 +++++ .../blocks/max-inner-blocks-example/edit.js | 129 ++++++++++++++++++ .../blocks/max-inner-blocks-example/index.js | 10 ++ example/src/index.js | 1 + hooks/index.js | 1 + hooks/use-max-inner-blocks/index.js | 68 +++++++++ hooks/use-max-inner-blocks/readme.md | 96 +++++++++++++ 7 files changed, 342 insertions(+) create mode 100644 example/src/blocks/max-inner-blocks-example/block.json create mode 100644 example/src/blocks/max-inner-blocks-example/edit.js create mode 100644 example/src/blocks/max-inner-blocks-example/index.js create mode 100644 hooks/use-max-inner-blocks/index.js create mode 100644 hooks/use-max-inner-blocks/readme.md diff --git a/example/src/blocks/max-inner-blocks-example/block.json b/example/src/blocks/max-inner-blocks-example/block.json new file mode 100644 index 00000000..5da5a736 --- /dev/null +++ b/example/src/blocks/max-inner-blocks-example/block.json @@ -0,0 +1,37 @@ +{ + "name": "example/max-inner-blocks-example", + "title": "Max Inner Blocks Example", + "description": "Example Block to show the useMaxInnerBlocks hook in usage", + "icon": "warning", + "category": "common", + "example": {}, + "supports": { + "html": false + }, + "attributes": { + "max": { + "type": "number", + "default": 2 + }, + "noticeType": { + "type": "string", + "default": "snackbar" + }, + "isDismissible": { + "type": "boolean", + "default": true + }, + "explicitDismiss": { + "type": "boolean", + "default": false + }, + "iconMode": { + "type": "string", + "default": "default" + }, + "withUndo": { + "type": "boolean", + "default": false + } + } +} diff --git a/example/src/blocks/max-inner-blocks-example/edit.js b/example/src/blocks/max-inner-blocks-example/edit.js new file mode 100644 index 00000000..c376ffbb --- /dev/null +++ b/example/src/blocks/max-inner-blocks-example/edit.js @@ -0,0 +1,129 @@ +import { InnerBlocks, InspectorControls } from '@wordpress/block-editor'; +import { + PanelBody, + RangeControl, + SelectControl, + ToggleControl, +} from '@wordpress/components'; +import { Icon, lock } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +import { useMaxInnerBlocks } from '@10up/block-components'; + +const ALLOWED_BLOCKS = ['core/paragraph', 'core/heading', 'core/image']; +const TEMPLATE = [['core/paragraph', { placeholder: 'Add a child block...' }]]; + +const customIcon = ; + +export function BlockEdit({ clientId, attributes, setAttributes }) { + const { max, noticeType, isDismissible, explicitDismiss, iconMode, withUndo } = attributes; + + const resolvedIcon = (() => { + if (iconMode === 'none') return null; + if (iconMode === 'custom') return customIcon; + return undefined; + })(); + + const noticeOptions = { + type: noticeType, + isDismissible, + explicitDismiss, + }; + + if (resolvedIcon !== undefined) { + noticeOptions.icon = resolvedIcon; + } + + if (withUndo) { + noticeOptions.actions = [ + { + label: __('Run action', 'example'), + onClick: () => { + // eslint-disable-next-line no-alert + window.alert(__('Action clicked — verifies noticeOptions.actions wiring.', 'example')); + }, + }, + ]; + } + + useMaxInnerBlocks({ + clientId, + max, + message: __( + `This block accepts at most ${max} children — extras will be removed.`, + 'example', + ), + noticeOptions, + }); + + return ( + <> + + + setAttributes({ max: value })} + __next40pxDefaultSize + /> + setAttributes({ noticeType: value })} + __next40pxDefaultSize + /> + setAttributes({ iconMode: value })} + __next40pxDefaultSize + /> + setAttributes({ isDismissible: value })} + __next40pxDefaultSize + /> + setAttributes({ explicitDismiss: value })} + __next40pxDefaultSize + /> + setAttributes({ withUndo: value })} + __next40pxDefaultSize + /> + + +
+

+ {__( + `Max children: ${max}. Try adding more than ${max} children — extras will be removed.`, + 'example', + )} +

+ +
+ + ); +} diff --git a/example/src/blocks/max-inner-blocks-example/index.js b/example/src/blocks/max-inner-blocks-example/index.js new file mode 100644 index 00000000..93c5a62c --- /dev/null +++ b/example/src/blocks/max-inner-blocks-example/index.js @@ -0,0 +1,10 @@ +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; + +import { BlockEdit } from './edit'; +import metadata from './block.json'; + +registerBlockType(metadata, { + edit: BlockEdit, + save: () => , +}); diff --git a/example/src/index.js b/example/src/index.js index 971797d1..84e5cff2 100644 --- a/example/src/index.js +++ b/example/src/index.js @@ -5,3 +5,4 @@ import './blocks/repeater-component-example'; import './blocks/link-example'; import './blocks/image-example'; import './blocks/rich-text-character-limit'; +import './blocks/max-inner-blocks-example'; diff --git a/hooks/index.js b/hooks/index.js index 15f37a2e..30725792 100644 --- a/hooks/index.js +++ b/hooks/index.js @@ -4,3 +4,4 @@ export { useIcons, useIcon } from './use-icons'; export { useFilteredList } from './use-filtered-list'; export { useMedia } from './use-media'; export { useBlockParentAttributes } from './use-block-parent-attributes'; +export { useMaxInnerBlocks } from './use-max-inner-blocks'; diff --git a/hooks/use-max-inner-blocks/index.js b/hooks/use-max-inner-blocks/index.js new file mode 100644 index 00000000..bbf756c8 --- /dev/null +++ b/hooks/use-max-inner-blocks/index.js @@ -0,0 +1,68 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect, useRef } from '@wordpress/element'; +import { Icon, info } from '@wordpress/icons'; + +/** + * Default icon. Uses `currentColor` so it adapts to the dark snackbar background. + */ +const defaultIcon = ; + +/** + * Enforce an upper bound on a block's direct innerBlocks. + * + * Identifies over-limit additions by diffing clientIds against the prior + * snapshot, so existing (potentially filled) children are never removed — + * even if a duplicate lands between them (e.g. [A, A-copy, B] → remove A-copy, + * keep [A, B]). Fires a notice whenever an extra is removed. + * + * @param {object} options Hook options. + * @param {string} options.clientId Parent block's clientId. + * @param {number} options.max Maximum allowed direct innerBlocks. + * @param {string} options.message Notice message. + * @param {('warning'|'info'|'success'|'error')} [options.status] Notice status. Defaults to `'warning'`. + * @param {object} [options.noticeOptions] Forwarded as the third argument to `createNotice`. + * Any key set here overrides the hook's defaults (`id`, `type: 'snackbar'`, `icon`, `isDismissible: true`). See + * https://github.com/WordPress/gutenberg/blob/trunk/packages/notices/src/store/actions.ts for the full list of supported keys. + * @returns {void} + */ +export const useMaxInnerBlocks = ({ + clientId, + max, + message, + status = 'warning', + noticeOptions = {}, +}) => { + const innerBlocks = useSelect( + (select) => select('core/block-editor').getBlock(clientId)?.innerBlocks ?? [], + [clientId], + ); + + const { removeBlocks } = useDispatch('core/block-editor'); + const { createNotice } = useDispatch('core/notices'); + + const prevIdsRef = useRef([]); + + useEffect(() => { + const currentIds = innerBlocks.map((block) => block.clientId); + + if (innerBlocks.length > max) { + const newIds = currentIds.filter((id) => !prevIdsRef.current.includes(id)); + if (newIds.length > 0) { + removeBlocks(newIds, false); + createNotice(status, message, { + id: `max-inner-blocks-${clientId}`, + type: 'snackbar', + icon: defaultIcon, + isDismissible: true, + ...noticeOptions, + }); + return; + } + } + + prevIdsRef.current = currentIds; + }, [innerBlocks, max, message, status, noticeOptions, clientId, removeBlocks, createNotice]); +}; diff --git a/hooks/use-max-inner-blocks/readme.md b/hooks/use-max-inner-blocks/readme.md new file mode 100644 index 00000000..f50b398d --- /dev/null +++ b/hooks/use-max-inner-blocks/readme.md @@ -0,0 +1,96 @@ +# `useMaxInnerBlocks` + +Enforce an upper bound on a block's direct `innerBlocks`. When an over-limit addition lands, the newest extras are removed and a notice is fired. Existing children are preserved even when a duplicate is pasted between them. + +## Usage + +```js +import { useMaxInnerBlocks } from '@10up/block-components'; +import { __ } from '@wordpress/i18n'; + +function BlockEdit({ clientId }) { + useMaxInnerBlocks({ + clientId, + max: 3, + message: __('You can only add up to 3 cards.', 'your-textdomain'), + }); + + return ( + // ... + ); +} +``` + +## Options + +| Prop | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `clientId` | `string` | yes | — | Parent block's clientId. | +| `max` | `number` | yes | — | Maximum allowed direct `innerBlocks`. | +| `message` | `string` | yes | — | Notice message. | +| `status` | `'warning' \| 'info' \| 'success' \| 'error'` | no | `'warning'` | Notice status. | +| `noticeOptions` | `object` | no | `{}` | Forwarded to `createNotice`'s third argument. Any key here overrides the hook's defaults (`id`, `type: 'snackbar'`, `icon`, `isDismissible: true`). | + +## Customizing the notice + +Anything `createNotice` accepts can be passed via `noticeOptions`. The full list of supported keys is documented in the [`@wordpress/notices` store actions](https://github.com/WordPress/gutenberg/blob/trunk/packages/notices/src/store/actions.ts) — including `actions`, `type`, `icon`, `isDismissible`, `explicitDismiss`, `onDismiss`, `speak`, and `context`. + +### Custom icon + +The default icon is `info` from [`@wordpress/icons`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-icons/). Override it with any other icon (or any React element). When using `Icon`, pass `fill="currentColor"` so the icon picks up the surrounding notice color (snackbars are dark, default notices are light): + +```js +import { useMaxInnerBlocks } from '@10up/block-components'; +import { Icon, lock } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +function BlockEdit({ clientId }) { + useMaxInnerBlocks({ + clientId, + max: 3, + message: __('You can only add up to 3 cards.', 'your-textdomain'), + noticeOptions: { + icon: , + }, + }); + + return ( + // ... + ); +} +``` + +Pass `icon: null` to suppress the icon entirely. + +> **Note:** Icons only render on snackbar notices. WordPress's default-type `` component accepts the `icon` prop but does not render it. + +### Sticky notice with a link + +Render a sticky in-canvas warning (instead of a transient snackbar) with a "Learn more" link to your docs: + +```js +import { useMaxInnerBlocks } from '@10up/block-components'; +import { __ } from '@wordpress/i18n'; + +function BlockEdit({ clientId }) { + useMaxInnerBlocks({ + clientId, + max: 3, + message: __('You can only add up to 3 cards.', 'your-textdomain'), + noticeOptions: { + type: 'default', + explicitDismiss: true, + actions: [ + { + label: __('Learn more', 'your-textdomain'), + url: 'https://example.com/docs/cards-block', + }, + ], + }, + }); + + return ( + // ... + ); +} +``` From 81a078fed78eb3e1e54f7a0426ab1f1243deef61 Mon Sep 17 00:00:00 2001 From: Colin Swinney Date: Wed, 6 May 2026 08:47:23 +0200 Subject: [PATCH 2/3] Set apiVersion 3 and TS editorScript on max-inner-blocks-example --- example/src/blocks/max-inner-blocks-example/block.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/src/blocks/max-inner-blocks-example/block.json b/example/src/blocks/max-inner-blocks-example/block.json index 5da5a736..0775da42 100644 --- a/example/src/blocks/max-inner-blocks-example/block.json +++ b/example/src/blocks/max-inner-blocks-example/block.json @@ -1,5 +1,6 @@ { "name": "example/max-inner-blocks-example", + "apiVersion": 3, "title": "Max Inner Blocks Example", "description": "Example Block to show the useMaxInnerBlocks hook in usage", "icon": "warning", @@ -33,5 +34,6 @@ "type": "boolean", "default": false } - } + }, + "editorScript": "file:./index.tsx" } From f6e8fca5742f1ce66d6465095a05eaf3f56edd6a Mon Sep 17 00:00:00 2001 From: Colin Swinney Date: Wed, 6 May 2026 09:06:25 +0200 Subject: [PATCH 3/3] Clarify icon-color note in useMaxInnerBlocks readme --- hooks/use-max-inner-blocks/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/use-max-inner-blocks/readme.md b/hooks/use-max-inner-blocks/readme.md index f50b398d..9292bce6 100644 --- a/hooks/use-max-inner-blocks/readme.md +++ b/hooks/use-max-inner-blocks/readme.md @@ -37,7 +37,7 @@ Anything `createNotice` accepts can be passed via `noticeOptions`. The full list ### Custom icon -The default icon is `info` from [`@wordpress/icons`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-icons/). Override it with any other icon (or any React element). When using `Icon`, pass `fill="currentColor"` so the icon picks up the surrounding notice color (snackbars are dark, default notices are light): +The default icon is `info` from [`@wordpress/icons`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-icons/). Override it with any other icon (or any React element). When using `Icon`, pass `fill="currentColor"` so the icon picks up the surrounding notice color (snackbars are dark): ```js import { useMaxInnerBlocks } from '@10up/block-components';