@seekora-ai/ui-sdk-react 0.2.23 → 0.2.25

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.
@@ -301,6 +301,9 @@ class SearchStateManager {
301
301
  constructor(config) {
302
302
  this.listeners = [];
303
303
  this.debounceTimer = null;
304
+ this.notifyScheduled = false;
305
+ this.searchCoalesceTimer = null;
306
+ this.searchCoalesceResolvers = [];
304
307
  this.client = config.client;
305
308
  this.autoSearch = config.autoSearch !== false;
306
309
  this.debounceMs = config.debounceMs || 300;
@@ -445,13 +448,27 @@ class SearchStateManager {
445
448
  this.debouncedSearch();
446
449
  }
447
450
  }
448
- // Manual search trigger
451
+ // Manual search trigger — coalesces rapid calls within 10ms into a single API request
449
452
  async search(additionalOptions) {
450
453
  // Clear debounce timer if exists
451
454
  if (this.debounceTimer) {
452
455
  clearTimeout(this.debounceTimer);
453
456
  this.debounceTimer = null;
454
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) {
455
472
  this.setState({ loading: true, error: null });
456
473
  try {
457
474
  const searchOptions = this.buildSearchOptions(additionalOptions);
@@ -548,19 +565,27 @@ class SearchStateManager {
548
565
  this.state = { ...this.state, ...updates };
549
566
  this.notifyListeners();
550
567
  }
551
- // Notify all listeners of state changes
568
+ // Notify all listeners of state changes, batched via microtask.
569
+ // Multiple synchronous mutations (e.g. addRefinement + page reset)
570
+ // coalesce into a single listener notification.
552
571
  notifyListeners() {
553
- const state = this.getState();
554
- this.listeners.forEach(listener => {
555
- try {
556
- listener(state);
557
- }
558
- catch (err) {
559
- const error = err instanceof Error ? err : new Error(String(err));
560
- log.error('SearchStateManager: Error in listener', {
561
- error: error.message,
562
- });
563
- }
572
+ if (this.notifyScheduled)
573
+ return;
574
+ this.notifyScheduled = true;
575
+ queueMicrotask(() => {
576
+ this.notifyScheduled = false;
577
+ const state = this.getState();
578
+ this.listeners.forEach(listener => {
579
+ try {
580
+ listener(state);
581
+ }
582
+ catch (err) {
583
+ const error = err instanceof Error ? err : new Error(String(err));
584
+ log.error('SearchStateManager: Error in listener', {
585
+ error: error.message,
586
+ });
587
+ }
588
+ });
564
589
  });
565
590
  }
566
591
  /** Explicitly clear results (bypasses keepResultsOnClear) */
@@ -606,10 +631,13 @@ class SearchStateManager {
606
631
  async fetchFilters(options) {
607
632
  log.verbose('SearchStateManager: Fetching filters', { options });
608
633
  try {
609
- const filterString = this.buildFilterString();
634
+ // Do NOT pass refinement-based filters to the Filters API.
635
+ // Facets should be generated from the search query only, not narrowed
636
+ // by active filter selections. This keeps facet options stable when
637
+ // users toggle filters (same behaviour as performSimplifiedFacetSearch
638
+ // in the search API).
610
639
  const response = await this.client.getFilters({
611
640
  q: this.state.query || undefined,
612
- filter: filterString || undefined,
613
641
  ...options,
614
642
  });
615
643
  log.info('SearchStateManager: Filters fetched', {
@@ -3519,13 +3547,22 @@ const useFilters = (options) => {
3519
3547
  }
3520
3548
  }
3521
3549
  }, [stateManager, options?.facetBy, options?.maxFacetValues, options?.disjunctiveFacets?.join(',')]);
3522
- // Refetch when query or refinements change
3550
+ // Track query + refinements to only refetch when they actually change
3551
+ const prevKeyRef = useRef('');
3552
+ // Refetch when query or refinements change (not on every state update)
3523
3553
  useEffect(() => {
3524
3554
  if (!autoFetch)
3525
3555
  return;
3526
- const unsubscribe = stateManager.subscribe((_state) => {
3556
+ const unsubscribe = stateManager.subscribe((state) => {
3557
+ const key = `${state.query}|${state.refinements.map(r => `${r.field}:${r.value}`).sort().join(',')}`;
3558
+ if (key === prevKeyRef.current)
3559
+ return;
3560
+ prevKeyRef.current = key;
3527
3561
  fetchFilters();
3528
3562
  });
3563
+ // subscribe() immediately invokes the listener with current state,
3564
+ // which handles the initial fetch (prevKeyRef starts as '' so key
3565
+ // will differ). No explicit fetchFilters() call needed here.
3529
3566
  return unsubscribe;
3530
3567
  }, [stateManager, autoFetch, fetchFilters]);
3531
3568
  // Fetch schema once on mount
@@ -5187,9 +5224,6 @@ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, o
5187
5224
  opacity: 0.6,
5188
5225
  }, "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())));
5189
5226
  };
5190
- if (refinements.length === 0) {
5191
- return null;
5192
- }
5193
5227
  // Group refinements by field for grouped layout
5194
5228
  const groupedRefinements = layout === 'grouped'
5195
5229
  ? refinements.reduce((acc, r) => {
@@ -5201,13 +5235,17 @@ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, o
5201
5235
  : null;
5202
5236
  const containerStyles = {
5203
5237
  ...style,
5238
+ // When a custom list theme is provided, make the container transparent to layout
5239
+ // so the list div becomes the direct layout child (enables overflow scroll from parent)
5240
+ ...(refinementsTheme.list && !style?.display ? { display: 'contents' } : {}),
5204
5241
  };
5205
5242
  const listStyles = {
5206
5243
  display: 'flex',
5207
- flexWrap: layout === 'vertical' ? 'nowrap' : 'wrap',
5208
5244
  flexDirection: layout === 'vertical' ? 'column' : 'row',
5209
5245
  alignItems: layout === 'vertical' ? 'flex-start' : 'center',
5210
- marginBottom: showClearAll ? theme.spacing.medium : 0,
5246
+ marginBottom: showClearAll && refinements.length > 0 ? theme.spacing.medium : 0,
5247
+ // Only apply flex-wrap if no custom list theme is provided (let theme classes control wrapping)
5248
+ ...(!refinementsTheme.list ? { flexWrap: layout === 'vertical' ? 'nowrap' : 'wrap' } : {}),
5211
5249
  };
5212
5250
  return (React.createElement("div", { className: clsx(refinementsTheme.container, className), style: containerStyles },
5213
5251
  React.createElement("style", null, `
@@ -5216,34 +5254,35 @@ const CurrentRefinements = ({ refinements: refinementsProp, onRefinementClear, o
5216
5254
  to { opacity: 1; transform: scale(1); }
5217
5255
  }
5218
5256
  `),
5219
- layout === 'grouped' && groupedRefinements ? (Object.entries(groupedRefinements).map(([field, items]) => (React.createElement("div", { key: field, className: refinementsTheme.group, style: { marginBottom: theme.spacing.medium } },
5220
- React.createElement("div", { className: refinementsTheme.groupLabel, style: {
5221
- fontSize: theme.typography.fontSize.small,
5222
- fontWeight: 600,
5223
- color: theme.colors.text,
5224
- marginBottom: theme.spacing.small,
5225
- textTransform: 'capitalize',
5226
- } }, items[0]?.label || field),
5227
- React.createElement("div", { role: "list", style: listStyles }, items.map((refinement, index) => {
5257
+ refinements.length > 0 && (React.createElement(React.Fragment, null,
5258
+ layout === 'grouped' && groupedRefinements ? (Object.entries(groupedRefinements).map(([field, items]) => (React.createElement("div", { key: field, className: refinementsTheme.group, style: { marginBottom: theme.spacing.medium } },
5259
+ React.createElement("div", { className: refinementsTheme.groupLabel, style: {
5260
+ fontSize: theme.typography.fontSize.small,
5261
+ fontWeight: 600,
5262
+ color: theme.colors.text,
5263
+ marginBottom: theme.spacing.small,
5264
+ textTransform: 'capitalize',
5265
+ } }, items[0]?.label || field),
5266
+ React.createElement("div", { role: "list", style: listStyles }, items.map((refinement, index) => {
5267
+ return renderRefinement
5268
+ ? renderRefinement(refinement, index)
5269
+ : defaultRenderRefinement(refinement, index);
5270
+ })))))) : (React.createElement("div", { role: "list", className: refinementsTheme.list, style: listStyles }, refinements.map((refinement, index) => {
5228
5271
  return renderRefinement
5229
5272
  ? renderRefinement(refinement, index)
5230
5273
  : defaultRenderRefinement(refinement, index);
5231
- })))))) : (React.createElement("div", { role: "list", className: refinementsTheme.list, style: listStyles }, refinements.map((refinement, index) => {
5232
- return renderRefinement
5233
- ? renderRefinement(refinement, index)
5234
- : defaultRenderRefinement(refinement, index);
5235
- }))),
5236
- showClearAll && refinements.length > 1 && (React.createElement("button", { type: "button", onClick: handleClearAll, className: refinementsTheme.clearAllButton, style: {
5237
- padding: `${theme.spacing.small} ${theme.spacing.medium}`,
5238
- border: `1px solid ${theme.colors.border}`,
5239
- borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
5240
- backgroundColor: theme.colors.background,
5241
- color: theme.colors.text,
5242
- cursor: 'pointer',
5243
- fontSize: theme.typography.fontSize.small,
5244
- textDecoration: 'underline',
5245
- transition: `background-color ${TRANSITIONS$7.fast}`,
5246
- } }, "Clear all filters"))));
5274
+ }))),
5275
+ showClearAll && refinements.length > 1 && (React.createElement("button", { type: "button", onClick: handleClearAll, className: refinementsTheme.clearAllButton, style: {
5276
+ padding: `${theme.spacing.small} ${theme.spacing.medium}`,
5277
+ border: `1px solid ${theme.colors.border}`,
5278
+ borderRadius: typeof theme.borderRadius === 'string' ? theme.borderRadius : theme.borderRadius.medium,
5279
+ backgroundColor: theme.colors.background,
5280
+ color: theme.colors.text,
5281
+ cursor: 'pointer',
5282
+ fontSize: theme.typography.fontSize.small,
5283
+ textDecoration: 'underline',
5284
+ transition: `background-color ${TRANSITIONS$7.fast}`,
5285
+ } }, "Clear all filters"))))));
5247
5286
  };
5248
5287
 
5249
5288
  /**
@@ -5311,16 +5350,17 @@ const ClearRefinements = ({ clearsQuery = false, resetLabel = 'Clear all filters
5311
5350
  padding: `${theme.spacing.small} ${theme.spacing.medium}`,
5312
5351
  fontSize: theme.typography.fontSize.medium,
5313
5352
  fontWeight: theme.typography.fontWeight?.medium || 500,
5314
- backgroundColor: canClear ? theme.colors.primary : theme.colors.hover,
5315
- color: canClear ? '#ffffff' : theme.colors.textSecondary,
5353
+ backgroundColor: theme.colors.primary,
5354
+ color: '#ffffff',
5316
5355
  border: 'none',
5317
5356
  borderRadius: typeof theme.borderRadius === 'string'
5318
5357
  ? theme.borderRadius
5319
5358
  : theme.borderRadius.medium,
5320
- cursor: canClear ? 'pointer' : 'not-allowed',
5321
- opacity: canClear ? 1 : 0.6,
5322
- transition: theme.transitions?.fast || '150ms ease-in-out',
5323
- }, "aria-label": canClear ? resetLabel : disabledLabel }, canClear ? resetLabel : disabledLabel)));
5359
+ cursor: canClear ? 'pointer' : 'default',
5360
+ opacity: canClear ? 1 : 0,
5361
+ pointerEvents: canClear ? 'auto' : 'none',
5362
+ transition: 'none',
5363
+ }, "aria-label": resetLabel }, resetLabel)));
5324
5364
  };
5325
5365
 
5326
5366
  /**
@@ -5358,7 +5398,7 @@ const SearchLayout = ({ sidebar, children, header, footer, sidebarWidth = '300px
5358
5398
  padding: responsivePadding,
5359
5399
  backgroundColor: theme.colors.background,
5360
5400
  color: theme.colors.text,
5361
- overflow: isMobile ? 'visible' : 'hidden',
5401
+ overflow: 'visible',
5362
5402
  } },
5363
5403
  sidebar && (!isMobile || showSidebarOnMobile) && (React.createElement("aside", { className: layoutTheme.sidebar, style: {
5364
5404
  width: isMobile ? '100%' : sidebarWidth,