Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,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

Expand Down
39 changes: 39 additions & 0 deletions example/src/blocks/max-inner-blocks-example/block.json
Original file line number Diff line number Diff line change
@@ -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"
}
151 changes: 151 additions & 0 deletions example/src/blocks/max-inner-blocks-example/edit.tsx
Original file line number Diff line number Diff line change
@@ -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<BlockAttributes>) => void;
}

const ALLOWED_BLOCKS = ['core/paragraph', 'core/heading', 'core/image'];
const TEMPLATE: Array<[string, Record<string, unknown>]> = [
['core/paragraph', { placeholder: 'Add a child block...' }],
];

const customIcon = <Icon icon={lock} fill="currentColor" />;

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<string, unknown> = {
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 (
<>
<InspectorControls>
<PanelBody title={__('Max Inner Blocks Settings', 'example')}>
<RangeControl
label={__('Max children', 'example')}
min={1}
max={5}
value={max}
onChange={(value) => setAttributes({ max: value ?? 1 })}
__next40pxDefaultSize
/>
<SelectControl
label={__('Notice type', 'example')}
value={noticeType}
options={[
{ label: 'Snackbar', value: 'snackbar' },
{ label: 'Default', value: 'default' },
]}
onChange={(value) =>
setAttributes({ noticeType: value as BlockAttributes['noticeType'] })
}
__next40pxDefaultSize
/>
<SelectControl
label={__('Icon', 'example')}
value={iconMode}
options={[
{ label: 'Default', value: 'default' },
{ label: 'Custom', value: 'custom' },
{ label: 'None', value: 'none' },
]}
onChange={(value) =>
setAttributes({ iconMode: value as BlockAttributes['iconMode'] })
}
__next40pxDefaultSize
/>
<ToggleControl
label={__('Dismissible', 'example')}
checked={isDismissible}
onChange={(value) => setAttributes({ isDismissible: value })}
__next40pxDefaultSize
/>
<ToggleControl
label={__('Explicit dismiss (sticky)', 'example')}
checked={explicitDismiss}
onChange={(value) => setAttributes({ explicitDismiss: value })}
__next40pxDefaultSize
/>
<ToggleControl
label={__('Include action button', 'example')}
checked={withUndo}
onChange={(value) => setAttributes({ withUndo: value })}
__next40pxDefaultSize
/>
</PanelBody>
</InspectorControls>
<div
style={{
border: '1px dashed #999',
padding: '1rem',
borderRadius: '4px',
}}
>
<p style={{ margin: '0 0 1rem', fontSize: '0.85em', color: '#555' }}>
{__(
`Max children: ${max}. Try adding more than ${max} children — extras will be removed.`,
'example',
)}
</p>
<InnerBlocks allowedBlocks={ALLOWED_BLOCKS} template={TEMPLATE} />
</div>
</>
);
};
11 changes: 11 additions & 0 deletions example/src/blocks/max-inner-blocks-example/index.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof registerBlockType>[0], {
edit: BlockEdit,
save: () => <InnerBlocks.Content />,
});
1 change: 1 addition & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
72 changes: 72 additions & 0 deletions hooks/use-max-inner-blocks/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* Default icon. Uses `currentColor` so it adapts to the dark snackbar background.
*/
const defaultIcon = <Icon icon={info} fill="currentColor" />;

/**
* 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<string[]>([]);

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]);
};
96 changes: 96 additions & 0 deletions hooks/use-max-inner-blocks/readme.md
Original file line number Diff line number Diff line change
@@ -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: <Icon icon={lock} fill="currentColor" />,
},
});

return (
// ...
);
}
```

Pass `icon: null` to suppress the icon entirely.

> **Note:** Icons only render on snackbar notices. WordPress's default-type `<Notice>` 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 (
// ...
);
}
```
Loading
Loading