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