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