From 9860a184cdd29650e263403caad59e04b602cebd Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 29 Mar 2026 15:19:53 +0200 Subject: [PATCH] Add product category tree sidebar to archive and single product pages (v0.1.7) Hierarchical category navigation with collapsible sub-levels up to 3 levels deep, using Bootstrap 5 list-group and collapse components. Sidebar renders on both archive/shop and single product pages with responsive offcanvas on mobile. Active category highlighting and ancestor auto-expand for intuitive navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 14 +++ CLAUDE.md | 26 +++++- assets/css/wc-bootstrap.css | 47 ++++++++++ functions.php | 85 +++++++++++++++++++ style.css | 2 +- templates/global/category-tree-node.html.twig | 46 ++++++++++ templates/global/category-tree.html.twig | 34 ++++++++ woocommerce/archive-product.php | 22 ++++- woocommerce/single-product.php | 64 +++++++++++++- 9 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 templates/global/category-tree-node.html.twig create mode 100644 templates/global/category-tree.html.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index 9232024..b4daccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [0.1.7] - 2026-03-29 + +### Added + +- **Product category tree sidebar** on archive/shop and single product pages: hierarchical navigation with collapsible sub-levels up to 3 levels deep, using Bootstrap 5 list-group and collapse components +- **`wc_bootstrap_get_category_tree()`** (`functions.php`): Builds nested category array with `is_active`/`is_ancestor` flags for current page context, parent-indexed lookup for O(n) tree construction +- **Category tree Twig templates** (`global/category-tree.html.twig`, `global/category-tree-node.html.twig`): Recursive rendering with "All products" link, product count badges, chevron toggle buttons, and `aria-current="page"` accessibility +- **Single product sidebar** (`woocommerce/single-product.php`): Added responsive sidebar layout (`col-lg-3` + `col-lg-9`) with offcanvas on mobile, matching the archive page pattern +- **Category tree CSS** (`wc-bootstrap.css`): Hover states, active highlighting (primary color), chevron rotation animation, nested indentation, dark mode compatible via Bootstrap CSS variables + +### Changed + +- **Archive sidebar always visible** (`woocommerce/archive-product.php`): Sidebar now renders when categories exist, not only when widgets are configured. Category tree renders above any widgets. + ## [0.1.6] - 2026-03-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 48944ff..eb6f706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -345,10 +345,34 @@ The child theme inherits from `wp-bootstrap` via WordPress `Template: wp-bootstr ## Version History -Current version: **v0.1.6** +Current version: **v0.1.7** ## Session History +### 2026-03-29 — v0.1.7 Add Product Category Tree Sidebar + +**Scope:** Added a hierarchical product category tree sidebar to archive/shop and single product pages, with collapsible sub-levels up to 3 levels deep. + +**Files created (2):** + +- `templates/global/category-tree.html.twig` — Main category tree template with "All products" link and Bootstrap list-group wrapper +- `templates/global/category-tree-node.html.twig` — Recursive node template with collapsible children, count badges, chevron toggle buttons + +**Files modified (5):** + +- `functions.php` — Added `wc_bootstrap_get_category_tree()`: builds hierarchical category array using parent-indexed lookup map, with `is_active`/`is_ancestor` flags based on current query; supports up to 3 nesting levels +- `woocommerce/archive-product.php` — Sidebar now renders when categories exist (not just when widgets are configured); category tree rendered above widgets via TwigService +- `woocommerce/single-product.php` — Added responsive sidebar layout (`col-lg-3` + `col-lg-9`) with offcanvas on mobile, matching archive page pattern; wraps product content in grid +- `assets/css/wc-bootstrap.css` — Category tree styles: hover states, active highlighting (primary color), chevron rotation animation, nested list indentation, dark mode compatible via Bootstrap CSS variables +- `style.css` — Version bump 0.1.6 → 0.1.7 + +**Key decisions:** + +- **Programmatic tree over widget:** Category tree renders from `get_terms()` data, not dependent on widgets being configured in WP admin. Widgets still render below the tree if configured. +- **Recursive Twig `{% include %}` for nesting:** `category-tree-node.html.twig` includes itself for child categories, keeping the template DRY and handling arbitrary depth (capped at 3 by PHP). +- **`is_ancestor` auto-expand:** When viewing a subcategory, all ancestor categories auto-expand their collapse panels (`show` class + `aria-expanded="true"`), so the user sees the full path. +- **Shared sidebar pattern:** Both archive and single product pages use the same offcanvas-lg responsive pattern — full sidebar on desktop, slide-out panel on mobile. + ### 2026-03-01 — v0.1.6 Add PHPUnit Test Suite **Scope:** Added PHPUnit test infrastructure with Brain\Monkey for WordPress function mocking, covering both PHP classes (`TemplateOverride`, `WooCommerceExtension`). Added test job to Gitea CI pipeline. diff --git a/assets/css/wc-bootstrap.css b/assets/css/wc-bootstrap.css index 64742f1..48b5b4f 100644 --- a/assets/css/wc-bootstrap.css +++ b/assets/css/wc-bootstrap.css @@ -644,3 +644,50 @@ header.sticky-top.is-stuck { .woocommerce-product-gallery--without-images { opacity: 1 !important; } + +/* ========================================================================== + Category Tree Sidebar + Hierarchical product category navigation with collapsible sub-levels. + ========================================================================== */ + +.category-tree { + font-size: 0.9rem; +} + +.category-tree .list-group-item { + background-color: transparent; +} + +.category-tree-link { + color: var(--bs-body-color); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.category-tree-link:hover { + background-color: var(--bs-tertiary-bg); + color: var(--bs-body-color); +} + +.category-tree-link.active { + background-color: var(--bs-primary); + color: #fff; +} + +.category-tree-link.active .badge { + background-color: rgba(255, 255, 255, 0.2) !important; + color: #fff !important; +} + +/* Rotate chevron icon when collapsed/expanded */ +.category-tree-toggle .bi { + transition: transform 0.2s ease; +} + +.category-tree-toggle[aria-expanded="false"] .bi { + transform: rotate(-90deg); +} + +/* Nested list indentation */ +.category-tree .list-group-flush .list-group-flush { + padding-left: 0.75rem; +} diff --git a/functions.php b/functions.php index 9a7c7ee..be9b665 100644 --- a/functions.php +++ b/functions.php @@ -293,6 +293,91 @@ function wc_bootstrap_loop_columns(): int { } add_filter( 'loop_shop_columns', 'wc_bootstrap_loop_columns' ); +/** + * Build a hierarchical product category tree up to a given depth. + * + * Returns a nested array of category objects with children, suitable for + * rendering a multi-level navigation sidebar. Each node contains the + * WP_Term object plus a 'children' array and an 'is_active'/'is_ancestor' + * flag based on the current query. + * + * @since 0.1.7 + * + * @param int $max_depth Maximum nesting depth (1 = top-level only, 3 = three levels). + * @return array Hierarchical category tree. + */ +function wc_bootstrap_get_category_tree( int $max_depth = 3 ): array { + $terms = get_terms( [ + 'taxonomy' => 'product_cat', + 'hide_empty' => true, + 'orderby' => 'name', + 'order' => 'ASC', + ] ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return []; + } + + // Determine the currently viewed category (if any). + $current_term_id = 0; + $ancestor_ids = []; + + if ( is_product_category() ) { + $queried = get_queried_object(); + if ( $queried instanceof \WP_Term ) { + $current_term_id = $queried->term_id; + $ancestor_ids = get_ancestors( $current_term_id, 'product_cat', 'taxonomy' ); + } + } elseif ( is_product() ) { + // On single product pages, highlight the first assigned category. + global $product; + if ( $product ) { + $cat_ids = $product->get_category_ids(); + if ( ! empty( $cat_ids ) ) { + $current_term_id = $cat_ids[0]; + $ancestor_ids = get_ancestors( $current_term_id, 'product_cat', 'taxonomy' ); + } + } + } + + // Index terms by parent for efficient tree building. + $by_parent = []; + foreach ( $terms as $term ) { + $by_parent[ $term->parent ][] = $term; + } + + /** + * Recursively build tree nodes from the parent-indexed map. + */ + $build = function ( int $parent_id, int $depth ) use ( &$build, $by_parent, $max_depth, $current_term_id, $ancestor_ids ): array { + if ( $depth > $max_depth || ! isset( $by_parent[ $parent_id ] ) ) { + return []; + } + + $nodes = []; + foreach ( $by_parent[ $parent_id ] as $term ) { + $children = $build( $term->term_id, $depth + 1 ); + $is_active = ( $term->term_id === $current_term_id ); + $is_ancestor = in_array( $term->term_id, $ancestor_ids, true ); + + $nodes[] = [ + 'term_id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'count' => $term->count, + 'url' => get_term_link( $term ), + 'is_active' => $is_active, + 'is_ancestor' => $is_ancestor, + 'children' => $children, + ]; + } + + return $nodes; + }; + + return $build( 0, 1 ); +} + /** * Remove WooCommerce's default sidebar hook. * diff --git a/style.css b/style.css index ace1e2a..d9e13de 100644 --- a/style.css +++ b/style.css @@ -7,7 +7,7 @@ Description: A Bootstrap 5 child theme for WP Bootstrap that overrides all WooCo Requires at least: 6.7 Tested up to: 6.7 Requires PHP: 8.3 -Version: 0.1.6 +Version: 0.1.7 License: GNU General Public License v2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html Template: wp-bootstrap diff --git a/templates/global/category-tree-node.html.twig b/templates/global/category-tree-node.html.twig new file mode 100644 index 0000000..8295c5c --- /dev/null +++ b/templates/global/category-tree-node.html.twig @@ -0,0 +1,46 @@ +{# + # Single Category Tree Node (recursive) + # + # Renders one category with optional collapsible children. + # Included recursively by category-tree.html.twig. + # + # Expected context: + # node - Category node from wc_bootstrap_get_category_tree() + # level - Current nesting depth (1-3) + # + # @package WcBootstrap + # @since 0.1.7 + #} + +
  • +
    + + + {{ node.name }} + + {{ node.count }} + + {% if node.children is not empty %} + + {% endif %} +
    + + {% if node.children is not empty %} +
      + {% for child in node.children %} + {% include 'global/category-tree-node.html.twig' with { node: child, level: level + 1 } only %} + {% endfor %} +
    + {% endif %} +
  • diff --git a/templates/global/category-tree.html.twig b/templates/global/category-tree.html.twig new file mode 100644 index 0000000..773a985 --- /dev/null +++ b/templates/global/category-tree.html.twig @@ -0,0 +1,34 @@ +{# + # Product Category Tree (Bootstrap 5) + # + # Renders a hierarchical product category navigation with collapsible + # sub-levels, up to 3 levels deep. Uses Bootstrap 5 list-group and + # collapse components for responsive tree navigation. + # + # Expected context: + # categories - Hierarchical array from wc_bootstrap_get_category_tree() + # shop_url - URL to the main shop page + # + # @package WcBootstrap + # @since 0.1.7 + #} + +{% if categories is not empty %} + +{% endif %} diff --git a/woocommerce/archive-product.php b/woocommerce/archive-product.php index b6c1be3..41c3d72 100644 --- a/woocommerce/archive-product.php +++ b/woocommerce/archive-product.php @@ -25,7 +25,9 @@ woocommerce_breadcrumb(); // Fire structured data hook (normally on woocommerce_before_main_content at priority 30). do_action( 'woocommerce_shop_loop_header' ); -$has_sidebar = is_active_sidebar( 'shop-sidebar' ); +$has_widgets = is_active_sidebar( 'shop-sidebar' ); +$category_tree = wc_bootstrap_get_category_tree(); +$has_sidebar = $has_widgets || ! empty( $category_tree ); if ( woocommerce_product_loop() ) { @@ -62,7 +64,23 @@ if ( woocommerce_product_loop() ) {
    - + render( 'global/category-tree.html.twig', [ + 'categories' => $category_tree, + 'shop_url' => wc_get_page_permalink( 'shop' ), + 'current_cat' => $current_cat, + ] ); + } + + // Render any configured widgets. + if ( $has_widgets ) { + dynamic_sidebar( 'shop-sidebar' ); + } + ?>
    diff --git a/woocommerce/single-product.php b/woocommerce/single-product.php index e70ff13..3ec4d63 100644 --- a/woocommerce/single-product.php +++ b/woocommerce/single-product.php @@ -22,10 +22,66 @@ defined( 'ABSPATH' ) || exit; */ do_action( 'woocommerce_before_main_content' ); -while ( have_posts() ) { - the_post(); - wc_get_template_part( 'content', 'single-product' ); -} +$category_tree = wc_bootstrap_get_category_tree(); +$has_widgets = is_active_sidebar( 'shop-sidebar' ); +$has_sidebar = ! empty( $category_tree ) || $has_widgets; + +?> +
    + + +
    + +
    + + + + +
    +
    +