From 1b1671679e582662550dfc64e57cdc27f8177c9f Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Thu, 30 Jul 2026 16:54:04 +0600 Subject: [PATCH] feat(accordion): open accordion item from URL hash Adds a frontend script that reads window.location.hash on page load, slugifies the hash and each accordion item's summary title, then opens and scrolls into view the single matching item. - src/js/coblocks-accordion-script.js: hash-driven open + scroll - src/js/test/coblocks-accordion-script.spec.js: jsdom smoke tests - webpack.config.js: add entry for the new script - includes/class-coblocks-block-assets.php: register + conditional enqueue via render_block filter Script only loads on pages that render coblocks/accordion or coblocks/accordion-item. Ambiguous matches (0 or 2+) are safely ignored. Works with the native details toggle path and the existing IE polyfill. Fixes #2663 --- includes/class-coblocks-block-assets.php | 20 +++ src/js/coblocks-accordion-script.js | 73 +++++++++ src/js/test/coblocks-accordion-script.spec.js | 152 ++++++++++++++++++ webpack.config.js | 1 + 4 files changed, 246 insertions(+) create mode 100644 src/js/coblocks-accordion-script.js create mode 100644 src/js/test/coblocks-accordion-script.spec.js diff --git a/includes/class-coblocks-block-assets.php b/includes/class-coblocks-block-assets.php index eef7f0e5fe6..b7dc5d3f575 100644 --- a/includes/class-coblocks-block-assets.php +++ b/includes/class-coblocks-block-assets.php @@ -508,6 +508,16 @@ public function frontend_scripts() { true ); + // Accordion block deep-link helper (issue #2663). + $asset_file = $this->get_asset_file( 'dist/js/coblocks-accordion-script' ); + wp_register_script( + 'coblocks-accordion-script', + $this->assets_dir . 'coblocks-accordion-script.js', + $asset_file['dependencies'], + COBLOCKS_VERSION, + true + ); + // Services block. $asset_file = $this->get_asset_file( 'dist/js/coblocks-services-script' ); wp_register_script( @@ -596,6 +606,16 @@ public function coblocks_enqueue_scripts_for_core_blocks( $block_content, $block ); } + // Allowed blocks for the accordion deep-link helper (issue #2663). + $accordion_allowed_blocks = array( + 'coblocks/accordion', + 'coblocks/accordion-item', + ); + + if ( in_array( $block_name, $accordion_allowed_blocks, true ) ) { + wp_enqueue_script( 'coblocks-accordion-script' ); + } + return $block_content; } diff --git a/src/js/coblocks-accordion-script.js b/src/js/coblocks-accordion-script.js new file mode 100644 index 00000000000..22867ba6581 --- /dev/null +++ b/src/js/coblocks-accordion-script.js @@ -0,0 +1,73 @@ +/** + * CoBlocks accordion deep-link helper. + * + * Reads window.location.hash on load, opens the single accordion item whose + * title slug matches the hash, and scrolls it into view. Works with + * the native
toggle and the legacy IE polyfill. + */ +const slugify = ( value ) => { + return value + .toLowerCase() + .replace( /[^a-z0-9]+/g, '-' ) + .replace( /^-+|-+$/g, '' ); +}; + +const setDetailsOpen = ( details ) => { + details.open = true; + // Set the attribute too so the legacy IE polyfill (which observes getAttribute('open')) picks it up. + details.setAttribute( 'open', '' ); +}; + +const openAccordionFromHash = () => { + const rawHash = window.location.hash || ''; + if ( ! rawHash || rawHash === '#' ) { + return; + } + + let decodedHash; + try { + decodedHash = decodeURIComponent( rawHash.slice( 1 ) ); + } catch ( error ) { + // Malformed percent-encoding: bail rather than throw. + return; + } + + const target = slugify( decodedHash ); + if ( ! target ) { + return; + } + + const summaries = document.querySelectorAll( '.wp-block-coblocks-accordion-item__title' ); + let match = null; + let matchCount = 0; + + summaries.forEach( ( summary ) => { + const title = slugify( summary.textContent || '' ); + if ( ! title || title !== target ) { + return; + } + + const details = summary.closest( 'details' ); + if ( ! details ) { + return; + } + + matchCount += 1; + match = matchCount === 1 ? details : null; + } ); + + if ( matchCount !== 1 || ! match ) { + return; + } + + setDetailsOpen( match ); + + const reduceMotion = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; + match.scrollIntoView( { behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' } ); +}; + +( function() { + 'use strict'; + + document.addEventListener( 'DOMContentLoaded', openAccordionFromHash, false ); +}() ); diff --git a/src/js/test/coblocks-accordion-script.spec.js b/src/js/test/coblocks-accordion-script.spec.js new file mode 100644 index 00000000000..a5f69d6673d --- /dev/null +++ b/src/js/test/coblocks-accordion-script.spec.js @@ -0,0 +1,152 @@ +/** + * Smoke tests for coblocks-accordion-script — the URL-hash deep-link helper. + * + * Source under test: src/js/coblocks-accordion-script.js + * + * The script registers a DOMContentLoaded listener that reads + * window.location.hash, compares a slugified version against the slugified + * text content of every , + * and (on a single match) opens the parent
then scrolls it into view. + */ + +const buildAccordion = ( titles ) => { + document.body.innerHTML = ''; + titles.forEach( ( title ) => { + const summary = document.createElement( 'summary' ); + summary.className = 'wp-block-coblocks-accordion-item__title'; + summary.textContent = title; + + const content = document.createElement( 'div' ); + content.className = 'wp-block-coblocks-accordion-item__content'; + + const details = document.createElement( 'details' ); + details.appendChild( summary ); + details.appendChild( content ); + + document.body.appendChild( details ); + } ); +}; + +describe( 'coblocks-accordion-script', () => { + let scrollSpy; + + // Register the script's DOMContentLoaded listener exactly once for the + // entire suite, before any test bodies run. globals.window.location.hash + // and the DOM are reset between tests in the beforeEach block. + require( '../coblocks-accordion-script.js' ); + + beforeEach( () => { + document.body.innerHTML = ''; + // jsdom does not implement scrollIntoView; stub it on every
. + scrollSpy = jest.fn(); + HTMLElement.prototype.scrollIntoView = scrollSpy; + window.location.hash = ''; + // Default matchMedia: motion allowed. Individual test overrides as needed. + window.matchMedia = jest.fn().mockImplementation( () => ( { + matches: false, + media: '', + } ) ); + } ); + + const fireDomLoaded = () => { + document.dispatchEvent( new Event( 'DOMContentLoaded' ) ); + }; + + it( 'does nothing when no hash is present', () => { + buildAccordion( [ 'Title A', 'Title B' ] ); + + fireDomLoaded(); + + const details = document.querySelectorAll( 'details' ); + details.forEach( ( d ) => expect( d.open ).toBe( false ) ); + expect( scrollSpy ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing when only a bare "#" is present', () => { + window.location.hash = '#'; + buildAccordion( [ 'Title A' ] ); + + fireDomLoaded(); + + expect( document.querySelector( 'details' ).open ).toBe( false ); + expect( scrollSpy ).not.toHaveBeenCalled(); + } ); + + it( 'opens the matching single item and scrolls it into view', () => { + buildAccordion( [ 'Title A', 'Title B', 'Title C' ] ); + window.location.hash = '#title-b'; + + fireDomLoaded(); + + const details = document.querySelectorAll( 'details' ); + expect( details[ 0 ].open ).toBe( false ); + expect( details[ 1 ].open ).toBe( true ); + expect( details[ 2 ].open ).toBe( false ); + expect( scrollSpy ).toHaveBeenCalledTimes( 1 ); + expect( scrollSpy ).toHaveBeenCalledWith( { behavior: 'smooth', block: 'start' } ); + } ); + + it( 'opens when the slug is matched after collapsing punctuation and spacing', () => { + buildAccordion( [ 'Hello — World!' ] ); + window.location.hash = '#Hello%20World!'; + + fireDomLoaded(); + + const details = document.querySelector( 'details' ); + expect( details.open ).toBe( true ); + expect( scrollSpy ).toHaveBeenCalled(); + } ); + + it( 'does nothing when the hash does not match any item', () => { + buildAccordion( [ 'Title A', 'Title B' ] ); + window.location.hash = '#does-not-exist'; + + fireDomLoaded(); + + const details = document.querySelectorAll( 'details' ); + details.forEach( ( d ) => expect( d.open ).toBe( false ) ); + expect( scrollSpy ).not.toHaveBeenCalled(); + } ); + + it( 'does not open anything when two items share a slug', () => { + buildAccordion( [ 'Same', 'Same' ] ); + window.location.hash = '#same'; + + fireDomLoaded(); + + const details = document.querySelectorAll( 'details' ); + details.forEach( ( d ) => expect( d.open ).toBe( false ) ); + expect( scrollSpy ).not.toHaveBeenCalled(); + } ); + + it( 'switches to instant scroll when the user prefers reduced motion', () => { + window.matchMedia = jest.fn().mockImplementation( ( query ) => ( { + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + } ) ); + + buildAccordion( [ 'Title A' ] ); + window.location.hash = '#title-a'; + + fireDomLoaded(); + + expect( scrollSpy ).toHaveBeenCalledWith( { behavior: 'auto', block: 'start' } ); + } ); + + it( 'does not crash when no accordion items exist on the page', () => { + window.location.hash = '#title-a'; + + expect( () => fireDomLoaded() ).not.toThrow(); + expect( scrollSpy ).not.toHaveBeenCalled(); + } ); + + it( 'also sets the open attribute for the legacy IE polyfill path', () => { + buildAccordion( [ 'Title A' ] ); + window.location.hash = '#title-a'; + + fireDomLoaded(); + + const details = document.querySelector( 'details' ); + expect( details.hasAttribute( 'open' ) ).toBe( true ); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 6fd72a40fff..14856a36c43 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,6 +12,7 @@ const fs = require( 'fs' ); const scripts = [ 'coblocks-accordion-polyfill', + 'coblocks-accordion-script', 'coblocks-animation', 'coblocks-checkbox-required', 'coblocks-counter-script',