@seekora-ai/ui-sdk-react 0.2.13 → 0.2.15

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.
Files changed (117) hide show
  1. package/dist/components/CurrentRefinements.d.ts +22 -2
  2. package/dist/components/CurrentRefinements.d.ts.map +1 -1
  3. package/dist/components/CurrentRefinements.js +259 -47
  4. package/dist/components/FacetDropdown.d.ts +92 -0
  5. package/dist/components/FacetDropdown.d.ts.map +1 -0
  6. package/dist/components/FacetDropdown.js +374 -0
  7. package/dist/components/Facets.d.ts +56 -1
  8. package/dist/components/Facets.d.ts.map +1 -1
  9. package/dist/components/Facets.js +602 -41
  10. package/dist/components/FederatedDropdown.d.ts.map +1 -1
  11. package/dist/components/FederatedDropdown.js +45 -31
  12. package/dist/components/HierarchicalMenu.d.ts.map +1 -1
  13. package/dist/components/HierarchicalMenu.js +112 -4
  14. package/dist/components/Pagination.d.ts +47 -1
  15. package/dist/components/Pagination.d.ts.map +1 -1
  16. package/dist/components/Pagination.js +166 -28
  17. package/dist/components/QuerySuggestionsDropdown.d.ts.map +1 -1
  18. package/dist/components/QuerySuggestionsDropdown.js +32 -18
  19. package/dist/components/RangeInput.d.ts.map +1 -1
  20. package/dist/components/RangeInput.js +6 -6
  21. package/dist/components/RangeSlider.d.ts.map +1 -1
  22. package/dist/components/RangeSlider.js +101 -32
  23. package/dist/components/RichQuerySuggestions.d.ts +7 -0
  24. package/dist/components/RichQuerySuggestions.d.ts.map +1 -1
  25. package/dist/components/RichQuerySuggestions.js +40 -26
  26. package/dist/components/SearchBar.d.ts +16 -0
  27. package/dist/components/SearchBar.d.ts.map +1 -1
  28. package/dist/components/SearchBar.js +139 -17
  29. package/dist/components/SearchBarWithSuggestions.js +3 -3
  30. package/dist/components/SearchLayout.d.ts.map +1 -1
  31. package/dist/components/SearchLayout.js +10 -1
  32. package/dist/components/SearchProvider.d.ts +8 -1
  33. package/dist/components/SearchProvider.d.ts.map +1 -1
  34. package/dist/components/SearchProvider.js +16 -4
  35. package/dist/components/SearchResults.d.ts +10 -0
  36. package/dist/components/SearchResults.d.ts.map +1 -1
  37. package/dist/components/SearchResults.js +46 -30
  38. package/dist/components/SortBy.d.ts +44 -4
  39. package/dist/components/SortBy.d.ts.map +1 -1
  40. package/dist/components/SortBy.js +154 -29
  41. package/dist/components/Stats.d.ts +14 -0
  42. package/dist/components/Stats.d.ts.map +1 -1
  43. package/dist/components/Stats.js +172 -23
  44. package/dist/components/primitives/ActionButtons.d.ts.map +1 -1
  45. package/dist/components/primitives/ActionButtons.js +34 -10
  46. package/dist/components/primitives/BadgeList.d.ts.map +1 -1
  47. package/dist/components/primitives/BadgeList.js +33 -13
  48. package/dist/components/primitives/ImageDisplay.d.ts.map +1 -1
  49. package/dist/components/primitives/ImageDisplay.js +11 -8
  50. package/dist/components/primitives/ImageZoom.js +26 -26
  51. package/dist/components/primitives/VariantSelector.js +10 -10
  52. package/dist/components/primitives/VariantSwatches.js +3 -3
  53. package/dist/components/product-page/ProductGallery.d.ts +8 -1
  54. package/dist/components/product-page/ProductGallery.d.ts.map +1 -1
  55. package/dist/components/product-page/ProductGallery.js +2 -2
  56. package/dist/components/section-primitives/SectionSearchProvider.d.ts +3 -1
  57. package/dist/components/section-primitives/SectionSearchProvider.d.ts.map +1 -1
  58. package/dist/components/section-primitives/SectionSearchProvider.js +3 -2
  59. package/dist/components/suggestions/AmazonDropdown.d.ts.map +1 -1
  60. package/dist/components/suggestions/AmazonDropdown.js +2 -4
  61. package/dist/components/suggestions/GoogleDropdown.d.ts.map +1 -1
  62. package/dist/components/suggestions/GoogleDropdown.js +2 -6
  63. package/dist/components/suggestions/MinimalDropdown.d.ts.map +1 -1
  64. package/dist/components/suggestions/MinimalDropdown.js +2 -4
  65. package/dist/components/suggestions/MobileSheetDropdown.d.ts.map +1 -1
  66. package/dist/components/suggestions/MobileSheetDropdown.js +20 -22
  67. package/dist/components/suggestions/PinterestDropdown.d.ts.map +1 -1
  68. package/dist/components/suggestions/PinterestDropdown.js +2 -6
  69. package/dist/components/suggestions/ShopifyDropdown.d.ts.map +1 -1
  70. package/dist/components/suggestions/ShopifyDropdown.js +39 -41
  71. package/dist/components/suggestions/SpotlightDropdown.d.ts.map +1 -1
  72. package/dist/components/suggestions/SpotlightDropdown.js +2 -4
  73. package/dist/components/suggestions/utils.d.ts +10 -1
  74. package/dist/components/suggestions/utils.d.ts.map +1 -1
  75. package/dist/components/suggestions/utils.js +36 -0
  76. package/dist/components/suggestions-primitives/DropdownPanel.d.ts.map +1 -1
  77. package/dist/components/suggestions-primitives/DropdownPanel.js +15 -2
  78. package/dist/components/suggestions-primitives/ItemCard.d.ts.map +1 -1
  79. package/dist/components/suggestions-primitives/ItemCard.js +21 -8
  80. package/dist/components/suggestions-primitives/ItemGrid.d.ts.map +1 -1
  81. package/dist/components/suggestions-primitives/ItemGrid.js +9 -3
  82. package/dist/components/suggestions-primitives/ProductCard.d.ts.map +1 -1
  83. package/dist/components/suggestions-primitives/ProductCard.js +25 -10
  84. package/dist/components/suggestions-primitives/ProductCardLayouts.d.ts.map +1 -1
  85. package/dist/components/suggestions-primitives/ProductCardLayouts.js +24 -12
  86. package/dist/components/suggestions-primitives/SearchInput.d.ts.map +1 -1
  87. package/dist/components/suggestions-primitives/SearchInput.js +28 -9
  88. package/dist/components/suggestions-primitives/SuggestionItem.d.ts.map +1 -1
  89. package/dist/components/suggestions-primitives/SuggestionItem.js +3 -0
  90. package/dist/components/suggestions-primitives/highlightMarkup.d.ts +16 -4
  91. package/dist/components/suggestions-primitives/highlightMarkup.d.ts.map +1 -1
  92. package/dist/components/suggestions-primitives/highlightMarkup.js +42 -4
  93. package/dist/hooks/useClickTracking.d.ts +36 -0
  94. package/dist/hooks/useClickTracking.d.ts.map +1 -0
  95. package/dist/hooks/useClickTracking.js +96 -0
  96. package/dist/hooks/useExperiment.d.ts +25 -0
  97. package/dist/hooks/useExperiment.d.ts.map +1 -0
  98. package/dist/hooks/useExperiment.js +146 -0
  99. package/dist/hooks/useKeyboardNavigation.d.ts +51 -0
  100. package/dist/hooks/useKeyboardNavigation.d.ts.map +1 -0
  101. package/dist/hooks/useKeyboardNavigation.js +113 -0
  102. package/dist/hooks/useQuerySuggestions.d.ts.map +1 -1
  103. package/dist/hooks/useQuerySuggestions.js +19 -3
  104. package/dist/hooks/useQuerySuggestionsEnhanced.d.ts.map +1 -1
  105. package/dist/hooks/useQuerySuggestionsEnhanced.js +23 -6
  106. package/dist/hooks/useSuggestionsAnalytics.d.ts.map +1 -1
  107. package/dist/hooks/useSuggestionsAnalytics.js +6 -1
  108. package/dist/index.d.ts +6 -1
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +1 -0
  111. package/dist/index.umd.js +1 -1
  112. package/dist/src/index.d.ts +345 -19
  113. package/dist/src/index.esm.js +2869 -787
  114. package/dist/src/index.esm.js.map +1 -1
  115. package/dist/src/index.js +2868 -785
  116. package/dist/src/index.js.map +1 -1
  117. package/package.json +6 -6
