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

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 (74) 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 +199 -47
  4. package/dist/components/Facets.d.ts +30 -1
  5. package/dist/components/Facets.d.ts.map +1 -1
  6. package/dist/components/Facets.js +418 -46
  7. package/dist/components/HierarchicalMenu.d.ts.map +1 -1
  8. package/dist/components/HierarchicalMenu.js +112 -4
  9. package/dist/components/Pagination.d.ts +47 -1
  10. package/dist/components/Pagination.d.ts.map +1 -1
  11. package/dist/components/Pagination.js +166 -28
  12. package/dist/components/RangeSlider.d.ts.map +1 -1
  13. package/dist/components/RangeSlider.js +49 -2
  14. package/dist/components/RichQuerySuggestions.d.ts +7 -0
  15. package/dist/components/RichQuerySuggestions.d.ts.map +1 -1
  16. package/dist/components/SearchBar.d.ts +16 -0
  17. package/dist/components/SearchBar.d.ts.map +1 -1
  18. package/dist/components/SearchBar.js +130 -16
  19. package/dist/components/SearchProvider.d.ts +8 -1
  20. package/dist/components/SearchProvider.d.ts.map +1 -1
  21. package/dist/components/SearchProvider.js +16 -4
  22. package/dist/components/SearchResults.d.ts +10 -0
  23. package/dist/components/SearchResults.d.ts.map +1 -1
  24. package/dist/components/SearchResults.js +9 -5
  25. package/dist/components/SortBy.d.ts +44 -4
  26. package/dist/components/SortBy.d.ts.map +1 -1
  27. package/dist/components/SortBy.js +154 -29
  28. package/dist/components/Stats.d.ts +14 -0
  29. package/dist/components/Stats.d.ts.map +1 -1
  30. package/dist/components/Stats.js +172 -23
  31. package/dist/components/suggestions/AmazonDropdown.d.ts.map +1 -1
  32. package/dist/components/suggestions/AmazonDropdown.js +2 -4
  33. package/dist/components/suggestions/GoogleDropdown.d.ts.map +1 -1
  34. package/dist/components/suggestions/GoogleDropdown.js +2 -6
  35. package/dist/components/suggestions/MinimalDropdown.d.ts.map +1 -1
  36. package/dist/components/suggestions/MinimalDropdown.js +2 -4
  37. package/dist/components/suggestions/MobileSheetDropdown.d.ts.map +1 -1
  38. package/dist/components/suggestions/MobileSheetDropdown.js +2 -4
  39. package/dist/components/suggestions/PinterestDropdown.d.ts.map +1 -1
  40. package/dist/components/suggestions/PinterestDropdown.js +2 -6
  41. package/dist/components/suggestions/ShopifyDropdown.d.ts.map +1 -1
  42. package/dist/components/suggestions/ShopifyDropdown.js +2 -4
  43. package/dist/components/suggestions/SpotlightDropdown.d.ts.map +1 -1
  44. package/dist/components/suggestions/SpotlightDropdown.js +2 -4
  45. package/dist/components/suggestions/utils.d.ts +10 -1
  46. package/dist/components/suggestions/utils.d.ts.map +1 -1
  47. package/dist/components/suggestions/utils.js +36 -0
  48. package/dist/components/suggestions-primitives/highlightMarkup.d.ts +16 -4
  49. package/dist/components/suggestions-primitives/highlightMarkup.d.ts.map +1 -1
  50. package/dist/components/suggestions-primitives/highlightMarkup.js +42 -4
  51. package/dist/hooks/useClickTracking.d.ts +36 -0
  52. package/dist/hooks/useClickTracking.d.ts.map +1 -0
  53. package/dist/hooks/useClickTracking.js +96 -0
  54. package/dist/hooks/useExperiment.d.ts +25 -0
  55. package/dist/hooks/useExperiment.d.ts.map +1 -0
  56. package/dist/hooks/useExperiment.js +146 -0
  57. package/dist/hooks/useKeyboardNavigation.d.ts +51 -0
  58. package/dist/hooks/useKeyboardNavigation.d.ts.map +1 -0
  59. package/dist/hooks/useKeyboardNavigation.js +113 -0
  60. package/dist/hooks/useQuerySuggestions.d.ts.map +1 -1
  61. package/dist/hooks/useQuerySuggestions.js +19 -3
  62. package/dist/hooks/useQuerySuggestionsEnhanced.d.ts.map +1 -1
  63. package/dist/hooks/useQuerySuggestionsEnhanced.js +23 -6
  64. package/dist/hooks/useSuggestionsAnalytics.d.ts.map +1 -1
  65. package/dist/hooks/useSuggestionsAnalytics.js +6 -1
  66. package/dist/index.d.ts +4 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.umd.js +1 -1
  69. package/dist/src/index.d.ts +217 -16
  70. package/dist/src/index.esm.js +1586 -249
  71. package/dist/src/index.esm.js.map +1 -1
  72. package/dist/src/index.js +1585 -248
  73. package/dist/src/index.js.map +1 -1
  74. package/package.json +3 -3
package/dist/src/index.js CHANGED
@@ -307,6 +307,9 @@ class SearchStateManager {
307
307
  this.autoSearch = config.autoSearch !== false;
308
308
  this.debounceMs = config.debounceMs || 300;
309
309
  this.defaultSearchOptions = config.defaultSearchOptions || { widget_mode: true };
310
+ this.keepResultsOnClear = config.keepResultsOnClear !== false;
311
+ this.abTestId = config.abTestId;
312
+ this.abVariant = config.abVariant;
310
313
  this.state = {
311
314
  query: config.initialQuery || '',
312
315
  refinements: [],
@@ -355,6 +358,15 @@ class SearchStateManager {
355
358
  }
356
359
  return;
357
360
  }
361
+ // When query is cleared to empty and keepResultsOnClear is true,
362
+ // preserve previous results and skip triggering a search
363
+ if (query === '' && this.keepResultsOnClear) {
364
+ log.verbose('SearchStateManager: Query cleared, keeping previous results');
365
+ this.state.query = '';
366
+ this.state.currentPage = 1;
367
+ this.notifyListeners();
368
+ return;
369
+ }
358
370
  this.state.query = query;
359
371
  this.state.currentPage = 1; // Reset to first page on new query
360
372
  log.verbose('SearchStateManager: Query updated', { query, triggerSearch, autoSearch: this.autoSearch });
@@ -553,6 +565,23 @@ class SearchStateManager {
553
565
  }
554
566
  });
555
567
  }
568
+ /** Explicitly clear results (bypasses keepResultsOnClear) */
569
+ clearResults() {
570
+ this.setState({ results: null, loading: false, error: null });
571
+ log.verbose('SearchStateManager: Results explicitly cleared');
572
+ }
573
+ // A/B test getters
574
+ getAbTestId() {
575
+ return this.abTestId;
576
+ }
577
+ getAbVariant() {
578
+ return this.abVariant;
579
+ }
580
+ setAbTest(abTestId, abVariant) {
581
+ this.abTestId = abTestId;
582
+ this.abVariant = abVariant;
583
+ log.verbose('SearchStateManager: A/B test updated', { abTestId, abVariant });
584
+ }
556
585
  // Clear all state
