3 Commits

Author SHA1 Message Date
1e7f82615b Restyle category tree to idiomatic Bootstrap list-group pattern (v0.1.8)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 48s
Create Release Package / PHPUnit Tests (push) Successful in 56s
Create Release Package / Build Release (push) Successful in 1m4s
Switch from custom link classes to Bootstrap's native list-group-item-action
pattern. Replace bold primary-colored active background with subtle tertiary
background, left accent border, and semibold text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:42:35 +02:00
9860a184cd Add product category tree sidebar to archive and single product pages (v0.1.7)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m4s
Create Release Package / PHPUnit Tests (push) Successful in 50s
Create Release Package / Build Release (push) Successful in 58s
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>
2026-03-29 15:19:53 +02:00
5e4af247fa Make PHP version configurable in Dockerfile
Add BuildKit syntax directive and PHP_VERSION build arg (default 8.4)
to allow building with different PHP versions without editing the file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:55:52 +01:00
10 changed files with 342 additions and 9 deletions

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.1.8] - 2026-03-29
### Changed
- **Category tree restyled** (`category-tree.html.twig`, `category-tree-node.html.twig`): Switched from custom link classes to Bootstrap's native `list-group-item list-group-item-action` pattern with nested collapse divs for a more idiomatic Bootstrap look
- **Subtler active state** (`wc-bootstrap.css`): Replaced bold primary-colored background with light background (`var(--bs-tertiary-bg)`), left accent border (`3px solid var(--bs-primary)`), and semibold text for a less prominent highlight
## [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 ## [0.1.6] - 2026-03-01
### Added ### Added

View File