@@ -1,21 +1,83 @@
1
1
  /**
2
2
  * Facets Component
3
3
  *
4
- * Displays facet filters for search results
4
+ * Displays facet filters for search results with multiple display variants,
5
+ * client-side search, count badges, and color swatch support.
5
6
  */
6
- import React, { useState } from 'react';
7
+ import React, { useState, useMemo } from 'react';
7
8
  import { useSearchContext } from './SearchProvider';
8
9
  import { useSearchState } from '../hooks/useSearchState';
9
10
  import { log } from '@seekora-ai/ui-sdk-core';
10
11
  import { clsx } from 'clsx';
11
- export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, }) => {
12
+ import { RangeSlider } from './RangeSlider';
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ /** Generate a deterministic colour from a string (used as fallback for color-swatch). */
17
+ function stringToColor(str) {
18
+ let hash = 0;
19
+ for (let i = 0; i < str.length; i++) {
20
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
21
+ hash |= 0; // Convert to 32-bit int
22
+ }
23
+ const h = Math.abs(hash) % 360;
24
+ return `hsl(${h}, 65%, 55%)`;
25
+ }
26
+ /** Return swatch pixel size from the size prop. */
27
+ function swatchSize(size) {
28
+ switch (size) {
29
+ case 'small':
30
+ return 24;
31
+ case 'large':
32
+ return 40;
33
+ case 'medium':
34
+ default:
35
+ return 32;
36
+ }
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Chevron SVG icon for collapsible variant
40
+ // ---------------------------------------------------------------------------
41
+ const ChevronIcon = ({ expanded, color = 'currentColor', size = 16, }) => (React.createElement("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", style: {
42
+ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
43
+ transition: 'transform 200ms ease',
44
+ flexShrink: 0,
45
+ }, "aria-hidden": "true" },
46
+ React.createElement("path", { d: "M4 6L8 10L12 6", stroke: color, strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })));
47
+ // ---------------------------------------------------------------------------
48
+ // Checkmark SVG for selected color swatches
49
+ // ---------------------------------------------------------------------------
50
+ const CheckmarkIcon = ({ size = 16 }) => (React.createElement("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true" },
51
+ React.createElement("path", { d: "M3.5 8.5L6.5 11.5L12.5 4.5", stroke: "#fff", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })));
52
+ // ---------------------------------------------------------------------------
53
+ // CSS variable defaults
54
+ // ---------------------------------------------------------------------------
55
+ const CSS_VAR_DEFAULTS = {
56
+ '--seekora-facet-bg': 'transparent',
57
+ '--seekora-facet-border': '#dee2e6',
58
+ '--seekora-facet-active-bg': 'rgba(0, 0, 0, 0.05)',
59
+ '--seekora-facet-swatch-size': '32px',
60
+ '--seekora-facet-count-bg': '#e9ecef',
61
+ '--seekora-facet-count-color': '#495057',
62
+ '--seekora-primary': '#3b82f6',
63
+ '--seekora-primary-text': '#ffffff',
64
+ };
65
+ // ---------------------------------------------------------------------------
66
+ // Component
67
+ // ---------------------------------------------------------------------------
68
+ export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, variant = 'checkbox', searchable = false, showCounts = true, colorMap, defaultCollapsed = false, size = 'medium', facetRanges, }) => {
12
69
  const { theme } = useSearchContext();
13
70
  const { results: stateResults, refinements, addRefinement, removeRefinement } = useSearchState();
14
71
  const facetsTheme = customTheme || {};
72
+ // expandedFacets is used for "Show more/less" in checkbox/color-swatch variants
73
+ // AND for collapse/expand in collapsible variant.
15
74
  const [expandedFacets, setExpandedFacets] = useState({});
75
+ const [searchTerms, setSearchTerms] = useState({});
16
76
  // Use results from prop if provided, otherwise from state manager
17
77
  const results = resultsProp || stateResults;
78
+ // -------------------------------------------------------------------
18
79
  // Extract facets from results
80
+ // -------------------------------------------------------------------
19
81
  const extractFacets = () => {
20
82
  if (facetsProp)
21
83
  return facetsProp;
@@ -53,6 +115,7 @@ export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange
53
115
  count: count.count,
54
116
  selected: refinements.some(r => r.field === fieldName && r.value === count.value),
55
117
  })) : [],
