diff --git a/packages/block-library/src/accordion-item/index.php b/packages/block-library/src/accordion-item/index.php index 6b6df425111fae..37306c61fcb85d 100644 --- a/packages/block-library/src/accordion-item/index.php +++ b/packages/block-library/src/accordion-item/index.php @@ -36,6 +36,7 @@ function block_core_accordion_item_render( $attributes, $content ) { $p->set_attribute( 'data-wp-context', '{ "id": "' . $unique_id . '", "openByDefault": ' . $open_by_default . ' }' ); $p->set_attribute( 'data-wp-class--is-open', 'state.isOpen' ); $p->set_attribute( 'data-wp-init', 'callbacks.initAccordionItems' ); + $p->set_attribute( 'data-wp-on-window--hashchange', 'callbacks.hashChange' ); if ( $p->next_tag( array( 'class_name' => 'wp-block-accordion-heading__toggle' ) ) ) { $p->set_attribute( 'data-wp-on--click', 'actions.toggle' ); diff --git a/packages/block-library/src/accordion/view.js b/packages/block-library/src/accordion/view.js index c2b3670490f2af..d1a444ee7fdfe3 100644 --- a/packages/block-library/src/accordion/view.js +++ b/packages/block-library/src/accordion/view.js @@ -3,7 +3,11 @@ */ import { store, getContext, withSyncEvent } from '@wordpress/interactivity'; -store( +// Whether the hash has been handled for the current page load. +// This is used to prevent the hash from being handled multiple times. +let hashHandled = false; + +const { actions } = store( 'core/accordion', { state: { @@ -75,15 +79,68 @@ store( nextButton.focus(); } } ), + openPanelByHash: () => { + if ( hashHandled || ! window.location?.hash?.length ) { + return; + } + + const context = getContext(); + const { id, accordionItems, autoclose } = context; + const hash = decodeURIComponent( + window.location.hash.slice( 1 ) + ); + const targetElement = window.document.getElementById( hash ); + + if ( ! targetElement ) { + return; + } + + const panelElement = window.document.querySelector( + '.wp-block-accordion-panel[aria-labelledby="' + id + '"]' + ); + + if ( + ! panelElement || + ! panelElement.contains( targetElement ) + ) { + return; + } + + hashHandled = true; + + if ( autoclose ) { + accordionItems.forEach( ( item ) => { + item.isOpen = item.id === id; + } ); + } else { + const targetItem = accordionItems.find( + ( item ) => item.id === id + ); + + if ( targetItem ) { + targetItem.isOpen = true; + } + } + + // Wait for the panel to be opened before scrolling to it. + window.setTimeout( () => { + targetElement.scrollIntoView(); + }, 0 ); + }, }, callbacks: { initAccordionItems: () => { const context = getContext(); - const { id, openByDefault } = context; - context.accordionItems.push( { + const { id, openByDefault, accordionItems } = context; + accordionItems.push( { id, isOpen: openByDefault, } ); + actions.openPanelByHash(); + }, + hashChange: () => { + hashHandled = false; + actions.openPanelByHash(); }, }, }, diff --git a/test/e2e/specs/editor/blocks/accordion.spec.js b/test/e2e/specs/editor/blocks/accordion.spec.js new file mode 100644 index 00000000000000..7c858705f879f4 --- /dev/null +++ b/test/e2e/specs/editor/blocks/accordion.spec.js @@ -0,0 +1,241 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Accordion', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should open by default when openByDefault is true', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/accordion', + innerBlocks: [ + { + name: 'core/accordion-item', + attributes: { openByDefault: true }, + innerBlocks: [ + { + name: 'core/accordion-heading', + attributes: { title: 'Accordion Title' }, + }, + { + name: 'core/accordion-panel', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Accordion Panel Content', + }, + }, + ], + }, + ], + }, + ], + } ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const accordionToggle = page.getByRole( 'button', { + name: 'Accordion Title', + } ); + await expect( accordionToggle ).toHaveAttribute( + 'aria-expanded', + 'true' + ); + const accordionPanel = page.getByRole( 'region', { + name: 'Accordion Title', + } ); + await expect( accordionPanel ).toBeVisible(); + } ); + + test( 'should close other accordion items when autoclose is true', async ( { + editor, + page, + } ) => { + const accordionItems = Array.from( { length: 2 }, ( _, index ) => ( { + name: 'core/accordion-item', + // Open the first accordion item by default + attributes: { openByDefault: index === 0 }, + innerBlocks: [ + { + name: 'core/accordion-heading', + attributes: { title: `Accordion Title ${ index + 1 }` }, + }, + { + name: 'core/accordion-panel', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: `Accordion Panel Content ${ + index + 1 + }`, + }, + }, + ], + }, + ], + } ) ); + + await editor.insertBlock( { + name: 'core/accordion', + attributes: { autoclose: true }, + innerBlocks: accordionItems, + } ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const firstAccordionToggle = page.getByRole( 'button', { + name: 'Accordion Title 1', + } ); + const firstAccordionPanel = page.getByRole( 'region', { + name: 'Accordion Title 1', + } ); + const secondAccordionToggle = page.getByRole( 'button', { + name: 'Accordion Title 2', + } ); + const secondAccordionPanel = page.getByRole( 'region', { + name: 'Accordion Title 2', + } ); + + // Check that the first accordion item is open and the second is closed + await expect( firstAccordionToggle ).toHaveAttribute( + 'aria-expanded', + 'true' + ); + await expect( secondAccordionToggle ).toHaveAttribute( + 'aria-expanded', + 'false' + ); + await expect( firstAccordionPanel ).toBeVisible(); + await expect( secondAccordionPanel ).toBeHidden(); + + // Click the second accordion item + await secondAccordionToggle.click(); + + // Check that the first accordion item is closed and the second is open + await expect( firstAccordionToggle ).toHaveAttribute( + 'aria-expanded', + 'false' + ); + await expect( firstAccordionPanel ).toBeHidden(); + await expect( secondAccordionToggle ).toHaveAttribute( + 'aria-expanded', + 'true' + ); + await expect( secondAccordionPanel ).toBeVisible(); + } ); + + test( 'should open accordion panel by default when it contains the URL hash target', async ( { + editor, + page, + } ) => { + // Insert a tall spacer block to ensure anchor scrolling behaves as expected. + await editor.insertBlock( { + name: 'core/spacer', + attributes: { height: '1000px' }, + } ); + await editor.insertBlock( { + name: 'core/accordion', + innerBlocks: [ + { + name: 'core/accordion-item', + innerBlocks: [ + { + name: 'core/accordion-heading', + attributes: { title: 'Accordion Title' }, + }, + { + name: 'core/accordion-panel', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + anchor: 'target', + content: 'Accordion Panel Content', + }, + }, + ], + }, + ], + }, + ], + } ); + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }#target` ); + + const accordionPanel = page.getByRole( 'region', { + name: 'Accordion Title', + } ); + await expect( accordionPanel ).toBeVisible(); + const targetParagraph = page.locator( '#target' ); + await expect( targetParagraph ).toBeInViewport(); + } ); + + test( 'should open accordion panel when clicking a link whose target is inside the panel', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: + 'Open panel and scroll to target', + }, + } ); + // Insert a tall spacer block to ensure anchor scrolling behaves as expected. + await editor.insertBlock( { + name: 'core/spacer', + attributes: { height: '1000px' }, + } ); + await editor.insertBlock( { + name: 'core/accordion', + innerBlocks: [ + { + name: 'core/accordion-item', + innerBlocks: [ + { + name: 'core/accordion-heading', + attributes: { title: 'Accordion Title' }, + }, + { + name: 'core/accordion-panel', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + anchor: 'target', + content: 'Accordion Panel Content', + }, + }, + ], + }, + ], + }, + ], + } ); + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + const link = page.getByRole( 'link', { + name: 'Open panel and scroll to target', + } ); + const accordionPanel = page.getByRole( 'region', { + name: 'Accordion Title', + } ); + const targetParagraph = page.locator( '#target' ); + + await expect( accordionPanel ).toBeHidden(); + await link.click(); + await expect( accordionPanel ).toBeVisible(); + await expect( targetParagraph ).toBeInViewport(); + } ); +} );