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