557
586
  clear() {
558
587
  this.state = {
@@ -1335,10 +1364,11 @@ const createTheme = (config) => {
1335
1364
  /**
1336
1365
  * SearchProvider Component
1337
1366
  *
1338
- * Provides Seekora client and context to child components
1367
+ * Provides Seekora client and context to child components.
1368
+ * Supports A/B testing via abTestId/abVariant props.
1339
1369
  */
1340
1370
  const SearchContext = React.createContext(null);
1341
- const SearchProvider = ({ client, theme: themeConfig, enableAnalytics = true, autoTrackSearch = true, stateManager: stateManagerConfig, children, }) => {
1371
+ const SearchProvider = ({ client, theme: themeConfig, enableAnalytics = true, autoTrackSearch = true, stateManager: stateManagerConfig, children, abTestId, abVariant, experiments: _experiments, }) => {
1342
1372
  const theme = React.useMemo(() => {
1343
1373
  return themeConfig ? createTheme(themeConfig) : defaultTheme;
1344
1374
  }, [themeConfig]);
@@ -1351,8 +1381,19 @@ const SearchProvider = ({ client, theme: themeConfig, enableAnalytics = true, au
1351
1381
  itemsPerPage: 10,
1352
1382
  defaultSearchOptions: { widget_mode: true },
1353
1383
  ...stateManagerConfig,
1384
+ abTestId,
1385
+ abVariant,
1354
1386
  });
1355
- }, [client, stateManagerConfig]);
1387
+ }, [client, stateManagerConfig, abTestId, abVariant]);
1388
+ // Update A/B test fields on state manager and SDK client when props change
1389
+ React.useEffect(() => {
1390
+ if (abTestId !== undefined || abVariant !== undefined) {
1391
+ stateManager.setAbTest(abTestId, abVariant);
1392
+ if (typeof client.setAbTest === 'function') {
1393
+ client.setAbTest(abTestId, abVariant);
1394
+ }
1395
+ }
1396
+ }, [stateManager, client, abTestId, abVariant]);
1356
1397
  const value = React.useMemo(() => ({
1357
1398
  client,
1358
1399
  theme,
@@ -1485,9 +1526,25 @@ const useQuerySuggestions = ({ client, query, enabled = true, debounceMs = 300,
1485
1526
  }
1486
1527
  catch (err) {
1487
1528
  const error = err instanceof Error ? err : new Error(String(err));
1488
- log.error('Error in useQuerySuggestions:', error);
1489
- setError(error);
1490
- setSuggestions([]);
1529
+ // Check if it's a 404 error (suggestions not enabled for this store).
1530
+ // The search SDK wraps axios errors into plain Error objects with the status
1531
+ // embedded in the message, e.g. "[getSuggestions] ... (404)".
1532
+ const errMsg = error.message || '';
1533
+ const is404 = err?.response?.status === 404 ||
1534
+ err?.status === 404 ||
1535
+ /\(404\)/.test(errMsg);
1536
+ if (is404) {
1537
+ // Silently handle 404 - suggestions feature not enabled
1538
+ log.verbose('Query suggestions not enabled for this store (404)');
1539
+ setSuggestions([]);
1540
+ setError(null);
1541
+ }
1542
+ else {
1543
+ // For other errors, log and set error state
1544
+ log.error('Error in useQuerySuggestions:', error);
1545
+ setError(error);
1546
+ setSuggestions([]);
1547
+ }
1491
1548
  }
1492
1549
  finally {
1493
1550
  setLoading(false);
@@ -1513,7 +1570,42 @@ function r(e){var t,f,n="";if("string"==typeof e||"number"==typeof e)n+=e;else i
1513
1570
  *
1514
1571
  * Interactive search input component with query suggestions support
1515
1572
  */
1516
- const SearchBar = ({ placeholder = 'Search...', showSuggestions = true, debounceMs = 300, minQueryLength = 2, maxSuggestions = 10, onSearch, onQueryChange, onSuggestionSelect, onSearchStateChange, searchOptions, className, style, theme: customTheme, showLoadingState = false, renderSuggestion, renderLoading, }) => {
1573
+ const SIZE_CONFIG = {
1574
+ small: {
1575
+ padding: '0.375rem 0.5rem',
1576
+ fontSize: '0.875rem',
1577
+ iconSize: 14,
1578
+ iconPaddingLeft: '1.75rem',
1579
+ iconPaddingRight: '1.75rem',
1580
+ iconLeft: '0.5rem',
1581
+ iconRight: '0.5rem',
1582
+ },
1583
+ medium: {
1584
+ padding: '0.625rem 1rem',
1585
+ fontSize: '1rem',
1586
+ iconSize: 18,
1587
+ iconPaddingLeft: '2.25rem',
1588
+ iconPaddingRight: '2.25rem',
1589
+ iconLeft: '0.625rem',
1590
+ iconRight: '0.625rem',
1591
+ },
1592
+ large: {
1593
+ padding: '0.875rem 1.25rem',
1594
+ fontSize: '1.25rem',
1595
+ iconSize: 22,
1596
+ iconPaddingLeft: '2.75rem',
1597
+ iconPaddingRight: '2.75rem',
1598
+ iconLeft: '0.75rem',
1599
+ iconRight: '0.75rem',
1600
+ },
1601
+ };
1602
+ const DefaultSearchIcon = ({ size = 18 }) => (React.createElement("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" },
1603
+ React.createElement("circle", { cx: "11", cy: "11", r: "8" }),
1604
+ React.createElement("line", { x1: "21", y1: "21", x2: "16.65", y2: "16.65" })));
1605
+ const DefaultClearIcon = ({ size = 14 }) => (React.createElement("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" },
1606
+ React.createElement("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
1607
+ React.createElement("line", { x1: "6", y1: "6", x2: "18", y2: "18" })));
1608
+ const SearchBar = ({ placeholder = 'Search...', showSuggestions = true, debounceMs = 300, minQueryLength = 2, maxSuggestions = 10, onSearch, onQueryChange, onSuggestionSelect, onSearchStateChange, searchOptions, className, style, theme: customTheme, showLoadingState = false, renderSuggestion, renderLoading, renderSearchIcon, showClearButton = true, renderClearIcon, showSubmitButton = false, renderSubmitButton, size = 'medium', }) => {
1517
1609
  const { client, theme, enableAnalytics, autoTrackSearch } = useSearchContext();
1518
1610
  const { query, setQuery, search: triggerSearch, results, loading: searchLoading, error: searchError } = useSearchState();
1519
1611
  const [isFocused, setIsFocused] = React.useState(false);
@@ -1654,28 +1746,104 @@ const SearchBar = ({ placeholder = 'Search...', showSuggestions = true, debounce
1654
1746
  const processingTime = res?.processingTimeMS
1655
1747
  || res?.data?.processingTimeMS
1656
1748
  || res?.data?.data?.processingTimeMS;
1749
+ // Size-based configuration
1750
+ const sizeConfig = SIZE_CONFIG[size];
1751
+ const hasClearBtn = showClearButton && query.length > 0;
1752
+ // Compute input padding accounting for icons
1753
+ const inputPaddingLeft = sizeConfig.iconPaddingLeft ;
1754
+ const inputPaddingRight = hasClearBtn ? sizeConfig.iconPaddingRight : sizeConfig.padding.split(' ')[1] || sizeConfig.padding;
1755
+ const borderRadius = typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium;
1756
+ const focusBorderColor = isFocused ? theme.colors.focus : theme.colors.border;
1757
+ const focusRingShadow = isFocused
1758
+ ? `0 0 0 3px ${theme.colors.focus}33`
1759
+ : undefined;
1760
+ const handleClear = React.useCallback(() => {
1761
+ setQuery('', false);
1762
+ setSelectedIndex(-1);
1763
+ inputRef.current?.focus();
1764
+ }, [setQuery]);
1765
+ const handleSubmit = React.useCallback(() => {
1766
+ const currentValue = inputRef.current?.value || query;
1767
+ handleSearch(currentValue);
1768
+ }, [query, handleSearch]);
1657
1769
  return (React.createElement("div", { ref: containerRef, className: clsx(searchBarTheme.container, className), style: {
1658
1770
  position: 'relative',
1659
1771
  display: 'flex',
1660
1772
  alignItems: 'center',
1773
+ // CSS custom properties for external styling
1774
+ '--seekora-searchbar-bg': theme.colors.background,
1775
+ '--seekora-searchbar-border': theme.colors.border,
1776
+ '--seekora-searchbar-focus-border': theme.colors.focus,
1777
+ '--seekora-searchbar-radius': borderRadius,
1778
+ '--seekora-searchbar-icon-color': theme.colors.textSecondary,
1661
1779
  ...style,
1662
1780
  } },
1663
- React.createElement("input", { ref: inputRef, type: "text", value: query, onChange: handleInputChange, onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, placeholder: placeholder, className: clsx(searchBarTheme.input, isFocused && searchBarTheme.inputFocused), style: {
1664
- flex: 1,
1665
- padding: theme.spacing.medium,
1666
- fontSize: theme.typography.fontSize.medium,
1781
+ React.createElement("div", { style: { position: 'relative', flex: 1, display: 'flex', alignItems: 'center' } },
1782
+ (React.createElement("span", { className: searchBarTheme.searchIcon, "aria-hidden": "true", style: {
1783
+ position: 'absolute',
1784
+ left: sizeConfig.iconLeft,
1785
+ top: '50%',
1786
+ transform: 'translateY(-50%)',
1787
+ display: 'flex',
1788
+ alignItems: 'center',
1789
+ justifyContent: 'center',
1790
+ pointerEvents: 'none',
1791
+ color: 'var(--seekora-searchbar-icon-color)',
1792
+ zIndex: 1,
1793
+ } }, renderSearchIcon ? renderSearchIcon() : React.createElement(DefaultSearchIcon, { size: sizeConfig.iconSize }))),
1794
+ React.createElement("input", { ref: inputRef, type: "text", value: query, onChange: handleInputChange, onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, placeholder: placeholder, className: clsx(searchBarTheme.input, isFocused && searchBarTheme.inputFocused), style: {
1795
+ width: '100%',
1796
+ paddingTop: sizeConfig.padding.split(' ')[0],
1797
+ paddingBottom: sizeConfig.padding.split(' ')[0],
1798
+ paddingLeft: inputPaddingLeft,
1799
+ paddingRight: inputPaddingRight,
1800
+ fontSize: sizeConfig.fontSize,
1801
+ fontFamily: theme.typography.fontFamily,
1802
+ backgroundColor: 'var(--seekora-searchbar-bg)',
1803
+ color: theme.colors.text,
1804
+ borderWidth: '1px',
1805
+ borderStyle: 'solid',
1806
+ borderColor: focusBorderColor,
1807
+ borderRadius: 'var(--seekora-searchbar-radius)',
1808
+ outline: 'none',
1809
+ boxShadow: focusRingShadow,
1810
+ transition: theme.transitions?.fast || '150ms ease-in-out',
1811
+ boxSizing: 'border-box',
1812
+ } }),
1813
+ hasClearBtn && (React.createElement("button", { type: "button", onClick: handleClear, className: searchBarTheme.clearButton, "aria-label": "Clear search", style: {
1814
+ position: 'absolute',
1815
+ right: sizeConfig.iconRight,
1816
+ top: '50%',
1817
+ transform: 'translateY(-50%)',
1818
+ display: 'flex',
1819
+ alignItems: 'center',
1820
+ justifyContent: 'center',
1821
+ background: 'none',
1822
+ border: 'none',
1823
+ cursor: 'pointer',
1824
+ padding: '2px',
1825
+ borderRadius: '50%',
1826
+ color: 'var(--seekora-searchbar-icon-color)',
1827
+ transition: theme.transitions?.fast || '150ms ease-in-out',
1828
+ zIndex: 1,
1829
+ }, onMouseDown: (e) => {
1830
+ // Prevent input blur so the clear action doesn't race with blur handler
1831
+ e.preventDefault();
1832
+ } }, renderClearIcon ? renderClearIcon() : React.createElement(DefaultClearIcon, { size: sizeConfig.iconSize - 4 })))),
1833
+ showSubmitButton && (renderSubmitButton ? (React.createElement("div", { onClick: handleSubmit, style: { marginLeft: theme.spacing.small, cursor: 'pointer' } }, renderSubmitButton())) : (React.createElement("button", { type: "button", onClick: handleSubmit, className: searchBarTheme.submitButton, style: {
1834
+ marginLeft: theme.spacing.small,
1835
+ padding: sizeConfig.padding,
1836
+ fontSize: sizeConfig.fontSize,
1667
1837
  fontFamily: theme.typography.fontFamily,
1668
- backgroundColor: theme.colors.background,
1669
- color: theme.colors.text,
1670
- borderWidth: '1px',
1671
- borderStyle: 'solid',
1672
- borderColor: isFocused ? theme.colors.focus : theme.colors.border,
1673
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
1674
- outline: 'none',
1675
- ...(isFocused && {
1676
- boxShadow: theme.shadows.small,
1677
- }),
1678
- } }),
1838
+ fontWeight: theme.typography.fontWeight?.medium ?? 500,
1839
+ backgroundColor: theme.colors.primary,
1840
+ color: '#ffffff',
1841
+ border: 'none',
1842
+ borderRadius: 'var(--seekora-searchbar-radius)',
1843
+ cursor: 'pointer',
1844
+ whiteSpace: 'nowrap',
1845
+ transition: theme.transitions?.fast || '150ms ease-in-out',
1846
+ } }, "Search"))),
1679
1847
  processingTime !== undefined && (React.createElement("span", { style: {
1680
1848
  marginLeft: theme.spacing.small,
1681
1849
  fontSize: theme.typography.fontSize.small,
@@ -1742,7 +1910,7 @@ const formatPrice$1 = (value, currency = '₹') => {
1742
1910
  }
1743
1911
  return String(value);
1744
1912
  };
1745
- const SearchResults = ({ results: resultsProp, loading: loadingProp, error: errorProp, onResultClick, renderResult, renderEmpty, showLoadingState = false, renderLoading, renderError, className, style, theme: customTheme, itemsPerPage = 20, showPagination = false, viewMode = 'list', fieldMapping, extractResults, enableKeyboardNavigation = true, autoFocus = false, }) => {
1913
+ const SearchResults = ({ results: resultsProp, loading: loadingProp, error: errorProp, onResultClick, renderResult, renderEmpty, showLoadingState = false, renderLoading, renderError, className, style, theme: customTheme, itemsPerPage = 20, showPagination = false, viewMode = 'list', fieldMapping, extractResults, enableKeyboardNavigation = true, autoFocus = false, minHeight = '400px', minWidth = '100%', loadingOpacity = 0.7, }) => {
1746
1914
  const { theme, client, enableAnalytics } = useSearchContext();
1747
1915
  const { results: stateResults, loading: stateLoading, error: stateError, currentPage, itemsPerPage: stateItemsPerPage } = useSearchState();
1748
1916
  const searchResultsTheme = customTheme || {};
@@ -2086,6 +2254,10 @@ const SearchResults = ({ results: resultsProp, loading: loadingProp, error: erro
2086
2254
  });
2087
2255
  // Determine container style based on view mode
2088
2256
  const containerStyle = {
2257
+ minHeight: `var(--seekora-results-min-height, ${minHeight})`,
2258
+ minWidth: `var(--seekora-results-min-width, ${minWidth})`,
2259
+ transition: 'opacity 200ms ease-in-out',
2260
+ opacity: loading && resultItems.length > 0 ? loadingOpacity : 1,
2089
2261
  ...style,
2090
2262
  };
2091
2263
  // Determine results list style based on view mode
@@ -2112,19 +2284,19 @@ const SearchResults = ({ results: resultsProp, loading: loadingProp, error: erro
2112
2284
  // When loading with no previous results, show loading only if showLoadingState (default: show previous results, no loading screen)
2113
2285
  if (loading && resultItems.length === 0 && showLoadingState) {
2114
2286
  log.verbose('SearchResults: Rendering loading state');
2115
- return (React.createElement("div", { className: clsx(searchResultsTheme.container, className), style: style }, renderLoading ? renderLoading() : defaultRenderLoading()));
2287
+ return (React.createElement("div", { className: clsx(searchResultsTheme.container, className), style: containerStyle }, renderLoading ? renderLoading() : defaultRenderLoading()));
2116
2288
  }
2117
- // When loading with previous results, fall through and render them (no loading screen)
2289
+ // When loading with previous results, fall through and render them (with opacity transition)
2118
2290
  if (error) {
2119
2291
  log.error('SearchResults: Rendering error state', {
2120
2292
  error: error.message,
2121
2293
  stack: error.stack,
2122
2294
  });
2123
- return (React.createElement("div", { className: clsx(searchResultsTheme.container, className), style: style }, renderError ? renderError(error) : defaultRenderError(error)));
2295
+ return (React.createElement("div", { className: clsx(searchResultsTheme.container, className), style: containerStyle }, renderError ? renderError(error) : defaultRenderError(error)));
2124
2296
  }
2125
2297
  if (!results || resultItems.length === 0) {
2126
2298
  log.verbose('SearchResults: No results to display');
2127
- return (React.createElement("div", { className: clsx(searchResultsTheme.container, className), style: style }, renderEmpty ? renderEmpty() : defaultRenderEmpty()));
2299
+ return (React.createElement("div", { className: clsx(searchResultsTheme.container, className), style: containerStyle }, renderEmpty ? renderEmpty() : defaultRenderEmpty()));
2128
2300
  }
2129
2301
  const renderFn = renderResult || defaultRenderResult;
2130
2302
  return (React.createElement("div", { ref: containerRef, className: clsx(searchResultsTheme.container, className), style: containerStyle, tabIndex: enableKeyboardNavigation ? 0 : undefined, onKeyDown: handleKeyDown, role: "listbox", "aria-label": "Search results", "aria-activedescendant": activeIndex >= 0 ? `result-${activeIndex}` : undefined },
@@ -2169,10 +2341,44 @@ const SearchResults = ({ results: resultsProp, loading: loadingProp, error: erro
2169
2341
  * Stats Component
2170
2342
  *
2171
2343
  * Displays search statistics (total results, processing time, etc.)
2344
+ * Supports inline, badge, and detailed display variants.
2172
2345
  */
2173
- const Stats = ({ results, renderStats, className, style, theme: customTheme, showProcessingTime = false, showQuery = false, separator = ' • ', }) => {
2346
+ /** CSS class name used for the animated count fade transition */
2347
+ const ANIMATE_CLASS = 'seekora-stats-animate';
2348
+ /** Inline <style> for the count-change animation and CSS custom properties */
2349
+ const statsStyleId = 'seekora-stats-styles';
2350
+ function ensureStatsStyles() {
2351
+ if (typeof document === 'undefined')
2352
+ return;
2353
+ if (document.getElementById(statsStyleId))
2354
+ return;
2355
+ const style = document.createElement('style');
2356
+ style.id = statsStyleId;
2357
+ style.textContent = `
2358
+ .seekora-stats-root {
2359
+ --seekora-stats-color: inherit;
2360
+ --seekora-stats-bg: transparent;
2361
+ --seekora-stats-font-size: inherit;
2362
+ --seekora-stats-badge-bg: inherit;
2363
+ --seekora-stats-badge-color: inherit;
2364
+ }
2365
+ .${ANIMATE_CLASS} {
2366
+ opacity: 0;
2367
+ transition: opacity 250ms ease-in-out;
2368
+ }
2369
+ `;
2370
+ document.head.appendChild(style);
2371
+ }
2372
+ const Stats = ({ results: resultsProp, renderStats, className, style, theme: customTheme, showProcessingTime = false, showQuery = false, separator = ' \u2022 ', variant = 'inline', showResultCount = true, formatNumber, }) => {
2174
2373
  const { theme } = useSearchContext();
2374
+ const { results: stateResults } = useSearchState();
2175
2375
  const statsTheme = customTheme || {};
2376
+ // Use results from prop if provided, otherwise from state manager
2377
+ const results = resultsProp || stateResults;
2378
+ // Inject keyframe / transition styles once
2379
+ React.useEffect(() => {
2380
+ ensureStatsStyles();
2381
+ }, []);
2176
2382
  // Extract stats from results
2177
2383
  const res = results;
2178
2384
  const totalResults = res?.totalResults
@@ -2183,44 +2389,173 @@ const Stats = ({ results, renderStats, className, style, theme: customTheme, sho
2183
2389
  || res?.data?.processingTimeMS
2184
2390
  || res?.data?.data?.processingTimeMS;
2185
2391
  const query = (res?.query ?? '');
2186
- const defaultRenderStats = () => {
2392
+ // Number formatter
2393
+ const fmt = formatNumber || ((n) => n.toLocaleString());
2394
+ // Animated count change — toggle a CSS class briefly on totalResults change
2395
+ const countRef = React.useRef(null);
2396
+ const prevTotalRef = React.useRef(totalResults);
2397
+ const [animating, setAnimating] = React.useState(false);
2398
+ React.useEffect(() => {
2399
+ if (prevTotalRef.current !== totalResults) {
2400
+ prevTotalRef.current = totalResults;
2401
+ setAnimating(true);
2402
+ // After a frame, remove the class so the transition plays (opacity 0 -> 1)
2403
+ const raf = requestAnimationFrame(() => {
2404
+ setAnimating(false);
2405
+ });
2406
+ return () => cancelAnimationFrame(raf);
2407
+ }
2408
+ }, [totalResults]);
2409
+ // Helper: border radius value from theme
2410
+ const borderRadiusSmall = typeof theme.borderRadius === 'string'
2411
+ ? theme.borderRadius
2412
+ : theme.borderRadius.small;
2413
+ // ─── Custom render ────────────────────────────────────────
2414
+ if (renderStats) {
2415
+ return (React.createElement("div", { className: clsx('seekora-stats-root', statsTheme.container, className), style: style }, renderStats({
2416
+ totalResults,
2417
+ processingTime,
2418
+ query,
2419
+ })));
2420
+ }
2421
+ // ─── Inline variant (original behaviour) ──────────────────
2422
+ if (variant === 'inline') {
2187
2423
  const parts = [];
2188
- if (totalResults > 0) {
2189
- parts.push(`${totalResults.toLocaleString()} result${totalResults !== 1 ? 's' : ''}`);
2190
- }
2191
- else {
2192
- parts.push('No results');
2424
+ if (showResultCount) {
2425
+ if (totalResults > 0) {
2426
+ parts.push(React.createElement("span", { key: "count", className: statsTheme.text },
2427
+ React.createElement("span", { ref: countRef, className: clsx(animating && ANIMATE_CLASS), style: { transition: 'opacity 250ms ease-in-out' } }, fmt(totalResults)),
2428
+ ` result${totalResults !== 1 ? 's' : ''}`));
2429
+ }
2430
+ else {
2431
+ parts.push(React.createElement("span", { key: "count", className: statsTheme.text }, "No results"));
2432
+ }
2193
2433
  }
2194
2434
  if (showProcessingTime && processingTime !== undefined) {
2195
- parts.push(`${processingTime}ms`);
2435
+ parts.push(React.createElement("span", { key: "time", className: statsTheme.text },
2436
+ fmt(processingTime),
2437
+ "ms"));
2196
2438
  }
2197
2439
  if (showQuery && query) {
2198
- parts.push(`for "${query}"`);
2440
+ parts.push(React.createElement("span", { key: "query", className: statsTheme.text },
2441
+ "for \"",
2442
+ query,
2443
+ "\""));
2199
2444
  }
2200
- return (React.createElement("div", { className: clsx(statsTheme.container, className), style: {
2201
- fontSize: theme.typography.fontSize.medium,
2202
- color: theme.colors.text,
2445
+ return (React.createElement("div", { className: clsx('seekora-stats-root', statsTheme.container, className), style: {
2446
+ fontSize: 'var(--seekora-stats-font-size,' + theme.typography.fontSize.medium + ')',
2447
+ color: 'var(--seekora-stats-color,' + theme.colors.text + ')',
2448
+ backgroundColor: 'var(--seekora-stats-bg, transparent)',
2203
2449
  ...style,
2204
2450
  } }, parts.map((part, index) => (React.createElement("span", { key: index },
2205
- index > 0 && React.createElement("span", { className: statsTheme.separator, style: { margin: `0 ${theme.spacing.small}` } }, separator),
2206
- React.createElement("span", { className: statsTheme.text }, part))))));
2207
- };
2208
- if (renderStats) {
2209
- return (React.createElement("div", { className: clsx(statsTheme.container, className), style: style }, renderStats({
2210
- totalResults,
2211
- processingTime,
2212
- query,
2213
- })));
2451
+ index > 0 && (React.createElement("span", { className: statsTheme.separator, style: { margin: `0 ${theme.spacing.small}` } }, separator)),
2452
+ part)))));
2453
+ }
2454
+ // ─── Badge variant ────────────────────────────────────────
2455
+ if (variant === 'badge') {
2456
+ const badges = [];
2457
+ const badgeStyle = {
2458
+ display: 'inline-flex',
2459
+ alignItems: 'center',
2460
+ gap: theme.spacing.small,
2461
+ padding: `${theme.spacing.small} ${theme.spacing.medium}`,
2462
+ borderRadius: borderRadiusSmall,
2463
+ backgroundColor: `var(--seekora-stats-badge-bg, ${theme.colors.primary}1A)`, // 10 % opacity hex
2464
+ color: `var(--seekora-stats-badge-color, ${theme.colors.primary})`,
2465
+ fontSize: theme.typography.fontSize.small,
2466
+ fontWeight: 500,
2467
+ };
2468
+ if (showResultCount) {
2469
+ badges.push(React.createElement("span", { key: "count", className: clsx(statsTheme.badge), style: badgeStyle },
2470
+ React.createElement("span", { className: statsTheme.badgeLabel }, "Results"),
2471
+ React.createElement("span", { className: clsx(statsTheme.badgeValue, animating && ANIMATE_CLASS), style: { fontWeight: 600, transition: 'opacity 250ms ease-in-out' } }, totalResults > 0 ? fmt(totalResults) : '0')));
2472
+ }
2473
+ if (showQuery && query) {
2474
+ badges.push(React.createElement("span", { key: "query", className: clsx(statsTheme.badge), style: badgeStyle },
2475
+ React.createElement("span", { className: statsTheme.badgeLabel }, "Query"),
2476
+ React.createElement("span", { className: statsTheme.badgeValue, style: { fontWeight: 600 } }, query)));
2477
+ }
2478
+ if (showProcessingTime && processingTime !== undefined) {
2479
+ badges.push(React.createElement("span", { key: "time", className: clsx(statsTheme.badge), style: badgeStyle },
2480
+ React.createElement("span", { className: statsTheme.badgeLabel }, "Time"),
2481
+ React.createElement("span", { className: statsTheme.badgeValue, style: { fontWeight: 600 } },
2482
+ fmt(processingTime),
2483
+ "ms")));
2484
+ }
2485
+ return (React.createElement("div", { className: clsx('seekora-stats-root', statsTheme.container, className), style: {
2486
+ display: 'flex',
2487
+ flexWrap: 'wrap',
2488
+ gap: theme.spacing.small,
2489
+ fontSize: 'var(--seekora-stats-font-size,' + theme.typography.fontSize.small + ')',
2490
+ color: 'var(--seekora-stats-color,' + theme.colors.text + ')',
2491
+ backgroundColor: 'var(--seekora-stats-bg, transparent)',
2492
+ ...style,
2493
+ } }, badges));
2494
+ }
2495
+ // ─── Detailed variant ─────────────────────────────────────
2496
+ if (variant === 'detailed') {
2497
+ const rows = [];
2498
+ if (showResultCount) {
2499
+ rows.push({
2500
+ label: 'Total Results',
2501
+ value: (React.createElement("span", { className: clsx(animating && ANIMATE_CLASS), style: { transition: 'opacity 250ms ease-in-out' } }, fmt(totalResults))),
2502
+ });
2503
+ }
2504
+ if (showQuery && query) {
2505
+ rows.push({ label: 'Query', value: query });
2506
+ }
2507
+ if (showProcessingTime && processingTime !== undefined) {
2508
+ rows.push({ label: 'Time', value: `${fmt(processingTime)}ms` });
2509
+ }
2510
+ return (React.createElement("div", { className: clsx('seekora-stats-root', statsTheme.detailed, statsTheme.container, className), style: {
2511
+ display: 'flex',
2512
+ flexDirection: 'column',
2513
+ gap: theme.spacing.small,
2514
+ fontSize: 'var(--seekora-stats-font-size,' + theme.typography.fontSize.medium + ')',
2515
+ color: 'var(--seekora-stats-color,' + theme.colors.text + ')',
2516
+ backgroundColor: 'var(--seekora-stats-bg, transparent)',
2517
+ ...style,
2518
+ } }, rows.map((row, index) => (React.createElement("div", { key: index, className: statsTheme.detailedRow, style: {
2519
+ display: 'flex',
2520
+ alignItems: 'center',
2521
+ gap: theme.spacing.small,
2522
+ } },
2523
+ React.createElement("span", { className: statsTheme.detailedLabel, style: {
2524
+ color: theme.colors.textSecondary,
2525
+ fontWeight: 500,
2526
+ } },
2527
+ row.label,
2528
+ ":"),
2529
+ React.createElement("span", { className: statsTheme.detailedValue, style: {
2530
+ color: theme.colors.text,
2531
+ fontWeight: 600,
2532
+ } }, row.value))))));
2214
2533
  }
2215
- return defaultRenderStats();
2534
+ // Fallback (should not be reached)
2535
+ return null;
2216
2536
  };
2217
2537
 
2218
2538
  /**
2219
2539
  * Pagination Component
2220
2540
  *
2221
- * Displays pagination controls for search results
2541
+ * Displays pagination controls for search results.
2542
+ * Supports three display variants: numbered, load-more, and simple.
2543
+ *
2544
+ * CSS Variables (applied to the container element):
2545
+ * --seekora-pagination-bg
2546
+ * --seekora-pagination-color
2547
+ * --seekora-pagination-active-bg
2548
+ * --seekora-pagination-active-color
2549
+ * --seekora-pagination-border
2550
+ * --seekora-pagination-radius
2222
2551
  */
2223
- const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsPerPage: itemsPerPageProp, totalPages: totalPagesProp, onPageChange, maxPages = 7, showFirstLast = true, showPrevNext = true, renderPageButton, className, style, theme: customTheme, }) => {
2552
+ /** Size-specific style tokens */
2553
+ const SIZE_TOKENS = {
2554
+ small: { paddingKey: 'small', fontSizeKey: 'small', minWidth: '32px' },
2555
+ medium: { paddingKey: 'small', fontSizeKey: 'medium', minWidth: '40px' },
2556
+ large: { paddingKey: 'medium', fontSizeKey: 'large', minWidth: '48px' },
2557
+ };
2558
+ const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsPerPage: itemsPerPageProp, totalPages: totalPagesProp, onPageChange, maxPages = 7, showFirstLast = true, showPrevNext = true, renderPageButton, className, style, theme: customTheme, variant = 'numbered', loadMoreText = 'Load More', size = 'medium', showPageInfo, previousLabel = 'Previous', nextLabel = 'Next', }) => {
2224
2559
  const { theme } = useSearchContext();
2225
2560
  const { results: stateResults, currentPage: stateCurrentPage, setPage } = useSearchState();
2226
2561
  const paginationTheme = customTheme || {};
@@ -2243,6 +2578,19 @@ const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsP
2243
2578
  || res?.data?.total_pages
2244
2579
  || res?.data?.data?.total_pages
2245
2580
  || Math.ceil(totalResults / itemsPerPage);
2581
+ // Resolve whether to show page info text
2582
+ const resolvedShowPageInfo = showPageInfo !== undefined
2583
+ ? showPageInfo
2584
+ : variant === 'simple';
2585
+ // Size tokens
2586
+ const sizeTokens = SIZE_TOKENS[size];
2587
+ // CSS variable aware helpers — allow overrides via custom properties
2588
+ const cssVarBg = 'var(--seekora-pagination-bg, ' + theme.colors.background + ')';
2589
+ const cssVarColor = 'var(--seekora-pagination-color, ' + theme.colors.text + ')';
2590
+ const cssVarActiveBg = 'var(--seekora-pagination-active-bg, ' + theme.colors.primary + ')';
2591
+ const cssVarActiveColor = 'var(--seekora-pagination-active-color, #fff)';
2592
+ const cssVarBorder = 'var(--seekora-pagination-border, ' + theme.colors.border + ')';
2593
+ const cssVarRadius = 'var(--seekora-pagination-radius, ' + (typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium) + ')';
2246
2594
  const handlePageChange = (page) => {
2247
2595
  if (page < 1 || page > totalPages || page === currentPage)
2248
2596
  return;
@@ -2253,22 +2601,26 @@ const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsP
2253
2601
  onPageChange(page);
2254
2602
  }
2255
2603
  };
2256
- const defaultRenderPageButton = (page, isActive, isDisabled) => (React.createElement("button", { type: "button", disabled: isDisabled, onClick: () => handlePageChange(page), className: clsx(paginationTheme.item, isActive && paginationTheme.itemActive, isDisabled), style: {
2257
- padding: theme.spacing.small,
2604
+ const defaultRenderPageButton = (page, isActive, isDisabled) => (React.createElement("button", { type: "button", disabled: isDisabled, onClick: () => handlePageChange(page), "aria-current": isActive ? 'page' : undefined, "aria-label": `Page ${page}`, className: clsx(paginationTheme.item, isActive && paginationTheme.itemActive, isDisabled), style: {
2605
+ padding: theme.spacing[sizeTokens.paddingKey],
2258
2606
  margin: `0 ${theme.spacing.small}`,
2259
- border: `1px solid ${theme.colors.border}`,
2260
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2261
- backgroundColor: isActive ? theme.colors.primary : theme.colors.background,
2262
- color: isActive ? '#fff' : theme.colors.text,
2607
+ border: `1px solid ${cssVarBorder}`,
2608
+ borderRadius: cssVarRadius,
2609
+ backgroundColor: isActive ? cssVarActiveBg : cssVarBg,
2610
+ color: isActive ? cssVarActiveColor : cssVarColor,
2263
2611
  cursor: 'pointer',
2264
2612
  opacity: 1,
2265
- fontSize: theme.typography.fontSize.medium,
2266
- minWidth: '40px',
2613
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2614
+ minWidth: sizeTokens.minWidth,
2267
2615
  ...(isActive && {
2268
2616
  fontWeight: 'bold',
2269
2617
  }),
2270
2618
  } }, page));
2271
- if (totalPages <= 1) {
2619
+ if (totalPages <= 1 && variant !== 'load-more') {
2620
+ return null;
2621
+ }
2622
+ // For load-more, hide when there are no more pages to load
2623
+ if (variant === 'load-more' && currentPage >= totalPages) {
2272
2624
  return null;
2273
2625
  }
2274
2626
  // Calculate page range to display
@@ -2301,6 +2653,94 @@ const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsP
2301
2653
  }
2302
2654
  return pages;
2303
2655
  };
2656
+ // ── Page info element ──────────────────────────────────────────────
2657
+ const pageInfoElement = resolvedShowPageInfo ? (React.createElement("span", { className: clsx(paginationTheme.pageInfo), style: {
2658
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2659
+ color: cssVarColor,
2660
+ padding: `0 ${theme.spacing.small}`,
2661
+ whiteSpace: 'nowrap',
2662
+ }, "aria-live": "polite" },
2663
+ "Page ",
2664
+ currentPage,
2665
+ " of ",
2666
+ totalPages)) : null;
2667
+ // ── Load More variant ──────────────────────────────────────────────
2668
+ if (variant === 'load-more') {
2669
+ const remaining = Math.max(0, totalResults - currentPage * itemsPerPage);
2670
+ return (React.createElement("nav", { className: clsx(paginationTheme.container, className), style: {
2671
+ display: 'flex',
2672
+ flexDirection: 'column',
2673
+ alignItems: 'center',
2674
+ gap: theme.spacing.small,
2675
+ ...style,
2676
+ }, "aria-label": "Pagination" },
2677
+ React.createElement("button", { type: "button", onClick: () => handlePageChange(currentPage + 1), className: clsx(paginationTheme.loadMoreButton), style: {
2678
+ padding: `${theme.spacing[sizeTokens.paddingKey]} ${theme.spacing.large}`,
2679
+ border: 'none',
2680
+ borderRadius: cssVarRadius,
2681
+ backgroundColor: cssVarActiveBg,
2682
+ color: cssVarActiveColor,
2683
+ cursor: 'pointer',
2684
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2685
+ fontWeight: theme.typography.fontWeight?.medium ?? 500,
2686
+ transition: theme.transitions?.fast ?? '150ms ease-in-out',
2687
+ minWidth: sizeTokens.minWidth,
2688
+ }, "aria-label": remaining > 0 ? `${loadMoreText} (${remaining} remaining)` : loadMoreText },
2689
+ loadMoreText,
2690
+ remaining > 0 && (React.createElement("span", { className: clsx(paginationTheme.loadMoreText), style: {
2691
+ marginLeft: theme.spacing.small,
2692
+ opacity: 0.85,
2693
+ fontSize: theme.typography.fontSize.small,
2694
+ } },
2695
+ "(",
2696
+ remaining,
2697
+ " remaining)"))),
2698
+ pageInfoElement));
2699
+ }
2700
+ // ── Simple variant ─────────────────────────────────────────────────
2701
+ if (variant === 'simple') {
2702
+ return (React.createElement("nav", { className: clsx(paginationTheme.container, paginationTheme.simpleContainer, className), style: {
2703
+ display: 'flex',
2704
+ alignItems: 'center',
2705
+ justifyContent: 'center',
2706
+ gap: theme.spacing.medium,
2707
+ ...style,
2708
+ }, "aria-label": "Pagination" },
2709
+ React.createElement("button", { type: "button", disabled: currentPage === 1, onClick: () => handlePageChange(currentPage - 1), className: clsx(paginationTheme.simpleButton, currentPage === 1 && paginationTheme.itemDisabled), style: {
2710
+ padding: `${theme.spacing[sizeTokens.paddingKey]} ${theme.spacing.medium}`,
2711
+ border: `1px solid ${cssVarBorder}`,
2712
+ borderRadius: cssVarRadius,
2713
+ backgroundColor: cssVarBg,
2714
+ color: cssVarColor,
2715
+ cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
2716
+ opacity: currentPage === 1 ? 0.5 : 1,
2717
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2718
+ minWidth: sizeTokens.minWidth,
2719
+ transition: theme.transitions?.fast ?? '150ms ease-in-out',
2720
+ }, "aria-label": "Previous page" }, previousLabel),
2721
+ React.createElement("span", { className: clsx(paginationTheme.simpleText, paginationTheme.pageInfo), style: {
2722
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2723
+ color: cssVarColor,
2724
+ whiteSpace: 'nowrap',
2725
+ }, "aria-live": "polite" },
2726
+ "Page ",
2727
+ currentPage,
2728
+ " of ",
2729
+ totalPages),
2730
+ React.createElement("button", { type: "button", disabled: currentPage === totalPages, onClick: () => handlePageChange(currentPage + 1), className: clsx(paginationTheme.simpleButton, currentPage === totalPages && paginationTheme.itemDisabled), style: {
2731
+ padding: `${theme.spacing[sizeTokens.paddingKey]} ${theme.spacing.medium}`,
2732
+ border: `1px solid ${cssVarBorder}`,
2733
+ borderRadius: cssVarRadius,
2734
+ backgroundColor: cssVarBg,
2735
+ color: cssVarColor,
2736
+ cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
2737
+ opacity: currentPage === totalPages ? 0.5 : 1,
2738
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2739
+ minWidth: sizeTokens.minWidth,
2740
+ transition: theme.transitions?.fast ?? '150ms ease-in-out',
2741
+ }, "aria-label": "Next page" }, nextLabel)));
2742
+ }
2743
+ // ── Numbered variant (default — original behavior) ─────────────────
2304
2744
  const pageNumbers = getPageNumbers();
2305
2745
  return (React.createElement("nav", { className: clsx(paginationTheme.container, className), style: style, "aria-label": "Pagination" },
2306
2746
  React.createElement("ul", { className: paginationTheme.list, style: {
@@ -2311,27 +2751,44 @@ const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsP
2311
2751
  padding: 0,
2312
2752
  margin: 0,
2313
2753
  flexWrap: 'wrap',
2754
+ }, tabIndex: 0, onKeyDown: (e) => {
2755
+ if (e.key === 'ArrowLeft') {
2756
+ e.preventDefault();
2757
+ handlePageChange(currentPage - 1);
2758
+ }
2759
+ else if (e.key === 'ArrowRight') {
2760
+ e.preventDefault();
2761
+ handlePageChange(currentPage + 1);
2762
+ }
2763
+ else if (e.key === 'Home') {
2764
+ e.preventDefault();
2765
+ handlePageChange(1);
2766
+ }
2767
+ else if (e.key === 'End') {
2768
+ e.preventDefault();
2769
+ handlePageChange(totalPages);
2770
+ }
2314
2771
  } },
2315
2772
  showPrevNext && (React.createElement("li", null,
2316
2773
  React.createElement("button", { type: "button", disabled: currentPage === 1, onClick: () => handlePageChange(currentPage - 1), className: clsx(paginationTheme.item, currentPage === 1 && paginationTheme.itemDisabled), style: {
2317
- padding: theme.spacing.small,
2774
+ padding: theme.spacing[sizeTokens.paddingKey],
2318
2775
  margin: `0 ${theme.spacing.small}`,
2319
- border: `1px solid ${theme.colors.border}`,
2320
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2321
- backgroundColor: theme.colors.background,
2322
- color: theme.colors.text,
2776
+ border: `1px solid ${cssVarBorder}`,
2777
+ borderRadius: cssVarRadius,
2778
+ backgroundColor: cssVarBg,
2779
+ color: cssVarColor,
2323
2780
  cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
2324
2781
  opacity: currentPage === 1 ? 0.5 : 1,
2325
- fontSize: theme.typography.fontSize.medium,
2326
- }, "aria-label": "Previous page" }, "Previous"))),
2782
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2783
+ }, "aria-label": "Previous page" }, previousLabel))),
2327
2784
  pageNumbers.map((page, index) => {
2328
2785
  if (page === 'ellipsis') {
2329
2786
  return (React.createElement("li", { key: `ellipsis-${index}` },
2330
2787
  React.createElement("span", { className: paginationTheme.ellipsis, style: {
2331
- padding: theme.spacing.small,
2788
+ padding: theme.spacing[sizeTokens.paddingKey],
2332
2789
  margin: `0 ${theme.spacing.small}`,
2333
- color: theme.colors.text,
2334
- fontSize: theme.typography.fontSize.medium,
2790
+ color: cssVarColor,
2791
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2335
2792
  } }, "...")));
2336
2793
  }
2337
2794
  const isActive = page === currentPage;
@@ -2342,89 +2799,274 @@ const Pagination = ({ results: resultsProp, currentPage: currentPageProp, itemsP
2342
2799
  }),
2343
2800
  showPrevNext && (React.createElement("li", null,
2344
2801
  React.createElement("button", { type: "button", disabled: currentPage === totalPages, onClick: () => handlePageChange(currentPage + 1), className: clsx(paginationTheme.item, currentPage === totalPages && paginationTheme.itemDisabled), style: {
2345
- padding: theme.spacing.small,
2802
+ padding: theme.spacing[sizeTokens.paddingKey],
2346
2803
  margin: `0 ${theme.spacing.small}`,
2347
- border: `1px solid ${theme.colors.border}`,
2348
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2349
- backgroundColor: theme.colors.background,
2350
- color: theme.colors.text,
2804
+ border: `1px solid ${cssVarBorder}`,
2805
+ borderRadius: cssVarRadius,
2806
+ backgroundColor: cssVarBg,
2807
+ color: cssVarColor,
2351
2808
  cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
2352
2809
  opacity: currentPage === totalPages ? 0.5 : 1,
2353
- fontSize: theme.typography.fontSize.medium,
2354
- }, "aria-label": "Next page" }, "Next"))))));
2810
+ fontSize: theme.typography.fontSize[sizeTokens.fontSizeKey],
2811
+ }, "aria-label": "Next page" }, nextLabel))),
2812
+ resolvedShowPageInfo && (React.createElement("li", { style: { marginLeft: theme.spacing.small } }, pageInfoElement)))));
2355
2813
  };
2356
2814
 
2357
2815
  /**
2358
2816
  * SortBy Component
2359
2817
  *
2360
- * Displays sort options for search results
2361
- * Integrates with SearchStateManager for automatic state sync
2818
+ * Displays sort options for search results in multiple display variants:
2819
+ * - dropdown (default) native select element
2820
+ * - button-group — horizontal row of toggle buttons
2821
+ * - radio-group — vertical list of radio inputs
2822
+ *
2823
+ * Integrates with SearchStateManager for automatic state sync.
2824
+ *
2825
+ * CSS Variables (apply on a parent element to customize):
2826
+ * --seekora-sort-bg — background color
2827
+ * --seekora-sort-color — text color
2828
+ * --seekora-sort-border — border color
2829
+ * --seekora-sort-active-bg — active item background
2830
+ * --seekora-sort-active-color — active item text color
2362
2831
  */
2363
- const SortBy = ({ options, value: valueProp, defaultValue, onSortChange, renderSelect, className, style, theme: customTheme, placeholder = 'Sort by...', syncWithState = true, }) => {
2832
+ // ---------------------------------------------------------------------------
2833
+ // Helpers
2834
+ // ---------------------------------------------------------------------------
2835
+ /** Map a size token to theme fontSize and spacing values. */
2836
+ function sizeStyles(size, theme) {
2837
+ switch (size) {
2838
+ case 'small':
2839
+ return { fontSize: theme.typography.fontSize.small, padding: theme.spacing.small };
2840
+ case 'large':
2841
+ return { fontSize: theme.typography.fontSize.large, padding: theme.spacing.medium };
2842
+ case 'medium':
2843
+ default:
2844
+ return { fontSize: theme.typography.fontSize.medium, padding: theme.spacing.small };
2845
+ }
2846
+ }
2847
+ function resolveBorderRadius(br) {
2848
+ return typeof br === 'string' ? br : br.medium;
2849
+ }
2850
+ // ---------------------------------------------------------------------------
2851
+ // Component
2852
+ // ---------------------------------------------------------------------------
2853
+ const SortBy = ({ options, value: valueProp, defaultValue, onSortChange, renderSelect, className, style, theme: customTheme, placeholder = 'Sort by...', syncWithState = true, variant = 'dropdown', label, showLabel, size = 'medium', }) => {
2364
2854
  const { theme } = useSearchContext();
2365
2855
  const { sortBy: stateManagerSortBy, setSortBy } = useSearchState();
2366
2856
  const sortByTheme = customTheme || {};
2367
- // Initialize with defaultValue or first option
2857
+ const instanceId = React.useId();
2858
+ // Determine whether the label should render.
2859
+ const shouldShowLabel = showLabel !== undefined ? showLabel : !!label;
2860
+ // ------ State ----------------------------------------------------------
2368
2861
  const [internalValue, setInternalValue] = React.useState(defaultValue || options[0]?.value || '');
2369
2862
  // Sync with StateManager on mount if defaultValue is set
2370
2863
  React.useEffect(() => {
2371
2864
  if (syncWithState && defaultValue && !stateManagerSortBy) {
2372
2865
  setSortBy(defaultValue, false); // Don't trigger search on initial sync
2373
2866
  }
2374
- }, []);
2867
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
2375
2868
  // Determine the current value: controlled prop > StateManager > internal
2376
2869
  const value = valueProp !== undefined
2377
2870
  ? valueProp
2378
- : (syncWithState && stateManagerSortBy)
2871
+ : syncWithState && stateManagerSortBy
2379
2872
  ? stateManagerSortBy
2380
2873
  : internalValue;
2874
+ // ------ Handlers -------------------------------------------------------
2381
2875
  const handleChange = (e) => {
2382
- const newValue = e.target.value;
2876
+ applyValue(e.target.value);
2877
+ };
2878
+ const applyValue = React.useCallback((newValue) => {
2383
2879
  setInternalValue(newValue);
2384
- // Update StateManager (automatically triggers search)
2385
2880
  if (syncWithState) {
2386
2881
  setSortBy(newValue);
2387
2882
  }
2388
- // Call callback for backwards compatibility
2389
2883
  if (onSortChange) {
2390
2884
  onSortChange(newValue);
2391
2885
  }
2886
+ }, [syncWithState, setSortBy, onSortChange]);
2887
+ // ------ Derived styles -------------------------------------------------
2888
+ const { fontSize, padding } = sizeStyles(size, theme);
2889
+ const borderRadius = resolveBorderRadius(theme.borderRadius);
2890
+ const cssVarStyle = {
2891
+ '--seekora-sort-bg': theme.colors.background,
2892
+ '--seekora-sort-color': theme.colors.text,
2893
+ '--seekora-sort-border': theme.colors.border,
2894
+ '--seekora-sort-active-bg': theme.colors.primary,
2895
+ '--seekora-sort-active-color': theme.colors.background,
2392
2896
  };
2393
- const defaultRenderSelect = () => (React.createElement("select", { value: value, onChange: handleChange, className: clsx(sortByTheme.select, className), style: {
2394
- padding: theme.spacing.small,
2395
- paddingRight: theme.spacing.medium,
2396
- fontSize: theme.typography.fontSize.medium,
2397
- border: `1px solid ${theme.colors.border}`,
2398
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2399
- backgroundColor: theme.colors.background,
2400
- color: theme.colors.text,
2401
- cursor: 'pointer',
2402
- outline: 'none',
2403
- ...style,
2404
- }, "aria-label": "Sort results" }, options.map((option) => (React.createElement("option", { key: option.value, value: option.value, className: sortByTheme.option }, option.label)))));
2405
- if (renderSelect) {
2406
- return (React.createElement("div", { className: clsx(sortByTheme.container, className), style: style }, renderSelect({
2407
- value,
2408
- onChange: handleChange,
2409
- options,
2410
- })));
2897
+ // ------ Label ----------------------------------------------------------
2898
+ const labelElement = shouldShowLabel && label ? (React.createElement("span", { className: clsx(sortByTheme.label), style: {
2899
+ display: 'block',
2900
+ marginBottom: theme.spacing.small,
2901
+ fontSize,
2902
+ color: 'var(--seekora-sort-color)',
2903
+ fontWeight: 500,
2904
+ } }, label)) : null;
2905
+ // ------ Dropdown variant (original) ------------------------------------
2906
+ if (variant === 'dropdown') {
2907
+ if (renderSelect) {
2908
+ return (React.createElement("div", { className: clsx(sortByTheme.container, className), style: { ...cssVarStyle, ...style } },
2909
+ labelElement,
2910
+ renderSelect({
2911
+ value,
2912
+ onChange: handleChange,
2913
+ options,
2914
+ })));
2915
+ }
2916
+ return (React.createElement("div", { className: clsx(sortByTheme.container, className), style: { ...cssVarStyle, ...style } },
2917
+ labelElement,
2918
+ React.createElement("select", { value: value, onChange: handleChange, className: clsx(sortByTheme.select), style: {
2919
+ padding,
2920
+ paddingRight: theme.spacing.medium,
2921
+ fontSize,
2922
+ border: '1px solid var(--seekora-sort-border)',
2923
+ borderRadius,
2924
+ backgroundColor: 'var(--seekora-sort-bg)',
2925
+ color: 'var(--seekora-sort-color)',
2926
+ cursor: 'pointer',
2927
+ outline: 'none',
2928
+ width: '100%',
2929
+ }, "aria-label": label || 'Sort results' }, options.map((option) => (React.createElement("option", { key: option.value, value: option.value, className: sortByTheme.option }, option.label))))));
2930
+ }
2931
+ // ------ Button group variant -------------------------------------------
2932
+ if (variant === 'button-group') {
2933
+ return (React.createElement("div", { className: clsx(sortByTheme.container, className), style: { ...cssVarStyle, ...style } },
2934
+ labelElement,
2935
+ React.createElement("div", { role: "group", "aria-label": label || 'Sort results', className: clsx(sortByTheme.buttonGroup), style: {
2936
+ display: 'inline-flex',
2937
+ borderRadius,
2938
+ overflow: 'hidden',
2939
+ border: '1px solid var(--seekora-sort-border)',
2940
+ } }, options.map((option, index) => {
2941
+ const isActive = option.value === value;
2942
+ return (React.createElement("button", { key: option.value, type: "button", role: "button", "aria-pressed": isActive, onClick: () => applyValue(option.value), className: clsx(sortByTheme.buttonGroupItem, isActive && sortByTheme.buttonGroupItemActive), style: {
2943
+ padding,
2944
+ fontSize,
2945
+ border: 'none',
2946
+ borderRight: index < options.length - 1
2947
+ ? '1px solid var(--seekora-sort-border)'
2948
+ : 'none',
2949
+ backgroundColor: isActive
2950
+ ? 'var(--seekora-sort-active-bg)'
2951
+ : 'var(--seekora-sort-bg)',
2952
+ color: isActive
2953
+ ? 'var(--seekora-sort-active-color)'
2954
+ : 'var(--seekora-sort-color)',
2955
+ cursor: 'pointer',
2956
+ fontWeight: isActive ? 600 : 400,
2957
+ transition: 'background-color 0.15s ease, color 0.15s ease',
2958
+ outline: 'none',
2959
+ } }, option.label));
2960
+ }))));
2961
+ }
2962
+ // ------ Radio group variant --------------------------------------------
2963
+ if (variant === 'radio-group') {
2964
+ const radioName = `seekora-sort-${instanceId}`;
2965
+ return (React.createElement("div", { className: clsx(sortByTheme.container, className), style: { ...cssVarStyle, ...style } },
2966
+ labelElement,
2967
+ React.createElement("div", { role: "radiogroup", "aria-label": label || 'Sort results', className: clsx(sortByTheme.radioGroup), style: {
2968
+ display: 'flex',
2969
+ flexDirection: 'column',
2970
+ gap: theme.spacing.small,
2971
+ } }, options.map((option) => {
2972
+ const isActive = option.value === value;
2973
+ const radioId = `${radioName}-${option.value}`;
2974
+ return (React.createElement("label", { key: option.value, htmlFor: radioId, className: clsx(sortByTheme.radioItem, isActive && sortByTheme.radioItemActive), style: {
2975
+ display: 'flex',
2976
+ alignItems: 'center',
2977
+ gap: theme.spacing.small,
2978
+ padding,
2979
+ borderRadius,
2980
+ cursor: 'pointer',
2981
+ backgroundColor: isActive
2982
+ ? 'var(--seekora-sort-active-bg)'
2983
+ : 'transparent',
2984
+ color: isActive
2985
+ ? 'var(--seekora-sort-active-color)'
2986
+ : 'var(--seekora-sort-color)',
2987
+ fontWeight: isActive ? 600 : 400,
2988
+ transition: 'background-color 0.15s ease, color 0.15s ease',
2989
+ } },
2990
+ React.createElement("input", { type: "radio", id: radioId, name: radioName, value: option.value, checked: isActive, onChange: () => applyValue(option.value), style: { margin: 0 } }),
2991
+ React.createElement("span", { className: clsx(sortByTheme.radioLabel), style: { fontSize } }, option.label)));
2992
+ }))));
2411
2993
  }
2412
- return defaultRenderSelect();
2994
+ // Fallback — should never reach here, but satisfies TS exhaustiveness
2995
+ return null;
2413
2996
  };
2414
2997
 
2415
2998
  /**
2416
2999
  * Facets Component
2417
3000
  *
2418
- * Displays facet filters for search results
3001
+ * Displays facet filters for search results with multiple display variants,
3002
+ * client-side search, count badges, and color swatch support.
2419
3003
  */
2420
- const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, }) => {
3004
+ // ---------------------------------------------------------------------------
3005
+ // Helpers
3006
+ // ---------------------------------------------------------------------------
3007
+ /** Generate a deterministic colour from a string (used as fallback for color-swatch). */
3008
+ function stringToColor(str) {
3009
+ let hash = 0;
3010
+ for (let i = 0; i < str.length; i++) {
3011
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
3012
+ hash |= 0; // Convert to 32-bit int
3013
+ }
3014
+ const h = Math.abs(hash) % 360;
3015
+ return `hsl(${h}, 65%, 55%)`;
3016
+ }
3017
+ /** Return swatch pixel size from the size prop. */
3018
+ function swatchSize(size) {
3019
+ switch (size) {
3020
+ case 'small':
3021
+ return 24;
3022
+ case 'large':
3023
+ return 40;
3024
+ case 'medium':
3025
+ default:
3026
+ return 32;
3027
+ }
3028
+ }
3029
+ // ---------------------------------------------------------------------------
3030
+ // Chevron SVG icon for collapsible variant
3031
+ // ---------------------------------------------------------------------------
3032
+ const ChevronIcon$1 = ({ 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: {
3033
+ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
3034
+ transition: 'transform 200ms ease',
3035
+ flexShrink: 0,
3036
+ }, "aria-hidden": "true" },
3037
+ React.createElement("path", { d: "M4 6L8 10L12 6", stroke: color, strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })));
3038
+ // ---------------------------------------------------------------------------
3039
+ // Checkmark SVG for selected color swatches
3040
+ // ---------------------------------------------------------------------------
3041
+ 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" },
3042
+ React.createElement("path", { d: "M3.5 8.5L6.5 11.5L12.5 4.5", stroke: "#fff", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })));
3043
+ // ---------------------------------------------------------------------------
3044
+ // CSS variable defaults
3045
+ // ---------------------------------------------------------------------------
3046
+ const CSS_VAR_DEFAULTS = {
3047
+ '--seekora-facet-bg': '#ffffff',
3048
+ '--seekora-facet-border': '#dee2e6',
3049
+ '--seekora-facet-active-bg': '#f0f7ff',
3050
+ '--seekora-facet-swatch-size': '32px',
3051
+ '--seekora-facet-count-bg': '#e9ecef',
3052
+ '--seekora-facet-count-color': '#495057',
3053
+ };
3054
+ // ---------------------------------------------------------------------------
3055
+ // Component
3056
+ // ---------------------------------------------------------------------------
3057
+ 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', }) => {
2421
3058
  const { theme } = useSearchContext();
2422
3059
  const { results: stateResults, refinements, addRefinement, removeRefinement } = useSearchState();
2423
3060
  const facetsTheme = customTheme || {};
3061
+ // expandedFacets is used for "Show more/less" in checkbox/color-swatch variants
3062
+ // AND for collapse/expand in collapsible variant.
2424
3063
  const [expandedFacets, setExpandedFacets] = React.useState({});
3064
+ const [searchTerms, setSearchTerms] = React.useState({});
2425
3065
  // Use results from prop if provided, otherwise from state manager
2426
3066
  const results = resultsProp || stateResults;
3067
+ // -------------------------------------------------------------------
2427
3068
  // Extract facets from results
3069
+ // -------------------------------------------------------------------
2428
3070
  const extractFacets = () => {
2429
3071
  if (facetsProp)
2430
3072
  return facetsProp;
@@ -2471,6 +3113,9 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
2471
3113
  return extracted;
2472
3114
  };
2473
3115
  const facets = extractFacets();
3116
+ // -------------------------------------------------------------------
3117
+ // Handlers
3118
+ // -------------------------------------------------------------------
2474
3119
  const handleFacetToggle = (field, value, selected) => {
2475
3120
  const newSelected = !selected;
2476
3121
  log.verbose('Facets: Facet toggled', { field, value, selected: newSelected });
@@ -2503,49 +3148,283 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
2503
3148
  [field]: !prev[field],
2504
3149
  }));
2505
3150
  };
3151
+ /** For collapsible variant — determine if a facet group is open. */
3152
+ const isFacetGroupOpen = (field) => {
3153
+ if (field in expandedFacets) {
3154
+ return expandedFacets[field];
3155
+ }
3156
+ // Default based on defaultCollapsed prop
3157
+ return !defaultCollapsed;
3158
+ };
3159
+ const toggleCollapsible = (field) => {
3160
+ setExpandedFacets((prev) => ({
3161
+ ...prev,
3162
+ [field]: !(prev[field] ?? !defaultCollapsed),
3163
+ }));
3164
+ };
3165
+ const getSearchTerm = (field) => searchTerms[field] || '';
3166
+ const setSearchTerm = (field, term) => {
3167
+ setSearchTerms((prev) => ({ ...prev, [field]: term }));
3168
+ };
3169
+ /** Filter facet items by search term. */
3170
+ const filterItems = (items, field) => {
3171
+ if (!searchable)
3172
+ return items;
3173
+ const term = getSearchTerm(field).toLowerCase();
3174
+ if (!term)
3175
+ return items;
3176
+ return items.filter((item) => item.value.toLowerCase().includes(term));
3177
+ };
3178
+ // -------------------------------------------------------------------
3179
+ // Size helpers
3180
+ // -------------------------------------------------------------------
3181
+ const sizeScale = React.useMemo(() => {
3182
+ switch (size) {
3183
+ case 'small':
3184
+ return { font: theme.typography.fontSize.small, padding: '0.25rem', gap: '0.25rem' };
3185
+ case 'large':
3186
+ return { font: theme.typography.fontSize.large, padding: '0.75rem', gap: '0.75rem' };
3187
+ case 'medium':
3188
+ default:
3189
+ return { font: theme.typography.fontSize.medium, padding: theme.spacing.small, gap: theme.spacing.small };
3190
+ }
3191
+ }, [size, theme]);
3192
+ // -------------------------------------------------------------------
3193
+ // Count badge renderer
3194
+ // -------------------------------------------------------------------
3195
+ const renderCountBadge = (count) => {
3196
+ if (!showCounts)
3197
+ return null;
3198
+ return (React.createElement("span", { className: clsx(facetsTheme.countBadge), style: {
3199
+ display: 'inline-flex',
3200
+ alignItems: 'center',
3201
+ justifyContent: 'center',
3202
+ minWidth: '1.5em',
3203
+ padding: '0.1em 0.5em',
3204
+ marginLeft: sizeScale.gap,
3205
+ fontSize: theme.typography.fontSize.small,
3206
+ fontWeight: theme.typography.fontWeight?.medium ?? 500,
3207
+ lineHeight: 1,
3208
+ color: 'var(--seekora-facet-count-color, #495057)',
3209
+ backgroundColor: 'var(--seekora-facet-count-bg, #e9ecef)',
3210
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.full,
3211
+ flexShrink: 0,
3212
+ } }, count));
3213
+ };
3214
+ // -------------------------------------------------------------------
3215
+ // Search input renderer
3216
+ // -------------------------------------------------------------------
3217
+ const renderSearchInput = (facet) => {
3218
+ if (!searchable)
3219
+ return null;
3220
+ 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: {
3221
+ width: '100%',
3222
+ boxSizing: 'border-box',
3223
+ padding: sizeScale.padding,
3224
+ marginBottom: sizeScale.gap,
3225
+ fontSize: theme.typography.fontSize.small,
3226
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
3227
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.small,
3228
+ outline: 'none',
3229
+ color: theme.colors.text,
3230
+ backgroundColor: 'var(--seekora-facet-bg, transparent)',
3231
+ } }));
3232
+ };
3233
+ // -------------------------------------------------------------------
3234
+ // Checkbox variant item renderer (original behaviour, preserved)
3235
+ // -------------------------------------------------------------------
2506
3236
  const defaultRenderFacetItem = (item, facet, index) => {
2507
3237
  const isExpanded = expandedFacets[facet.field] || index < maxItems;
2508
3238
  if (!isExpanded && index >= maxItems) {
2509
3239
  return null;
2510
3240
  }
2511
- 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: {
3241
+ const isChecked = refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected || false;
3242
+ 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: {
2512
3243
  display: 'flex',
2513
3244
  alignItems: 'center',
2514
- padding: theme.spacing.small,
3245
+ padding: sizeScale.padding,
3246
+ cursor: 'pointer',
3247
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
3248
+ marginBottom: sizeScale.gap,
3249
+ backgroundColor: isChecked
3250
+ ? 'var(--seekora-facet-active-bg, ' + theme.colors.hover + ')'
3251
+ : 'transparent',
3252
+ transition: 'background-color 0.2s ease',
3253
+ } },
3254
+ 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: {
3255
+ marginRight: sizeScale.gap,
3256
+ cursor: 'pointer',
3257
+ }, "aria-label": `Filter by ${item.value}` }),
3258
+ React.createElement("span", { className: facetsTheme.facetItemLabel, style: {
3259
+ flex: 1,
3260
+ fontSize: sizeScale.font,
3261
+ color: theme.colors.text,
3262
+ } }, item.value),
3263
+ renderCountBadge(item.count)));
3264
+ };
3265
+ // -------------------------------------------------------------------
3266
+ // Color swatch variant item renderer
3267
+ // -------------------------------------------------------------------
3268
+ const renderColorSwatchItem = (item, facet, index) => {
3269
+ const isExpanded = expandedFacets[facet.field] || index < maxItems;
3270
+ if (!isExpanded && index >= maxItems) {
3271
+ return null;
3272
+ }
3273
+ const isChecked = refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected || false;
3274
+ const color = colorMap?.[item.value] ?? stringToColor(item.value);
3275
+ const pxSize = swatchSize(size);
3276
+ 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: {
3277
+ display: 'inline-flex',
3278
+ flexDirection: 'column',
3279
+ alignItems: 'center',
2515
3280
  cursor: 'pointer',
3281
+ margin: sizeScale.gap,
3282
+ } },
3283
+ React.createElement("div", { className: clsx(facetsTheme.colorSwatch, isChecked && facetsTheme.colorSwatchSelected), style: {
3284
+ width: `var(--seekora-facet-swatch-size, ${pxSize}px)`,
3285
+ height: `var(--seekora-facet-swatch-size, ${pxSize}px)`,
3286
+ borderRadius: '50%',
3287
+ backgroundColor: color,
3288
+ border: isChecked
3289
+ ? `3px solid ${theme.colors.primary}`
3290
+ : `2px solid var(--seekora-facet-border, ${theme.colors.border})`,
3291
+ display: 'flex',
3292
+ alignItems: 'center',
3293
+ justifyContent: 'center',
3294
+ transition: 'border 0.2s ease, box-shadow 0.2s ease',
3295
+ boxShadow: isChecked ? `0 0 0 2px ${theme.colors.primary}33` : 'none',
3296
+ position: 'relative',
3297
+ } }, isChecked && (React.createElement("span", { className: clsx(facetsTheme.colorSwatchInner), style: {
3298
+ display: 'flex',
3299
+ alignItems: 'center',
3300
+ justifyContent: 'center',
3301
+ } },
3302
+ React.createElement(CheckmarkIcon, { size: Math.round(pxSize * 0.5) })))),
3303
+ React.createElement("span", { className: facetsTheme.facetItemLabel, style: {
3304
+ fontSize: theme.typography.fontSize.small,
3305
+ color: theme.colors.text,
3306
+ marginTop: '0.25rem',
3307
+ textAlign: 'center',
3308
+ maxWidth: `${pxSize + 16}px`,
3309
+ overflow: 'hidden',
3310
+ textOverflow: 'ellipsis',
3311
+ whiteSpace: 'nowrap',
3312
+ } }, item.value),
3313
+ showCounts && (React.createElement("span", { className: clsx(facetsTheme.countBadge), style: {
3314
+ fontSize: theme.typography.fontSize.small,
3315
+ color: 'var(--seekora-facet-count-color, ' + (theme.colors.textSecondary || theme.colors.text) + ')',
3316
+ lineHeight: 1,
3317
+ marginTop: '0.125rem',
3318
+ } }, item.count))));
3319
+ };
3320
+ // -------------------------------------------------------------------
3321
+ // Item renderer dispatcher
3322
+ // -------------------------------------------------------------------
3323
+ const renderItem = (item, facet, index) => {
3324
+ if (renderFacetItem) {
3325
+ return renderFacetItem(item, facet, index);
3326
+ }
3327
+ switch (variant) {
3328
+ case 'color-swatch':
3329
+ return renderColorSwatchItem(item, facet, index);
3330
+ case 'collapsible':
3331
+ case 'checkbox':
3332
+ default:
3333
+ return defaultRenderFacetItem(item, facet, index);
3334
+ }
3335
+ };
3336
+ // -------------------------------------------------------------------
3337
+ // Keyboard handler (shared across variants)
3338
+ // -------------------------------------------------------------------
3339
+ const handleListKeyDown = (e, visibleItems, facet) => {
3340
+ const currentEl = e.currentTarget.querySelector('[aria-selected="true"]');
3341
+ const allItems = Array.from(e.currentTarget.querySelectorAll('[role="option"]'));
3342
+ const currentIdx = currentEl ? allItems.indexOf(currentEl) : -1;
3343
+ if (e.key === 'ArrowDown') {
3344
+ e.preventDefault();
3345
+ const next = Math.min(currentIdx + 1, allItems.length - 1);
3346
+ allItems[next]?.focus();
3347
+ }
3348
+ else if (e.key === 'ArrowUp') {
3349
+ e.preventDefault();
3350
+ const prev = Math.max(currentIdx - 1, 0);
3351
+ allItems[prev]?.focus();
3352
+ }
3353
+ else if (e.key === 'Enter' || e.key === ' ') {
3354
+ e.preventDefault();
3355
+ if (currentIdx >= 0 && currentIdx < visibleItems.length) {
3356
+ handleFacetToggle(facet.field, visibleItems[currentIdx].value, visibleItems[currentIdx].selected || false);
3357
+ }
3358
+ }
3359
+ };
3360
+ // -------------------------------------------------------------------
3361
+ // Show more / less buttons (shared)
3362
+ // -------------------------------------------------------------------
3363
+ const renderShowMoreLess = (facet, filteredItems) => {
3364
+ const isExpanded = expandedFacets[facet.field] || false;
3365
+ const hasMore = filteredItems.length > maxItems;
3366
+ return (React.createElement(React.Fragment, null,
3367
+ showMore && hasMore && !isExpanded && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
3368
+ marginTop: sizeScale.gap,
3369
+ padding: sizeScale.padding,
3370
+ border: 'none',
3371
+ backgroundColor: 'transparent',
3372
+ color: theme.colors.primary,
3373
+ cursor: 'pointer',
3374
+ fontSize: theme.typography.fontSize.small,
3375
+ textDecoration: 'underline',
3376
+ } },
3377
+ "Show more (",
3378
+ filteredItems.length - maxItems,
3379
+ " more)")),
3380
+ isExpanded && hasMore && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
3381
+ marginTop: sizeScale.gap,
3382
+ padding: sizeScale.padding,
3383
+ border: 'none',
3384
+ backgroundColor: 'transparent',
3385
+ color: theme.colors.primary,
3386
+ cursor: 'pointer',
3387
+ fontSize: theme.typography.fontSize.small,
3388
+ textDecoration: 'underline',
3389
+ } }, "Show less"))));
3390
+ };
3391
+ // -------------------------------------------------------------------
3392
+ // Default facet group renderer — Checkbox variant
3393
+ // -------------------------------------------------------------------
3394
+ const renderCheckboxFacet = (facet, _index) => {
3395
+ const filteredItems = filterItems(facet.items, facet.field);
3396
+ const isExpanded = expandedFacets[facet.field] || false;
3397
+ const visibleItems = isExpanded ? filteredItems : filteredItems.slice(0, maxItems);
3398
+ return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
3399
+ marginBottom: theme.spacing.large,
3400
+ padding: theme.spacing.medium,
3401
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
3402
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
2516
3403
  borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2517
- marginBottom: theme.spacing.small,
2518
- backgroundColor: (refinements.some(r => r.field === facet.field && r.value === item.value) || item.selected) ? theme.colors.hover : 'transparent',
2519
- transition: 'background-color 0.2s ease',
2520
3404
  } },
