Skip to content
Open
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
20 changes: 20 additions & 0 deletions includes/class-coblocks-block-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}

Expand Down
73 changes: 73 additions & 0 deletions src/js/coblocks-accordion-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* CoBlocks accordion deep-link helper.
*
* Reads window.location.hash on load, opens the single accordion item whose
* <summary> title slug matches the hash, and scrolls it into view. Works with
* the native <details> 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 );
}() );
152 changes: 152 additions & 0 deletions src/js/test/coblocks-accordion-script.spec.js
Original file line number Diff line number Diff line change
@@ -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 <summary class="wp-block-coblocks-accordion-item__title">,
* and (on a single match) opens the parent <details> 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 <details>.
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 );
} );
} );
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const fs = require( 'fs' );

const scripts = [
'coblocks-accordion-polyfill',
'coblocks-accordion-script',
'coblocks-animation',
'coblocks-checkbox-required',
'coblocks-counter-script',
Expand Down