@seekora-ai/ui-sdk-react 0.2.24 → 0.2.26

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.
@@ -485,6 +485,8 @@ interface FacetsProps {
485
485
  hideEmptyFacets?: boolean;
486
486
  /** Fields that should start collapsed (collapsible variant only). Overrides defaultCollapsed for listed fields. */
487
487
  defaultCollapsedFields?: string[];
488
+ /** Callback fired when facet availability changes. Receives true when facets are available, false when empty. */
489
+ onFacetsAvailable?: (available: boolean) => void;
488
490
  }
489
491
  declare const Facets: React__default.FC<FacetsProps>;
490
492
 
@@ -302,6 +302,8 @@ class SearchStateManager {
302
302
  this.listeners = [];
303
303
  this.debounceTimer = null;
304
304
  this.notifyScheduled = false;
305
+ this.searchCoalesceTimer = null;
306
+ this.searchCoalesceResolvers = [];
305
307
  this.client = config.client;
306
308
  this.autoSearch = config.autoSearch !== false;
307
309
  this.debounceMs = config.debounceMs || 300;
@@ -446,13 +448,27 @@ class SearchStateManager {
446
448
  this.debouncedSearch();
447
449
  }
448
450
  }
449
- // Manual search trigger
451
+ // Manual search trigger — coalesces rapid calls within 10ms into a single API request
450
452
  async search(additionalOptions) {
451
453
  // Clear debounce timer if exists
452
454
  if (this.debounceTimer) {
453
455
  clearTimeout(this.debounceTimer);
454
456
  this.debounceTimer = null;
455
457
  }
458
+ return new Promise((resolve, reject) => {
459
+ this.searchCoalesceResolvers.push({ resolve, reject });
460
+ if (this.searchCoalesceTimer) {
461
+ clearTimeout(this.searchCoalesceTimer);
462
+ }
463
+ this.searchCoalesceTimer = setTimeout(() => {
464
+ this.searchCoalesceTimer = null;
465
+ const resolvers = [...this.searchCoalesceResolvers];
466
+ this.searchCoalesceResolvers = [];
467
+ this._executeSearch(additionalOptions).then((result) => resolvers.forEach(r => r.resolve(result)), (err) => resolvers.forEach(r => r.reject(err)));
468
+ }, 10);
469
+ });
470
+ }
471
+ async _executeSearch(additionalOptions) {
456
472
  this.setState({ loading: true, error: null });
457
473
  try {
458
474
  const searchOptions = this.buildSearchOptions(additionalOptions);
@@ -2693,11 +2709,8 @@ function getCurrentBreakpoint(width) {
2693
2709
  * Hook to get current breakpoint
2694
2710
  */
2695
2711
  function useBreakpoint() {
2696
- const [breakpoint, setBreakpoint] = useState(() => {
2697
- if (typeof window === 'undefined')
2698
- return 'lg';
2699
- return getCurrentBreakpoint(window.innerWidth);
2700
- });
2712
+ // Always start with 'lg' to avoid hydration mismatch between server and client
2713
+ const [breakpoint, setBreakpoint] = useState('lg');
2701
2714
  useEffect(() => {
2702
2715
  const handleResize = () => {
2703
2716
  setBreakpoint(getCurrentBreakpoint(window.innerWidth));
@@ -2712,11 +2725,8 @@ function useBreakpoint() {
2712
2725
  * Hook to check if current viewport matches breakpoint
2713
2726
  */
2714
2727
  function useMediaQuery(query) {
2715
- const [matches, setMatches] = useState(() => {
2716
- if (typeof window === 'undefined')
2717
- return false;
2718
- return window.matchMedia(query).matches;
2719
- });
2728
+ // Always start with false to avoid hydration mismatch between server and client
2729
+ const [matches, setMatches] = useState(false);
2720
2730
  useEffect(() => {
2721
2731
  const mediaQuery = window.matchMedia(query);
2722
2732
  const handleChange = () => setMatches(mediaQuery.matches);
@@ -3512,15 +3522,22 @@ const useFilters = (options) => {
3512
3522
  const [error, setError] = useState(null);
3513
3523
  const mountedRef = useRef(true);
3514
3524
  const autoFetch = options?.autoFetch !== false;
3525
+ // Track whether we've completed the first fetch (to avoid skeleton flash on refetches)
3526
+ const hasDataRef = useRef(false);
3515
3527
  // Extract non-autoFetch options to pass to fetchFilters
3516
3528
  const fetchFilters = useCallback(async () => {
3517
- setLoading(true);
3529
+ // Only show loading spinner on the very first fetch; subsequent refetches
3530
+ // keep previous data visible to avoid skeleton/flash on facet changes.
3531
+ if (!hasDataRef.current) {
3532
+ setLoading(true);
3533
+ }
3518
3534
  setError(null);
3519
3535
  try {
3520
3536
  const { autoFetch: _, ...filterOptions } = options || {};
3521
3537
  const response = await stateManager.fetchFilters(filterOptions);
3522
3538
  if (mountedRef.current) {
3523
3539
  setFilters(response?.filters || []);
3540
+ hasDataRef.current = true;
3524
3541
  setLoading(false);
3525
3542
  }
3526
3543
  }
@@ -3531,13 +3548,26 @@ const useFilters = (options) => {
3531
3548
  }
3532
3549
  }
3533
3550
  }, [stateManager, options?.facetBy, options?.maxFacetValues, options?.disjunctiveFacets?.join(',')]);
3534
- // Refetch when query or refinements change
3551
+ // Track query to only refetch filters when query actually changes.
3552
+ // Sentinel ensures the first subscribe callback always triggers a fetch.
3553
+ const prevKeyRef = useRef(null);
3554
+ // Refetch when query or refinements change (not on every state update)
3535
3555
  useEffect(() => {
3536
3556
  if (!autoFetch)
3537
3557
  return;
3538
- const unsubscribe = stateManager.subscribe((_state) => {
3558
+ const unsubscribe = stateManager.subscribe((state) => {
3559
+ // Only track query changes — refinements are NOT passed to the Filters API
3560
+ // (facets are generated from search query only, not narrowed by active filters).
3561
+ // This prevents redundant filters refetches on every facet toggle.
3562
+ const key = state.query;
3563
+ if (key === prevKeyRef.current)
3564
+ return;
3565
+ prevKeyRef.current = key;
3539
3566
  fetchFilters();
3540
3567
  });
3568
+ // subscribe() immediately invokes the listener with current state,
3569
+ // which handles the initial fetch (prevKeyRef starts as '' so key
3570
+ // will differ). No explicit fetchFilters() call needed here.
3541
3571
  return unsubscribe;
3542
3572
  }, [stateManager, autoFetch, fetchFilters]);
3543
3573
  // Fetch schema once on mount
@@ -3879,7 +3909,7 @@ const CSS_VAR_DEFAULTS = {
3879
3909
  // ---------------------------------------------------------------------------
3880
3910
  // Component
3881
3911
  // ---------------------------------------------------------------------------
3882
- const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, variant = 'checkbox', searchable = false, showCounts = true, colorMap, defaultCollapsed = false, size = 'medium', facetRanges, useFiltersApi = false, disjunctiveFacets, hideEmptyFacets = true, defaultCollapsedFields, }) => {
3912
+ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, renderFacet, renderFacetItem, maxItems = 10, showMore = true, className, style, theme: customTheme, variant = 'checkbox', searchable = false, showCounts = true, colorMap, defaultCollapsed = false, size = 'medium', facetRanges, useFiltersApi = false, disjunctiveFacets, hideEmptyFacets = true, defaultCollapsedFields, onFacetsAvailable, }) => {
3883
3913
  const { theme } = useSearchContext();
3884
3914
  const { results: stateResults, refinements, addRefinement, removeRefinement } = useSearchState();
3885
3915
  const facetsTheme = customTheme || {};
@@ -3957,9 +3987,22 @@ const Facets = ({ results: resultsProp, facets: facetsProp, onFacetChange, rende
3957
3987
  return extracted;
3958
3988
  };
3959
3989
  const rawFacetList = extractFacets();
3990
+ const hasStats = (stats) => stats != null && (stats.min != null || stats.max != null);
3960
3991
  const facets = hideEmptyFacets
3961
- ? rawFacetList.filter(f => f.items.length > 0 || f.stats != null)
3992
+ ? rawFacetList.filter(f => f.items.length > 0 || hasStats(f.stats))
3962
3993
  : rawFacetList;
3994
+ // Notify parent about facet availability
3995
+ const facetCount = facets.length;
3996
+ const prevFacetAvailableRef = useRef(null);
3997
+ useEffect(() => {
3998
+ if (!onFacetsAvailable)
3999
+ return;
4000
+ const available = facetCount > 0;
4001
+ if (prevFacetAvailableRef.current !== available) {
4002
+ prevFacetAvailableRef.current = available;
4003
+ onFacetsAvailable(available);
4004
+ }
4005
+ }, [facetCount, onFacetsAvailable]);
3963
4006
  // -------------------------------------------------------------------
3964
4007
  // Handlers
3965
4008
  // -------------------------------------------------------------------
@@ -5199,9 +5242,6 @@ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, o
5199
5242
  opacity: 0.6,
5200
5243
  }, "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())));
5201
5244
  };
5202
- if (refinements.length === 0) {
5203
- return null;
5204
- }
5205
5245
  // Group refinements by field for grouped layout
5206
5246
  const groupedRefinements = layout === 'grouped'
5207
5247
  ? refinements.reduce((acc, r) => {
@@ -5213,13 +5253,17 @@ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, o
5213
5253
  : null;
5214
5254
  const containerStyles = {
5215
5255
  ...style,
5256
+ // When a custom list theme is provided, make the container transparent to layout
5257
+ // so the list div becomes the direct layout child (enables overflow scroll from parent)
5258
+ ...(refinementsTheme.list && !style?.display ? { display: 'contents' } : {}),
5216
5259
  };
5217
5260
  const listStyles = {
5218
5261
  display: 'flex',
5219
- flexWrap: layout === 'vertical' ? 'nowrap' : 'wrap',
5220
5262
  flexDirection: layout === 'vertical' ? 'column' : 'row',
5221
5263
  alignItems: layout === 'vertical' ? 'flex-start' : 'center',
5222
- marginBottom: showClearAll ? theme.spacing.medium : 0,
5264
+ marginBottom: showClearAll && refinements.length > 0 ? theme.spacing.medium : 0,
5265
+ // Only apply flex-wrap if no custom list theme is provided (let theme classes control wrapping)
5266
+ ...(!refinementsTheme.list ? { flexWrap: layout === 'vertical' ? 'nowrap' : 'wrap' } : {}),
5223
5267
  };
5224
5268
  return (React.createElement("div", { className: clsx(refinementsTheme.container, className), style: containerStyles },
5225
5269
  React.createElement("style", null, `
@@ -5228,34 +5272,35 @@ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, o
5228
5272
  to { opacity: 1; transform: scale(1); }
5229
5273
  }
5230
5274
  `),
5231
- layout === 'grouped' && groupedRefinements ? (Object.entries(groupedRefinements).map(([field, items]) => (React.createElement("div", { key: field, className: refinementsTheme.group, style: { marginBottom: theme.spacing.medium } },
5232
- React.createElement("div", { className: refinementsTheme.groupLabel, style: {
5233
- fontSize: theme.typography.fontSize.small,
5234
- fontWeight: 600,
5235
- color: theme.colors.text,
5236
- marginBottom: theme.spacing.small,
5237
- textTransform: 'capitalize',
5238
- } }, items[0]?.label || field),
5239
- React.createElement("div", { role: "list", style: listStyles }, items.map((refinement, index) => {
5275
+ refinements.length > 0 && (React.createElement(React.Fragment, null,
5276
+ layout === 'grouped' && groupedRefinements ? (Object.entries(groupedRefinements).map(([field, items]) => (React.createElement("div", { key: field, className: refinementsTheme.group, style: { marginBottom: theme.spacing.medium } },
5277
+ React.createElement("div", { className: refinementsTheme.groupLabel, style: {
5278
+ fontSize: theme.typography.fontSize.small,
5279
+ fontWeight: 600,
5280
+ color: theme.colors.text,
5281
+ marginBottom: theme.spacing.small,
5282
+ textTransform: 'capitalize',
5283
+ } }, items[0]?.label || field),
5284
+ React.createElement("div", { role: "list", style: listStyles }, items.map((refinement, index) => {
5285
+ return renderRefinement
5286
+ ? renderRefinement(refinement, index)
5287
+ : defaultRenderRefinement(refinement, index);
5288
+ })))))) : (React.createElement("div", { role: "list", className: refinementsTheme.list, style: listStyles }, refinements.map((refinement, index) => {
5240
5289
  return renderRefinement
5241
5290
  ? renderRefinement(refinement, index)
5242
5291
  : defaultRenderRefinement(refinement, index);
5243
- })))))) : (React.createElement("div", { role: "list", className: refinementsTheme.list, style: listStyles }, refinements.map((refinement, index) => {
5244
- return renderRefinement
5245
- ? renderRefinement(refinement, index)
5246
- : defaultRenderRefinement(refinement, index);
5247
- }))),
5248
- showClearAll && refinements.length > 1 && (React.createElement("button", { type: "button", onClick: handleClearAll, className: refinementsTheme.clearAllButton, style: {
5249
- padding: `${theme.spacing.small} ${theme.spacing.medium}`,
5250
- border: `1px solid ${theme.colors.border}`,
5251
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
5252
- backgroundColor: theme.colors.background,
5253
- color: theme.colors.text,
5254
- cursor: 'pointer',
5255
- fontSize: theme.typography.fontSize.small,
5256
- textDecoration: 'underline',
5257
- transition: `background-color ${TRANSITIONS$7.fast}`,
5258
- } }, "Clear all filters"))));
5292
+ }))),
5293
+ showClearAll && refinements.length > 1 && (React.createElement("button", { type: "button", onClick: handleClearAll, className: refinementsTheme.clearAllButton, style: {
5294
+ padding: `${theme.spacing.small} ${theme.spacing.medium}`,
5295
+ border: `1px solid ${theme.colors.border}`,
5296
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
5297
+ backgroundColor: theme.colors.background,
5298
+ color: theme.colors.text,
5299
+ cursor: 'pointer',
5300
+ fontSize: theme.typography.fontSize.small,
5301
+ textDecoration: 'underline',
5302
+ transition: `background-color ${TRANSITIONS$7.fast}`,
5303
+ } }, "Clear all filters"))))));
5259
5304
  };
5260
5305
 
5261
5306
  /**
@@ -5323,16 +5368,17 @@ const ClearRefinements = ({ clearsQuery = false, resetLabel = 'Clear all filters
5323
5368
  padding: `${theme.spacing.small} ${theme.spacing.medium}`,
5324
5369
  fontSize: theme.typography.fontSize.medium,
5325
5370
  fontWeight: theme.typography.fontWeight?.medium || 500,
5326
- backgroundColor: canClear ? theme.colors.primary : theme.colors.hover,
5327
- color: canClear ? '#ffffff' : theme.colors.textSecondary,
5371
+ backgroundColor: theme.colors.primary,
5372
+ color: '#ffffff',
5328
5373
  border: 'none',
5329
5374
  borderRadius: typeof theme.borderRadius === 'string'
5330
5375
  ? theme.borderRadius
5331
5376
  : theme.borderRadius.medium,
5332
- cursor: canClear ? 'pointer' : 'not-allowed',
5333
- opacity: canClear ? 1 : 0.6,
5334
- transition: theme.transitions?.fast || '150ms ease-in-out',
5335
- }, "aria-label": canClear ? resetLabel : disabledLabel }, canClear ? resetLabel : disabledLabel)));
5377
+ cursor: canClear ? 'pointer' : 'default',
5378
+ opacity: canClear ? 1 : 0,
5379
+ pointerEvents: canClear ? 'auto' : 'none',
5380
+ transition: 'none',
5381
+ }, "aria-label": resetLabel }, resetLabel)));
5336
5382
  };
5337
5383
 
5338
5384
  /**
@@ -5370,7 +5416,7 @@ const SearchLayout = ({ sidebar, children, header, footer, sidebarWidth = '300px
5370
5416
  padding: responsivePadding,
5371
5417
  backgroundColor: theme.colors.background,
5372
5418
  color: theme.colors.text,
5373
- overflow: isMobile ? 'visible' : 'hidden',
5419
+ overflow: 'visible',
5374
5420
  } },
5375
5421
  sidebar && (!isMobile || showSidebarOnMobile) && (React.createElement("aside", { className: layoutTheme.sidebar, style: {
5376
5422
  width: isMobile ? '100%' : sidebarWidth,