2521
- 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: {
2522
- marginRight: theme.spacing.small,
2523
- cursor: 'pointer',
2524
- }, "aria-label": `Filter by ${item.value}` }),
2525
- React.createElement("span", { className: facetsTheme.facetItemLabel, style: {
2526
- flex: 1,
2527
- fontSize: theme.typography.fontSize.medium,
3405
+ React.createElement("h3", { className: facetsTheme.facetTitle, style: {
3406
+ fontSize: theme.typography.fontSize.large,
3407
+ fontWeight: 'bold',
3408
+ margin: 0,
3409
+ marginBottom: theme.spacing.medium,
2528
3410
  color: theme.colors.text,
2529
- } }, item.value),
2530
- React.createElement("span", { className: facetsTheme.facetItemCount, style: {
2531
- fontSize: theme.typography.fontSize.small,
2532
- color: theme.colors.textSecondary || theme.colors.text,
2533
- opacity: 0.7,
2534
- marginLeft: theme.spacing.small,
2535
- } },
2536
- "(",
2537
- item.count,
2538
- ")")));
3411
+ } }, facet.label || facet.field),
3412
+ renderSearchInput(facet),
3413
+ 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))),
3414
+ renderShowMoreLess(facet, filteredItems)));
2539
3415
  };
2540
- const defaultRenderFacet = (facet, index) => {
3416
+ // -------------------------------------------------------------------
3417
+ // Color-swatch facet group renderer
3418
+ // -------------------------------------------------------------------
3419
+ const renderColorSwatchFacet = (facet, _index) => {
3420
+ const filteredItems = filterItems(facet.items, facet.field);
2541
3421
  const isExpanded = expandedFacets[facet.field] || false;
2542
- const visibleItems = isExpanded ? facet.items : facet.items.slice(0, maxItems);
2543
- const hasMore = facet.items.length > maxItems;
3422
+ const visibleItems = isExpanded ? filteredItems : filteredItems.slice(0, maxItems);
2544
3423
  return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
2545
3424
  marginBottom: theme.spacing.large,
2546
3425
  padding: theme.spacing.medium,
2547
- backgroundColor: theme.colors.background,
2548
- border: `1px solid ${theme.colors.border}`,
3426
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
3427
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
2549
3428
  borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2550
3429
  } },
