From 0a900f51c40d191f70b62aa2efbc122c3609db4e Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Thu, 28 May 2026 20:10:43 -0500 Subject: [PATCH 1/3] feat(blocks): fork I4 widgets cluster (search, latest-posts) (#412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forks the 2-block widgets cluster from `core/*` → `artisanpack/*` per docs/plans/13-block-fork.md §2.2.1 + §3. artisanpack/search — static fork (I1–I3 pattern): block.json, TS-ported edit (drops the block-library-internal ToolsPanel dropdown hook), null save (search is rendered outside save), empty deprecation chain, bidirectional core/search ↔ artisanpack/search transforms, the default variation, inline inserter icon, combined CSS, upstream-state.json, and the four-test suite. The Blade view delegates to core/search and the React/Vue renderers reuse the core/search component, so V1's #338 button-icon a11y fix carries forward unchanged — covered by a carry-forward test in each renderer. The Vue renderer never received the #338 fix, so it's brought into parity here (has-icon + aria-label + svg) to match Blade/React. artisanpack/latest-posts — dynamic fork (artisanpack/form pattern): block.json (full upstream attribute schema), edit previews through the package's seam with a ported PanelBody inspector, null save, the category-string deprecation, bidirectional transforms, inline icon, and tests. Server rendering is a new LatestPostsBlock DynamicBlock querying cms-framework's Post model (modeled on ArchivesBlock), registered in the service provider behind the BlogManager guard. As a dynamic block it is served by the Blade DynamicBlockRegistry, so — like categories/archives/ form — it is intentionally absent from renderer-parity.json. Pinned upstream: @wordpress/block-library v9.43.0. Closes #412 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/renderer-parity.json | 3 +- .../views/blocks/artisanpack/search.blade.php | 2 + .../tests/Unit/BlockRendererTest.php | 11 + .../src/blocks/registerCoreBlocks.ts | 4 + .../tests/BlockTree.test.tsx | 11 + .../src/blocks/core/design.ts | 54 +- .../src/blocks/registerCoreBlocks.ts | 4 + .../tests/BlockTree.test.ts | 27 +- .../latest-posts/__tests__/deprecated.test.ts | 41 + .../latest-posts/__tests__/edit.test.tsx | 87 +++ .../latest-posts/__tests__/save.test.tsx | 33 + .../latest-posts/__tests__/transforms.test.ts | 48 ++ .../blocks/latest-posts/block.json | 133 ++++ .../blocks/latest-posts/constants.ts | 12 + .../blocks/latest-posts/deprecated.ts | 46 ++ .../blocks/latest-posts/edit.tsx | 268 +++++++ .../blocks/latest-posts/index.ts | 30 + .../blocks/latest-posts/inserter-icon.tsx | 22 + .../blocks/latest-posts/save.tsx | 12 + .../blocks/latest-posts/transforms.ts | 41 + .../blocks/latest-posts/upstream-state.json | 43 ++ .../search/__tests__/deprecated.test.ts | 18 + .../blocks/search/__tests__/edit.test.tsx | 139 ++++ .../blocks/search/__tests__/save.test.tsx | 32 + .../search/__tests__/transforms.test.ts | 48 ++ .../js/visual-editor/blocks/search/block.json | 96 +++ .../visual-editor/blocks/search/constants.ts | 15 + .../visual-editor/blocks/search/deprecated.ts | 12 + .../js/visual-editor/blocks/search/edit.tsx | 713 ++++++++++++++++++ .../js/visual-editor/blocks/search/index.ts | 35 + .../blocks/search/inserter-icon.tsx | 22 + .../js/visual-editor/blocks/search/save.tsx | 14 + .../js/visual-editor/blocks/search/search.css | 47 ++ .../visual-editor/blocks/search/transforms.ts | 40 + .../blocks/search/upstream-state.json | 43 ++ .../visual-editor/blocks/search/variations.ts | 19 + src/Blocks/Core/LatestPostsBlock.php | 345 +++++++++ src/VisualEditorServiceProvider.php | 17 +- .../Blocks/Core/LatestPostsBlockTest.php | 186 +++++ 39 files changed, 2763 insertions(+), 10 deletions(-) create mode 100644 packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/search.blade.php create mode 100644 resources/js/visual-editor/blocks/latest-posts/__tests__/deprecated.test.ts create mode 100644 resources/js/visual-editor/blocks/latest-posts/__tests__/edit.test.tsx create mode 100644 resources/js/visual-editor/blocks/latest-posts/__tests__/save.test.tsx create mode 100644 resources/js/visual-editor/blocks/latest-posts/__tests__/transforms.test.ts create mode 100644 resources/js/visual-editor/blocks/latest-posts/block.json create mode 100644 resources/js/visual-editor/blocks/latest-posts/constants.ts create mode 100644 resources/js/visual-editor/blocks/latest-posts/deprecated.ts create mode 100644 resources/js/visual-editor/blocks/latest-posts/edit.tsx create mode 100644 resources/js/visual-editor/blocks/latest-posts/index.ts create mode 100644 resources/js/visual-editor/blocks/latest-posts/inserter-icon.tsx create mode 100644 resources/js/visual-editor/blocks/latest-posts/save.tsx create mode 100644 resources/js/visual-editor/blocks/latest-posts/transforms.ts create mode 100644 resources/js/visual-editor/blocks/latest-posts/upstream-state.json create mode 100644 resources/js/visual-editor/blocks/search/__tests__/deprecated.test.ts create mode 100644 resources/js/visual-editor/blocks/search/__tests__/edit.test.tsx create mode 100644 resources/js/visual-editor/blocks/search/__tests__/save.test.tsx create mode 100644 resources/js/visual-editor/blocks/search/__tests__/transforms.test.ts create mode 100644 resources/js/visual-editor/blocks/search/block.json create mode 100644 resources/js/visual-editor/blocks/search/constants.ts create mode 100644 resources/js/visual-editor/blocks/search/deprecated.ts create mode 100644 resources/js/visual-editor/blocks/search/edit.tsx create mode 100644 resources/js/visual-editor/blocks/search/index.ts create mode 100644 resources/js/visual-editor/blocks/search/inserter-icon.tsx create mode 100644 resources/js/visual-editor/blocks/search/save.tsx create mode 100644 resources/js/visual-editor/blocks/search/search.css create mode 100644 resources/js/visual-editor/blocks/search/transforms.ts create mode 100644 resources/js/visual-editor/blocks/search/upstream-state.json create mode 100644 resources/js/visual-editor/blocks/search/variations.ts create mode 100644 src/Blocks/Core/LatestPostsBlock.php create mode 100644 tests/Unit/VisualEditor/Blocks/Core/LatestPostsBlockTest.php diff --git a/packages/renderer-parity.json b/packages/renderer-parity.json index 9655cd91c..71c3cdad5 100644 --- a/packages/renderer-parity.json +++ b/packages/renderer-parity.json @@ -78,6 +78,7 @@ "artisanpack/button", "artisanpack/separator", "artisanpack/spacer", - "artisanpack/details" + "artisanpack/details", + "artisanpack/search" ] } diff --git a/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/search.blade.php b/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/search.blade.php new file mode 100644 index 000000000..c789efc3f --- /dev/null +++ b/packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/search.blade.php @@ -0,0 +1,2 @@ +{{-- artisanpack/search — delegates to core/search (carries the #338 button-icon a11y fix). --}} +@include('visual-editor-renderer-blade::blocks.core.search', ['attributes' => $attributes ?? [], 'innerContent' => $innerContent ?? '', 'blockName' => $blockName ?? 'artisanpack/search']) diff --git a/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php b/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php index a3fe12e05..f802a0b59 100644 --- a/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php +++ b/packages/visual-editor-renderer-blade/tests/Unit/BlockRendererTest.php @@ -469,6 +469,17 @@ public function render( array $attrs ) expect( $html )->toContain( 'aria-label="Find stuff"' ); } ); +it( 'carries the #338 a11y fix forward to artisanpack/search (I4 fork)', function () { + $tree = [ makeBlock( 'artisanpack/search', [ 'buttonText' => 'Go', 'buttonUseIcon' => true ] ) ]; + + $html = makeRenderer()->render( $tree ); + + expect( $html )->toContain( 'class="wp-block-search__button has-icon"' ) + ->toContain( 'aria-label="Go"' ) + ->toContain( 'not->toContain( '' ); +} ); + it( 'preserves fractional column widths', function () { $tree = [ makeBlock( 'core/columns', [], [ diff --git a/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts b/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts index 96389a975..827992b16 100644 --- a/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts +++ b/packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts @@ -127,6 +127,10 @@ const CORE_BLOCKS: Record = { 'core/details': DetailsBlock, 'artisanpack/details': DetailsBlock, 'core/search': SearchBlock, + // Fork: artisanpack/search reuses the core/search renderer, which + // already carries the #338 button-icon a11y fix, so the forked block's + // front-end output is byte-equivalent. Phase I4 widgets cluster (#412). + 'artisanpack/search': SearchBlock, 'core/separator': SeparatorBlock, 'artisanpack/separator': SeparatorBlock, 'core/spacer': SpacerBlock, diff --git a/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx b/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx index e36c811c1..bcf94d55b 100644 --- a/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx +++ b/packages/visual-editor-renderer-react/tests/BlockTree.test.tsx @@ -574,6 +574,17 @@ describe('Core design blocks', () => { expect(renderTree(tree)).toContain('aria-label="Find stuff"'); }); + it('carries the #338 a11y fix forward to artisanpack/search (I4 fork)', () => { + const tree = [makeBlock('artisanpack/search', { buttonText: 'Go', buttonUseIcon: true })]; + + const html = renderTree(tree); + + expect(html).toContain('class="wp-block-search__button has-icon"'); + expect(html).toContain('aria-label="Go"'); + expect(html).toContain(''); + }); + it('renders the artisanpack/callout reference block', () => { const tree = [ makeBlock('artisanpack/callout', { diff --git a/packages/visual-editor-renderer-vue/src/blocks/core/design.ts b/packages/visual-editor-renderer-vue/src/blocks/core/design.ts index b0540ef53..2e70c758f 100644 --- a/packages/visual-editor-renderer-vue/src/blocks/core/design.ts +++ b/packages/visual-editor-renderer-vue/src/blocks/core/design.ts @@ -300,9 +300,23 @@ export const SearchBlock = defineComponent({ const buttonText = attrString(props.attributes.buttonText, 'Search'); const useIcon = attrBoolean(props.attributes.buttonUseIcon); const queryName = attrString(attrRecord(props.attributes.query).name, 's'); - const buttonLabel = useIcon ? '' : buttonText; const className = attrString(props.attributes.className); + // #338: an icon-only button must still expose an accessible + // name. Precedence matches the Blade + React renderers so all + // three stay byte-identical: buttonText → label → "Search". + const ariaLabel = + buttonText.trim() !== '' + ? buttonText + : label.trim() !== '' + ? label + : 'Search'; + + const buttonClasses = classList([ + 'wp-block-search__button', + useIcon ? 'has-icon' : null, + ]); + const classes = classList([ 'wp-block-search', !showLabel ? 'wp-block-search__button-inside' : null, @@ -330,11 +344,39 @@ export const SearchBlock = defineComponent({ name: queryName, placeholder: placeholder === '' ? undefined : placeholder, }), - h( - 'button', - { type: 'submit', class: 'wp-block-search__button' }, - buttonLabel - ), + useIcon + ? h( + 'button', + { + type: 'submit', + class: buttonClasses, + 'aria-label': ariaLabel, + }, + [ + h( + 'svg', + { + class: 'wp-block-search__button-icon', + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + width: '24', + height: '24', + 'aria-hidden': 'true', + focusable: 'false', + }, + [ + h('path', { + d: 'M13.5 6C10.5 6 8 8.5 8 11.5c0 1.1.3 2.1.9 3l-3.4 3 1 1.1 3.4-3c1 .9 2.2 1.4 3.6 1.4 3 0 5.5-2.5 5.5-5.5C19 8.5 16.5 6 13.5 6zm0 9.5c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z', + }), + ] + ), + ] + ) + : h( + 'button', + { type: 'submit', class: buttonClasses }, + buttonText + ), ]) ); diff --git a/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts b/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts index bf4fe833e..17a932b7e 100644 --- a/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts +++ b/packages/visual-editor-renderer-vue/src/blocks/registerCoreBlocks.ts @@ -127,6 +127,10 @@ const CORE_BLOCKS: Record = { 'core/details': DetailsBlock, 'artisanpack/details': DetailsBlock, 'core/search': SearchBlock, + // Fork: artisanpack/search reuses the core/search renderer, which + // already carries the #338 button-icon a11y fix, so the forked block's + // front-end output is byte-equivalent. Phase I4 widgets cluster (#412). + 'artisanpack/search': SearchBlock, 'core/separator': SeparatorBlock, 'artisanpack/separator': SeparatorBlock, 'core/spacer': SpacerBlock, diff --git a/packages/visual-editor-renderer-vue/tests/BlockTree.test.ts b/packages/visual-editor-renderer-vue/tests/BlockTree.test.ts index 964939078..c7a80f898 100644 --- a/packages/visual-editor-renderer-vue/tests/BlockTree.test.ts +++ b/packages/visual-editor-renderer-vue/tests/BlockTree.test.ts @@ -554,10 +554,33 @@ describe('Core design blocks', () => { expect(ids[0]).not.toBe(ids[1]); }); - it('uses an empty button label when buttonUseIcon is true', () => { + it('renders an icon-only submit button with accessible name when buttonUseIcon is true', () => { const tree = [makeBlock('core/search', { buttonText: 'Go', buttonUseIcon: true })]; - expect(renderTree(tree)).toContain(''); + const html = renderTree(tree); + + expect(html).toContain('class="wp-block-search__button has-icon"'); + expect(html).toContain('aria-label="Go"'); + expect(html).toContain(''); + }); + + it('falls back to label when buttonText is empty and buttonUseIcon is true', () => { + const tree = [ + makeBlock('core/search', { label: 'Find stuff', buttonText: '', buttonUseIcon: true }), + ]; + + expect(renderTree(tree)).toContain('aria-label="Find stuff"'); + }); + + it('carries the #338 a11y fix forward to artisanpack/search (I4 fork)', () => { + const tree = [makeBlock('artisanpack/search', { buttonText: 'Go', buttonUseIcon: true })]; + + const html = renderTree(tree); + + expect(html).toContain('class="wp-block-search__button has-icon"'); + expect(html).toContain('aria-label="Go"'); + expect(html).toContain(' { diff --git a/resources/js/visual-editor/blocks/latest-posts/__tests__/deprecated.test.ts b/resources/js/visual-editor/blocks/latest-posts/__tests__/deprecated.test.ts new file mode 100644 index 000000000..35c00b0d7 --- /dev/null +++ b/resources/js/visual-editor/blocks/latest-posts/__tests__/deprecated.test.ts @@ -0,0 +1,41 @@ +/** + * Locks the deprecation chain for `artisanpack/latest-posts`. + * + * Mirrors upstream's single v1 entry, which migrates the legacy string + * `categories` attribute (a bare term id) to the array-of-objects shape. + */ + +import { describe, it, expect } from 'vitest'; + +import deprecated from '../deprecated'; + +describe('latest-posts deprecation chain', () => { + it('ships one deprecation entry (v1) matching upstream', () => { + expect(Array.isArray(deprecated)).toBe(true); + expect(deprecated).toHaveLength(1); + }); + + it('v1 declares the legacy string categories attribute', () => { + const v1 = deprecated[0]; + expect((v1.attributes as Record).categories.type).toBe('string'); + }); + + it('v1 is eligible only when categories is a string', () => { + const v1 = deprecated[0]; + expect(v1.isEligible({ categories: '7' })).toBe(true); + expect(v1.isEligible({ categories: [{ id: 7 }] as unknown as string })).toBe(false); + expect(v1.isEligible({})).toBe(false); + }); + + it('v1 migrate() upgrades the string id to an array of objects', () => { + const v1 = deprecated[0]; + const migrated = v1.migrate({ categories: '7', postsToShow: 3 }); + expect(migrated.categories).toEqual([{ id: 7 }]); + expect(migrated.postsToShow).toBe(3); + }); + + it('v1 save() returns null (dynamic block)', () => { + const v1 = deprecated[0]; + expect(v1.save()).toBeNull(); + }); +}); diff --git a/resources/js/visual-editor/blocks/latest-posts/__tests__/edit.test.tsx b/resources/js/visual-editor/blocks/latest-posts/__tests__/edit.test.tsx new file mode 100644 index 000000000..492c96c06 --- /dev/null +++ b/resources/js/visual-editor/blocks/latest-posts/__tests__/edit.test.tsx @@ -0,0 +1,87 @@ +/** + * Tests for the `artisanpack/latest-posts` edit component. + * + * The preview is server-rendered, so these assertions focus on the + * inspector controls and that the edit wires the package's + * seam to the `artisanpack/latest-posts` block name. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +vi.mock('@wordpress/i18n', () => ({ + __: (text: string) => text, +})); + +vi.mock('../../../vendor/i18n', () => ({ + TEXT_DOMAIN: 'artisanpack-visual-editor', +})); + +vi.mock('../../../editor/server-side-render', () => ({ + ServerSideRender: ({ block, attributes }: { block: string; attributes: Record }) => ( +
+ ), +})); + +vi.mock('@wordpress/components', () => { + const Panel = ({ title, children }: { title?: string; children?: React.ReactNode }) => ( +
{children}
+ ); + const Toggle = ({ label, checked }: { label?: string; checked?: boolean }) => ( +