@c-rex/components 0.3.0-build.38 → 0.3.0-build.40

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.
@@ -2,7 +2,11 @@ import { Tags } from "@c-rex/interfaces";
2
2
  import {
3
3
  clearData,
4
4
  memoizeFilteredTags,
5
+ pruneHierarchyFacetItemsForMerge,
6
+ resolveHierarchyFacetCountLabel,
7
+ resolveFacetTaxonomy,
5
8
  resolveLabelByLanguage,
9
+ shouldRenderFacetHierarchy,
6
10
  updateFilterParam,
7
11
  removeFilterItem,
8
12
  } from "../utils";
@@ -63,7 +67,7 @@ describe("filter-sidebar utils", () => {
63
67
  const result = clearData(tags, { uiLanguage: "en" });
64
68
 
65
69
  expect(result.informationSubjects).toHaveLength(2);
66
- expect(result.informationSubjects?.map((item) => item.shortId)).toEqual(["subA", "subB"]);
70
+ expect(result.informationSubjects?.map((item) => item.shortId)).toEqual(["subB", "subA"]);
67
71
  });
68
72
 
69
73
  it("clearData excludes zero-hit entries when includeZeroHits is false", () => {
@@ -89,7 +93,8 @@ describe("filter-sidebar utils", () => {
89
93
  },
90
94
  });
91
95
 
92
- expect(result.informationSubjects?.[0]).toMatchObject({
96
+ const overriddenItem = result.informationSubjects?.find((item) => item.shortId === "subA");
97
+ expect(overriddenItem).toMatchObject({
93
98
  label: "Overridden Label",
94
99
  groupLabel: "Subject Group",
95
100
  sectionLabel: "Process",
@@ -97,6 +102,168 @@ describe("filter-sidebar utils", () => {
97
102
  });
98
103
  });
99
104
 
105
+ it("clearData resolves facet presentation by id when shortId lookup is unavailable", () => {
106
+ const tags = createTags();
107
+ const result = clearData(tags, {
108
+ uiLanguage: "en",
109
+ labelOverrides: {
110
+ informationSubjects: {
111
+ "https://example.org/subA": {
112
+ label: "ID Matched Label",
113
+ sectionLabel: "Process",
114
+ },
115
+ },
116
+ },
117
+ });
118
+
119
+ expect(result.informationSubjects?.find((item) => item.shortId === "subA")).toMatchObject({
120
+ label: "ID Matched Label",
121
+ sectionLabel: "Process",
122
+ taxonomyId: "https://example.org/subA",
123
+ });
124
+ });
125
+
126
+ it("clearData keeps party role section labels from overrides instead of raw predicate labels", () => {
127
+ const tags = {
128
+ parties: {
129
+ labels: ["related to party"],
130
+ items: [
131
+ {
132
+ hits: 1,
133
+ total: 1,
134
+ labels: [{ language: "de", value: "Deutsche Gesetzliche Unfallversicherung e.V." }],
135
+ shortId: "dguv",
136
+ id: "https://example.org/party/dguv",
137
+ } as unknown as Tags[string]["items"][number],
138
+ ],
139
+ },
140
+ } as unknown as Tags;
141
+
142
+ const result = clearData(tags, {
143
+ uiLanguage: "de-DE",
144
+ labelOverrides: {
145
+ parties: {
146
+ dguv: {
147
+ label: "Deutsche Gesetzliche Unfallversicherung e.V.",
148
+ sectionLabel: "Traeger",
149
+ },
150
+ },
151
+ },
152
+ });
153
+
154
+ expect(result.parties?.[0]).toMatchObject({
155
+ label: "Deutsche Gesetzliche Unfallversicherung e.V.",
156
+ sectionLabel: "Traeger",
157
+ });
158
+ expect(result.parties?.[0]?.sectionLabel).not.toBe("related to party");
159
+ });
160
+
161
+ it("resolves categories taxonomy through the cached informationSubjects hierarchy alias", () => {
162
+ const taxonomy = {
163
+ generatedAt: "2026-04-10T00:00:00.000Z",
164
+ roots: [],
165
+ nodesById: {
166
+ "category-root": {
167
+ id: "category-root",
168
+ shortId: "catRoot",
169
+ label: "Root category",
170
+ parentIds: [],
171
+ },
172
+ },
173
+ };
174
+
175
+ expect(resolveFacetTaxonomy("categories", { informationSubjects: taxonomy })).toBe(taxonomy);
176
+ });
177
+
178
+ it("clearData merges category tags into the cached category hierarchy alias", () => {
179
+ const tags = {
180
+ categories: {
181
+ labels: ["Categories"],
182
+ items: [
183
+ {
184
+ hits: 2,
185
+ total: 2,
186
+ labels: [{ language: "de", value: "Unterkategorie" }],
187
+ shortId: "catChild",
188
+ id: "category-child",
189
+ } as unknown as Tags[string]["items"][number],
190
+ ],
191
+ },
192
+ } as unknown as Tags;
193
+
194
+ const result = clearData(tags, {
195
+ uiLanguage: "de-DE",
196
+ facetTaxonomies: {
197
+ informationSubjects: {
198
+ generatedAt: "2026-04-10T00:00:00.000Z",
199
+ roots: [],
200
+ nodesById: {
201
+ "category-root": {
202
+ id: "category-root",
203
+ shortId: "catRoot",
204
+ label: "Wurzel",
205
+ parentIds: [],
206
+ },
207
+ "category-child": {
208
+ id: "category-child",
209
+ shortId: "catChild",
210
+ label: "Unterkategorie",
211
+ parentIds: ["category-root"],
212
+ },
213
+ },
214
+ },
215
+ },
216
+ });
217
+
218
+ expect(result.categories?.[0]).toMatchObject({
219
+ shortId: "catChild",
220
+ label: "Unterkategorie",
221
+ taxonomyId: "category-child",
222
+ parentIds: ["category-root"],
223
+ });
224
+ });
225
+
226
+ it("clearData hides denied qualification groups from semantic facet output", () => {
227
+ const tags = {
228
+ qualifications: {
229
+ labels: ["Qualifications"],
230
+ items: [
231
+ {
232
+ hits: 4,
233
+ total: 4,
234
+ labels: [{ language: "en", value: "License A" }],
235
+ shortId: "qualA",
236
+ id: "qualification-a",
237
+ } as unknown as Tags[string]["items"][number],
238
+ ],
239
+ },
240
+ } as unknown as Tags;
241
+
242
+ const result = clearData(tags, {
243
+ uiLanguage: "en-US",
244
+ facetTaxonomies: {
245
+ qualifications: {
246
+ generatedAt: "2026-04-10T00:00:00.000Z",
247
+ roots: [],
248
+ nodesById: {
249
+ "qualification-a": {
250
+ id: "qualification-a",
251
+ shortId: "qualA",
252
+ label: "License A",
253
+ parentIds: [],
254
+ classRef: {
255
+ id: "https://ids.c-crex.net/ns/iirds/ext#ContentLicense",
256
+ label: "Content License",
257
+ },
258
+ },
259
+ },
260
+ },
261
+ },
262
+ });
263
+
264
+ expect(result.qualifications).toBeUndefined();
265
+ });
266
+
100
267
  it("memoizeFilteredTags marks active filters and active package", () => {
101
268
  const tags = createTags();
102
269
  const result = memoizeFilteredTags(tags, "informationSubjects.shortId=subA", "pkgA", {
@@ -108,6 +275,136 @@ describe("filter-sidebar utils", () => {
108
275
  expect(result.packages?.find((item) => item.shortId === "pkgA")?.active).toBe(true);
109
276
  });
110
277
 
278
+ it("prunes zero-hit category siblings from the hierarchy merge seed when a category is selected", () => {
279
+ const items = [
280
+ {
281
+ shortId: "selected",
282
+ taxonomyId: "selected-id",
283
+ label: "Selected",
284
+ active: true,
285
+ hits: 211,
286
+ total: 211,
287
+ parentIds: ["parent-id"],
288
+ },
289
+ {
290
+ shortId: "sibling-zero",
291
+ taxonomyId: "sibling-zero-id",
292
+ label: "Sibling Zero",
293
+ active: false,
294
+ hits: 0,
295
+ total: 0,
296
+ parentIds: ["parent-id"],
297
+ },
298
+ {
299
+ shortId: "sibling-live",
300
+ taxonomyId: "sibling-live-id",
301
+ label: "Sibling Live",
302
+ active: false,
303
+ hits: 5,
304
+ total: 5,
305
+ parentIds: ["parent-id"],
306
+ },
307
+ ];
308
+
309
+ expect(pruneHierarchyFacetItemsForMerge("categories", [...items])).toEqual([items[0], items[2]]);
310
+ });
311
+
312
+ it("keeps zero-hit category items untouched when no category is selected", () => {
313
+ const items = [
314
+ {
315
+ shortId: "category-a",
316
+ taxonomyId: "category-a-id",
317
+ label: "Category A",
318
+ active: false,
319
+ hits: 0,
320
+ total: 0,
321
+ parentIds: [],
322
+ },
323
+ {
324
+ shortId: "category-b",
325
+ taxonomyId: "category-b-id",
326
+ label: "Category B",
327
+ active: false,
328
+ hits: 3,
329
+ total: 3,
330
+ parentIds: [],
331
+ },
332
+ ];
333
+
334
+ expect(pruneHierarchyFacetItemsForMerge("informationSubjects", items)).toEqual(items);
335
+ });
336
+
337
+ it("hides the misleading 0/0 count for structural category parents with visible children", () => {
338
+ const item = {
339
+ shortId: "category-parent",
340
+ taxonomyId: "category-parent-id",
341
+ label: "Category Parent",
342
+ active: false,
343
+ hits: 0,
344
+ total: 0,
345
+ parentIds: [],
346
+ isStructural: true,
347
+ };
348
+ const child = {
349
+ shortId: "category-child",
350
+ taxonomyId: "category-child-id",
351
+ label: "Category Child",
352
+ active: false,
353
+ hits: 4,
354
+ total: 4,
355
+ parentIds: ["category-parent-id"],
356
+ };
357
+
358
+ expect(resolveHierarchyFacetCountLabel(item, [child])).toBeUndefined();
359
+ });
360
+
361
+ it("keeps count labels for live hierarchy nodes", () => {
362
+ const item = {
363
+ shortId: "category-child",
364
+ taxonomyId: "category-child-id",
365
+ label: "Category Child",
366
+ active: false,
367
+ hits: 4,
368
+ total: 7,
369
+ parentIds: ["category-parent-id"],
370
+ };
371
+
372
+ expect(resolveHierarchyFacetCountLabel(item, [])).toBe("(4/7)");
373
+ });
374
+
375
+ it("renders taxonomy-backed facets as flat groups when no parent relations exist", () => {
376
+ const items = [
377
+ {
378
+ shortId: "subject-a",
379
+ taxonomyId: "subject-a-id",
380
+ label: "Abkuerzungsbeschreibung",
381
+ sectionLabel: "Formalitaet",
382
+ active: false,
383
+ hits: 3,
384
+ total: 3,
385
+ parentIds: [],
386
+ },
387
+ ];
388
+
389
+ expect(shouldRenderFacetHierarchy(items, { hasTaxonomy: true })).toBe(false);
390
+ });
391
+
392
+ it("renders taxonomy-backed facets as hierarchy when parent relations exist", () => {
393
+ const items = [
394
+ {
395
+ shortId: "category-child",
396
+ taxonomyId: "category-child-id",
397
+ label: "Unterkategorie",
398
+ active: false,
399
+ hits: 4,
400
+ total: 4,
401
+ parentIds: ["category-root-id"],
402
+ },
403
+ ];
404
+
405
+ expect(shouldRenderFacetHierarchy(items, { hasTaxonomy: true })).toBe(true);
406
+ });
407
+
111
408
  it("updateFilterParam and removeFilterItem keep query behavior stable", () => {
112
409
  expect(updateFilterParam("packages", { shortId: "pkgA", hits: 0, total: 0, label: "", active: false }, null)).toEqual({
113
410
  packages: "pkgA",
@@ -126,4 +423,3 @@ describe("filter-sidebar utils", () => {
126
423
  ).toEqual({ filter: "packages.shortId=pkgA" });
127
424
  });
128
425
  });
129
-
@@ -21,14 +21,19 @@ import { Button } from "@c-rex/ui/button";
21
21
  import { parseAsString, useQueryStates } from "nuqs";
22
22
  import {
23
23
  applyFacetPropertyVisibility,
24
- FacetLabelOverrides,
24
+ FacetPresentation,
25
25
  FacetTaxonomies,
26
26
  memoizeFilteredTags,
27
+ pruneHierarchyFacetItemsForMerge,
27
28
  removeFilterItem,
29
+ resolveHierarchyFacetCountLabel,
30
+ resolveFacetTaxonomy,
28
31
  ResolvedFilterItem,
29
32
  resolveLabelByLanguage,
33
+ shouldRenderFacetHierarchy,
30
34
  updateFilterParam
31
35
  } from "./utils";
36
+ import { buildTaxonomyHierarchy } from "../../taxonomy/hierarchy";
32
37
  import { FilterItem, Tags } from "@c-rex/interfaces";
33
38
  import type { TaxonomyResult } from "@c-rex/services/read-models";
34
39
  import { useSearchNavigationStore } from "../../stores/search-navigation-store";
@@ -39,7 +44,8 @@ import { useFilterSidebarState } from "./context";
39
44
  export interface FilterSidebarProps {
40
45
  tags?: Tags
41
46
  totalItemCount?: number
42
- facetLabelOverrides?: FacetLabelOverrides
47
+ facetPresentation?: FacetPresentation
48
+ facetLabelOverrides?: FacetPresentation
43
49
  facetTaxonomies?: FacetTaxonomies
44
50
  includeZeroHits?: boolean
45
51
  includeProperties?: string[]
@@ -49,6 +55,7 @@ export interface FilterSidebarProps {
49
55
  const FilterSidebar: FC<FilterSidebarProps> = ({
50
56
  tags,
51
57
  totalItemCount,
58
+ facetPresentation,
52
59
  facetLabelOverrides,
53
60
  facetTaxonomies,
54
61
  includeZeroHits = true,
@@ -56,6 +63,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
56
63
  excludeProperties,
57
64
  }) => {
58
65
  const t = useTranslations();
66
+ const tagT = useTranslations("filter.tags");
59
67
  const locale = useLocale();
60
68
  const device = useBreakpoint();
61
69
  const isMobile = device !== null && (device === DEVICE_OPTIONS.MOBILE || device === DEVICE_OPTIONS.TABLET);
@@ -68,16 +76,17 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
68
76
  shallow: false,
69
77
  });
70
78
  const startSearchNavigation = useSearchNavigationStore((state) => state.start);
79
+ const effectiveFacetPresentation = facetPresentation || facetLabelOverrides;
71
80
 
72
81
  const filteredTags = useMemo(() => {
73
82
  const resolved = memoizeFilteredTags(tags, params.filter, params.packages, {
74
83
  uiLanguage: locale,
75
- labelOverrides: facetLabelOverrides,
84
+ facetPresentation: effectiveFacetPresentation,
76
85
  includeZeroHits,
77
86
  facetTaxonomies,
78
87
  });
79
88
  return applyFacetPropertyVisibility(resolved, includeProperties, excludeProperties);
80
- }, [tags, params.filter, params.packages, locale, facetLabelOverrides, facetTaxonomies, includeZeroHits, includeProperties, excludeProperties]);
89
+ }, [tags, params.filter, params.packages, locale, effectiveFacetPresentation, facetTaxonomies, includeZeroHits, includeProperties, excludeProperties]);
81
90
  const [expandedFacetItems, setExpandedFacetItems] = useState<Record<string, number>>({});
82
91
  const [expandedHierarchyNodes, setExpandedHierarchyNodes] = useState<Record<string, boolean>>({});
83
92
  const getVisibleCount = (key: string, total: number) => expandedFacetItems[key] || Math.min(total, 10);
@@ -106,14 +115,23 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
106
115
  }
107
116
 
108
117
  const resolvePropertyLabel = (propertyKey: string): string => {
109
- const taxonomy = facetTaxonomies?.[propertyKey];
118
+ const taxonomy = resolveFacetTaxonomy(propertyKey, facetTaxonomies);
110
119
  const virtualRootLabel = taxonomy?.roots?.length === 1 && taxonomy.roots[0]?.isVirtualGroup
111
120
  ? taxonomy.roots[0].label
112
121
  : undefined;
113
- if (virtualRootLabel) {
122
+ if (virtualRootLabel && virtualRootLabel !== "Ungrouped") {
114
123
  return virtualRootLabel;
115
124
  }
116
125
 
126
+ try {
127
+ const translated = tagT(propertyKey as Parameters<typeof tagT>[0]);
128
+ if (translated && translated !== propertyKey) {
129
+ return translated;
130
+ }
131
+ } catch {
132
+ // Fallback to API labels when no UI translation exists for the facet key.
133
+ }
134
+
117
135
  const rawLabels = (tags?.[propertyKey]?.labels as unknown[] | undefined) || [];
118
136
  const normalized = rawLabels
119
137
  .map((item) => {
@@ -135,100 +153,14 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
135
153
  return resolveLabelByLanguage(normalized, locale) || propertyKey;
136
154
  };
137
155
 
138
- const isHierarchicalFacet = (items: ResolvedFilterItem[]): boolean => items.some((item) => (item.parentIds || []).length > 0);
139
-
140
156
  const buildHierarchy = (propertyKey: string, items: ResolvedFilterItem[]) => {
141
- const taxonomy = facetTaxonomies?.[propertyKey] as TaxonomyResult | undefined;
157
+ const taxonomy = resolveFacetTaxonomy(propertyKey, facetTaxonomies) as TaxonomyResult | undefined;
142
158
  if (taxonomy) {
143
- const selectedById = new Map<string, ResolvedFilterItem>();
144
- const includedIds = new Set<string>();
145
-
146
- items.forEach((item) => {
147
- const itemId = item.taxonomyId || item.shortId;
148
- if (!itemId) return;
149
- selectedById.set(itemId, item);
150
- includedIds.add(itemId);
151
-
152
- const taxonomyNode = taxonomy.nodesById[itemId];
153
- (taxonomyNode?.ancestorIds || []).forEach((ancestorId) => {
154
- if (taxonomy.nodesById[ancestorId]) includedIds.add(ancestorId);
155
- });
156
- (taxonomyNode?.parentIds || []).forEach((parentId) => {
157
- if (taxonomy.nodesById[parentId]) includedIds.add(parentId);
158
- });
159
- });
160
-
161
- const byId = new Map<string, ResolvedFilterItem>();
162
- const children = new Map<string, ResolvedFilterItem[]>();
163
-
164
- Array.from(includedIds).forEach((nodeId) => {
165
- const taxonomyNode = taxonomy.nodesById[nodeId];
166
- if (!taxonomyNode) return;
167
-
168
- const selected = selectedById.get(nodeId);
169
-
170
- byId.set(nodeId, selected || {
171
- shortId: taxonomyNode.shortId || nodeId,
172
- label: taxonomyNode.label,
173
- active: false,
174
- hits: 0,
175
- total: 0,
176
- taxonomyId: taxonomyNode.id,
177
- parentIds: taxonomyNode.parentIds || [],
178
- isStructural: true,
179
- });
180
- children.set(nodeId, []);
181
- });
182
-
183
- Array.from(includedIds).forEach((nodeId) => {
184
- const current = byId.get(nodeId);
185
- const taxonomyNode = taxonomy.nodesById[nodeId];
186
- if (!current || !taxonomyNode) return;
187
-
188
- const parentKey = (taxonomyNode.parentIds || []).find((id) => byId.has(id));
189
- if (parentKey) {
190
- children.get(parentKey)?.push(current);
191
- }
192
- });
193
-
194
- const roots = Array.from(byId.values()).filter((node) => {
195
- const nodeId = node.taxonomyId || node.shortId;
196
- const taxonomyNode = nodeId ? taxonomy.nodesById[nodeId] : undefined;
197
- return !(taxonomyNode?.parentIds || []).some((id) => byId.has(id));
198
- });
199
-
200
- const sortItems = (nodes: ResolvedFilterItem[]) => nodes.sort((a, b) => a.label.localeCompare(b.label));
201
- sortItems(roots);
202
- children.forEach((nodes) => sortItems(nodes));
203
-
204
- return { roots, children };
159
+ const mergeSeedItems = pruneHierarchyFacetItemsForMerge(propertyKey, items);
160
+ return buildTaxonomyHierarchy(mergeSeedItems, taxonomy);
205
161
  }
206
162
 
207
- const byId = new Map<string, ResolvedFilterItem>();
208
- const children = new Map<string, ResolvedFilterItem[]>();
209
- const roots: ResolvedFilterItem[] = [];
210
-
211
- items.forEach((item) => {
212
- const key = item.taxonomyId || item.shortId;
213
- byId.set(key, item);
214
- children.set(key, []);
215
- });
216
-
217
- items.forEach((item) => {
218
- const parentKey = (item.parentIds || []).find((id) => byId.has(id));
219
- if (!parentKey) {
220
- roots.push(item);
221
- return;
222
- }
223
-
224
- children.get(parentKey)?.push(item);
225
- });
226
-
227
- const sortItems = (nodes: ResolvedFilterItem[]) => nodes.sort((a, b) => a.label.localeCompare(b.label));
228
- sortItems(roots);
229
- children.forEach((nodes) => sortItems(nodes));
230
-
231
- return { roots, children };
163
+ return buildTaxonomyHierarchy(items);
232
164
  };
233
165
 
234
166
  const renderFacetItems = (key: string, items: ResolvedFilterItem[]) => {
@@ -341,7 +273,13 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
341
273
  const itemKey = item.taxonomyId || item.shortId;
342
274
  const children = childrenMap.get(itemKey) || [];
343
275
  const hasChildren = children.length > 0;
344
- const expanded = expandedHierarchyNodes[itemKey] !== false;
276
+ const countLabel = resolveHierarchyFacetCountLabel(item, children);
277
+ const expanded = hasChildren
278
+ ? depth === 0
279
+ ? expandedHierarchyNodes[itemKey] === true
280
+ : expandedHierarchyNodes[itemKey] !== false
281
+ : false;
282
+ const toggleSlotClass = "mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center";
345
283
 
346
284
  return (
347
285
  <SidebarMenuSubItem key={item.shortId}>
@@ -350,16 +288,14 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
350
288
  {hasChildren ? (
351
289
  <button
352
290
  type="button"
353
- className="mt-1 rounded p-0.5 hover:bg-sidebar-accent"
291
+ className={`${toggleSlotClass} rounded hover:bg-sidebar-accent`}
354
292
  onClick={() => setExpandedHierarchyNodes((prev) => ({ ...prev, [itemKey]: !prev[itemKey] }))}
355
293
  aria-label={expanded ? "Collapse" : "Expand"}
356
294
  >
357
295
  {expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
358
296
  </button>
359
- ) : depth > 0 ? (
360
- <span className="inline-block w-5" />
361
297
  ) : (
362
- <span className="inline-block w-0" />
298
+ <span className={toggleSlotClass} aria-hidden="true" />
363
299
  )}
364
300
  <SidebarMenuSubButton
365
301
  className={item.isStructural ? "!py-1.5 cursor-default text-muted-foreground" : "cursor-pointer !py-1.5"}
@@ -369,7 +305,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
369
305
  onClick={item.isStructural ? undefined : () => onClickHandler(propertyKey, item)}
370
306
  style={{ marginLeft: depth * 14 }}
371
307
  >
372
- {item.label} ({item.hits}/{item.total})
308
+ {item.label}{countLabel ? ` ${countLabel}` : ""}
373
309
  {item.active && <Check className="ml-2" />}
374
310
  </SidebarMenuSubButton>
375
311
  </div>
@@ -387,10 +323,10 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
387
323
  <SidebarContent className="!gap-0 capitalize" suppressHydrationWarning>
388
324
  {Object.entries(filteredTags).map(([key, value]) => {
389
325
  const items = value as ResolvedFilterItem[];
390
- const hasTaxonomy = Boolean(facetTaxonomies?.[key]);
391
- const hasSectionLabels = !hasTaxonomy && items.some((item) => Boolean(item.sectionLabel));
326
+ const hasTaxonomy = Boolean(resolveFacetTaxonomy(key, facetTaxonomies));
392
327
  const propertyLabel = resolvePropertyLabel(key);
393
- const hierarchicalFacet = hasTaxonomy || (!hasSectionLabels && isHierarchicalFacet(items));
328
+ const hasSectionLabels = items.some((item) => Boolean(item.sectionLabel));
329
+ const hierarchicalFacet = shouldRenderFacetHierarchy(items, { hasTaxonomy });
394
330
  const hierarchy = hierarchicalFacet ? buildHierarchy(key, items) : undefined;
395
331
  const visibleRootCount = getVisibleCount(key, hierarchy?.roots.length || 0);
396
332
  const visibleRoots = hierarchy?.roots.slice(0, visibleRootCount) || [];
@@ -455,7 +391,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
455
391
  <div className="sr-only">{propertyLabel}</div>
456
392
  {Object.entries(
457
393
  items.reduce<Record<string, ResolvedFilterItem[]>>((acc, item) => {
458
- const sectionLabel = item.sectionLabel || "Other";
394
+ const sectionLabel = item.sectionLabel || propertyLabel;
459
395
  if (!acc[sectionLabel]) acc[sectionLabel] = [];
460
396
  acc[sectionLabel].push(item);
461
397
  return acc;
@@ -464,6 +400,13 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
464
400
  .sort(([a], [b]) => a.localeCompare(b))
465
401
  .map(([sectionLabel, sectionItems]) => {
466
402
  const hasNestedGroups = sectionItems.some((item) => Boolean(item.groupLabel));
403
+ const sectionHasTaxonomy = Boolean(resolveFacetTaxonomy(key, facetTaxonomies));
404
+ const sectionHierarchy = sectionHasTaxonomy ? buildHierarchy(key, sectionItems) : undefined;
405
+ const shouldRenderSectionHierarchy = sectionHasTaxonomy && (sectionHierarchy?.roots.length || 0) > 0;
406
+ const visibleSectionRootCount = getVisibleCount(`${key}:${sectionLabel}`, sectionHierarchy?.roots.length || 0);
407
+ const visibleSectionRoots = sectionHierarchy?.roots.slice(0, visibleSectionRootCount) || [];
408
+ const hasMoreSectionRoots = (sectionHierarchy?.roots.length || 0) > visibleSectionRootCount;
409
+ const canShowLessSectionRoots = visibleSectionRootCount > getInitialVisibleCount(sectionHierarchy?.roots.length || 0);
467
410
  return (
468
411
  <Collapsible
469
412
  defaultOpen
@@ -484,13 +427,39 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
484
427
  <SidebarGroupContent>
485
428
  <SidebarMenu className="!gap-0.5">
486
429
  <SidebarMenuSub className="!ml-2 !pl-1.5 !py-0 !gap-0.5">
487
- {!hasNestedGroups &&
430
+ {shouldRenderSectionHierarchy && !hasNestedGroups &&
431
+ visibleSectionRoots.map((item) => renderHierarchyNode(key, item, sectionHierarchy!.children, 0))}
432
+ {shouldRenderSectionHierarchy && !hasNestedGroups && (hasMoreSectionRoots || canShowLessSectionRoots) && (
433
+ <SidebarMenuSubItem>
434
+ <div className="flex items-center gap-3 px-2">
435
+ {hasMoreSectionRoots && (
436
+ <Button
437
+ variant="ghost"
438
+ className="justify-start px-0 text-sm"
439
+ onClick={() => showMore(`${key}:${sectionLabel}`, sectionHierarchy?.roots.length || 0)}
440
+ >
441
+ {t("more")}
442
+ </Button>
443
+ )}
444
+ {canShowLessSectionRoots && (
445
+ <Button
446
+ variant="ghost"
447
+ className="justify-start px-0 text-sm"
448
+ onClick={() => showLess(`${key}:${sectionLabel}`, sectionHierarchy?.roots.length || 0)}
449
+ >
450
+ {t("less")}
451
+ </Button>
452
+ )}
453
+ </div>
454
+ </SidebarMenuSubItem>
455
+ )}
456
+ {!shouldRenderSectionHierarchy && !hasNestedGroups &&
488
457
  renderGroupedItems(key, `${key}:${sectionLabel}`, sectionItems)}
489
458
 
490
459
  {hasNestedGroups &&
491
460
  Object.entries(
492
461
  sectionItems.reduce<Record<string, ResolvedFilterItem[]>>((acc, item) => {
493
- const groupLabel = item.groupLabel || "Other";
462
+ const groupLabel = item.groupLabel || sectionLabel;
494
463
  if (!acc[groupLabel]) acc[groupLabel] = [];
495
464
  acc[groupLabel].push(item);
496
465
  return acc;