2551
3430
  React.createElement("h3", { className: facetsTheme.facetTitle, style: {
@@ -2555,36 +3434,106 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
2555
3434
  marginBottom: theme.spacing.medium,
2556
3435
  color: theme.colors.text,
2557
3436
  } }, facet.label || facet.field),
2558
- React.createElement("div", { className: facetsTheme.facetList }, visibleItems.map((item, itemIndex) => {
2559
- const actualIndex = isExpanded ? itemIndex : itemIndex;
2560
- return renderFacetItem
2561
- ? renderFacetItem(item, facet, actualIndex)
2562
- : defaultRenderFacetItem(item, facet, actualIndex);
2563
- })),
2564
- showMore && hasMore && !isExpanded && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
2565
- marginTop: theme.spacing.small,
2566
- padding: theme.spacing.small,
3437
+ renderSearchInput(facet),
3438
+ React.createElement("div", { className: facetsTheme.facetList, role: "listbox", "aria-label": `${facet.label || facet.field} filters`, tabIndex: 0, onKeyDown: (e) => handleListKeyDown(e, visibleItems, facet), style: {
3439
+ display: 'flex',
3440
+ flexWrap: 'wrap',
3441
+ gap: sizeScale.gap,
3442
+ } }, visibleItems.map((item, itemIndex) => renderItem(item, facet, itemIndex))),
3443
+ renderShowMoreLess(facet, filteredItems)));
3444
+ };
3445
+ // -------------------------------------------------------------------
3446
+ // Collapsible facet group renderer
3447
+ // -------------------------------------------------------------------
3448
+ const renderCollapsibleFacet = (facet, _index) => {
3449
+ const isOpen = isFacetGroupOpen(facet.field);
3450
+ const filteredItems = filterItems(facet.items, facet.field);
3451
+ expandedFacets[facet.field] || false;
3452
+ // Note: For collapsible, the expandedFacets state controls the collapse/expand
3453
+ // of the group itself. We use a separate concept for Show more/less within items.
3454
+ // To avoid collision, Show more/less for collapsible uses the same expandedFacets
3455
+ // key prefixed with `_items_`.
3456
+ const isShowMoreExpanded = expandedFacets[`_items_${facet.field}`] || false;
3457
+ const visibleItems = isShowMoreExpanded ? filteredItems : filteredItems.slice(0, maxItems);
3458
+ const hasMore = filteredItems.length > maxItems;
3459
+ return (React.createElement("div", { key: facet.field, className: facetsTheme.facet, style: {
3460
+ marginBottom: theme.spacing.large,
3461
+ backgroundColor: 'var(--seekora-facet-bg, ' + theme.colors.background + ')',
3462
+ border: `1px solid var(--seekora-facet-border, ${theme.colors.border})`,
3463
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
3464
+ overflow: 'hidden',
3465
+ } },
3466
+ React.createElement("button", { type: "button", className: clsx(facetsTheme.collapsibleHeader), onClick: () => toggleCollapsible(facet.field), "aria-expanded": isOpen, "aria-controls": `facet-group-${facet.field}`, style: {
3467
+ display: 'flex',
3468
+ alignItems: 'center',
3469
+ justifyContent: 'space-between',
3470
+ width: '100%',
3471
+ padding: theme.spacing.medium,
2567
3472
  border: 'none',
2568
3473
  backgroundColor: 'transparent',
2569
- color: theme.colors.primary,
2570
3474
  cursor: 'pointer',
2571
- fontSize: theme.typography.fontSize.small,
2572
- textDecoration: 'underline',
3475
+ textAlign: 'left',
2573
3476
  } },
2574
- "Show more (",
2575
- facet.items.length - maxItems,
2576
- " more)")),
2577
- isExpanded && hasMore && (React.createElement("button", { type: "button", onClick: () => toggleFacetExpansion(facet.field), style: {
2578
- marginTop: theme.spacing.small,
2579
- padding: theme.spacing.small,
2580
- border: 'none',
2581
- backgroundColor: 'transparent',
2582
- color: theme.colors.primary,
2583
- cursor: 'pointer',
2584
- fontSize: theme.typography.fontSize.small,
2585
- textDecoration: 'underline',
2586
- } }, "Show less"))));
3477
+ React.createElement("span", { className: facetsTheme.facetTitle, style: {
3478
+ fontSize: theme.typography.fontSize.large,
3479
+ fontWeight: 'bold',
3480
+ color: theme.colors.text,
3481
+ flex: 1,
3482
+ } }, facet.label || facet.field),
3483
+ React.createElement("span", { className: clsx(facetsTheme.collapsibleIcon) },
3484
+ React.createElement(ChevronIcon$1, { expanded: isOpen, color: theme.colors.textSecondary || theme.colors.text }))),
3485
+ isOpen && (React.createElement("div", { id: `facet-group-${facet.field}`, style: {
3486
+ padding: `0 ${theme.spacing.medium} ${theme.spacing.medium}`,
3487
+ } },
3488
+ renderSearchInput(facet),
3489
+ 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))),
3490
+ showMore && hasMore && !isShowMoreExpanded && (React.createElement("button", { type: "button", onClick: () => setExpandedFacets((prev) => ({
3491
+ ...prev,
3492
+ [`_items_${facet.field}`]: true,
3493
+ })), style: {
3494
+ marginTop: sizeScale.gap,
3495
+ padding: sizeScale.padding,
3496
+ border: 'none',
3497
+ backgroundColor: 'transparent',
3498
+ color: theme.colors.primary,
3499
+ cursor: 'pointer',
3500
+ fontSize: theme.typography.fontSize.small,
3501
+ textDecoration: 'underline',
3502
+ } },
3503
+ "Show more (",
3504
+ filteredItems.length - maxItems,
3505
+ " more)")),
3506
+ isShowMoreExpanded && hasMore && (React.createElement("button", { type: "button", onClick: () => setExpandedFacets((prev) => ({
3507
+ ...prev,
3508
+ [`_items_${facet.field}`]: false,
3509
+ })), style: {
3510
+ marginTop: sizeScale.gap,
3511
+ padding: sizeScale.padding,
3512
+ border: 'none',
3513
+ backgroundColor: 'transparent',
3514
+ color: theme.colors.primary,
3515
+ cursor: 'pointer',
3516
+ fontSize: theme.typography.fontSize.small,
3517
+ textDecoration: 'underline',
3518
+ } }, "Show less"))))));
2587
3519
  };
