diff --git a/README.md b/README.md index fc3fac05..38cd87fb 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ These components read/write information from the global post object or a `PostCo - [useScript](./hooks/use-script/) - [useIsPluginActive](./hooks/use-is-plugin-active/) - [usePopover](./hooks/use-popover/) +- [useMaxInnerBlocks](./hooks/use-max-inner-blocks/) ### Post related hooks 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..0775da42 --- /dev/null +++ b/example/src/blocks/max-inner-blocks-example/block.json @@ -0,0 +1,39 @@ +{ + "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", + "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 + } + }, + "editorScript": "file:./index.tsx" +} diff --git a/example/src/blocks/max-inner-blocks-example/edit.tsx b/example/src/blocks/max-inner-blocks-example/edit.tsx new file mode 100644 index 00000000..72ab6673 --- /dev/null +++ b/example/src/blocks/max-inner-blocks-example/edit.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +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'; + +interface BlockAttributes { + max: number; + noticeType: 'snackbar' | 'default'; + isDismissible: boolean; + explicitDismiss: boolean; + iconMode: 'default' | 'custom' | 'none'; + withUndo: boolean; +} + +interface BlockEditProps { + clientId: string; + attributes: BlockAttributes; + setAttributes: (attrs: Partial) => void; +} + +const ALLOWED_BLOCKS = ['core/paragraph', 'core/heading', 'core/image']; +const TEMPLATE: Array<[string, Record]> = [ + ['core/paragraph', { placeholder: 'Add a child block...' }], +]; + +const customIcon = ; + +export const BlockEdit = ({ clientId, attributes, setAttributes }: BlockEditProps) => { + const { max, noticeType, isDismissible, explicitDismiss, iconMode, withUndo } = attributes; + + const resolvedIcon = (() => { + if (iconMode === 'none') return null; + if (iconMode === 'custom') return customIcon; + return undefined; + })(); + + const noticeOptions: Record = { + 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 ?? 1 })} + __next40pxDefaultSize + /> + + setAttributes({ noticeType: value as BlockAttributes['noticeType'] }) + } + __next40pxDefaultSize + /> + + setAttributes({ iconMode: value as BlockAttributes['iconMode'] }) + } + __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.tsx b/example/src/blocks/max-inner-blocks-example/index.tsx new file mode 100644 index 00000000..11b4703d --- /dev/null +++ b/example/src/blocks/max-inner-blocks-example/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; + +import { BlockEdit } from './edit'; +import metadata from './block.json'; + +registerBlockType(metadata as Parameters[0], { + edit: BlockEdit, + save: () => , +}); diff --git a/hooks/index.ts b/hooks/index.ts index bc979ad2..71a8fc37 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -19,3 +19,4 @@ export { useTaxonomy } from './use-taxonomy'; export { useIsSupportedMetaField } from './use-is-supported-meta-value'; export { useFlatInnerBlocks } from './use-flat-inner-blocks'; export { useRenderAppenderWithLimit } from './use-render-appender-with-limit'; +export { useMaxInnerBlocks } from './use-max-inner-blocks'; diff --git a/hooks/use-max-inner-blocks/index.tsx b/hooks/use-max-inner-blocks/index.tsx new file mode 100644 index 00000000..2042020d --- /dev/null +++ b/hooks/use-max-inner-blocks/index.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useEffect, useRef } from '@wordpress/element'; +import { Icon, info } from '@wordpress/icons'; + +type NoticeStatus = 'warning' | 'info' | 'success' | 'error'; + +interface UseMaxInnerBlocksOptions { + clientId: string; + max: number; + message: string; + status?: NoticeStatus; + noticeOptions?: Record; +} + +/** + * 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. + */ +export const useMaxInnerBlocks = ({ + clientId, + max, + message, + status = 'warning', + noticeOptions = {}, +}: UseMaxInnerBlocksOptions): void => { + const innerBlocks = useSelect( + (select) => { + // @ts-expect-error - TS doesn't know about the block editor store + return select(blockEditorStore).getBlock(clientId)?.innerBlocks ?? []; + }, + [clientId], + ); + + const { removeBlocks } = useDispatch(blockEditorStore); + const { createNotice } = useDispatch('core/notices'); + + const prevIdsRef = useRef([]); + + useEffect(() => { + const currentIds: string[] = innerBlocks.map( + (block: { clientId: string }) => 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..9292bce6 --- /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): + +```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 ( + // ... + ); +} +```