From 53e4c0620ad29017476d41821be4316e5f11fd0b Mon Sep 17 00:00:00 2001 From: mricoul Date: Fri, 24 Jul 2026 16:43:34 +0200 Subject: [PATCH 1/3] chore(workflows): remove quality workflows triggered on push --- .github/workflows/quality-js.yml | 8 -------- .github/workflows/quality-php.yml | 4 ---- 2 files changed, 12 deletions(-) diff --git a/.github/workflows/quality-js.yml b/.github/workflows/quality-js.yml index d51628c..c56ea0b 100644 --- a/.github/workflows/quality-js.yml +++ b/.github/workflows/quality-js.yml @@ -9,14 +9,6 @@ on: - 'src/**' - 'package.json' - 'package-lock.json' - push: - branches: - - main - - develop - paths: - - 'src/**' - - 'package.json' - - 'package-lock.json' # Cancels all previous workflow runs for pull requests that have not completed. concurrency: diff --git a/.github/workflows/quality-php.yml b/.github/workflows/quality-php.yml index 740f312..4ffe7aa 100644 --- a/.github/workflows/quality-php.yml +++ b/.github/workflows/quality-php.yml @@ -5,10 +5,6 @@ on: branches: - main - develop - push: - branches: - - main - - develop # Cancels all previous workflow runs for pull requests that have not completed. concurrency: From 4e058cfb0062c045f5a7aad705f435a020ff504d Mon Sep 17 00:00:00 2001 From: mricoul Date: Thu, 20 Aug 2026 14:19:30 +0200 Subject: [PATCH 2/3] feat: add scroll-spy, smooth scroll, and sticky support to Anchors List --- src/anchor/style.scss | 6 +- src/anchors-list/block.json | 4 + src/anchors-list/view.js | 229 ++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/anchors-list/view.js diff --git a/src/anchor/style.scss b/src/anchor/style.scss index deeb2c2..9ca7db6 100644 --- a/src/anchor/style.scss +++ b/src/anchor/style.scss @@ -5,4 +5,8 @@ * Replace them with your own styles or remove the file completely. */ -.wp-block-blockparty-anchor {} +.wp-block-blockparty-anchor { + --wp-block-anchor--scroll-margin-top: var(--wp-admin--admin-bar--height, 0px); + + scroll-margin-top: var(--wp-block-anchor--scroll-margin-top); +} diff --git a/src/anchors-list/block.json b/src/anchors-list/block.json index 4c8f68a..4de1e2a 100644 --- a/src/anchors-list/block.json +++ b/src/anchors-list/block.json @@ -29,6 +29,9 @@ }, "html": false, "multiple": false, + "position": { + "sticky": true + }, "spacing": { "margin": true, "padding": true @@ -46,5 +49,6 @@ "textdomain": "blockparty-anchors", "editorScript": "file:./index.js", "editorStyle": "file:./index.css", + "viewScript": "file:./view.js", "style": "file:./style-index.css" } diff --git a/src/anchors-list/view.js b/src/anchors-list/view.js new file mode 100644 index 0000000..a37dfdf --- /dev/null +++ b/src/anchors-list/view.js @@ -0,0 +1,229 @@ +/** + * Frontend behavior for the Anchors List block. + * + * - Tracks which page section is in view (IntersectionObserver). + * - Toggles an `is-active` class on the matching list item and link. + * - Smooth-scrolls to the target on click and updates the URL hash. + * - Syncs the active item from the current hash on load. + * - Scrolls the active link into view inside the horizontal scroller. + */ + +const BLOCK_SELECTOR = '.wp-block-blockparty-anchors-list'; +const ITEM_SELECTOR = '.wp-block-blockparty-anchors-list__item'; +const LINK_SELECTOR = '.wp-block-blockparty-anchors-list__link[href^="#"]'; +const ACTIVE_CLASS = 'is-active'; + +/** + * Resolve hash targets for every in-page link in a list. + * + * @param {NodeList|HTMLAnchorElement[]} links Anchor links. + * @return {Array<{ id: string, element: Element, link: HTMLAnchorElement }>} Matched targets. + */ +const getAnchorTargets = ( links ) => { + const targets = []; + + links.forEach( ( link ) => { + const href = link.getAttribute( 'href' ) || ''; + const id = href.slice( 1 ); + + if ( ! id ) { + return; + } + + const element = document.getElementById( id ); + + if ( element ) { + targets.push( { id, element, link } ); + } + } ); + + return targets; +}; + +/** + * Scroll an active link into view horizontally without moving the page. + * + * @param {HTMLElement} link Active list link. + */ +const scrollLinkIntoListView = ( link ) => { + const scroller = link.closest( + '.wp-block-blockparty-anchors-list__scroll' + ); + + if ( ! scroller ) { + return; + } + + const linkRect = link.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + + if ( + linkRect.left >= scrollerRect.left && + linkRect.right <= scrollerRect.right + ) { + return; + } + + const nextScrollLeft = + scroller.scrollLeft + + ( linkRect.left - scrollerRect.left ) - + ( scrollerRect.width - linkRect.width ) / 2; + + scroller.scrollTo( { + left: Math.max( 0, nextScrollLeft ), + behavior: 'smooth', + } ); +}; + +/** + * Toggle active state on list items and links. + * + * @param {HTMLElement} list List root. + * @param {NodeList|HTMLAnchorElement[]} links All list links. + * @param {string} activeId Active target id. + */ +const updateActiveLink = ( list, links, activeId ) => { + if ( list.dataset.activeAnchor === activeId ) { + return; + } + + links.forEach( ( link ) => { + const linkTarget = ( link.getAttribute( 'href' ) || '' ).slice( 1 ); + const parentItem = link.closest( ITEM_SELECTOR ); + const isActive = linkTarget === activeId; + + link.classList.toggle( ACTIVE_CLASS, isActive ); + + if ( isActive ) { + link.setAttribute( 'aria-current', 'location' ); + scrollLinkIntoListView( link ); + } else { + link.removeAttribute( 'aria-current' ); + } + + if ( parentItem ) { + parentItem.classList.toggle( ACTIVE_CLASS, isActive ); + } + } ); + + list.dataset.activeAnchor = activeId; +}; + +/** + * Pick the active target among currently intersecting sections. + * Prefers the last intersecting target in document order (closest to the spy band). + * + * @param {Map} visibleSections Visible targets keyed by id. + * @param {Array} orderedTargets Targets in document order. + * @return {string|null} Active target id, or null when none are visible. + */ +const getMostRelevantActiveId = ( visibleSections, orderedTargets ) => { + if ( ! visibleSections.size ) { + return null; + } + + let activeId = null; + + orderedTargets.forEach( ( { id } ) => { + if ( visibleSections.has( id ) ) { + activeId = id; + } + } ); + + return activeId; +}; + +/** + * Initialize scroll-spy and click handling for one Anchors List block. + * + * @param {HTMLElement} list Block root element. + */ +const initAnchorsList = ( list ) => { + const links = list.querySelectorAll( LINK_SELECTOR ); + + if ( ! links.length ) { + return; + } + + const targets = getAnchorTargets( links ); + + if ( ! targets.length ) { + return; + } + + const visibleSections = new Map(); + + // eslint-disable-next-line no-undef -- browser API + const observer = new IntersectionObserver( + ( entries ) => { + entries.forEach( ( entry ) => { + const targetId = entry.target.id; + + if ( entry.isIntersecting ) { + visibleSections.set( targetId, entry.target ); + } else { + visibleSections.delete( targetId ); + } + } ); + + const activeId = getMostRelevantActiveId( + visibleSections, + targets + ); + + if ( activeId ) { + updateActiveLink( list, links, activeId ); + } + }, + { + root: null, + // Narrow horizontal band near the top of the viewport. + rootMargin: '-20% 0px -70% 0px', + threshold: 0, + } + ); + + targets.forEach( ( { element } ) => { + observer.observe( element ); + } ); + + links.forEach( ( link ) => { + link.addEventListener( 'click', ( event ) => { + const targetId = ( link.getAttribute( 'href' ) || '' ).slice( 1 ); + const targetElement = document.getElementById( targetId ); + + if ( ! targetElement ) { + return; + } + + event.preventDefault(); + + targetElement.scrollIntoView( { + behavior: 'smooth', + block: 'start', + } ); + + if ( window.history?.pushState ) { + window.history.pushState( null, '', `#${ targetId }` ); + } + + updateActiveLink( list, links, targetId ); + } ); + } ); + + const hashId = window.location.hash.slice( 1 ); + + if ( hashId && targets.some( ( { id } ) => id === hashId ) ) { + updateActiveLink( list, links, hashId ); + } +}; + +const init = () => { + document.querySelectorAll( BLOCK_SELECTOR ).forEach( initAnchorsList ); +}; + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); +} From 8a0b1d52d90c4ac255f29e0a28f34e58d74c9c38 Mon Sep 17 00:00:00 2001 From: mricoul Date: Thu, 20 Aug 2026 14:22:15 +0200 Subject: [PATCH 3/3] chore: bump version to 1.0.2 --- .plugin-data | 2 +- .wordpress-org/blueprints/blueprint.json | 2 +- CHANGELOG.md | 4 ++++ blockparty-anchors.php | 4 ++-- languages/blockparty-anchors-fr_FR.po | 2 +- languages/blockparty-anchors.pot | 2 +- package-lock.json | 4 ++-- package.json | 2 +- readme.txt | 5 ++++- src/anchor/block.json | 2 +- src/anchors-list/block.json | 2 +- 11 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.plugin-data b/.plugin-data index 8bec436..c8e06bf 100644 --- a/.plugin-data +++ b/.plugin-data @@ -1,4 +1,4 @@ { - "version": "1.0.1", + "version": "1.0.2", "slug": "blockparty-anchors" } diff --git a/.wordpress-org/blueprints/blueprint.json b/.wordpress-org/blueprints/blueprint.json index c75e96e..44553e2 100644 --- a/.wordpress-org/blueprints/blueprint.json +++ b/.wordpress-org/blueprints/blueprint.json @@ -11,7 +11,7 @@ "pluginData": { "resource": "git:directory", "url": "https://github.com/BeAPI/blockparty-anchors", - "ref": "1.0.1", + "ref": "1.0.2", "refType": "tag" }, "options": { diff --git a/CHANGELOG.md b/CHANGELOG.md index ef427f0..b8e7337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to Blockparty Anchors are documented in this file. +## 1.0.2 + +- Add frontend scroll-spy on the Anchors List block, with an `is-active` state, smooth in-page scrolling, URL hash sync, and sticky position support. + ## 1.0.1 - Collect anchors from block theme templates and template parts in document order, including content rendered via `core/post-content`. diff --git a/blockparty-anchors.php b/blockparty-anchors.php index d9bf054..b0a764a 100644 --- a/blockparty-anchors.php +++ b/blockparty-anchors.php @@ -2,7 +2,7 @@ /** * Plugin Name: Blockparty Anchors * Description: Adds two new blocks to the WordPress editor: an anchor block to create an anchor system on your pages, and an anchor list block to list those anchors. - * Version: 1.0.1 + * Version: 1.0.2 * Requires at least: 6.8 * Requires PHP: 8.1 * Author: Be API Technical Team @@ -24,7 +24,7 @@ include_once __DIR__ . '/vendor/autoload.php'; } -define( 'BLOCKPARTY_ANCHORS_VERSION', '1.0.1' ); +define( 'BLOCKPARTY_ANCHORS_VERSION', '1.0.2' ); define( 'BLOCKPARTY_ANCHORS_URL', plugin_dir_url( __FILE__ ) ); define( 'BLOCKPARTY_ANCHORS_DIR', plugin_dir_path( __FILE__ ) ); define( 'BLOCKPARTY_ANCHORS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); diff --git a/languages/blockparty-anchors-fr_FR.po b/languages/blockparty-anchors-fr_FR.po index 9dbdc5f..ba1b7f5 100644 --- a/languages/blockparty-anchors-fr_FR.po +++ b/languages/blockparty-anchors-fr_FR.po @@ -2,7 +2,7 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: Blockparty Anchors 1.0.1\n" +"Project-Id-Version: Blockparty Anchors 1.0.2\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/blockparty-" "anchors\n" "POT-Creation-Date: 2026-07-09T09:18:45+00:00\n" diff --git a/languages/blockparty-anchors.pot b/languages/blockparty-anchors.pot index e61d35a..c807c07 100644 --- a/languages/blockparty-anchors.pot +++ b/languages/blockparty-anchors.pot @@ -2,7 +2,7 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: Blockparty Anchors 1.0.1\n" +"Project-Id-Version: Blockparty Anchors 1.0.2\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/blockparty-anchors\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/package-lock.json b/package-lock.json index e28b564..2c9d71d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockparty-anchors", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockparty-anchors", - "version": "1.0.1", + "version": "1.0.2", "license": "GPL-2.0-or-later", "dependencies": { "@beapi/icons": "1.2.7", diff --git a/package.json b/package.json index ec4adbc..ce3bfa0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockparty-anchors", - "version": "1.0.1", + "version": "1.0.2", "description": "Adds two new blocks to the WordPress editor: an anchor block to create an anchor system on your pages, and an anchor list block to list those anchors.", "author": "Be API", "license": "GPL-2.0-or-later", diff --git a/readme.txt b/readme.txt index 3263787..9932421 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: block, gutenberg, anchor, navigation, table of contents Requires at least: 6.8 Tested up to: 6.8 Requires PHP: 8.1 -Stable tag: 1.0.1 +Stable tag: 1.0.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -96,6 +96,9 @@ Yes. Anchor blocks are collected recursively, including when placed inside colum == Changelog == += 1.0.2 = +* Add frontend scroll-spy on the Anchors List block, with an is-active state, smooth in-page scrolling, URL hash sync, and sticky position support. + = 1.0.1 = * Collect anchors from block theme templates and template parts in document order, including content rendered via core/post-content. diff --git a/src/anchor/block.json b/src/anchor/block.json index 25e1204..581939e 100644 --- a/src/anchor/block.json +++ b/src/anchor/block.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "blockparty/anchor", - "version": "1.0.1", + "version": "1.0.2", "title": "Anchor", "category": "widgets", "description": "Place an anchor on your page. It will be listed in the anchor list block.", diff --git a/src/anchors-list/block.json b/src/anchors-list/block.json index 4de1e2a..9f49420 100644 --- a/src/anchors-list/block.json +++ b/src/anchors-list/block.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "blockparty/anchors-list", - "version": "1.0.1", + "version": "1.0.2", "title": "Anchors List", "category": "widgets", "description": "Displays the list of anchors placed on your page.",