You've already forked wc-bootstrap
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) <noreply@anthropic.com>
This commit is contained in:
14
CHANGELOG.md
14
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
|
||||
|
||||
26
CLAUDE.md
26
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
46
templates/global/category-tree-node.html.twig
Normal file
46
templates/global/category-tree-node.html.twig
Normal file
@@ -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
|
||||
#}
|
||||
|
||||
<li class="list-group-item px-0 py-1 border-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<a href="{{ node.url }}"
|
||||
class="category-tree-link d-block text-decoration-none py-1 px-2 rounded flex-grow-1{% if node.is_active %} active fw-semibold{% endif %}"
|
||||
{% if node.is_active %}aria-current="page"{% endif %}>
|
||||
<span class="ps-{{ (level - 1) * 2 }}">
|
||||
{{ node.name }}
|
||||
</span>
|
||||
<span class="badge bg-body-secondary text-body-secondary rounded-pill ms-1 small">{{ node.count }}</span>
|
||||
</a>
|
||||
{% if node.children is not empty %}
|
||||
<button class="btn btn-sm btn-link text-body-secondary p-0 ms-1 category-tree-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#cat-{{ node.term_id }}"
|
||||
aria-expanded="{{ node.is_active or node.is_ancestor ? 'true' : 'false' }}"
|
||||
aria-controls="cat-{{ node.term_id }}"
|
||||
aria-label="{{ __('Toggle subcategories', 'wc-bootstrap') }}">
|
||||
<i class="bi bi-chevron-down small" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if node.children is not empty %}
|
||||
<ul class="list-group list-group-flush collapse{{ node.is_active or node.is_ancestor ? ' show' : '' }}"
|
||||
id="cat-{{ node.term_id }}">
|
||||
{% for child in node.children %}
|
||||
{% include 'global/category-tree-node.html.twig' with { node: child, level: level + 1 } only %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
34
templates/global/category-tree.html.twig
Normal file
34
templates/global/category-tree.html.twig
Normal file
@@ -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 %}
|
||||
<nav aria-label="{{ __('Product categories', 'wc-bootstrap') }}">
|
||||
<h3 class="sidebar-heading h6 text-uppercase fw-semibold">
|
||||
{{ __('Categories', 'wc-bootstrap') }}
|
||||
</h3>
|
||||
|
||||
<ul class="list-group list-group-flush category-tree">
|
||||
<li class="list-group-item px-0 py-1">
|
||||
<a href="{{ shop_url }}"
|
||||
class="category-tree-link d-block text-decoration-none py-1 px-2 rounded{% if current_cat == 0 %} active fw-semibold{% endif %}">
|
||||
{{ __('All products', 'wc-bootstrap') }}
|
||||
</a>
|
||||
</li>
|
||||
{% for cat in categories %}
|
||||
{% include 'global/category-tree-node.html.twig' with { node: cat, level: 1 } only %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -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() ) {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-0">
|
||||
<?php dynamic_sidebar( 'shop-sidebar' ); ?>
|
||||
<?php
|
||||
// Render the category tree.
|
||||
if ( ! empty( $category_tree ) ) {
|
||||
$twig = \WPBootstrap\Twig\TwigService::getInstance();
|
||||
$current_cat = is_product_category() ? get_queried_object_id() : 0;
|
||||
echo $twig->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' );
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -22,10 +22,66 @@ defined( 'ABSPATH' ) || exit;
|
||||
*/
|
||||
do_action( 'woocommerce_before_main_content' );
|
||||
|
||||
$category_tree = wc_bootstrap_get_category_tree();
|
||||
$has_widgets = is_active_sidebar( 'shop-sidebar' );
|
||||
$has_sidebar = ! empty( $category_tree ) || $has_widgets;
|
||||
|
||||
?>
|
||||
<div class="row g-4">
|
||||
<?php if ( $has_sidebar ) : ?>
|
||||
<aside class="col-lg-3 mb-4 mb-lg-0">
|
||||
<button class="btn btn-outline-secondary w-100 d-lg-none mb-3"
|
||||
type="button"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#shopSidebar"
|
||||
aria-controls="shopSidebar">
|
||||
<i class="bi bi-list me-1" aria-hidden="true"></i>
|
||||
<?php esc_html_e( 'Categories', 'wc-bootstrap' ); ?>
|
||||
</button>
|
||||
|
||||
<div class="offcanvas-lg offcanvas-start"
|
||||
id="shopSidebar"
|
||||
tabindex="-1"
|
||||
aria-labelledby="shopSidebarLabel">
|
||||
<div class="offcanvas-header d-lg-none">
|
||||
<h5 class="offcanvas-title" id="shopSidebarLabel">
|
||||
<?php esc_html_e( 'Categories', 'wc-bootstrap' ); ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-0">
|
||||
<?php
|
||||
if ( ! empty( $category_tree ) ) {
|
||||
$twig = \WPBootstrap\Twig\TwigService::getInstance();
|
||||
echo $twig->render( 'global/category-tree.html.twig', [
|
||||
'categories' => $category_tree,
|
||||
'shop_url' => wc_get_page_permalink( 'shop' ),
|
||||
'current_cat' => 0,
|
||||
] );
|
||||
}
|
||||
|
||||
if ( $has_widgets ) {
|
||||
dynamic_sidebar( 'shop-sidebar' );
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="col-lg-9">
|
||||
<?php else : ?>
|
||||
<div class="col-12">
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
while ( have_posts() ) {
|
||||
the_post();
|
||||
wc_get_template_part( 'content', 'single-product' );
|
||||
}
|
||||
?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Hook: woocommerce_after_main_content.
|
||||
|
||||
Reference in New Issue
Block a user