-
- {{ await comp.diffuse.status() }}
+
- {{ await comp.nav({ url, facets }) }}
-
{{ content }}
diff --git a/src/catalogue.vto b/src/catalogue.vto
new file mode 100644
index 00000000..ac471bab
--- /dev/null
+++ b/src/catalogue.vto
@@ -0,0 +1,11 @@
+---
+layout: layouts/kitchen.vto
+base: ../
+title: Catalogue | Diffuse
+---
+
+
Featured
+
+
+ {{ await comp.grid({ id: "catalogue", items: facets }) }}
+
diff --git a/src/common/pages/grid.js b/src/common/pages/grid.js
index 7610c984..6c0cc7bd 100644
--- a/src/common/pages/grid.js
+++ b/src/common/pages/grid.js
@@ -10,37 +10,104 @@ import { output } from "./output.js";
export function setupFilter() {
/** @type {NodeListOf} */
- const buttons = document.querySelectorAll(".grid-filter button[data-filter]");
+ const kindButtons = document.querySelectorAll(
+ ".grid-filter button[data-filter]",
+ );
/** @type {NodeListOf} */
const items = document.querySelectorAll(".grid-item");
- /** @param {string} filter */
- function applyFilter(filter) {
- buttons.forEach((b) => {
- if (b.dataset.filter === filter) b.classList.remove("button--transparent");
- else b.classList.add("button--transparent");
+ // Build category buttons from the categories present in the current grid
+ const categoriesEl = document.querySelector(".grid-filter--categories");
+ const categories = /** @type {string[]} */ (
+ [...new Set([...items].map((i) => i.dataset.category).filter(Boolean))]
+ .sort()
+ );
+
+ /** @type {HTMLElement | null} */
+ let categoryLabelEl = null;
+ /** @type {HTMLElement | null} */
+ let categoryMenuEl = null;
+
+ if (categoriesEl && categories.length > 1) {
+ categoryLabelEl = document.createElement("span");
+ categoryLabelEl.textContent = "All";
+
+ const triggerBtn = document.createElement("button");
+ triggerBtn.className = "button--border button--tiny button--transparent";
+ triggerBtn.setAttribute("popovertarget", "grid-category-menu");
+ const span = document.createElement("span");
+ span.className = "with-icon";
+ span.appendChild(categoryLabelEl);
+ const caret = document.createElement("i");
+ caret.className = "ph-bold ph-caret-down";
+ span.appendChild(caret);
+ triggerBtn.appendChild(span);
+
+ categoryMenuEl = document.createElement("div");
+ categoryMenuEl.id = "grid-category-menu";
+ categoryMenuEl.className = "dropdown";
+ categoryMenuEl.setAttribute("popover", "");
+
+ for (const cat of ["all", ...categories]) {
+ const item = document.createElement("button");
+ item.dataset.category = cat;
+ item.textContent = cat === "all" ? "All" : cat;
+ item.addEventListener("click", () => {
+ activeCategory = cat;
+ const url = new URL(location.href);
+ if (cat === "all") url.searchParams.delete("category");
+ else url.searchParams.set("category", cat);
+ history.replaceState(null, "", url);
+ categoryMenuEl?.hidePopover();
+ applyFilter(activeKind, activeCategory);
+ });
+ categoryMenuEl.appendChild(item);
+ }
+
+ categoriesEl.appendChild(triggerBtn);
+ categoriesEl.appendChild(categoryMenuEl);
+ }
+
+ let activeKind = "all";
+ let activeCategory = "all";
+
+ /**
+ * @param {string} kind
+ * @param {string} category
+ */
+ function applyFilter(kind, category) {
+ kindButtons.forEach((b) => {
+ const transparent = b.dataset.filter !== kind;
+ if (b.classList.contains("button--transparent") !== transparent) {
+ b.classList.toggle("button--transparent", transparent);
+ }
});
+ if (categoryLabelEl) {
+ categoryLabelEl.textContent = category === "all" ? "All" : category;
+ }
items.forEach((item) => {
- const kind = item.dataset.kind;
- const show = filter === "all" || kind === filter;
- item.hidden = !show;
+ const kindMatch = kind === "all" || item.dataset.kind === kind;
+ const catMatch = category === "all" || item.dataset.category === category;
+ item.hidden = !(kindMatch && catMatch);
});
}
- buttons.forEach((b) => {
+ kindButtons.forEach((b) => {
b.addEventListener("click", () => {
- const filter = b.dataset.filter ?? "all";
+ activeKind = b.dataset.filter ?? "all";
const url = new URL(location.href);
- if (filter === "all") url.searchParams.delete("filter");
- else url.searchParams.set("filter", filter);
+ if (activeKind === "all") url.searchParams.delete("filter");
+ else url.searchParams.set("filter", activeKind);
history.replaceState(null, "", url);
- applyFilter(filter);
+ applyFilter(activeKind, activeCategory);
});
});
- const initial = new URL(location.href).searchParams.get("filter") ?? "all";
- applyFilter(initial);
+ const params = new URL(location.href).searchParams;
+ activeKind = params.get("filter") ?? "all";
+ activeCategory = params.get("category") ?? "all";
+ applyFilter(activeKind, activeCategory);
}
////////////////////////////////////////////
diff --git a/src/common/pages/nav.js b/src/common/pages/nav.js
index de081d47..1d91123a 100644
--- a/src/common/pages/nav.js
+++ b/src/common/pages/nav.js
@@ -1,3 +1,21 @@
+/**
+ * Updates the active state of nav links based on the current URL.
+ * Required because the nav now lives in and is not replaced by PPR.
+ */
+export function updateActiveLinks() {
+ const nav = document.getElementById("diffuse-nav");
+ const menu = document.getElementById("nav-overflow-menu");
+
+ for (const container of [nav, menu]) {
+ if (!container) continue;
+ for (const link of container.querySelectorAll("a[href]")) {
+ const a = /** @type {HTMLAnchorElement} */ (link);
+ const isActive = new URL(a.href).pathname === location.pathname;
+ a.classList.toggle("button--transparent", !isActive);
+ }
+ }
+}
+
export function update() {
const nav = document.getElementById("diffuse-nav");
const btn = document.getElementById("nav-overflow-btn");
@@ -11,16 +29,29 @@ export function update() {
for (const item of items) item.style.display = "";
btn.style.display = "none";
+ // Available width is the nav-container's width (nav may be content-sized in column layouts)
+ const availableWidth = (nav.parentElement ?? nav).clientWidth;
+
+ // Measure total content width directly — scrollWidth is unreliable with
+ // justify-content: flex-end because overflow spills left (negative direction)
+ // and isn't reflected in scrollWidth.
+ const gap = parseFloat(getComputedStyle(nav).columnGap) || 0;
+ const contentWidth = () => {
+ const visible = items.filter((el) => el.style.display !== "none");
+ return visible.reduce((acc, el) => acc + el.offsetWidth, 0)
+ + gap * Math.max(0, visible.length - 1);
+ };
+
// No overflow — nothing to do
- if (nav.scrollWidth <= nav.clientWidth) return;
+ if (contentWidth() <= availableWidth) return;
- // Show button (nav shrinks to accommodate it via flex)
+ // Show button (takes up space; subtract its width from available)
btn.style.display = "";
- // Hide items from right until nav content fits
+ // Hide items from right until content fits
const hidden = [];
for (let i = items.length - 1; i >= 0; i--) {
- if (nav.scrollWidth <= nav.clientWidth) break;
+ if (contentWidth() <= availableWidth - btn.offsetWidth) break;
items[i].style.display = "none";
hidden.unshift(items[i]);
}
@@ -43,7 +74,14 @@ export function update() {
}
}
+let _observer = /** @type {ResizeObserver | undefined} */ (undefined);
+
export function watchResize() {
const nav = document.getElementById("diffuse-nav");
- if (nav) new ResizeObserver(update).observe(nav);
+ if (!nav) return;
+ _observer?.disconnect();
+ _observer = new ResizeObserver(update);
+ // Observe the container — in column layouts the nav is content-sized and
+ // won't resize, but the container does as the header width changes.
+ _observer.observe(nav.parentElement ?? nav);
}
diff --git a/src/common/pages/ppr.js b/src/common/pages/ppr.js
index a80bc7d1..a58724ce 100644
--- a/src/common/pages/ppr.js
+++ b/src/common/pages/ppr.js
@@ -28,6 +28,7 @@ async function initJsBasedOnPage(url) {
const path = relativePathname(url.pathname);
Nav.update();
+ Nav.updateActiveLinks();
Nav.watchResize();
Grid.setupFilter();
@@ -72,6 +73,7 @@ function navigateHandler(event) {
const url = new URL(event.destination.url);
if (url.origin !== location.origin) return;
+ if (url.pathname === location.pathname) return;
// Only intercept paths one level deep
const relative = relativePathname(url.pathname);
diff --git a/src/styles/diffuse/page.css b/src/styles/diffuse/page.css
index f3c63161..2508b28c 100644
--- a/src/styles/diffuse/page.css
+++ b/src/styles/diffuse/page.css
@@ -75,6 +75,14 @@ header {
display: flex;
gap: var(--space-lg);
}
+
+ .header__nav {
+ align-items: center;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ flex: 1;
+ gap: var(--space-lg);
+ }
}
header,
@@ -197,7 +205,8 @@ dialog {
background: transparent;
}
- & > a {
+ & > a,
+ & > button {
cursor: pointer;
display: flex;
min-width: var(--space-3xl);
@@ -209,7 +218,20 @@ dialog {
}
}
- & > a:not(:last-child) {
+ & > button {
+ background: none;
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+ text-align: left;
+ width: 100%;
+ }
+
+ & > a:not(:last-child),
+ & > button:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
@@ -406,6 +428,7 @@ textarea {
font-weight: 600;
margin-right: var(--space-2xs);
opacity: 0.4;
+ white-space: nowrap;
@media (min-width: 31.5rem) {
display: block;
@@ -425,6 +448,12 @@ textarea {
opacity: 0;
transition: opacity 250ms;
}
+
+ .grid-filter--categories {
+ align-items: center;
+ display: flex;
+ gap: var(--space-2xs);
+ }
}
/**
@@ -760,11 +789,10 @@ ul li::marker {
.nav-container {
align-items: center;
- border-bottom: 1px solid var(--border-color);
display: flex;
+ flex: 1;
gap: var(--space-xs);
- margin-top: var(--space-md);
- padding-bottom: var(--space-md);
+ min-width: 0;
}
nav {
@@ -774,6 +802,7 @@ nav {
flex-wrap: nowrap;
font-size: var(--fs-sm);
gap: var(--space-xs);
+ justify-content: flex-end;
overflow: hidden;
.button {