118
+ stats: facet.stats || undefined,
56
119
  };
57
120
  });
58
121
  log.verbose('Facets: Extracted facets', {
@@ -62,6 +125,9 @@ export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange
62
125
  return extracted;
63
126
  };
64
127
  const facets = extractFacets();
128
+ // -------------------------------------------------------------------
129
+ // Handlers
130
+ // -------------------------------------------------------------------
65
131
  const handleFacetToggle = (field, value, selected) => {
66
132
  const newSelected = !selected;
67
133
  log.verbose('Facets: Facet toggled', { field, value, selected: newSelected });
@@ -94,49 +160,260 @@ export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange
94
160
  [field]: !prev[field],
95
161
  }));
96
162
  };
163
+ /** For collapsible variant — determine if a facet group is open. */
164
+ const isFacetGroupOpen = (field) => {
165
+ if (field in expandedFacets) {
166
+ return expandedFacets[field];
167
+ }
168
+ // Default based on defaultCollapsed prop
169
+ return !defaultCollapsed;
170
+ };
171
+ const toggleCollapsible = (field) => {
172
+ setExpandedFacets((prev) => ({
173
+ ...prev,
174
+ [field]: !(prev[field] ?? !defaultCollapsed),
175
+ }));
176
+ };
177
+ const getSearchTerm = (field) => searchTerms[field] || '';
178
+ const setSearchTerm = (field, term) => {
179
+ setSearchTerms((prev) => ({ ...prev, [field]: term }));
180
+ };
181
+ /** Filter facet items by search term. */
182
+ const filterItems = (items, field) => {
183
+ if (!searchable)
184
+ return items;
185
+ const term = getSearchTerm(field).toLowerCase();
186
+ if (!term)
187
+ return items;
188
+ return items.filter((item) => item.value.toLowerCase().includes(term));
189
+ };
190
+ // -------------------------------------------------------------------
191
+ // Size helpers
192
+ // -------------------------------------------------------------------
193
+ const sizeScale = useMemo(() => {
194
+ switch (size) {
195
+ case 'small':
196
+ return { font: theme.typography.fontSize.small, padding: '0.5rem', gap: '0.5rem' };
197
+ case 'large':
198
+ return { font: theme.typography.fontSize.large, padding: '0.75rem', gap: '0.75rem' };
199
+ case 'medium':
200
+ default:
201
+ return { font: theme.typography.fontSize.medium, padding: theme.spacing.small, gap: theme.spacing.small };
202
+ }
203
+ }, [size, theme]);
204
+ // -------------------------------------------------------------------
205
+ // Count badge renderer
206
+ // -------------------------------------------------------------------
207
+ const renderCountBadge = (count) => {
208
+ if (!showCounts)
209
+ return null;
210
+ return (React.createElement("span", { className: clsx(facetsTheme.countBadge), style: {
211
+ display: 'inline-flex',
212
+ alignItems: 'center',
213
+ justifyContent: 'center',
214
+ minWidth: '1.5em',
215
+ padding: '0.1em 0.5em',
216
+ marginLeft: sizeScale.gap,
217
+ fontSize: theme.typography.fontSize.small,
218
+ fontWeight: theme.typography.fontWeight?.medium ?? 500,
219
+ lineHeight: 1,
220
+ color: 'var(--seekora-facet-count-color, #495057)',
221
+ backgroundColor: 'var(--seekora-facet-count-bg, #e9ecef)',
222
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.full,
223
+ flexShrink: 0,
224
+ } }, count));
225
+ };
226
+ // -------------------------------------------------------------------
227
+ // Search input renderer
228
+ // -------------------------------------------------------------------
229
+ const renderSearchInput = (facet) => {
230
+ if (!searchable)
231
+ return null;
232
+ return (React.createElement("input", { type: "text", value: getSearchTerm(facet.field), onChange: (e) => setSearchTerm(facet.field, e.target.value), placeholder: `Search ${facet.label || facet.field}...`, className: clsx(facetsTheme.searchInput), "aria-label": `Search within ${facet.label || facet.field}`, style: {
233
+ width: '100%',
234
+ boxSizing: 'border-box',
235
+ padding: sizeScale.padding,
236
+ marginBottom: sizeScale.gap,
237
+ fontSize: theme.typography.fontSize.small,
238
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
239
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.small,
240
+ outline: 'none',
241
+ color: theme.colors.text,
242
+ backgroundColor: 'var(--seekora-facet-bg, transparent)',
243
+ } }));
244
+ };
245
+ // -------------------------------------------------------------------
246
+ // Checkbox variant item renderer (original behaviour, preserved)
247
+ // -------------------------------------------------------------------
97
248
  const defaultRenderFacetItem = (item, facet, index) => {
98
249
  const isExpanded = expandedFacets[facet.field] || index < maxItems;
99
250
  if (!isExpanded && index >= maxItems) {
100
251
  return null;
101
252
  }
102
- return (React.createElement("div", { key: `${facet.field}-${item.value}`, className: clsx(facetsTheme.facetItem, (refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected) && facetsTheme.facetItemActive), onClick: () => handleFacetToggle(facet.field, item.value, item.selected || false), style: {
253
+ const isChecked = refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected || false;
254
+ return (React.createElement("div", { key: `${facet.field}-${item.value}`, className: clsx(facetsTheme.facetItem, isChecked && facetsTheme.facetItemActive), role: "option", "aria-selected": isChecked, "aria-checked": isChecked, tabIndex: -1, onClick: () => handleFacetToggle(facet.field, item.value, item.selected || false), style: {
103
255
  display: 'flex',
104
256
  alignItems: 'center',
105
- padding: theme.spacing.small,
257
+ padding: sizeScale.padding,
106
258
  cursor: 'pointer',
107
259
  borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
108
- marginBottom: theme.spacing.small,
109
- backgroundColor: (refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected) ? theme.colors.hover : 'transparent',
260
+ marginBottom: sizeScale.gap,
261
+ backgroundColor: isChecked
262
+ ? 'var(--seekora-facet-active-bg, ' + theme.colors.hover + ')'
263
+ : 'transparent',
110
264
  transition: 'background-color 0.2s ease',
265
+ boxSizing: 'border-box',
111
266
  } },
112
267
  React.createElement("input", { type: "checkbox", checked: refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected || false, onChange: () => handleFacetToggle(facet.field, item.value, item.selected || false), className: facetsTheme.checkbox, style: {
113
- marginRight: theme.spacing.small,
268
+ marginRight: sizeScale.gap,
114
269
  cursor: 'pointer',
115
270
  }, "aria-label": `Filter by ${item.value}` }),
116
271
  React.createElement("span", { className: facetsTheme.facetItemLabel, style: {
117
272
  flex: 1,
118
- fontSize: theme.typography.fontSize.medium,
273
+ fontSize: sizeScale.font,
274
+ lineHeight: 1.5,
119
275
  color: theme.colors.text,
120
276
  } }, item.value),
121
- React.createElement("span", { className: facetsTheme.facetItemCount, style: {
277
+ renderCountBadge(item.count)));
278
+ };
279
+ // -------------------------------------------------------------------
280
+ // Color swatch variant item renderer
281
+ // -------------------------------------------------------------------
282
+ const renderColorSwatchItem = (item, facet, index) => {
283
+ const isExpanded = expandedFacets[facet.field] || index < maxItems;
284
+ if (!isExpanded && index >= maxItems) {
285
+ return null;
286
+ }
287
+ const isChecked = refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected || false;
288
+ const color = colorMap?.[item.value] ?? stringToColor(item.value);
289
+ const pxSize = swatchSize(size);
290
+ return (React.createElement("div", { key: `${facet.field}-${item.value}`, className: clsx(facetsTheme.facetItem, isChecked && facetsTheme.facetItemActive), role: "option", "aria-selected": isChecked, "aria-checked": isChecked, tabIndex: -1, onClick: () => handleFacetToggle(facet.field, item.value, item.selected || false), title: `${item.value}${showCounts ? ` (${item.count})` : ''}`, style: {
291
+ display: 'inline-flex',
292
+ flexDirection: 'column',
293
+ alignItems: 'center',
294
+ cursor: 'pointer',
295
+ margin: sizeScale.gap,
296
+ } },
297
+ React.createElement("div", { className: clsx(facetsTheme.colorSwatch, isChecked && facetsTheme.colorSwatchSelected), style: {
298
+ width: `var(--seekora-facet-swatch-size, ${pxSize}px)`,
299
+ height: `var(--seekora-facet-swatch-size, ${pxSize}px)`,
300
+ borderRadius: '50%',
301
+ backgroundColor: color,
302
+ border: isChecked
303
+ ? `3px solid var(--seekora-primary, ${theme.colors.primary})`
304
+ : `2px solid var(--seekora-facet-border, ${theme.colors.border})`,
305
+ display: 'flex',
306
+ alignItems: 'center',
307
+ justifyContent: 'center',
308
+ transition: 'border 0.2s ease, box-shadow 0.2s ease',
309
+ boxShadow: isChecked ? `0 0 0 2px var(--seekora-primary-alpha, ${theme.colors.primary}33)` : 'none',
310
+ position: 'relative',
311
+ } }, isChecked && (React.createElement("span", { className: clsx(facetsTheme.colorSwatchInner), style: {
312
+ display: 'flex',
313
+ alignItems: 'center',
314
+ justifyContent: 'center',
315
+ } },
316
+ React.createElement(CheckmarkIcon, { size: Math.round(pxSize * 0.5) })))),
317
+ React.createElement("span", { className: facetsTheme.facetItemLabel, style: {
122
318
  fontSize: theme.typography.fontSize.small,
123
- color: theme.colors.textSecondary || theme.colors.text,
124
- opacity: 0.7,
125
- marginLeft: theme.spacing.small,
319
+ color: theme.colors.text,
320
+ marginTop: '0.25rem',
321
+ textAlign: 'center',
322
+ maxWidth: `${pxSize + 16}px`,
323
+ overflow: 'hidden',
324
+ textOverflow: 'ellipsis',
325
+ whiteSpace: 'nowrap',
326
+ } }, item.value),
327
+ showCounts && (React.createElement("span", { className: clsx(facetsTheme.countBadge), style: {
328
+ fontSize: theme.typography.fontSize.small,
329
+ color: 'var(--seekora-facet-count-color, ' + (theme.colors.textSecondary || theme.colors.text) + ')',
330
+ lineHeight: 1,
331
+ marginTop: '0.125rem',
332
+ } }, item.count))));
333
+ };
334
+ // -------------------------------------------------------------------
335
+ // Item renderer dispatcher
336
+ // -------------------------------------------------------------------
337
+ const renderItem = (item, facet, index) => {
338
+ if (renderFacetItem) {
339
+ return renderFacetItem(item, facet, index);
340
+ }
341
+ switch (variant) {
342
+ case 'color-swatch':
343
+ return renderColorSwatchItem(item, facet, index);
344
+ case 'collapsible':
345
+ case 'checkbox':
346
+ default:
347
+ return defaultRenderFacetItem(item, facet, index);
348
+ }
349
+ };
350
+ // -------------------------------------------------------------------
351
+ // Keyboard handler (shared across variants)
352
+ // -------------------------------------------------------------------
353
+ const handleListKeyDown = (e, visibleItems, facet) => {
354
+ const currentEl = e.currentTarget.querySelector('[aria-selected="true"]');
355
+ const allItems = Array.from(e.currentTarget.querySelectorAll('[role="option"]'));
356
+ const currentIdx = currentEl ? allItems.indexOf(currentEl) : -1;
357
+ if (e.key === 'ArrowDown') {
358
+ e.preventDefault();
359
+ const next = Math.min(currentIdx + 1, allItems.length - 1);
360
+ allItems[next]?.focus();
361
+ }
362
+ else if (e.key === 'ArrowUp') {
363
+ e.preventDefault();
364
+ const prev = Math.max(currentIdx - 1, 0);
365
+ allItems[prev]?.focus();
366
+ }
367
+ else if (e.key === 'Enter' || e.key === ' ') {
368
+ e.preventDefault();
369
+ if (currentIdx >= 0 && currentIdx < visibleItems.length) {
370
+ handleFacetToggle(facet.field, visibleItems[currentIdx].value, visibleItems[currentIdx].selected || false);
371
+ }
372
+ }
373
+ };
374
+ // -------------------------------------------------------------------
375
+ // Show more / less buttons (shared)
376
+ // -------------------------------------------------------------------
377
+ const renderShowMoreLess = (facet, filteredItems) => {
378
+ const isExpanded = expandedFacets[facet.field] || false;
379
+ const hasMore = filteredItems.length > maxItems;
380
+ return (React.createElement(React.Fragment, null,
381
+ showMore && hasMore && !isExpanded && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
382
+ marginTop: sizeScale.gap,
383
+ padding: sizeScale.padding,
384
+ border: 'none',
385
+ backgroundColor: 'transparent',
386
+ color: theme.colors.primary,
387
+ cursor: 'pointer',
388
+ fontSize: theme.typography.fontSize.small,
389
+ textDecoration: 'underline',
126
390
  } },
127
- "(",
128
- item.count,
129
- ")")));
391
+ "Show more (",
392
+ filteredItems.length - maxItems,
393
+ " more)")),
394
+ isExpanded && hasMore && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
395
+ marginTop: sizeScale.gap,
396
+ padding: sizeScale.padding,
397
+ border: 'none',
398
+ backgroundColor: 'transparent',
399
+ color: theme.colors.primary,
400
+ cursor: 'pointer',
401
+ fontSize: theme.typography.fontSize.small,
402
+ textDecoration: 'underline',
403
+ } }, "Show less"))));
130
404
  };