3520
+ // -------------------------------------------------------------------
3521
+ // Default facet renderer dispatcher
3522
+ // -------------------------------------------------------------------
3523
+ const defaultRenderFacet = (facet, index) => {
3524
+ switch (variant) {
3525
+ case 'color-swatch':
3526
+ return renderColorSwatchFacet(facet);
3527
+ case 'collapsible':
3528
+ return renderCollapsibleFacet(facet);
3529
+ case 'checkbox':
3530
+ default:
3531
+ return renderCheckboxFacet(facet);
3532
+ }
3533
+ };
3534
+ // -------------------------------------------------------------------
3535
+ // Empty state
3536
+ // -------------------------------------------------------------------
2588
3537
  if (facets.length === 0) {
2589
3538
  log.verbose('Facets: No facets to display', {
2590
3539
  hasResults: !!results,
@@ -2592,7 +3541,13 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
2592
3541
  });
2593
3542
  return null;
2594
3543
  }
2595
- return (React.createElement("div", { className: clsx(facetsTheme.container, className), style: style }, facets.map((facet, index) => {
3544
+ // -------------------------------------------------------------------
3545
+ // Render
3546
+ // -------------------------------------------------------------------
3547
+ return (React.createElement("div", { className: clsx(facetsTheme.container, className), style: {
3548
+ ...CSS_VAR_DEFAULTS,
3549
+ ...style,
3550
+ } }, facets.map((facet, index) => {
2596
3551
  return renderFacet
2597
3552
  ? renderFacet(facet, index)
2598
3553
  : defaultRenderFacet(facet);
@@ -2602,72 +3557,222 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
2602
3557
  /**
2603
3558
  * CurrentRefinements Component
2604
3559
  *
2605
- * Displays currently active filters/refinements with ability to clear them
3560
+ * Displays currently active filters/refinements with ability to clear them.
3561
+ * Supports StateManager auto-sync, display variants, layout modes, and animations.
2606
3562
  */
2607
- const CurrentRefinements = ({ refinements = [], onRefinementClear, onClearAll, renderRefinement, showClearAll = true, className, style, theme: customTheme, }) => {
3563
+ /** Get variant-specific styles */
3564
+ const getVariantStyles = (variant, themeColors, themeSpacing, themeBorderRadius, fieldColor) => {
3565
+ const baseBg = fieldColor || `var(--seekora-refinement-bg, ${themeColors.hover})`;
3566
+ const baseColor = `var(--seekora-refinement-color, ${themeColors.text})`;
3567
+ const baseBorder = `var(--seekora-refinement-border, ${themeColors.border})`;
3568
+ switch (variant) {
3569
+ case 'tags':
3570
+ return {
3571
+ display: 'inline-flex',
3572
+ alignItems: 'center',
3573
+ padding: `2px ${themeSpacing.medium}`,
3574
+ backgroundColor: baseBg,
3575
+ border: `1px solid ${baseBorder}`,
3576
+ borderRadius: `var(--seekora-refinement-radius, 4px)`,
3577
+ fontSize: '12px',
3578
+ color: baseColor,
3579
+ fontWeight: 500,
3580
+ };
3581
+ case 'pills':
3582
+ return {
3583
+ display: 'inline-flex',
3584
+ alignItems: 'center',
3585
+ padding: `${themeSpacing.small} ${themeSpacing.medium}`,
3586
+ backgroundColor: baseBg,
3587
+ border: 'none',
3588
+ borderRadius: `var(--seekora-refinement-radius, 9999px)`,
3589
+ fontSize: '13px',
3590
+ color: baseColor,
3591
+ };
3592
+ case 'badges':
3593
+ return {
3594
+ display: 'inline-flex',
3595
+ alignItems: 'center',
3596
+ padding: `3px ${themeSpacing.small}`,
3597
+ backgroundColor: themeColors.primary,
3598
+ border: 'none',
3599
+ borderRadius: `var(--seekora-refinement-radius, 4px)`,
3600
+ fontSize: '11px',
3601
+ color: '#fff',
3602
+ fontWeight: 600,
3603
+ textTransform: 'uppercase',
3604
+ letterSpacing: '0.5px',
3605
+ };
3606
+ case 'text-list':
3607
+ return {
3608
+ display: 'flex',
3609
+ alignItems: 'center',
3610
+ padding: `${themeSpacing.small} 0`,
3611
+ backgroundColor: 'transparent',
3612
+ border: 'none',
3613
+ borderBottom: `1px solid ${baseBorder}`,
3614
+ borderRadius: '0',
3615
+ fontSize: '14px',
3616
+ color: baseColor,
3617
+ };
3618
+ case 'chips':
3619
+ default:
3620
+ return {
3621
+ display: 'inline-flex',
3622
+ alignItems: 'center',
3623
+ padding: `${themeSpacing.small} ${themeSpacing.medium}`,
3624
+ backgroundColor: baseBg,
3625
+ border: `1px solid ${baseBorder}`,
3626
+ borderRadius: `var(--seekora-refinement-radius, ${typeof themeBorderRadius === 'string' ? themeBorderRadius : themeBorderRadius.medium})`,
3627
+ fontSize: '13px',
3628
+ color: baseColor,
3629
+ };
3630
+ }
3631
+ };
3632
+ /** Get variant-specific class name from theme */
3633
+ const getVariantClass = (variant, refinementsTheme) => {
3634
+ switch (variant) {
3635
+ case 'tags': return refinementsTheme.tag;
3636
+ case 'pills': return refinementsTheme.pill;
3637
+ case 'badges': return refinementsTheme.badge;
3638
+ case 'chips': return refinementsTheme.chip;
3639
+ default: return refinementsTheme.item;
3640
+ }
3641
+ };
3642
+ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, onClearAll, renderRefinement, showClearAll = true, variant = 'chips', layout = 'horizontal', fieldColors, renderCloseIcon, className, style, theme: customTheme, }) => {
2608
3643
  const { theme } = useSearchContext();
3644
+ const { refinements: stateRefinements, removeRefinement, clearRefinements } = useSearchState();
2609
3645
  const refinementsTheme = customTheme || {};
3646
+ // Use props if provided, otherwise auto-read from StateManager
3647
+ const refinements = refinementsProp !== undefined
3648
+ ? refinementsProp
3649
+ : stateRefinements.map(r => ({ field: r.field, value: r.value }));
3650
+ // Track items for entry/exit animations
3651
+ const [visibleItems, setVisibleItems] = React.useState(new Set());
3652
+ const [exitingItems, setExitingItems] = React.useState(new Set());
3653
+ const prevRefinementsRef = React.useRef([]);
3654
+ React.useEffect(() => {
3655
+ const currentKeys = new Set(refinements.map(r => `${r.field}:${r.value}`));
3656
+ const prevKeys = new Set(prevRefinementsRef.current.map(r => `${r.field}:${r.value}`));
3657
+ // Detect removed items for exit animation
3658
+ const removed = new Set();
3659
+ prevKeys.forEach(key => {
3660
+ if (!currentKeys.has(key))
3661
+ removed.add(key);
3662
+ });
3663
+ if (removed.size > 0) {
3664
+ setExitingItems(removed);
3665
+ // Remove after animation completes
3666
+ setTimeout(() => setExitingItems(new Set()), 200);
3667
+ }
3668
+ // Mark new items for entry animation
3669
+ setVisibleItems(currentKeys);
3670
+ prevRefinementsRef.current = [...refinements];
3671
+ }, [refinements]);
2610
3672
  const handleClear = (field, value) => {
3673
+ // If synced with StateManager and no prop provided, auto-clear via StateManager
3674
+ if (refinementsProp === undefined) {
3675
+ removeRefinement(field, value);
3676
+ }
2611
3677
  if (onRefinementClear) {
2612
3678
  onRefinementClear(field, value);
2613
3679
  }
2614
3680
  };
2615
3681
  const handleClearAll = () => {
3682
+ // If synced with StateManager, auto-clear all via StateManager
3683
+ if (refinementsProp === undefined) {
3684
+ clearRefinements();
3685
+ }
2616
3686
  if (onClearAll) {
2617
3687
  onClearAll();
2618
3688
  }
2619
3689
  };
2620
- const defaultRenderRefinement = (refinement, index) => (React.createElement("div", { key: `${refinement.field}-${refinement.value}-${index}`, className: refinementsTheme.item, style: {
2621
- display: 'inline-flex',
2622
- alignItems: 'center',
2623
- padding: `${theme.spacing.small} ${theme.spacing.medium}`,
2624
- margin: `0 ${theme.spacing.small} ${theme.spacing.small} 0`,
2625
- backgroundColor: theme.colors.hover,
2626
- border: `1px solid ${theme.colors.border}`,
2627
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
2628
- fontSize: theme.typography.fontSize.small,
2629
- } },
2630
- React.createElement("span", { className: refinementsTheme.label, style: {
2631
- marginRight: theme.spacing.small,
2632
- color: theme.colors.text,
2633
- fontWeight: '500',
3690
+ const defaultCloseIcon = () => (React.createElement("span", { "aria-hidden": "true", style: { lineHeight: 1 } }, "\u00D7"));
3691
+ const defaultRenderRefinement = (refinement, index) => {
3692
+ const key = `${refinement.field}:${refinement.value}`;
3693
+ const fieldColor = fieldColors?.[refinement.field];
3694
+ const isEntering = visibleItems.has(key) && !prevRefinementsRef.current.some(r => `${r.field}:${r.value}` === key);
3695
+ const variantStyles = getVariantStyles(variant, theme.colors, theme.spacing, theme.borderRadius, fieldColor);
3696
+ const variantClass = getVariantClass(variant, refinementsTheme);
3697
+ return (React.createElement("div", { key: `${key}-${index}`, className: clsx(refinementsTheme.item, variantClass), role: "listitem", style: {
3698
+ ...variantStyles,
3699
+ margin: layout === 'vertical'
3700
+ ? `0 0 ${theme.spacing.small} 0`
3701
+ : `0 ${theme.spacing.small} ${theme.spacing.small} 0`,
3702
+ transition: 'all 200ms ease-in-out',
3703
+ opacity: exitingItems.has(key) ? 0 : 1,
3704
+ transform: exitingItems.has(key) ? 'scale(0.8)' : 'scale(1)',
3705
+ animation: isEntering ? 'seekoraChipIn 200ms ease-out' : undefined,
2634
3706
  } },
2635
- refinement.label || refinement.field,
2636
- ":"),
2637
- React.createElement("span", { className: refinementsTheme.value, style: {
2638
- marginRight: theme.spacing.small,
2639
- color: theme.colors.text,
2640
- } }, refinement.displayValue || refinement.value),
2641
- React.createElement("button", { type: "button", onClick: () => handleClear(refinement.field, refinement.value), className: refinementsTheme.clearButton, style: {
2642
- border: 'none',
2643
- backgroundColor: 'transparent',
2644
- color: theme.colors.text,
2645
- cursor: 'pointer',
2646
- fontSize: theme.typography.fontSize.medium,
2647
- padding: 0,
2648
- marginLeft: theme.spacing.small,
2649
- width: '20px',
2650
- height: '20px',
2651
- display: 'flex',
2652
- alignItems: 'center',
2653
- justifyContent: 'center',
2654
- borderRadius: '50%',
2655
- transition: theme.transitions?.fast || '150ms ease-in-out',
2656
- }, "aria-label": `Clear ${refinement.label || refinement.field}: ${refinement.value}` }, "\u00D7")));
3707
+ variant !== 'badges' && (React.createElement("span", { className: refinementsTheme.label, style: {
3708
+ marginRight: theme.spacing.small,
3709
+ fontWeight: '500',
3710
+ opacity: 0.7,
3711
+ } },
3712
+ refinement.label || refinement.field,
3713
+ ":")),
3714
+ React.createElement("span", { className: refinementsTheme.value }, refinement.displayValue || refinement.value),
3715
+ React.createElement("button", { type: "button", onClick: () => handleClear(refinement.field, refinement.value), className: refinementsTheme.clearButton, style: {
3716
+ border: 'none',
3717
+ backgroundColor: 'transparent',
3718
+ color: 'inherit',
3719
+ cursor: 'pointer',
3720
+ fontSize: '14px',
3721
+ padding: '0 0 0 6px',
3722
+ display: 'flex',
3723
+ alignItems: 'center',
3724
+ justifyContent: 'center',
3725
+ borderRadius: '50%',
3726
+ transition: 'opacity 150ms ease-in-out',
3727
+ opacity: 0.6,
3728
+ }, "aria-label": `Clear ${refinement.label || refinement.field}: ${refinement.value}`, onMouseEnter: e => (e.currentTarget.style.opacity = '1'), onMouseLeave: e => (e.currentTarget.style.opacity = '0.6') }, renderCloseIcon ? renderCloseIcon() : defaultCloseIcon())));
3729
+ };
2657
3730
  if (refinements.length === 0) {
2658
3731
  return null;
2659
3732
  }
2660
- return (React.createElement("div", { className: clsx(refinementsTheme.container, className), style: style },
2661
- React.createElement("div", { className: refinementsTheme.list, style: {
2662
- display: 'flex',
2663
- flexWrap: 'wrap',
2664
- alignItems: 'center',
2665
- marginBottom: showClearAll ? theme.spacing.medium : 0,
2666
- } }, refinements.map((refinement, index) => {
3733
+ // Group refinements by field for grouped layout
3734
+ const groupedRefinements = layout === 'grouped'
3735
+ ? refinements.reduce((acc, r) => {
3736
+ if (!acc[r.field])
3737
+ acc[r.field] = [];
3738
+ acc[r.field].push(r);
3739
+ return acc;
3740
+ }, {})
3741
+ : null;
3742
+ const containerStyles = {
3743
+ ...style,
3744
+ };
3745
+ const listStyles = {
3746
+ display: 'flex',
3747
+ flexWrap: layout === 'vertical' ? 'nowrap' : 'wrap',
3748
+ flexDirection: layout === 'vertical' ? 'column' : 'row',
3749
+ alignItems: layout === 'vertical' ? 'flex-start' : 'center',
3750
+ marginBottom: showClearAll ? theme.spacing.medium : 0,
3751
+ };
3752
+ return (React.createElement("div", { className: clsx(refinementsTheme.container, className), style: containerStyles },
3753
+ React.createElement("style", null, `
3754
+ @keyframes seekoraChipIn {
3755
+ from { opacity: 0; transform: scale(0.8); }
3756
+ to { opacity: 1; transform: scale(1); }
3757
+ }
3758
+ `),
3759
+ layout === 'grouped' && groupedRefinements ? (Object.entries(groupedRefinements).map(([field, items]) => (React.createElement("div", { key: field, className: refinementsTheme.group, style: { marginBottom: theme.spacing.medium } },
3760
+ React.createElement("div", { className: refinementsTheme.groupLabel, style: {
3761
+ fontSize: theme.typography.fontSize.small,
3762
+ fontWeight: 600,
3763
+ color: theme.colors.text,
3764
+ marginBottom: theme.spacing.small,
3765
+ textTransform: 'capitalize',
3766
+ } }, items[0]?.label || field),
3767
+ React.createElement("div", { role: "list", style: listStyles }, items.map((refinement, index) => {
3768
+ return renderRefinement
3769
+ ? renderRefinement(refinement, index)
3770
+ : defaultRenderRefinement(refinement, index);
3771
+ })))))) : (React.createElement("div", { role: "list", className: refinementsTheme.list, style: listStyles }, refinements.map((refinement, index) => {
2667
3772
  return renderRefinement
2668
3773
  ? renderRefinement(refinement, index)
2669
3774
  : defaultRenderRefinement(refinement, index);
2670
- })),
3775
+ }))),
2671
3776
  showClearAll && refinements.length > 1 && (React.createElement("button", { type: "button", onClick: handleClearAll, className: refinementsTheme.clearAllButton, style: {
2672
3777
  padding: `${theme.spacing.small} ${theme.spacing.medium}`,
2673
3778
  border: `1px solid ${theme.colors.border}`,
@@ -2677,6 +3782,7 @@ const CurrentRefinements = ({ refinements = [], onRefinementClear, onClearAll, r
2677
3782
  cursor: 'pointer',
2678
3783
  fontSize: theme.typography.fontSize.small,
2679
3784
  textDecoration: 'underline',
3785
+ transition: 'background-color 150ms ease-in-out',
2680
3786
  } }, "Clear all filters"))));
2681
3787
  };
2682
3788
 
@@ -3525,6 +4631,110 @@ const HierarchicalMenu = ({ attributes, separator = ' > ', limit = 10, showMore
3525
4631
  const toggleShowMore = (level) => {
3526
4632
  setExpanded(prev => ({ ...prev, [level]: !prev[level] }));
3527
4633
  };
4634
+ const containerRef = React.useRef(null);
4635
+ // Collect all visible treeitem elements within the container
4636
+ const getVisibleTreeItems = React.useCallback(() => {
4637
+ if (!containerRef.current)
4638
+ return [];
4639
+ return Array.from(containerRef.current.querySelectorAll('[role="treeitem"]'));
4640
+ }, []);
4641
+ // Keyboard navigation handler for the tree
4642
+ const handleKeyDown = React.useCallback((e) => {
4643
+ const items = getVisibleTreeItems();
4644
+ if (items.length === 0)
4645
+ return;
4646
+ const activeElement = document.activeElement;
4647
+ // Find the treeitem that is or contains the active element
4648
+ const currentItem = items.find(item => item === activeElement || item.contains(activeElement));
4649
+ const currentIndex = currentItem ? items.indexOf(currentItem) : -1;
4650
+ switch (e.key) {
4651
+ case 'ArrowDown': {
4652
+ e.preventDefault();
4653
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
4654
+ const button = items[nextIndex].querySelector('button');
4655
+ if (button)
4656
+ button.focus();
4657
+ else
4658
+ items[nextIndex].focus();
4659
+ break;
4660
+ }
4661
+ case 'ArrowUp': {
4662
+ e.preventDefault();
4663
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
4664
+ const button = items[prevIndex].querySelector('button');
4665
+ if (button)
4666
+ button.focus();
4667
+ else
4668
+ items[prevIndex].focus();
4669
+ break;
4670
+ }
4671
+ case 'ArrowRight': {
4672
+ if (!currentItem)
4673
+ break;
4674
+ const isExpanded = currentItem.getAttribute('aria-expanded');
4675
+ if (isExpanded === 'false') {
4676
+ // Expand (select) this item
4677
+ e.preventDefault();
4678
+ const button = currentItem.querySelector('button');
4679
+ if (button)
4680
+ button.click();
4681
+ }
4682
+ else if (isExpanded === 'true') {
4683
+ // Move to first child
4684
+ e.preventDefault();
4685
+ const childList = currentItem.querySelector('[role="group"]');
4686
+ if (childList) {
4687
+ const firstChild = childList.querySelector('[role="treeitem"]');
4688
+ if (firstChild) {
4689
+ const button = firstChild.querySelector('button');
4690
+ if (button)
4691
+ button.focus();
4692
+ else
4693
+ firstChild.focus();
4694
+ }
4695
+ }
4696
+ }
4697
+ break;
4698
+ }
4699
+ case 'ArrowLeft': {
4700
+ if (!currentItem)
4701
+ break;
4702
+ const isExpanded = currentItem.getAttribute('aria-expanded');
4703
+ if (isExpanded === 'true') {
4704
+ // Collapse this item
4705
+ e.preventDefault();
4706
+ const button = currentItem.querySelector('button');
4707
+ if (button)
4708
+ button.click();
4709
+ }
4710
+ else {
4711
+ // Move focus to parent treeitem
4712
+ e.preventDefault();
4713
+ const parentGroup = currentItem.closest('[role="group"]');
4714
+ if (parentGroup) {
4715
+ const parentItem = parentGroup.closest('[role="treeitem"]');
4716
+ if (parentItem) {
4717
+ const button = parentItem.querySelector(':scope > button');
4718
+ if (button)
4719
+ button.focus();
4720
+ else
4721
+ parentItem.focus();
4722
+ }
4723
+ }
4724
+ }
4725
+ break;
4726
+ }
4727
+ case 'Enter': {
4728
+ if (!currentItem)
4729
+ break;
4730
+ e.preventDefault();
4731
+ const button = currentItem.querySelector('button');
4732
+ if (button)
4733
+ button.click();
4734
+ break;
4735
+ }
4736
+ }
4737
+ }, [getVisibleTreeItems]);
3528
4738
  // Render a level of the hierarchy
3529
4739
  const renderLevel = (items, level) => {
3530
4740
  if (!items || items.length === 0)
@@ -3533,12 +4743,14 @@ const HierarchicalMenu = ({ attributes, separator = ' > ', limit = 10, showMore
3533
4743
  const displayLimit = isExpanded ? showMoreLimit : limit;
3534
4744
  const displayItems = items.slice(0, displayLimit);
3535
4745
  const hasMore = items.length > displayLimit;
3536
- return (React.createElement("ul", { className: hierarchicalTheme.list, style: {
4746
+ return (React.createElement("ul", { role: level === 0 ? 'tree' : 'group', className: hierarchicalTheme.list, style: {
3537
4747
  listStyle: 'none',
3538
4748
  margin: 0,
3539
4749
  padding: level > 0 ? `0 0 0 ${theme.spacing.medium}` : 0,
3540
4750
  } },
3541
- displayItems.map((item, index) => (React.createElement("li", { key: item.value, className: clsx(hierarchicalTheme.item, item.isRefined && hierarchicalTheme.itemSelected, item.data && item.data.length > 0 && hierarchicalTheme.itemParent), style: {
4751
+ displayItems.map((item, index) => (React.createElement("li", { key: item.value, role: "treeitem", ...(item.data && item.data.length > 0
4752
+ ? { 'aria-expanded': !!item.isRefined }
4753
+ : {}), className: clsx(hierarchicalTheme.item, item.isRefined && hierarchicalTheme.itemSelected, item.data && item.data.length > 0 && hierarchicalTheme.itemParent), style: {
3542
4754
  padding: `${theme.spacing.small} 0`,
3543
4755
  } }, renderItem ? (renderItem(item, level)) : (React.createElement(React.Fragment, null,
3544
4756
  React.createElement("button", { type: "button", onClick: () => handleItemClick(item, level), className: hierarchicalTheme.link, style: {
@@ -3576,7 +4788,7 @@ const HierarchicalMenu = ({ attributes, separator = ' > ', limit = 10, showMore
3576
4788
  if (processedItems.length === 0) {
3577
4789
  return null;
3578
4790
  }
3579
- return (React.createElement("div", { className: clsx(hierarchicalTheme.root, className), style: style }, renderLevel(processedItems, 0)));
4791
+ return (React.createElement("div", { ref: containerRef, className: clsx(hierarchicalTheme.root, className), style: style, tabIndex: 0, onKeyDown: handleKeyDown }, renderLevel(processedItems, 0)));
3580
4792
  };
3581
4793
 
3582
4794
  /**
@@ -3676,6 +4888,53 @@ const RangeSlider = ({ field, label, min, max, step = 1, currentMin: currentMinP
3676
4888
  const handleDragEnd = () => {
3677
4889
  setIsDragging(false);
3678
4890
  };
4891
+ // Handle keyboard navigation for enhanced control (Shift+Arrow for 10x step, Home/End)
4892
+ const handleMinKeyDown = (e) => {
4893
+ let newValue = null;
4894
+ if (e.key === 'Home') {
4895
+ e.preventDefault();
4896
+ newValue = min;
4897
+ }
4898
+ else if (e.key === 'End') {
4899
+ e.preventDefault();
4900
+ newValue = internalMax - step;
4901
+ }
4902
+ else if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowDown')) {
4903
+ e.preventDefault();
4904
+ newValue = Math.max(min, internalMin - step * 10);
4905
+ }
4906
+ else if (e.shiftKey && (e.key === 'ArrowRight' || e.key === 'ArrowUp')) {
4907
+ e.preventDefault();
4908
+ newValue = Math.min(internalMax - step, internalMin + step * 10);
4909
+ }
4910
+ if (newValue !== null) {
4911
+ setInternalMin(newValue);
4912
+ debouncedUpdate(newValue, internalMax);
4913
+ }
4914
+ };
4915
+ const handleMaxKeyDown = (e) => {
4916
+ let newValue = null;
4917
+ if (e.key === 'Home') {
4918
+ e.preventDefault();
4919
+ newValue = internalMin + step;
4920
+ }
4921
+ else if (e.key === 'End') {
4922
+ e.preventDefault();
4923
+ newValue = max;
4924
+ }
4925
+ else if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowDown')) {
4926
+ e.preventDefault();
4927
+ newValue = Math.max(internalMin + step, internalMax - step * 10);
4928
+ }
4929
+ else if (e.shiftKey && (e.key === 'ArrowRight' || e.key === 'ArrowUp')) {
4930
+ e.preventDefault();
4931
+ newValue = Math.min(max, internalMax + step * 10);
4932
+ }
4933
+ if (newValue !== null) {
4934
+ setInternalMax(newValue);
4935
+ debouncedUpdate(internalMin, newValue);
4936
+ }
4937
+ };
3679
4938
  // Calculate filled track position
3680
4939
  const minPercent = ((internalMin - min) / (max - min)) * 100;
3681
4940
  const maxPercent = ((internalMax - min) / (max - min)) * 100;
@@ -3711,7 +4970,7 @@ const RangeSlider = ({ field, label, min, max, step = 1, currentMin: currentMinP
3711
4970
  backgroundColor: theme.colors.primary,
3712
4971
  borderRadius: '2px',
3713
4972
  } }),
3714
- React.createElement("input", { type: "range", min: min, max: max, step: step, value: internalMin, onChange: handleMinChange, onMouseUp: handleDragEnd, onTouchEnd: handleDragEnd, className: rangeSliderTheme.thumb, style: {
4973
+ React.createElement("input", { type: "range", min: min, max: max, step: step, value: internalMin, onChange: handleMinChange, onMouseUp: handleDragEnd, onTouchEnd: handleDragEnd, onKeyDown: handleMinKeyDown, tabIndex: 0, "aria-valuenow": internalMin, "aria-valuemin": min, "aria-valuemax": max, className: rangeSliderTheme.thumb, style: {
3715
4974
  position: 'absolute',
3716
4975
  width: '100%',
3717
4976
  height: '4px',
@@ -3721,7 +4980,7 @@ const RangeSlider = ({ field, label, min, max, step = 1, currentMin: currentMinP
3721
4980
  cursor: 'pointer',
3722
4981
  pointerEvents: 'none',
3723
4982
  }, "aria-label": `Minimum ${label || field}` }),
3724
- React.createElement("input", { type: "range", min: min, max: max, step: step, value: internalMax, onChange: handleMaxChange, onMouseUp: handleDragEnd, onTouchEnd: handleDragEnd, className: rangeSliderTheme.thumb, style: {
4983
+ React.createElement("input", { type: "range", min: min, max: max, step: step, value: internalMax, onChange: handleMaxChange, onMouseUp: handleDragEnd, onTouchEnd: handleDragEnd, onKeyDown: handleMaxKeyDown, tabIndex: 0, "aria-valuenow": internalMax, "aria-valuemin": min, "aria-valuemax": max, className: rangeSliderTheme.thumb, style: {
3725
4984
  position: 'absolute',
3726
4985
  width: '100%',
3727
4986
  height: '4px',
@@ -4699,12 +5958,29 @@ function useQuerySuggestionsEnhanced(options) {
4699
5958
  const error = err instanceof Error ? err : new Error(String(err));
4700
5959
  if (error.name === 'AbortError')
4701
5960
  return;
4702
- log.error('Failed to fetch query suggestions', { query: searchQuery, error: error.message });
4703
- setError(error);
4704
- setSuggestions([]);
4705
- setDropdownRecommendations(null);
4706
- if (onError) {
4707
- onError(error);
5961
+ // Check if it's a 404 error (suggestions not enabled for this store).
5962
+ // The search SDK wraps axios errors into plain Error objects with the status
5963
+ // embedded in the message, e.g. "[getSuggestions] ... (404)".
5964
+ const errMsg = error.message || '';
5965
+ const is404 = err?.response?.status === 404 ||
5966
+ err?.status === 404 ||
5967
+ /\(404\)/.test(errMsg);
5968
+ if (is404) {
5969
+ // Silently handle 404 - suggestions feature not enabled
5970
+ log.verbose('Query suggestions not enabled for this store (404)');
5971
+ setSuggestions([]);
5972
+ setDropdownRecommendations(null);
5973
+ setError(null);
5974
+ }
5975
+ else {
5976
+ // For other errors, log and set error state
5977
+ log.error('Failed to fetch query suggestions', { query: searchQuery, error: error.message });
5978
+ setError(error);
5979
+ setSuggestions([]);
5980
+ setDropdownRecommendations(null);
5981
+ if (onError) {
5982
+ onError(error);
5983
+ }
4708
5984
  }
4709
5985
  }
4710
5986
  finally {
@@ -6271,7 +7547,14 @@ const EVENTS = {
6271
7547
  TRENDING_CLICK: 'suggestions.trending_click',
6272
7548
  SEARCH_SUBMIT: 'suggestions.search_submit',
6273
7549
  DROPDOWN_OPEN: 'suggestions.dropdown_open',
6274
- DROPDOWN_CLOSE: 'suggestions.dropdown_close'};
7550
+ DROPDOWN_CLOSE: 'suggestions.dropdown_close',
7551
+ // Variant & product interaction events
7552
+ VARIANT_SELECT: 'product.variant_select',
7553
+ VARIANT_HOVER: 'product.variant_hover',
7554
+ ADD_TO_CART: 'product.add_to_cart',
7555
+ PRODUCT_IMPRESSION: 'product.impression',
7556
+ SWATCH_CLICK: 'product.swatch_click',
7557
+ };
6275
7558
  // ============================================================================
6276
7559
  // Hook Implementation
6277
7560
  // ============================================================================
@@ -6295,11 +7578,16 @@ function useSuggestionsAnalytics(options) {
6295
7578
  return;
6296
7579
  const searchContext = context ?? contextOption;
6297
7580
  try {
7581
+ // Extract query for search-related events (backend requires query at top level for search events)
7582
+ const { query, ...restMetadata } = metadata;
7583
+ const isSearchEvent = eventName.includes('search') || eventName === EVENTS.SEARCH_SUBMIT;
6298
7584
  await client.trackEvent?.({
6299
7585
  event_name: eventName,
6300
7586
  analytics_tags: analyticsTags,
7587
+ // Include query at top level for search events
7588
+ ...(isSearchEvent && query ? { query } : {}),
6301
7589
  metadata: {
6302
- ...metadata,
7590
+ ...restMetadata,
6303
7591
  timestamp: Date.now(),
6304
7592
  source: 'suggestions_dropdown',
6305
7593
  },
@@ -7122,7 +8410,7 @@ function DropdownPanel({ children, position = 'absolute', top = '100%', left = 0
7122
8410
 
7123
8411
  /**
7124
8412
  * Parses suggestion text containing <mark>...</mark> and returns React nodes
7125
- * with the marked segments rendered as <mark> elements. Safe: inner content
8413
+ * with the marked segments rendered as styled elements. Safe: inner content
7126
8414
  * is rendered as text, not HTML.
7127
8415
  */
7128
8416
  const defaultMarkStyle = {
@@ -7131,9 +8419,34 @@ const defaultMarkStyle = {
7131
8419
  borderRadius: '2px',
7132
8420
  padding: '0 2px',
7133
8421
  };
8422
+ /** Compute styles based on highlight options */
8423
+ function computeHighlightStyles(options) {
8424
+ const style = options.highlightStyle || 'background';
8425
+ const base = {};
8426
+ switch (style) {
8427
+ case 'background':
8428
+ base.backgroundColor = options.highlightColor || 'var(--seekora-highlight-bg, rgba(251, 191, 36, 0.4))';
8429
+ base.borderRadius = '2px';
8430
+ base.padding = '0 2px';
8431
+ break;
8432
+ case 'underline':
8433
+ base.textDecoration = 'underline';
8434
+ base.textDecorationColor = options.highlightColor || 'var(--seekora-highlight-bg, rgba(251, 191, 36, 0.8))';
8435
+ base.textUnderlineOffset = '2px';
8436
+ break;
8437
+ }
8438
+ if (options.highlightTextColor) {
8439
+ base.color = options.highlightTextColor;
8440
+ }
8441
+ else {
8442
+ base.color = 'var(--seekora-highlight-color, inherit)';
8443
+ }
8444
+ base.fontWeight = options.highlightFontWeight || 'var(--seekora-highlight-weight, 500)';
8445
+ return base;
8446
+ }
7134
8447
  /**
7135
8448
  * Converts a string like "lined <mark>blue</mark>" into React nodes with
7136
- * the marked part rendered as a <mark> element. When no <mark> tags are
8449
+ * the marked part rendered as a styled element. When no <mark> tags are
7137
8450
  * present, returns the string as-is.
7138
8451
  */
7139
8452
  function parseHighlightMarkup(text, options = {}) {
@@ -7142,11 +8455,18 @@ function parseHighlightMarkup(text, options = {}) {
7142
8455
  const parts = text.split(/(<mark>[\s\S]*?<\/mark>)/g);
7143
8456
  if (parts.length <= 1)
7144
8457
  return text;
7145
- const { markClassName, markStyle } = options;
8458
+ const { markClassName, markStyle, highlightTag } = options;
8459
+ const Tag = (highlightTag || 'mark');
8460
+ // Compute styles: if no custom options provided, use legacy defaults
8461
+ const hasCustomOptions = options.highlightColor || options.highlightTextColor
8462
+ || options.highlightFontWeight || options.highlightStyle;
8463
+ const computedStyle = hasCustomOptions
8464
+ ? computeHighlightStyles(options)
8465
+ : defaultMarkStyle;
7146
8466
  return (React.createElement(React.Fragment, null, parts.map((part, i) => {
7147
8467
  const m = part.match(/^<mark>([\s\S]*)<\/mark>$/);
7148
8468
  if (m) {
7149
- return (React.createElement("mark", { key: i, className: markClassName, style: { ...defaultMarkStyle, ...markStyle } }, m[1]));
8469
+ return (React.createElement(Tag, { key: i, className: markClassName, style: { ...computedStyle, ...markStyle } }, m[1]));
7150
8470
  }
7151
8471
  return part;
7152
8472
  })));
@@ -8021,6 +9341,41 @@ const highlightText = (text, query, options = {}) => {
8021
9341
  const classAttr = className ? ` class="${className}"` : '';
8022
9342
  return escapeHtml(text).replace(new RegExp(`(${escapeHtml(escapedQuery)})`, 'gi'), `<${tag}${classAttr}${styleAttr}>$1</${tag}>`);
8023
9343
  };
9344
+ /**
9345
+ * Safe React-based highlight rendering (no dangerouslySetInnerHTML).
9346
+ * Returns React nodes with matched text wrapped in the specified tag element.
9347
+ */
9348
+ const highlightTextReact = (text, query, options = {}) => {
9349
+ if (!query || !text)
9350
+ return text;
9351
+ const { tag = 'mark', className, style } = options;
9352
+ const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9353
+ const regex = new RegExp(`(${escapedQuery})`, 'gi');
9354
+ const parts = text.split(regex);
9355
+ if (parts.length <= 1)
9356
+ return text;
9357
+ const defaultHighlightStyle = {
9358
+ backgroundColor: 'var(--seekora-highlight-bg, rgba(251, 191, 36, 0.4))',
9359
+ fontWeight: 500,
9360
+ borderRadius: '2px',
9361
+ padding: '0 2px',
9362
+ ...style,
9363
+ };
9364
+ return parts.map((part, i) => {
9365
+ if (regex.test(part)) {
9366
+ // Reset lastIndex after test
9367
+ regex.lastIndex = 0;
9368
+ return React.createElement(tag, {
9369
+ key: i,
9370
+ className,
9371
+ style: defaultHighlightStyle,
9372
+ }, part);
9373
+ }
9374
+ // Reset lastIndex after test
9375
+ regex.lastIndex = 0;
9376
+ return part;
9377
+ });
9378
+ };
8024
9379
  // ============================================================================
8025
9380
  // Variant Utilities
8026
9381
  // ============================================================================
@@ -10565,9 +11920,7 @@ const AmazonDropdown = React.forwardRef(function AmazonDropdown(props, ref) {
10565
11920
  React.createElement("div", { style: styles.suggestionIcon },
10566
11921
  React.createElement(SearchIcon$5, null)),
10567
11922
  React.createElement("div", { style: styles.suggestionContent },
10568
- React.createElement("div", { style: styles.suggestionQuery, dangerouslySetInnerHTML: {
10569
- __html: highlightText(suggestion.query, query, { className: 'highlight' })
10570
- } }),
11923
+ React.createElement("div", { style: styles.suggestionQuery }, highlightTextReact(suggestion.query, query, { className: 'highlight' })),
10571
11924
  showDepartments && firstCategory && (React.createElement("div", { style: styles.suggestionContext },
10572
11925
  React.createElement("span", { style: styles.suggestionDepartment },
10573
11926
  "in ",
@@ -10912,11 +12265,7 @@ const GoogleDropdown = React.forwardRef(function GoogleDropdown(props, ref) {
10912
12265
  item.type === 'trending' ? React.createElement(TrendingIcon$2, null) :
10913
12266
  React.createElement(SearchIcon$4, null)),
10914
12267
  React.createElement("div", { style: styles.itemContent },
10915
- React.createElement("span", { style: styles.itemText, dangerouslySetInnerHTML: {
10916
- __html: query
10917
- ? highlightText(item.query, query, { tag: 'b' })
10918
- : item.query
10919
- } }),
12268
+ React.createElement("span", { style: styles.itemText }, query ? highlightTextReact(item.query, query, { tag: 'b' }) : item.query),
10920
12269
  React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
10921
12270
  item.type === 'trending' && showTrendingIndicator && (React.createElement("span", { style: styles.trending },
10922
12271
  React.createElement(TrendingIcon$2, null),
@@ -11371,11 +12720,7 @@ const PinterestDropdown = React.forwardRef(function PinterestDropdown(props, ref
11371
12720
  setHoveredSuggestion(idx);
11372
12721
  setActiveIndex(idx);
11373
12722
  }, onMouseLeave: () => setHoveredSuggestion(-1) },
11374
- React.createElement("span", { style: styles.suggestionIcon, dangerouslySetInnerHTML: {
11375
- __html: query
11376
- ? highlightText(s.query, query, { tag: 'strong' })
11377
- : s.query
11378
- } })))))),
12723
+ React.createElement("span", { style: styles.suggestionIcon }, query ? highlightTextReact(s.query, query, { tag: 'strong' }) : s.query)))))),
11379
12724
  displayProducts.length > 0 && (React.createElement(React.Fragment, null,
11380
12725
  React.createElement("div", { style: styles.sectionTitle },
11381
12726
  React.createElement(TrendingIcon$1, null),
@@ -11905,9 +13250,7 @@ const SpotlightDropdown = React.forwardRef(function SpotlightDropdown(props, ref
11905
13250
  }, onMouseEnter: () => setActiveIndex(idx) },
11906
13251
  React.createElement("div", { style: mergeStyles(styles.itemIcon, isActive ? styles.itemIconActive : undefined) }, item.icon),
11907
13252
  React.createElement("div", { style: styles.itemContent },
11908
- React.createElement("div", { style: mergeStyles(styles.itemTitle, isActive ? styles.itemTitleActive : undefined), dangerouslySetInnerHTML: {
11909
- __html: highlightText(item.title, query, { tag: 'strong' })
11910
- } }))));
13253
+ React.createElement("div", { style: mergeStyles(styles.itemTitle, isActive ? styles.itemTitleActive : undefined) }, highlightTextReact(item.title, query, { tag: 'strong' })))));
11911
13254
  }))),
11912
13255
  processedProducts.length > 0 && (React.createElement("div", { style: styles.section },
11913
13256
  React.createElement("div", { style: styles.sectionTitle }, "Products"),
@@ -12347,9 +13690,7 @@ const ShopifyDropdown = React.forwardRef(function ShopifyDropdown(props, ref) {
12347
13690
  React.createElement("div", { style: styles.suggestionIcon },
12348
13691
  React.createElement(SearchIcon$1, null)),
12349
13692
  React.createElement("div", { style: styles.suggestionContent },
12350
- React.createElement("div", { style: styles.suggestionQuery, dangerouslySetInnerHTML: {
12351
- __html: highlightText(suggestion.query, query, { tag: 'mark' })
12352
- } }),
13693
+ React.createElement("div", { style: styles.suggestionQuery }, highlightTextReact(suggestion.query, query, { tag: 'mark' })),
12353
13694
  suggestion.count && (React.createElement("div", { style: styles.suggestionMeta },
12354
13695
  suggestion.count,
12355
13696
  " results"))),
@@ -12852,9 +14193,7 @@ const MobileSheetDropdown = React.forwardRef(function MobileSheetDropdown(props,
12852
14193
  React.createElement("div", { style: styles.itemIcon },
12853
14194
  React.createElement(SearchIcon, null)),
12854
14195
  React.createElement("div", { style: styles.itemContent },
12855
- React.createElement("div", { style: styles.itemTitle, dangerouslySetInnerHTML: {
12856
- __html: highlightText(s.query, inputValue, { tag: 'strong' })
12857
- } }),
14196
+ React.createElement("div", { style: styles.itemTitle }, highlightTextReact(s.query, inputValue, { tag: 'strong' })),
12858
14197
  s.count && (React.createElement("div", { style: styles.itemSubtitle },
12859
14198
  s.count,
12860
14199
  " results"))),
@@ -13155,9 +14494,7 @@ const MinimalDropdown = React.forwardRef(function MinimalDropdown(props, ref) {
13155
14494
  return (React.createElement("div", { key: s.id || `suggestion-${idx}`, "data-index": itemIdx, style: mergeStyles(styles.item, isActive ? styles.itemActive : undefined, isLast ? styles.itemLast : undefined), onClick: () => onSuggestionSelect?.(s._raw, idx), onMouseEnter: () => setActiveIndex(itemIdx) },
13156
14495
  showIndices && (React.createElement("span", { style: styles.itemIndex }, itemIdx + 1)),
13157
14496
  React.createElement("div", { style: styles.itemContent },
13158
- React.createElement("div", { style: styles.itemQuery, dangerouslySetInnerHTML: {
13159
- __html: highlightText(s.query, query, { tag: 'mark' })
13160
- } }),
14497
+ React.createElement("div", { style: styles.itemQuery }, highlightTextReact(s.query, query, { tag: 'mark' })),
13161
14498
  s.count && (React.createElement("div", { style: styles.itemMeta },
13162
14499
  s.count.toLocaleString(),
13163
14500
  " results"))),