@dogsbay/docs-layout 0.2.0-beta.71 → 0.2.0-beta.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/DocsLayout.astro +1 -0
- package/src/SearchDialog.astro +247 -28
- package/src/search-facets.ts +433 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/docs-layout",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.73",
|
|
4
4
|
"description": "Standard documentation layout components for Dogsbay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"./json-ld": "./src/json-ld.ts"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@dogsbay/ui": "0.2.0-beta.
|
|
33
|
-
"@dogsbay/primitives": "0.2.0-beta.
|
|
32
|
+
"@dogsbay/ui": "0.2.0-beta.73",
|
|
33
|
+
"@dogsbay/primitives": "0.2.0-beta.73"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"vitest": "^3.0.0"
|
package/src/DocsLayout.astro
CHANGED
|
@@ -792,6 +792,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
792
792
|
{!hideSearch && (
|
|
793
793
|
<SearchDialog
|
|
794
794
|
pagefindUrl={pagefindUrl}
|
|
795
|
+
navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
|
|
795
796
|
taxonomyDisplay={taxonomyDisplay}
|
|
796
797
|
/>
|
|
797
798
|
)}
|
package/src/SearchDialog.astro
CHANGED
|
@@ -37,6 +37,17 @@ interface Props {
|
|
|
37
37
|
* search dialog throws on first open with a clear console error.
|
|
38
38
|
*/
|
|
39
39
|
pagefindUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
|
|
42
|
+
* Used to render hierarchical facets in document order: when the
|
|
43
|
+
* `category` facet (or any taxonomy flagged `hierarchical: true`)
|
|
44
|
+
* is segment-encoded — i.e. its values are individual path segments
|
|
45
|
+
* not slash-joined — the tree shape is derived from nav. Without
|
|
46
|
+
* `navUrl`, hierarchical segment-encoded facets fall back to a flat
|
|
47
|
+
* list (still functional, just no tree structure). Slash-encoded
|
|
48
|
+
* taxonomies don't need nav and build trees from their values directly.
|
|
49
|
+
*/
|
|
50
|
+
navUrl?: string;
|
|
40
51
|
/** Placeholder text for the search input */
|
|
41
52
|
placeholder?: string;
|
|
42
53
|
/**
|
|
@@ -50,6 +61,7 @@ interface Props {
|
|
|
50
61
|
|
|
51
62
|
const {
|
|
52
63
|
pagefindUrl,
|
|
64
|
+
navUrl,
|
|
53
65
|
placeholder = "Search docs...",
|
|
54
66
|
taxonomyDisplay,
|
|
55
67
|
} = Astro.props;
|
|
@@ -58,6 +70,7 @@ const {
|
|
|
58
70
|
<dialog
|
|
59
71
|
data-search-dialog
|
|
60
72
|
data-pagefind-url={pagefindUrl}
|
|
73
|
+
data-nav-url={navUrl}
|
|
61
74
|
data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
|
|
62
75
|
class="fixed left-1/2 top-[10vh] z-50 w-[calc(100vw-2rem)] max-w-4xl -translate-x-1/2 rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/40 backdrop:backdrop-blur-sm"
|
|
63
76
|
>
|
|
@@ -161,13 +174,19 @@ const {
|
|
|
161
174
|
shapeFacets,
|
|
162
175
|
resolveFacetLabel,
|
|
163
176
|
resolveFacetTitle,
|
|
177
|
+
sortFacetNames,
|
|
164
178
|
filterStateToUrlParams,
|
|
165
179
|
parseFiltersFromUrl,
|
|
166
180
|
filterStateToPagefindFilters,
|
|
167
181
|
toggleFilter,
|
|
168
182
|
countActiveFilters,
|
|
183
|
+
buildFacetTree,
|
|
184
|
+
computeTreeState,
|
|
185
|
+
toggleTreeNode,
|
|
169
186
|
type FacetMap,
|
|
187
|
+
type FacetTreeNode,
|
|
170
188
|
type FilterState,
|
|
189
|
+
type NavLike,
|
|
171
190
|
type TaxonomyDisplayMap,
|
|
172
191
|
} from "./search-facets.js";
|
|
173
192
|
|
|
@@ -182,10 +201,15 @@ const {
|
|
|
182
201
|
meta: { title?: string };
|
|
183
202
|
sub_results?: Array<{ title: string; url: string; excerpt: string }>;
|
|
184
203
|
};
|
|
204
|
+
// Filter values match what filterStateToPagefindFilters emits:
|
|
205
|
+
// each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
|
|
206
|
+
// Pagefind also accepts other operator shapes (`all`/`none`/`not`,
|
|
207
|
+
// bare strings, bare arrays) but we only emit the `any` form.
|
|
208
|
+
type PagefindFilterValue = { any: string[] };
|
|
185
209
|
type PagefindModule = {
|
|
186
210
|
search(
|
|
187
211
|
query: string,
|
|
188
|
-
options?: { filters?: Record<string,
|
|
212
|
+
options?: { filters?: Record<string, PagefindFilterValue> },
|
|
189
213
|
): Promise<{ results: PagefindResult[] }>;
|
|
190
214
|
filters(): Promise<Record<string, Record<string, number>>>;
|
|
191
215
|
};
|
|
@@ -209,6 +233,15 @@ const {
|
|
|
209
233
|
let filters: FilterState = {};
|
|
210
234
|
let availableFacets: FacetMap = {};
|
|
211
235
|
|
|
236
|
+
// Nav data cached after first fetch — used to derive the
|
|
237
|
+
// segment-encoded hierarchical-facet tree (the auto-`category`
|
|
238
|
+
// case). Null while pending or absent. The fetch fires lazily
|
|
239
|
+
// the first time renderFacets() encounters a hierarchical facet,
|
|
240
|
+
// not eagerly on dialog open, so sites without hierarchical
|
|
241
|
+
// facets never pay for it.
|
|
242
|
+
let navData: NavLike[] | null = null;
|
|
243
|
+
let loadingNav: Promise<void> | null = null;
|
|
244
|
+
|
|
212
245
|
// Display config baked into a data attribute by the Astro
|
|
213
246
|
// template — parsed lazily.
|
|
214
247
|
const taxonomyDisplay: TaxonomyDisplayMap = (() => {
|
|
@@ -257,6 +290,49 @@ const {
|
|
|
257
290
|
return loadingPagefind;
|
|
258
291
|
}
|
|
259
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Lazy nav.json fetch, kicked off the first time a hierarchical
|
|
295
|
+
* facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
|
|
296
|
+
* one fetch per session, `same-origin` credentials so cookied
|
|
297
|
+
* mounts work, module-level cache.
|
|
298
|
+
*
|
|
299
|
+
* When the fetch resolves we re-call `renderFacets()` so the
|
|
300
|
+
* previously-flat segment-encoded facet upgrades to its tree
|
|
301
|
+
* shape — without this, a slow nav fetch would leave the facets
|
|
302
|
+
* stuck on the flat fallback until the user toggled a filter.
|
|
303
|
+
*
|
|
304
|
+
* Network errors are logged and swallowed; the helper's flat
|
|
305
|
+
* fallback keeps the dialog functional.
|
|
306
|
+
*/
|
|
307
|
+
async function ensureNavLoaded() {
|
|
308
|
+
if (navData !== null) return;
|
|
309
|
+
if (loadingNav) return loadingNav;
|
|
310
|
+
const url = dialog!.dataset.navUrl;
|
|
311
|
+
if (!url) return; // navUrl not provided — flat fallback stays
|
|
312
|
+
loadingNav = (async () => {
|
|
313
|
+
try {
|
|
314
|
+
const res = await fetch(url, { credentials: "same-origin" });
|
|
315
|
+
if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
|
|
316
|
+
const data = (await res.json()) as unknown;
|
|
317
|
+
navData = Array.isArray(data) ? (data as NavLike[]) : [];
|
|
318
|
+
// Re-render so hierarchical segment-encoded facets pick up
|
|
319
|
+
// the nav shape. Cheap — no Pagefind round-trip, just a
|
|
320
|
+
// DOM rebuild from `availableFacets`.
|
|
321
|
+
if (Object.keys(availableFacets).length > 0) {
|
|
322
|
+
renderFacets();
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.warn(
|
|
326
|
+
"[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
|
|
327
|
+
err,
|
|
328
|
+
);
|
|
329
|
+
// Mark as loaded-with-empty so we don't keep retrying.
|
|
330
|
+
navData = [];
|
|
331
|
+
}
|
|
332
|
+
})();
|
|
333
|
+
return loadingNav;
|
|
334
|
+
}
|
|
335
|
+
|
|
260
336
|
function escapeHtml(s: string): string {
|
|
261
337
|
return s
|
|
262
338
|
.replace(/&/g, "&")
|
|
@@ -333,54 +409,170 @@ const {
|
|
|
333
409
|
resultsBox!.innerHTML = html;
|
|
334
410
|
}
|
|
335
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Hierarchical-facet caches, rebuilt on every renderFacets() so
|
|
414
|
+
* they stay aligned with the current filter state + available
|
|
415
|
+
* facets. The trees are the source for `value → node` lookups
|
|
416
|
+
* during click handling (parent click expands selection to all
|
|
417
|
+
* descendants — needs the node to know what to add).
|
|
418
|
+
*/
|
|
419
|
+
const facetTrees = new Map<string, FacetTreeNode[]>();
|
|
420
|
+
const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Decide whether a facet renders as a tree. `category` defaults
|
|
424
|
+
* to hierarchical (auto-derived path segments are the canonical
|
|
425
|
+
* use case); explicit override via `taxonomyDisplay[name].hierarchical`
|
|
426
|
+
* wins both ways.
|
|
427
|
+
*/
|
|
428
|
+
function isHierarchicalFacet(name: string): boolean {
|
|
429
|
+
const flag = taxonomyDisplay[name]?.hierarchical;
|
|
430
|
+
if (typeof flag === "boolean") return flag;
|
|
431
|
+
return name === "category";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Flatten a tree into a `value → node` map so the click handler
|
|
436
|
+
* can look up the clicked node without re-walking the tree.
|
|
437
|
+
*/
|
|
438
|
+
function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
|
|
439
|
+
const out = new Map<string, FacetTreeNode>();
|
|
440
|
+
const visit = (n: FacetTreeNode): void => {
|
|
441
|
+
out.set(n.value, n);
|
|
442
|
+
for (const c of n.children) visit(c);
|
|
443
|
+
};
|
|
444
|
+
for (const r of nodes) visit(r);
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Recursive HTML for one tree node. Synthetic parents (`hasValue:
|
|
450
|
+
* false`) render as a clickable group header — checking them
|
|
451
|
+
* still cascades to descendants via toggleTreeNode. Indent scales
|
|
452
|
+
* with `node.depth`.
|
|
453
|
+
*/
|
|
454
|
+
function renderTreeNode(name: string, node: FacetTreeNode): string {
|
|
455
|
+
const state = computeTreeState(node, name, filters);
|
|
456
|
+
const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
457
|
+
const indent = node.depth * 12;
|
|
458
|
+
const label = escapeHtml(node.label);
|
|
459
|
+
const countText = node.hasValue ? `${node.count}` : "";
|
|
460
|
+
const stateAttr =
|
|
461
|
+
state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
|
|
462
|
+
const childrenHtml = node.children
|
|
463
|
+
.map((c) => renderTreeNode(name, c))
|
|
464
|
+
.join("");
|
|
465
|
+
return `
|
|
466
|
+
<li>
|
|
467
|
+
<label
|
|
468
|
+
for="${id}"
|
|
469
|
+
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
|
|
470
|
+
style="padding-left: ${0.5 + indent / 16}rem"
|
|
471
|
+
>
|
|
472
|
+
<input
|
|
473
|
+
type="checkbox"
|
|
474
|
+
id="${id}"
|
|
475
|
+
data-facet-name="${escapeHtml(name)}"
|
|
476
|
+
data-facet-value="${escapeHtml(node.value)}"
|
|
477
|
+
data-tree-node="true"
|
|
478
|
+
${state === "checked" ? "checked" : ""}
|
|
479
|
+
${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
|
|
480
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
481
|
+
/>
|
|
482
|
+
<span class="flex-1 truncate">${label}</span>
|
|
483
|
+
<span class="text-xs text-muted-foreground">${countText}</span>
|
|
484
|
+
</label>
|
|
485
|
+
${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
|
|
486
|
+
</li>
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
|
|
336
490
|
/**
|
|
337
491
|
* Build the facets sidebar. Runs once after Pagefind discovers
|
|
338
492
|
* filters, then again whenever filter state changes (so checkbox
|
|
339
493
|
* `checked` reflects current selections). When the corpus has no
|
|
340
494
|
* filters, the sidebar stays hidden — single-column layout.
|
|
495
|
+
*
|
|
496
|
+
* Branches per facet: hierarchical taxonomies (and the built-in
|
|
497
|
+
* `category` default) render as a tree; flat taxonomies keep the
|
|
498
|
+
* original checkbox-list shape. The hierarchical path falls back
|
|
499
|
+
* to a flat list automatically when segment-encoded values lack a
|
|
500
|
+
* `nav` source — preserves render-ability until Phase 3 wires the
|
|
501
|
+
* nav.json fetch.
|
|
341
502
|
*/
|
|
342
503
|
function renderFacets() {
|
|
343
|
-
const facetNames =
|
|
504
|
+
const facetNames = sortFacetNames(
|
|
505
|
+
Object.keys(availableFacets),
|
|
506
|
+
taxonomyDisplay,
|
|
507
|
+
);
|
|
344
508
|
if (facetNames.length === 0) {
|
|
345
509
|
facetsBox!.classList.add("hidden");
|
|
346
510
|
return;
|
|
347
511
|
}
|
|
348
512
|
facetsBox!.classList.remove("hidden");
|
|
349
513
|
|
|
514
|
+
facetTrees.clear();
|
|
515
|
+
facetNodesByValue.clear();
|
|
516
|
+
|
|
350
517
|
const activeCount = countActiveFilters(filters);
|
|
351
518
|
const clearAll = activeCount > 0
|
|
352
519
|
? `<button type="button" data-clear-filters class="mb-3 w-full rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">Clear all (${activeCount})</button>`
|
|
353
520
|
: "";
|
|
354
521
|
|
|
522
|
+
let needsNav = false;
|
|
355
523
|
const groups = facetNames
|
|
356
524
|
.map((name) => {
|
|
357
525
|
const entries = availableFacets[name];
|
|
358
526
|
const title = escapeHtml(resolveFacetTitle(name));
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
527
|
+
const hierarchical = isHierarchicalFacet(name);
|
|
528
|
+
|
|
529
|
+
let items: string;
|
|
530
|
+
if (hierarchical) {
|
|
531
|
+
// Slash-encoded values build a tree directly from their
|
|
532
|
+
// values; segment-encoded values need nav.json for
|
|
533
|
+
// structure. The helper detects which and falls back to
|
|
534
|
+
// a flat list when segment-encoded + no nav yet — phase 3
|
|
535
|
+
// upgrades to the real tree as soon as ensureNavLoaded
|
|
536
|
+
// resolves and re-renders.
|
|
537
|
+
const segmentEncoded = !entries.some((e) =>
|
|
538
|
+
e.value.includes("/"),
|
|
539
|
+
);
|
|
540
|
+
if (segmentEncoded && navData === null) needsNav = true;
|
|
541
|
+
const tree = buildFacetTree(name, entries, {
|
|
542
|
+
display: taxonomyDisplay,
|
|
543
|
+
nav: navData ?? undefined,
|
|
544
|
+
});
|
|
545
|
+
facetTrees.set(name, tree);
|
|
546
|
+
facetNodesByValue.set(name, indexTree(tree));
|
|
547
|
+
items = tree.map((n) => renderTreeNode(name, n)).join("");
|
|
548
|
+
} else {
|
|
549
|
+
items = entries
|
|
550
|
+
.map((entry) => {
|
|
551
|
+
const checked = (filters[name] ?? []).includes(entry.value);
|
|
552
|
+
const label = escapeHtml(
|
|
553
|
+
resolveFacetLabel(name, entry.value, taxonomyDisplay),
|
|
554
|
+
);
|
|
555
|
+
const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
|
|
556
|
+
return `
|
|
557
|
+
<li>
|
|
558
|
+
<label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
|
559
|
+
<input
|
|
560
|
+
type="checkbox"
|
|
561
|
+
id="${id}"
|
|
562
|
+
data-facet-name="${escapeHtml(name)}"
|
|
563
|
+
data-facet-value="${escapeHtml(entry.value)}"
|
|
564
|
+
${checked ? "checked" : ""}
|
|
565
|
+
class="size-3.5 rounded border-border accent-primary"
|
|
566
|
+
/>
|
|
567
|
+
<span class="flex-1 truncate">${label}</span>
|
|
568
|
+
<span class="text-xs text-muted-foreground">${entry.count}</span>
|
|
569
|
+
</label>
|
|
570
|
+
</li>
|
|
571
|
+
`;
|
|
572
|
+
})
|
|
573
|
+
.join("");
|
|
574
|
+
}
|
|
575
|
+
|
|
384
576
|
return `
|
|
385
577
|
<fieldset class="mb-3">
|
|
386
578
|
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
|
|
@@ -391,6 +583,20 @@ const {
|
|
|
391
583
|
.join("");
|
|
392
584
|
|
|
393
585
|
facetsBox!.innerHTML = clearAll + groups;
|
|
586
|
+
|
|
587
|
+
// `indeterminate` is a JS property, not an HTML attribute —
|
|
588
|
+
// can't set via innerHTML. Walk new checkboxes and set it
|
|
589
|
+
// post-paint so the visual tri-state matches state.
|
|
590
|
+
const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
|
|
591
|
+
'input[type="checkbox"][data-tree-indeterminate="true"]',
|
|
592
|
+
);
|
|
593
|
+
for (const el of Array.from(indet)) el.indeterminate = true;
|
|
594
|
+
|
|
595
|
+
// Lazy nav fetch — fired only when at least one hierarchical
|
|
596
|
+
// segment-encoded facet rendered with the flat fallback. The
|
|
597
|
+
// fetch's resolution handler calls renderFacets() again so the
|
|
598
|
+
// facet upgrades to its real tree shape without user action.
|
|
599
|
+
if (needsNav) void ensureNavLoaded();
|
|
394
600
|
}
|
|
395
601
|
|
|
396
602
|
async function runSearch(query: string) {
|
|
@@ -495,13 +701,26 @@ const {
|
|
|
495
701
|
});
|
|
496
702
|
|
|
497
703
|
// Facet checkbox toggling — event delegation on the sidebar.
|
|
704
|
+
// Tree nodes route through toggleTreeNode (parent click expands
|
|
705
|
+
// to all descendants); flat checkboxes use toggleFilter as before.
|
|
706
|
+
// We compute state from the pre-click filter, NOT from the
|
|
707
|
+
// checkbox's post-click `checked` value — the browser has already
|
|
708
|
+
// flipped it by the time `change` fires, so reading it would
|
|
709
|
+
// invert our toggle direction for indeterminate parents.
|
|
498
710
|
facetsBox.addEventListener("change", (e) => {
|
|
499
711
|
const target = e.target as HTMLInputElement;
|
|
500
712
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
|
|
501
713
|
const name = target.dataset.facetName;
|
|
502
714
|
const value = target.dataset.facetValue;
|
|
503
715
|
if (!name || !value) return;
|
|
504
|
-
|
|
716
|
+
if (target.dataset.treeNode === "true") {
|
|
717
|
+
const node = facetNodesByValue.get(name)?.get(value);
|
|
718
|
+
if (!node) return;
|
|
719
|
+
const currentState = computeTreeState(node, name, filters);
|
|
720
|
+
filters = toggleTreeNode(filters, name, node, currentState);
|
|
721
|
+
} else {
|
|
722
|
+
filters = toggleFilter(filters, name, value);
|
|
723
|
+
}
|
|
505
724
|
renderFacets();
|
|
506
725
|
runSearch(input!.value);
|
|
507
726
|
syncUrl();
|
package/src/search-facets.ts
CHANGED
|
@@ -16,6 +16,33 @@ import type { PrefixDisplay } from "./tag-list-data.js";
|
|
|
16
16
|
export interface TaxonomyDisplay {
|
|
17
17
|
prefixes?: Record<string, PrefixDisplay>;
|
|
18
18
|
labels?: Record<string, string>;
|
|
19
|
+
/**
|
|
20
|
+
* Sort weight for the facet column in the search dialog. Lower
|
|
21
|
+
* numbers appear first. Facets without an `order` sort
|
|
22
|
+
* alphabetically *after* any pinned ones — so `{ type: { order: 1 } }`
|
|
23
|
+
* promotes "Type" to the top while everything else stays alpha.
|
|
24
|
+
*/
|
|
25
|
+
order?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Render this facet as a tree instead of a flat checkbox list.
|
|
28
|
+
* See `buildFacetTree` for the two derivation strategies (slash-
|
|
29
|
+
* encoded vs nav-driven segment-encoded) and the canonical
|
|
30
|
+
* @dogsbay/types definition for full semantics.
|
|
31
|
+
*/
|
|
32
|
+
hierarchical?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal nav item shape — mirrors `@dogsbay/types`' `NavItem`
|
|
37
|
+
* locally so this module has no runtime dependency on the types
|
|
38
|
+
* package. Used as input to `buildFacetTree` for segment-encoded
|
|
39
|
+
* facets (the auto-`category` case), where the document tree is
|
|
40
|
+
* the source of structure and order.
|
|
41
|
+
*/
|
|
42
|
+
export interface NavLike {
|
|
43
|
+
label: string;
|
|
44
|
+
href?: string;
|
|
45
|
+
children?: NavLike[];
|
|
19
46
|
}
|
|
20
47
|
|
|
21
48
|
/** Map of taxonomy name → display config. */
|
|
@@ -120,6 +147,40 @@ export function resolveFacetTitle(facetName: string): string {
|
|
|
120
147
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Return facet names in sidebar render order.
|
|
152
|
+
*
|
|
153
|
+
* Pagefind's `filters()` map iterates in index-discovery order
|
|
154
|
+
* (effectively the first page Pagefind happened to read), so without
|
|
155
|
+
* sorting the column order is arbitrary and shifts between builds.
|
|
156
|
+
* Order rule:
|
|
157
|
+
* 1. Facets with a numeric `order` in their `TaxonomyDisplay`
|
|
158
|
+
* come first, ascending — use this to pin "Type" or "Audience"
|
|
159
|
+
* to the top regardless of name.
|
|
160
|
+
* 2. Everything else sorts alphabetically by facet name.
|
|
161
|
+
* Stable within each tier so ties don't shuffle.
|
|
162
|
+
*/
|
|
163
|
+
export function sortFacetNames(
|
|
164
|
+
names: string[],
|
|
165
|
+
display?: TaxonomyDisplayMap,
|
|
166
|
+
): string[] {
|
|
167
|
+
const withOrder = (name: string): number | undefined => {
|
|
168
|
+
const o = display?.[name]?.order;
|
|
169
|
+
return typeof o === "number" ? o : undefined;
|
|
170
|
+
};
|
|
171
|
+
return [...names].sort((a, b) => {
|
|
172
|
+
const oa = withOrder(a);
|
|
173
|
+
const ob = withOrder(b);
|
|
174
|
+
if (oa !== undefined && ob !== undefined) {
|
|
175
|
+
if (oa !== ob) return oa - ob;
|
|
176
|
+
return a.localeCompare(b);
|
|
177
|
+
}
|
|
178
|
+
if (oa !== undefined) return -1;
|
|
179
|
+
if (ob !== undefined) return 1;
|
|
180
|
+
return a.localeCompare(b);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
// ── URL persistence ──────────────────────────────────────────────
|
|
124
185
|
|
|
125
186
|
/**
|
|
@@ -175,21 +236,32 @@ export function parseFiltersFromUrl(
|
|
|
175
236
|
}
|
|
176
237
|
|
|
177
238
|
/**
|
|
178
|
-
* Pagefind's `
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
239
|
+
* Build Pagefind's `filters` argument from internal facet state.
|
|
240
|
+
*
|
|
241
|
+
* Pagefind's array shape (`{ tag: ["a", "b"] }`) is AND by default —
|
|
242
|
+
* "page must have BOTH tags" — per
|
|
243
|
+
* https://pagefind.app/docs/js-api-filtering/ ("All filtering
|
|
244
|
+
* defaults to AND filtering"). That's the wrong UX for faceted
|
|
245
|
+
* search: clicking two checkboxes in the same group should widen
|
|
246
|
+
* the result set, not collapse it to zero. We wrap each facet in
|
|
247
|
+
* `{ any: [...] }` so multi-select within a facet is OR. Across
|
|
248
|
+
* different facets Pagefind already ANDs the keys, which matches
|
|
249
|
+
* the standard "narrow with each additional dimension" UX.
|
|
250
|
+
*
|
|
251
|
+
* Single-value selections also go through `{ any: ["x"] }` —
|
|
252
|
+
* Pagefind treats a one-element `any` identically to a bare string,
|
|
253
|
+
* so there's no behavioural delta and the code stays branch-free.
|
|
182
254
|
*
|
|
183
255
|
* Empty filter state → `{}` (Pagefind returns the unfiltered
|
|
184
|
-
* result set). A facet with an empty array
|
|
185
|
-
*
|
|
256
|
+
* result set). A facet with an empty array drops out so we don't
|
|
257
|
+
* accidentally narrow to "must equal nothing".
|
|
186
258
|
*/
|
|
187
259
|
export function filterStateToPagefindFilters(
|
|
188
260
|
filters: FilterState,
|
|
189
|
-
): Record<string, string[]> {
|
|
190
|
-
const out: Record<string, string[]> = {};
|
|
261
|
+
): Record<string, { any: string[] }> {
|
|
262
|
+
const out: Record<string, { any: string[] }> = {};
|
|
191
263
|
for (const [name, values] of Object.entries(filters)) {
|
|
192
|
-
if (values.length > 0) out[name] = [...values];
|
|
264
|
+
if (values.length > 0) out[name] = { any: [...values] };
|
|
193
265
|
}
|
|
194
266
|
return out;
|
|
195
267
|
}
|
|
@@ -230,3 +302,355 @@ export function countActiveFilters(filters: FilterState): number {
|
|
|
230
302
|
for (const values of Object.values(filters)) n += values.length;
|
|
231
303
|
return n;
|
|
232
304
|
}
|
|
305
|
+
|
|
306
|
+
// ── Hierarchical facets ──────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* One node in a hierarchical facet tree (see `buildFacetTree`).
|
|
310
|
+
*
|
|
311
|
+
* `value` is the canonical Pagefind filter value for this node —
|
|
312
|
+
* for slash-encoded facets the full slash-joined path
|
|
313
|
+
* (`concept/a11y`); for segment-encoded facets a single segment
|
|
314
|
+
* (`installing_aws`). `value` matches Pagefind's value key 1:1
|
|
315
|
+
* for "real" entries; synthetic parents (intermediate trie nodes
|
|
316
|
+
* that don't correspond to a Pagefind entry) reuse the same
|
|
317
|
+
* derived value but carry `hasValue: false` and `count: 0`.
|
|
318
|
+
*/
|
|
319
|
+
export interface FacetTreeNode {
|
|
320
|
+
value: string;
|
|
321
|
+
label: string;
|
|
322
|
+
count: number;
|
|
323
|
+
depth: number;
|
|
324
|
+
/** True if Pagefind reported this exact value; false for synthetic parents. */
|
|
325
|
+
hasValue: boolean;
|
|
326
|
+
children: FacetTreeNode[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a hierarchical facet tree from Pagefind's flat entry list.
|
|
331
|
+
*
|
|
332
|
+
* Two strategies depending on the values' shape:
|
|
333
|
+
*
|
|
334
|
+
* - **Slash-encoded.** When at least one entry value contains `/`,
|
|
335
|
+
* each value is treated as a path through the tree (`concept/a11y`
|
|
336
|
+
* → `concept → a11y`). Splits on `/`, inserts into a trie.
|
|
337
|
+
* Parent entries that don't have a Pagefind value of their own
|
|
338
|
+
* (e.g. `concept` standalone is unindexed but `concept/a11y` is)
|
|
339
|
+
* appear as synthetic intermediate nodes with `hasValue: false`.
|
|
340
|
+
* Within a parent, children sort by count desc then alpha — the
|
|
341
|
+
* same rule as flat facets.
|
|
342
|
+
*
|
|
343
|
+
* - **Segment-encoded.** When NO entry contains `/`, each value is
|
|
344
|
+
* a single path segment. Hierarchy comes from `nav` — every nav
|
|
345
|
+
* leaf's href segments become a chain in the trie. Counts come
|
|
346
|
+
* from Pagefind's entry list, attached to nodes whose segment
|
|
347
|
+
* name matches an entry value. **Caveat:** Pagefind reports one
|
|
348
|
+
* count per distinct segment name regardless of where in the
|
|
349
|
+
* tree it lives, so a segment that appears at multiple positions
|
|
350
|
+
* (`installing/.../ipi`, `installing_azure/.../ipi`) gets the
|
|
351
|
+
* same total count at every position — clicking selects the
|
|
352
|
+
* shared value, returning pages from every position. This
|
|
353
|
+
* matches the underlying Pagefind filter behaviour and is
|
|
354
|
+
* documented in plans/hierarchical-facets.md.
|
|
355
|
+
*
|
|
356
|
+
* Order is nav-document order, NOT count desc. Pagefind entries
|
|
357
|
+
* not represented in nav are appended as orphans at the root in
|
|
358
|
+
* alpha order.
|
|
359
|
+
*
|
|
360
|
+
* When `nav` is undefined for the segment-encoded case, falls
|
|
361
|
+
* back to a flat list (every entry a root-level node with no
|
|
362
|
+
* children, in count-desc order) — preserves render-ability
|
|
363
|
+
* when nav.json hasn't loaded yet.
|
|
364
|
+
*
|
|
365
|
+
* Returns the top-level nodes. Empty `entries` → empty array.
|
|
366
|
+
*/
|
|
367
|
+
export function buildFacetTree(
|
|
368
|
+
facetName: string,
|
|
369
|
+
entries: FacetEntry[],
|
|
370
|
+
options: {
|
|
371
|
+
nav?: NavLike[];
|
|
372
|
+
display?: TaxonomyDisplayMap;
|
|
373
|
+
} = {},
|
|
374
|
+
): FacetTreeNode[] {
|
|
375
|
+
if (entries.length === 0) return [];
|
|
376
|
+
|
|
377
|
+
const slashEncoded = entries.some((e) => e.value.includes("/"));
|
|
378
|
+
if (slashEncoded) {
|
|
379
|
+
return buildSlashTree(facetName, entries, options.display);
|
|
380
|
+
}
|
|
381
|
+
return buildSegmentTree(facetName, entries, options.nav, options.display);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Slash-encoded tree builder. Each entry value is a slash-joined
|
|
386
|
+
* path; split, walk a trie, attach Pagefind counts to terminal
|
|
387
|
+
* nodes. Synthetic intermediate nodes (a path component used by a
|
|
388
|
+
* descendant but absent from `entries`) get `hasValue: false` and
|
|
389
|
+
* `count: 0` so they render but don't contribute to selection.
|
|
390
|
+
*/
|
|
391
|
+
function buildSlashTree(
|
|
392
|
+
facetName: string,
|
|
393
|
+
entries: FacetEntry[],
|
|
394
|
+
display?: TaxonomyDisplayMap,
|
|
395
|
+
): FacetTreeNode[] {
|
|
396
|
+
// Trie keyed by full slash-path. Children are tracked on each
|
|
397
|
+
// node's own `children` array (built unsorted in pass 1, sorted
|
|
398
|
+
// recursively in pass 2).
|
|
399
|
+
const byPath = new Map<string, FacetTreeNode>();
|
|
400
|
+
const rootValues: string[] = [];
|
|
401
|
+
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
const segments = entry.value.split("/").filter((s) => s.length > 0);
|
|
404
|
+
if (segments.length === 0) continue;
|
|
405
|
+
let pathSoFar = "";
|
|
406
|
+
let parent: FacetTreeNode | null = null;
|
|
407
|
+
for (let i = 0; i < segments.length; i++) {
|
|
408
|
+
pathSoFar = pathSoFar ? `${pathSoFar}/${segments[i]}` : segments[i];
|
|
409
|
+
let node = byPath.get(pathSoFar);
|
|
410
|
+
if (!node) {
|
|
411
|
+
node = {
|
|
412
|
+
value: pathSoFar,
|
|
413
|
+
label: resolveFacetLabel(facetName, pathSoFar, display),
|
|
414
|
+
count: 0,
|
|
415
|
+
depth: i,
|
|
416
|
+
hasValue: false,
|
|
417
|
+
children: [],
|
|
418
|
+
};
|
|
419
|
+
byPath.set(pathSoFar, node);
|
|
420
|
+
if (parent) parent.children.push(node);
|
|
421
|
+
else rootValues.push(pathSoFar);
|
|
422
|
+
}
|
|
423
|
+
if (i === segments.length - 1) {
|
|
424
|
+
node.count = entry.count;
|
|
425
|
+
node.hasValue = true;
|
|
426
|
+
}
|
|
427
|
+
parent = node;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Pass 2: sort children at every depth. Mirrors sortFacetEntries
|
|
432
|
+
// (count desc, alpha tiebreaker).
|
|
433
|
+
const sortChildren = (node: FacetTreeNode): void => {
|
|
434
|
+
if (node.children.length === 0) return;
|
|
435
|
+
node.children.sort((a, b) => {
|
|
436
|
+
if (a.count !== b.count) return b.count - a.count;
|
|
437
|
+
return a.value.localeCompare(b.value);
|
|
438
|
+
});
|
|
439
|
+
for (const c of node.children) sortChildren(c);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const rootList = rootValues.map((v) => byPath.get(v)!);
|
|
443
|
+
rootList.sort((a, b) => {
|
|
444
|
+
if (a.count !== b.count) return b.count - a.count;
|
|
445
|
+
return a.value.localeCompare(b.value);
|
|
446
|
+
});
|
|
447
|
+
for (const r of rootList) sortChildren(r);
|
|
448
|
+
return rootList;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Segment-encoded tree builder. Walks `nav` to discover the
|
|
453
|
+
* canonical document hierarchy, then attaches Pagefind counts to
|
|
454
|
+
* any node whose segment name matches an entry. Pagefind entries
|
|
455
|
+
* not in nav are appended as alpha-sorted root orphans so they
|
|
456
|
+
* don't disappear.
|
|
457
|
+
*/
|
|
458
|
+
function buildSegmentTree(
|
|
459
|
+
facetName: string,
|
|
460
|
+
entries: FacetEntry[],
|
|
461
|
+
nav: NavLike[] | undefined,
|
|
462
|
+
display?: TaxonomyDisplayMap,
|
|
463
|
+
): FacetTreeNode[] {
|
|
464
|
+
const byValue = new Map<string, FacetEntry>();
|
|
465
|
+
for (const e of entries) byValue.set(e.value, e);
|
|
466
|
+
|
|
467
|
+
if (!nav || nav.length === 0) {
|
|
468
|
+
// Flat fallback in count-desc order — preserves render-ability
|
|
469
|
+
// when nav hasn't loaded.
|
|
470
|
+
return entries.map((e) => ({
|
|
471
|
+
value: e.value,
|
|
472
|
+
label: resolveFacetLabel(facetName, e.value, display),
|
|
473
|
+
count: e.count,
|
|
474
|
+
depth: 0,
|
|
475
|
+
hasValue: true,
|
|
476
|
+
children: [],
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const seenValues = new Set<string>();
|
|
481
|
+
const roots: FacetTreeNode[] = [];
|
|
482
|
+
const rootIndex = new Map<string, FacetTreeNode>();
|
|
483
|
+
|
|
484
|
+
/** Insert a path's parent segments (excluding the leaf filename) into the trie. */
|
|
485
|
+
const insertPath = (segments: string[]): void => {
|
|
486
|
+
if (segments.length === 0) return;
|
|
487
|
+
let parentList: FacetTreeNode[] = roots;
|
|
488
|
+
let parentIndex: Map<string, FacetTreeNode> = rootIndex;
|
|
489
|
+
for (let i = 0; i < segments.length; i++) {
|
|
490
|
+
const seg = segments[i];
|
|
491
|
+
let node = parentIndex.get(seg);
|
|
492
|
+
if (!node) {
|
|
493
|
+
const entry = byValue.get(seg);
|
|
494
|
+
node = {
|
|
495
|
+
value: seg,
|
|
496
|
+
label: resolveFacetLabel(facetName, seg, display),
|
|
497
|
+
count: entry ? entry.count : 0,
|
|
498
|
+
depth: i,
|
|
499
|
+
hasValue: entry !== undefined,
|
|
500
|
+
children: [],
|
|
501
|
+
};
|
|
502
|
+
parentList.push(node);
|
|
503
|
+
parentIndex.set(seg, node);
|
|
504
|
+
if (entry) seenValues.add(seg);
|
|
505
|
+
}
|
|
506
|
+
// Walk into the child list for the next iteration. Each
|
|
507
|
+
// node carries its own child index lazily via a WeakMap-like
|
|
508
|
+
// closure; here we use a regular Map keyed off the node.
|
|
509
|
+
let nextIndex = childIndex.get(node);
|
|
510
|
+
if (!nextIndex) {
|
|
511
|
+
nextIndex = new Map();
|
|
512
|
+
childIndex.set(node, nextIndex);
|
|
513
|
+
}
|
|
514
|
+
parentList = node.children;
|
|
515
|
+
parentIndex = nextIndex;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const childIndex = new Map<FacetTreeNode, Map<string, FacetTreeNode>>();
|
|
520
|
+
|
|
521
|
+
// Walk every nav leaf. Use href segments (excluding the leaf
|
|
522
|
+
// filename) — those are the auto-derived `category` values
|
|
523
|
+
// emitted by parseMeta's deriveCategoryFromSlug.
|
|
524
|
+
const walkNav = (items: NavLike[]): void => {
|
|
525
|
+
for (const item of items) {
|
|
526
|
+
if (item.href) {
|
|
527
|
+
const parents = hrefToCategorySegments(item.href);
|
|
528
|
+
insertPath(parents);
|
|
529
|
+
}
|
|
530
|
+
if (item.children && item.children.length > 0) walkNav(item.children);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
walkNav(nav);
|
|
534
|
+
|
|
535
|
+
// Pagefind values not represented in nav → root-level orphans in
|
|
536
|
+
// alpha order. Without this they vanish from the UI silently.
|
|
537
|
+
const orphans: FacetTreeNode[] = [];
|
|
538
|
+
for (const e of entries) {
|
|
539
|
+
if (seenValues.has(e.value)) continue;
|
|
540
|
+
orphans.push({
|
|
541
|
+
value: e.value,
|
|
542
|
+
label: resolveFacetLabel(facetName, e.value, display),
|
|
543
|
+
count: e.count,
|
|
544
|
+
depth: 0,
|
|
545
|
+
hasValue: true,
|
|
546
|
+
children: [],
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
orphans.sort((a, b) => a.value.localeCompare(b.value));
|
|
550
|
+
return [...roots, ...orphans];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Strip an href down to its parent path segments — the `category`
|
|
555
|
+
* derivation logic from `parseMeta.deriveCategoryFromSlug`. Drops
|
|
556
|
+
* leading/trailing slashes, drops the leaf filename segment.
|
|
557
|
+
*/
|
|
558
|
+
function hrefToCategorySegments(href: string): string[] {
|
|
559
|
+
// Drop scheme/host if absolute (we only treat path segments)
|
|
560
|
+
let path = href;
|
|
561
|
+
try {
|
|
562
|
+
if (/^[a-z]+:\/\//i.test(href)) path = new URL(href).pathname;
|
|
563
|
+
} catch {
|
|
564
|
+
// not a URL — treat as-is
|
|
565
|
+
}
|
|
566
|
+
// Drop hash + query — facets care about the URL path only
|
|
567
|
+
path = path.split("#")[0].split("?")[0];
|
|
568
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
569
|
+
// Match deriveCategoryFromSlug: parents = everything except the leaf
|
|
570
|
+
if (segments.length < 2) return [];
|
|
571
|
+
return segments.slice(0, -1);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Flat list of every "real" Pagefind value at or under `node`
|
|
576
|
+
* (i.e. every node where `hasValue === true`). Synthetic parents
|
|
577
|
+
* are excluded since adding them to the filter would be a no-op
|
|
578
|
+
* (Pagefind has no pages tagged with the synthetic value).
|
|
579
|
+
*
|
|
580
|
+
* Used by `toggleTreeNode` when the user ticks a parent — we add
|
|
581
|
+
* every real descendant value to the selection in one shot.
|
|
582
|
+
*/
|
|
583
|
+
export function collectDescendantValues(node: FacetTreeNode): string[] {
|
|
584
|
+
const out: string[] = [];
|
|
585
|
+
const visit = (n: FacetTreeNode): void => {
|
|
586
|
+
if (n.hasValue) out.push(n.value);
|
|
587
|
+
for (const c of n.children) visit(c);
|
|
588
|
+
};
|
|
589
|
+
visit(node);
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Tri-state of a tree node given the current filter state.
|
|
595
|
+
*
|
|
596
|
+
* - `"checked"` — every real descendant value (and self if
|
|
597
|
+
* self has a value) is in `filters[facetName]`.
|
|
598
|
+
* - `"unchecked"` — none of them are.
|
|
599
|
+
* - `"indeterminate"`— at least one is, but not all.
|
|
600
|
+
*
|
|
601
|
+
* Synthetic parents with no real-value descendants resolve to
|
|
602
|
+
* "unchecked" (defensive default — shouldn't occur in valid trees).
|
|
603
|
+
*/
|
|
604
|
+
export function computeTreeState(
|
|
605
|
+
node: FacetTreeNode,
|
|
606
|
+
facetName: string,
|
|
607
|
+
filters: FilterState,
|
|
608
|
+
): "checked" | "unchecked" | "indeterminate" {
|
|
609
|
+
const descendants = collectDescendantValues(node);
|
|
610
|
+
if (descendants.length === 0) return "unchecked";
|
|
611
|
+
const selected = new Set(filters[facetName] ?? []);
|
|
612
|
+
let hits = 0;
|
|
613
|
+
for (const v of descendants) if (selected.has(v)) hits++;
|
|
614
|
+
if (hits === 0) return "unchecked";
|
|
615
|
+
if (hits === descendants.length) return "checked";
|
|
616
|
+
return "indeterminate";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Toggle a tree node's selection.
|
|
621
|
+
*
|
|
622
|
+
* - If currently checked → remove self + all descendant values
|
|
623
|
+
* from `filters[facetName]`.
|
|
624
|
+
* - If unchecked or indeterminate → add self + all descendant
|
|
625
|
+
* values (auto-select). Matches the decided UX: parent click
|
|
626
|
+
* fans out to children.
|
|
627
|
+
*
|
|
628
|
+
* Returns a new `FilterState` — input is not mutated. Same
|
|
629
|
+
* immutability contract as `toggleFilter`.
|
|
630
|
+
*/
|
|
631
|
+
export function toggleTreeNode(
|
|
632
|
+
filters: FilterState,
|
|
633
|
+
facetName: string,
|
|
634
|
+
node: FacetTreeNode,
|
|
635
|
+
currentState: "checked" | "unchecked" | "indeterminate",
|
|
636
|
+
): FilterState {
|
|
637
|
+
const descendants = collectDescendantValues(node);
|
|
638
|
+
if (descendants.length === 0) return filters;
|
|
639
|
+
const current = filters[facetName] ?? [];
|
|
640
|
+
let nextValues: string[];
|
|
641
|
+
if (currentState === "checked") {
|
|
642
|
+
const drop = new Set(descendants);
|
|
643
|
+
nextValues = current.filter((v) => !drop.has(v));
|
|
644
|
+
} else {
|
|
645
|
+
const merged = new Set(current);
|
|
646
|
+
for (const v of descendants) merged.add(v);
|
|
647
|
+
nextValues = Array.from(merged);
|
|
648
|
+
}
|
|
649
|
+
const next: FilterState = { ...filters };
|
|
650
|
+
if (nextValues.length === 0) {
|
|
651
|
+
delete next[facetName];
|
|
652
|
+
} else {
|
|
653
|
+
next[facetName] = nextValues;
|
|
654
|
+
}
|
|
655
|
+
return next;
|
|
656
|
+
}
|