131
- const defaultRenderFacet = (facet, index) => {
405
+ // -------------------------------------------------------------------
406
+ // Default facet group renderer — Checkbox variant
407
+ // -------------------------------------------------------------------
408
+ const renderCheckboxFacet = (facet, _index) => {
409
+ const filteredItems = filterItems(facet.items, facet.field);
132
410
  const isExpanded = expandedFacets[facet.field] || false;
133
- const visibleItems = isExpanded ? facet.items : facet.items.slice(0, maxItems);
134
- const hasMore = facet.items.length > maxItems;
411
+ const visibleItems = isExpanded ? filteredItems : filteredItems.slice(0, maxItems);
135
412
  return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
136
413
  marginBottom: theme.spacing.large,
137
414
  padding: theme.spacing.medium,
138
- backgroundColor: theme.colors.background,
139
- border: `1px solid ${theme.colors.border}`,
415
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
416
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
140
417
  borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
141
418
  } },
142
419
  React.createElement("h3", { className: facetsTheme.facetTitle, style: {
@@ -146,36 +423,314 @@ export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange
146
423
  marginBottom: theme.spacing.medium,
147
424
  color: theme.colors.text,
148
425
  } }, facet.label || facet.field),
149
- React.createElement("div", { className: facetsTheme.facetList }, visibleItems.map((item, itemIndex) => {
150
- const actualIndex = isExpanded ? itemIndex : itemIndex;
151
- return renderFacetItem
152
- ? renderFacetItem(item, facet, actualIndex)
153
- : defaultRenderFacetItem(item, facet, actualIndex);
154
- })),
155
- showMore && hasMore && !isExpanded && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
156
- marginTop: theme.spacing.small,
157
- padding: theme.spacing.small,
426
+ renderSearchInput(facet),
427
+ React.createElement("div", { className: facetsTheme.facetList, role: "listbox", "aria-label": `${facet.label || facet.field} filters`, tabIndex: 0, onKeyDown: (e) => handleListKeyDown(e, visibleItems, facet) }, visibleItems.map((item, itemIndex) => renderItem(item, facet, itemIndex))),
428
+ renderShowMoreLess(facet, filteredItems)));
429
+ };
430
+ // -------------------------------------------------------------------
431
+ // Color-swatch facet group renderer
432
+ // -------------------------------------------------------------------
433
+ const renderColorSwatchFacet = (facet, _index) => {
434
+ const filteredItems = filterItems(facet.items, facet.field);
435
+ const isExpanded = expandedFacets[facet.field] || false;
436
+ const visibleItems = isExpanded ? filteredItems : filteredItems.slice(0, maxItems);
437
+ return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
438
+ marginBottom: theme.spacing.large,
439
+ padding: theme.spacing.medium,
440
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
441
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
442
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
443
+ } },
444
+ React.createElement("h3", { className: facetsTheme.facetTitle, style: {
445
+ fontSize: theme.typography.fontSize.large,
446
+ fontWeight: 'bold',
447
+ margin: 0,
448
+ marginBottom: theme.spacing.medium,
449
+ color: theme.colors.text,
450
+ } }, facet.label || facet.field),
451
+ renderSearchInput(facet),
452
+ React.createElement("div", { className: facetsTheme.facetList, role: "listbox", "aria-label": `${facet.label || facet.field} filters`, tabIndex: 0, onKeyDown: (e) => handleListKeyDown(e, visibleItems, facet), style: {
453
+ display: 'flex',
454
+ flexWrap: 'wrap',
455
+ gap: sizeScale.gap,
456
+ } }, visibleItems.map((item, itemIndex) => renderItem(item, facet, itemIndex))),
457
+ renderShowMoreLess(facet, filteredItems)));
458
+ };
459
+ // -------------------------------------------------------------------
460
+ // Collapsible facet group renderer
461
+ // -------------------------------------------------------------------
462
+ const renderCollapsibleFacet = (facet, _index) => {
463
+ const isOpen = isFacetGroupOpen(facet.field);
464
+ const filteredItems = filterItems(facet.items, facet.field);
465
+ const isItemsExpanded = expandedFacets[facet.field] || false;
466
+ // Note: For collapsible, the expandedFacets state controls the collapse/expand
467
+ // of the group itself. We use a separate concept for Show more/less within items.
468
+ // To avoid collision, Show more/less for collapsible uses the same expandedFacets
469
+ // key prefixed with `_items_`.
470
+ const isShowMoreExpanded = expandedFacets[`_items_${facet.field}`] || false;
471
+ const visibleItems = isShowMoreExpanded ? filteredItems : filteredItems.slice(0, maxItems);
472
+ const hasMore = filteredItems.length > maxItems;
473
+ return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
474
+ marginBottom: theme.spacing.large,
475
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
476
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
477
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
478
+ overflow: 'hidden',
479
+ } },
480
+ React.createElement("button", { type: "button", className: clsx(facetsTheme.collapsibleHeader), onClick: () => toggleCollapsible(facet.field), "aria-expanded": isOpen, "aria-controls": `facet-group-${facet.field}`, style: {
481
+ display: 'flex',
482
+ alignItems: 'center',
483
+ justifyContent: 'space-between',
484
+ width: '100%',
485
+ padding: theme.spacing.medium,
158
486
  border: 'none',
159
487
  backgroundColor: 'transparent',
160
- color: theme.colors.primary,
161
488
  cursor: 'pointer',
162
- fontSize: theme.typography.fontSize.small,
163
- textDecoration: 'underline',
489
+ textAlign: 'left',
164
490
  } },
165
- "Show more (",
166
- facet.items.length - maxItems,
167
- " more)")),
168
- isExpanded && hasMore && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
169
- marginTop: theme.spacing.small,
170
- padding: theme.spacing.small,
491
+ React.createElement("span", { className: facetsTheme.facetTitle, style: {
492
+ fontSize: theme.typography.fontSize.large,
493
+ fontWeight: 'bold',
494
+ color: theme.colors.text,
495
+ flex: 1,
496
+ } }, facet.label || facet.field),
497
+ React.createElement("span", { className: clsx(facetsTheme.collapsibleIcon) },
498
+ React.createElement(ChevronIcon, { expanded: isOpen, color: theme.colors.textSecondary || theme.colors.text }))),
499
+ isOpen && (React.createElement("div", { id: `facet-group-${facet.field}`, style: {
500
+ padding: `0 ${theme.spacing.medium} ${theme.spacing.medium}`,
501
+ } },
502
+ renderSearchInput(facet),
503
+ React.createElement("div", { className: facetsTheme.facetList, role: "listbox", "aria-label": `${facet.label || facet.field} filters`, tabIndex: 0, onKeyDown: (e) => handleListKeyDown(e, visibleItems, facet) }, visibleItems.map((item, itemIndex) => renderItem(item, facet, itemIndex))),
504
+ showMore && hasMore && !isShowMoreExpanded && (React.createElement("button", { type: "button", onClick: () => setExpandedFacets((prev) => ({
505
+ ...prev,
506
+ [`_items_${facet.field}`]: true,
507
+ })), style: {
508
+ marginTop: sizeScale.gap,
509
+ padding: sizeScale.padding,
510
+ border: 'none',
511
+ backgroundColor: 'transparent',
512
+ color: theme.colors.primary,
513
+ cursor: 'pointer',
514
+ fontSize: theme.typography.fontSize.small,
515
+ textDecoration: 'underline',
516
+ } },
517
+ "Show more (",
518
+ filteredItems.length - maxItems,
519
+ " more)")),
520
+ isShowMoreExpanded && hasMore && (React.createElement("button", { type: "button", onClick: () => setExpandedFacets((prev) => ({
521
+ ...prev,
522
+ [`_items_${facet.field}`]: false,
523
+ })), style: {
524
+ marginTop: sizeScale.gap,
525
+ padding: sizeScale.padding,
526
+ border: 'none',
527
+ backgroundColor: 'transparent',
528
+ color: theme.colors.primary,
529
+ cursor: 'pointer',
530
+ fontSize: theme.typography.fontSize.small,
531
+ textDecoration: 'underline',
532
+ } }, "Show less"))))));
533
+ };
534
+ // -------------------------------------------------------------------
535
+ // Render-type detection for numeric / range facets
536
+ // -------------------------------------------------------------------
537
+ const determineFacetRenderType = (fieldName, stats) => {
538
+ // If explicit range config exists for this field → range buttons
539
+ if (facetRanges?.some((rc) => rc.field === fieldName)) {
540
+ return 'range-buttons';
541
+ }
542
+ // If stats with valid min/max → range slider
543
+ if (stats && typeof stats.min === 'number' && typeof stats.max === 'number' && stats.min !== stats.max) {
544
+ return 'range-slider';
545
+ }
546
+ return 'list';
547
+ };
548
+ // -------------------------------------------------------------------
549
+ // Range button facet renderer
550
+ // -------------------------------------------------------------------
551
+ const renderRangeButtonFacet = (facet, _index) => {
552
+ const rangeConfig = facetRanges?.find((rc) => rc.field === facet.field);
553
+ if (!rangeConfig)
554
+ return null;
555
+ // Build a lookup from item value → count
556
+ const countMap = new Map();
557
+ facet.items.forEach((item) => countMap.set(item.value, item.count));
558
+ // Detect currently active range from refinements
559
+ const activeMin = refinements.find((r) => r.field === facet.field && r.value.startsWith('>='));
560
+ const activeMax = refinements.find((r) => r.field === facet.field && r.value.startsWith('<='));
561
+ const activeFromVal = activeMin ? parseFloat(activeMin.value.slice(2)) : undefined;
562
+ const activeToVal = activeMax ? parseFloat(activeMax.value.slice(2)) : undefined;
563
+ const isRangeActive = (range) => {
564
+ const fromMatch = range.from === undefined
565
+ ? activeFromVal === undefined
566
+ : activeFromVal === range.from;
567
+ const toMatch = range.to === undefined
568
+ ? activeToVal === undefined
569
+ : activeToVal === range.to;
570
+ return fromMatch && toMatch;
571
+ };
572
+ const handleRangeClick = (range) => {
573
+ // Clear existing range refinements for this field
574
+ refinements
575
+ .filter((r) => r.field === facet.field)
576
+ .forEach((r) => removeRefinement(facet.field, r.value, false));
577
+ if (isRangeActive(range)) {
578
+ // Was active → just clear (already removed above), trigger search
579
+ addRefinement(facet.field, '__noop__', false);
580
+ removeRefinement(facet.field, '__noop__', true);
581
+ return;
582
+ }
583
+ // Set new range
584
+ if (range.from !== undefined) {
585
+ addRefinement(facet.field, `>=${range.from}`, false);
586
+ }
587
+ if (range.to !== undefined) {
588
+ addRefinement(facet.field, `<=${range.to}`, true);
589
+ }
590
+ else if (range.from !== undefined) {
591
+ // Only "from" set — trigger search
592
+ addRefinement(facet.field, `>=${range.from}`, true);
593
+ }
594
+ };
595
+ const hasActiveRange = activeMin !== undefined || activeMax !== undefined;
596
+ const handleClear = () => {
597
+ refinements
598
+ .filter((r) => r.field === facet.field)
599
+ .forEach((r) => removeRefinement(facet.field, r.value, false));
600
+ // Trigger search after clearing
601
+ addRefinement(facet.field, '__noop__', false);
602
+ removeRefinement(facet.field, '__noop__', true);
603
+ };
604
+ return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
605
+ marginBottom: theme.spacing.large,
606
+ padding: theme.spacing.medium,
607
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
608
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
609
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
610
+ } },
611
+ React.createElement("h3", { className: facetsTheme.facetTitle, style: {
612
+ fontSize: theme.typography.fontSize.large,
613
+ fontWeight: 'bold',
614
+ margin: 0,
615
+ marginBottom: theme.spacing.medium,
616
+ color: theme.colors.text,
617
+ } }, facet.label || facet.field),
618
+ React.createElement("div", { style: {
619
+ display: 'flex',
620
+ flexWrap: 'wrap',
621
+ gap: sizeScale.gap,
622
+ } }, rangeConfig.ranges.map((range) => {
623
+ const count = countMap.get(range.label) ?? 0;
624
+ const active = isRangeActive(range);
625
+ return (React.createElement("button", { key: range.label, type: "button", className: clsx(facetsTheme.rangeButton, active && facetsTheme.rangeButtonActive), disabled: count === 0 && !active, onClick: () => handleRangeClick(range), style: {
626
+ display: 'inline-flex',
627
+ alignItems: 'center',
628
+ gap: '0.35em',
629
+ padding: `${sizeScale.padding} 0.75em`,
630
+ fontSize: sizeScale.font,
631
+ border: `1px solid ${active ? 'var(--seekora-primary, ' + theme.colors.primary + ')' : 'var(--seekora-facet-border, ' + theme.colors.border + ')'}`,
632
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.full,
633
+ backgroundColor: active ? 'var(--seekora-primary, ' + theme.colors.primary + ')' : 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
634
+ color: active ? 'var(--seekora-primary-text, #fff)' : theme.colors.text,
635
+ cursor: count === 0 && !active ? 'not-allowed' : 'pointer',
636
+ opacity: count === 0 && !active ? 0.5 : 1,
637
+ transition: 'all 0.2s ease',
638
+ } },
639
+ range.label,
640
+ showCounts && (React.createElement("span", { className: clsx(facetsTheme.rangeButtonCount), style: {
641
+ fontSize: theme.typography.fontSize.small,
642
+ opacity: 0.8,
643
+ } },
644
+ "(",
645
+ count,
646
+ ")"))));
647
+ })),
648
+ hasActiveRange && (React.createElement("button", { type: "button", className: clsx(facetsTheme.rangeClear), onClick: handleClear, style: {
649
+ marginTop: sizeScale.gap,
650
+ padding: sizeScale.padding,
171
651
  border: 'none',
172
652
  backgroundColor: 'transparent',
173
653
  color: theme.colors.primary,
174
654
  cursor: 'pointer',
175
655
  fontSize: theme.typography.fontSize.small,
176
656
  textDecoration: 'underline',
177
- } }, "Show less"))));
657
+ } }, "Clear"))));
658
+ };
659
+ // -------------------------------------------------------------------
660
+ // Range slider facet renderer
661
+ // -------------------------------------------------------------------
662
+ const renderRangeSliderFacet = (facet, _index) => {
663
+ if (!facet.stats)
664
+ return null;
665
+ const { min: statsMin, max: statsMax } = facet.stats;
666
+ // Auto-detect formatting for common field names
667
+ const fieldLower = facet.field.toLowerCase();
668
+ const isPriceField = fieldLower.includes('price') || fieldLower.includes('cost') || fieldLower.includes('amount');
669
+ const formatValue = isPriceField
670
+ ? (v) => `$${v.toFixed(2)}`
671
+ : (v) => v.toString();
672
+ // Determine step: use 0.01 for price, 1 for integer ranges, 0.1 otherwise
673
+ const range = statsMax - statsMin;
674
+ const step = isPriceField ? 0.01 : range > 10 ? 1 : 0.1;
675
+ // Check if there's an active range refinement
676
+ const hasActiveRange = refinements.some((r) => r.field === facet.field && (r.value.startsWith('>=') || r.value.startsWith('<=')));
677
+ const handleClear = () => {
678
+ refinements
679
+ .filter((r) => r.field === facet.field && (r.value.startsWith('>=') || r.value.startsWith('<=')))
680
+ .forEach((r) => removeRefinement(facet.field, r.value, false));
681
+ // Trigger search after clearing
682
+ addRefinement(facet.field, '__noop__', false);
683
+ removeRefinement(facet.field, '__noop__', true);
684
+ };
685
+ return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
686
+ marginBottom: theme.spacing.large,
687
+ padding: theme.spacing.medium,
688
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
689
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
690
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
691
+ } },
692
+ React.createElement("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.spacing.small } },
693
+ React.createElement("h3", { className: facetsTheme.facetTitle, style: {
694
+ fontSize: theme.typography.fontSize.large,
695
+ fontWeight: 'bold',
696
+ margin: 0,
697
+ color: theme.colors.text,
698
+ } }, facet.label || facet.field),
699
+ hasActiveRange && (React.createElement("button", { type: "button", className: clsx(facetsTheme.rangeClear), onClick: handleClear, style: {
700
+ padding: '0.15em 0.5em',
701
+ border: 'none',
702
+ backgroundColor: 'transparent',
703
+ color: theme.colors.primary,
704
+ cursor: 'pointer',
705
+ fontSize: theme.typography.fontSize.small,
706
+ textDecoration: 'underline',
707
+ } }, "Clear"))),
708
+ React.createElement(RangeSlider, { field: facet.field, min: statsMin, max: statsMax, step: step, formatValue: formatValue, syncWithState: true })));
709
+ };
710
+ // -------------------------------------------------------------------
711
+ // Default facet renderer dispatcher
712
+ // -------------------------------------------------------------------
713
+ const defaultRenderFacet = (facet, index) => {
714
+ const renderType = determineFacetRenderType(facet.field, facet.stats);
715
+ if (renderType === 'range-buttons') {
716
+ return renderRangeButtonFacet(facet, index);
717
+ }
718
+ if (renderType === 'range-slider') {
719
+ return renderRangeSliderFacet(facet, index);
720
+ }
721
+ switch (variant) {
722
+ case 'color-swatch':
723
+ return renderColorSwatchFacet(facet, index);
724
+ case 'collapsible':
725
+ return renderCollapsibleFacet(facet, index);
726
+ case 'checkbox':
727
+ default:
728
+ return renderCheckboxFacet(facet, index);
729
+ }
178
730
  };
731
+ // -------------------------------------------------------------------
732
+ // Empty state
733
+ // -------------------------------------------------------------------
179
734
  if (facets.length === 0) {
180
735
  log.verbose('Facets: No facets to display', {
181
736
  hasResults: !!results,
@@ -187,7 +742,13 @@ export const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange
187
742
  }
188
743
  return null;
189
744
  }
190
- return (React.createElement("div", { className: clsx(facetsTheme.container, className), style: style }, facets.map((facet, index) => {
745
+ // -------------------------------------------------------------------
746
+ // Render
747
+ // -------------------------------------------------------------------
748
+ return (React.createElement("div", { className: clsx(facetsTheme.container, className), style: {
749
+ ...CSS_VAR_DEFAULTS,
750
+ ...style,
751
+ } }, facets.map((facet, index) => {
191
752
  return renderFacet
192
753
  ? renderFacet(facet, index)
193
754
  : defaultRenderFacet(facet, index);