@@ -345,10 +345,45 @@ The child theme inherits from `wp-bootstrap` via WordPress `Template: wp-bootstr
## Version History ## Version History
Current version: **v0.1.6** Current version: **v0.1.8**
## Session History ## Session History
### 2026-03-29 — v0.1.8 Restyle Category Tree to Idiomatic Bootstrap
**Scope:** Restyled the category tree sidebar to use Bootstrap's native `list-group-item-action` pattern with subtler active highlighting.
**Files modified (4):**
- `templates/global/category-tree.html.twig` — Switched to `list-group-item list-group-item-action` links in a `list-group-flush` container
- `templates/global/category-tree-node.html.twig` — Replaced nested `<ul>` lists with `<div>` wrappers and Bootstrap `collapse` divs; level indentation via inline `padding-left`
- `assets/css/wc-bootstrap.css` — Replaced primary-colored active background with subtle tertiary background, left accent border, and semibold text; removed unused `.category-tree-link` rules
- `style.css` — Version bump 0.1.7 → 0.1.8
### 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 ### 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. **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.

View File

@@ -644,3 +644,39 @@ header.sticky-top.is-stuck {
.woocommerce-product-gallery--without-images { .woocommerce-product-gallery--without-images {
opacity: 1 !important; opacity: 1 !important;
} }
/* ==========================================================================
Category Tree Sidebar
Hierarchical product category navigation with collapsible sub-levels.
Uses Bootstrap list-group-item-action with subtle active styling.
========================================================================== */
.category-tree {
font-size: 0.9rem;
}
.category-tree .list-group-item {
background-color: transparent;
}
/* Subtle active state: fw-semibold + left accent border instead of
full primary background, keeping the look understated. */
.category-tree .list-group-item.active {
background-color: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border-left: 3px solid var(--bs-primary);
font-weight: 600;
}
.category-tree .list-group-item.active small {
color: var(--bs-body-secondary) !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);
}

View File

@@ -1,3 +1,6 @@
#syntax=docker/dockerfile:1
ARG PHP_VERSION=8.4
############################################################################### ###############################################################################
# Stage 1 — Download WooCommerce from WordPress.org # Stage 1 — Download WooCommerce from WordPress.org
############################################################################### ###############################################################################
@@ -48,7 +51,7 @@ RUN composer install --no-dev --optimize-autoloader --no-interaction
############################################################################### ###############################################################################
# Stage 4 — Final WordPress image # Stage 4 — Final WordPress image
############################################################################### ###############################################################################
FROM wordpress:php8.4 AS wp_runtime FROM wordpress:php${PHP_VERSION} AS wp_runtime
RUN curl -sSfL -o /usr/local/bin/wp \ RUN curl -sSfL -o /usr/local/bin/wp \
https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \ https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \

View File

@@ -293,6 +293,91 @@ function wc_bootstrap_loop_columns(): int {
} }
add_filter( 'loop_shop_columns', 'wc_bootstrap_loop_columns' ); 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. * Remove WooCommerce's default sidebar hook.
* *

View File

@@ -7,7 +7,7 @@ Description: A Bootstrap 5 child theme for WP Bootstrap that overrides all WooCo
Requires at least: 6.7 Requires at least: 6.7
Tested up to: 6.7 Tested up to: 6.7
Requires PHP: 8.3 Requires PHP: 8.3
Version: 0.1.6 Version: 0.1.8
License: GNU General Public License v2 or later License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
Template: wp-bootstrap Template: wp-bootstrap

View File

@@ -0,0 +1,47 @@
{#
# Single Category Tree Node (recursive)
#
# Renders one category as a list-group-item-action link with optional
# collapsible nested list for children. Included recursively.
#
# Expected context:
# node - Category node from wc_bootstrap_get_category_tree()
# level - Current nesting depth (1-3)
#
# @package WcBootstrap
# @since 0.1.7
#}
{% set has_children = node.children is not empty %}
{% set is_open = node.is_active or node.is_ancestor %}
<div class="category-tree-item">
<div class="d-flex align-items-center">
<a href="{{ node.url }}"
class="list-group-item list-group-item-action border-0 py-1 flex-grow-1{% if node.is_active %} active{% endif %}"
style="padding-left: {{ 0.5 + (level - 1) * 1 }}rem;"
{% if node.is_active %}aria-current="page"{% endif %}>
{{ node.name }}
<small class="text-body-secondary ms-1">({{ node.count }})</small>
</a>
{% if has_children %}
<button class="btn btn-sm btn-link text-body-secondary p-0 px-2 category-tree-toggle"
type="button"
data-bs-toggle="collapse"
data-bs-target="#cat-{{ node.term_id }}"
aria-expanded="{{ is_open ? '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 has_children %}
<div class="collapse{{ is_open ? ' 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 %}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,32 @@
{#
# Product Category Tree (Bootstrap 5)
#
# Renders a hierarchical product category navigation using Bootstrap
# list-group with nested sub-lists and action links.
#
# Expected context:
# categories - Hierarchical array from wc_bootstrap_get_category_tree()
# shop_url - URL to the main shop page
# current_cat - Term ID of the currently viewed category (0 = 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>
<div class="list-group list-group-flush category-tree">
<a href="{{ shop_url }}"
class="list-group-item list-group-item-action border-0 px-2 py-1{% if current_cat == 0 %} active{% endif %}">
{{ __('All products', 'wc-bootstrap') }}
</a>
{% for cat in categories %}
{% include 'global/category-tree-node.html.twig' with { node: cat, level: 1 } only %}
{% endfor %}
</div>
</nav>
{% endif %}

View File

@@ -25,7 +25,9 @@ woocommerce_breadcrumb();
// Fire structured data hook (normally on woocommerce_before_main_content at priority 30). // Fire structured data hook (normally on woocommerce_before_main_content at priority 30).
do_action( 'woocommerce_shop_loop_header' ); 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() ) { 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> <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
</div> </div>
<div class="offcanvas-body p-0"> <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>
</div> </div>
</aside> </aside>

View File

@@ -22,10 +22,66 @@ defined( 'ABSPATH' ) || exit;
*/ */
do_action( 'woocommerce_before_main_content' ); do_action( 'woocommerce_before_main_content' );
while ( have_posts() ) { $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(); the_post();
wc_get_template_part( 'content', 'single-product' ); wc_get_template_part( 'content', 'single-product' );
} }
?>
</div>
</div>
<?php
/** /**
* Hook: woocommerce_after_main_content. * Hook: woocommerce_after_main_content.