@seekora-ai/docsearch-react 0.1.1

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.
@@ -0,0 +1,1030 @@
1
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
+ import { useRef, useEffect, useCallback, useReducer, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { SeekoraClient } from '@seekora-ai/search-sdk';
5
+
6
+ /**
7
+ * Modal component that renders via React Portal to document.body.
8
+ * This ensures the search overlay renders independently of its integration context,
9
+ * avoiding clipping/positioning issues from parent overflow, transform, or stacking contexts.
10
+ */
11
+ function Modal({ isOpen, onClose, children }) {
12
+ const overlayRef = useRef(null);
13
+ const containerRef = useRef(null);
14
+ // Handle click outside to close
15
+ useEffect(() => {
16
+ const handleClickOutside = (event) => {
17
+ if (containerRef.current &&
18
+ !containerRef.current.contains(event.target)) {
19
+ onClose();
20
+ }
21
+ };
22
+ if (isOpen) {
23
+ document.addEventListener('mousedown', handleClickOutside);
24
+ }
25
+ return () => {
26
+ document.removeEventListener('mousedown', handleClickOutside);
27
+ };
28
+ }, [isOpen, onClose]);
29
+ // Lock body scroll when open
30
+ useEffect(() => {
31
+ if (isOpen) {
32
+ const originalOverflow = document.body.style.overflow;
33
+ document.body.style.overflow = 'hidden';
34
+ return () => {
35
+ document.body.style.overflow = originalOverflow;
36
+ };
37
+ }
38
+ }, [isOpen]);
39
+ // Focus trap
40
+ useEffect(() => {
41
+ if (!isOpen || !containerRef.current)
42
+ return;
43
+ const container = containerRef.current;
44
+ const focusableElements = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
45
+ const firstElement = focusableElements[0];
46
+ const lastElement = focusableElements[focusableElements.length - 1];
47
+ const handleTabKey = (e) => {
48
+ if (e.key !== 'Tab')
49
+ return;
50
+ if (e.shiftKey) {
51
+ if (document.activeElement === firstElement) {
52
+ e.preventDefault();
53
+ lastElement?.focus();
54
+ }
55
+ }
56
+ else {
57
+ if (document.activeElement === lastElement) {
58
+ e.preventDefault();
59
+ firstElement?.focus();
60
+ }
61
+ }
62
+ };
63
+ container.addEventListener('keydown', handleTabKey);
64
+ return () => container.removeEventListener('keydown', handleTabKey);
65
+ }, [isOpen]);
66
+ if (!isOpen || typeof document === 'undefined')
67
+ return null;
68
+ const modalContent = (jsx("div", { ref: overlayRef, className: "seekora-docsearch-overlay", role: "dialog", "aria-modal": "true", "aria-label": "Search documentation", children: jsx("div", { ref: containerRef, className: "seekora-docsearch-container", children: children }) }));
69
+ // Portal to document.body so the modal renders above all site content,
70
+ // independent of parent overflow, transform, or stacking context.
71
+ return createPortal(modalContent, document.body);
72
+ }
73
+
74
+ function SearchBox({ value, onChange, onKeyDown, placeholder = 'Search documentation...', isLoading = false, onClear, }) {
75
+ const inputRef = useRef(null);
76
+ // Focus input when mounted
77
+ useEffect(() => {
78
+ if (inputRef.current) {
79
+ inputRef.current.focus();
80
+ }
81
+ }, []);
82
+ const handleChange = (event) => {
83
+ onChange(event.target.value);
84
+ };
85
+ const handleClear = () => {
86
+ onChange('');
87
+ onClear?.();
88
+ inputRef.current?.focus();
89
+ };
90
+ return (jsxs("div", { className: "seekora-docsearch-searchbox", children: [jsx("label", { className: "seekora-docsearch-searchbox-icon", htmlFor: "seekora-docsearch-input", children: isLoading ? (jsx("span", { className: "seekora-docsearch-spinner", "aria-hidden": "true", children: jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", children: jsx("circle", { cx: "10", cy: "10", r: "8", stroke: "currentColor", strokeWidth: "2", fill: "none", strokeDasharray: "40", strokeDashoffset: "10", children: jsx("animateTransform", { attributeName: "transform", type: "rotate", from: "0 10 10", to: "360 10 10", dur: "0.8s", repeatCount: "indefinite" }) }) }) })) : (jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: jsx("path", { d: "M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z", fill: "currentColor" }) })) }), jsx("input", { ref: inputRef, id: "seekora-docsearch-input", className: "seekora-docsearch-input", type: "text", value: value, onChange: handleChange, onKeyDown: onKeyDown, placeholder: placeholder, autoComplete: "off", autoCorrect: "off", autoCapitalize: "off", spellCheck: "false", "aria-autocomplete": "list", "aria-controls": "seekora-docsearch-results" }), value && (jsx("button", { type: "button", className: "seekora-docsearch-clear", onClick: handleClear, "aria-label": "Clear search", children: jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: jsx("path", { d: "M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z", fill: "currentColor" }) }) }))] }));
91
+ }
92
+
93
+ /**
94
+ * Renders text with highlighted matches.
95
+ * Expects highlightedValue to contain <mark> tags around matches.
96
+ */
97
+ function Highlight({ value, highlightedValue }) {
98
+ // If no highlighted value, render plain text
99
+ if (!highlightedValue) {
100
+ return jsx("span", { children: value });
101
+ }
102
+ // Parse the highlighted value and render with mark tags
103
+ // The API returns text with <mark> tags for highlighting
104
+ return (jsx("span", { className: "seekora-docsearch-highlight", dangerouslySetInnerHTML: { __html: sanitizeHtml(highlightedValue) } }));
105
+ }
106
+ /**
107
+ * Sanitize HTML to only allow <mark> tags for highlighting.
108
+ * This prevents XSS attacks while allowing highlighting.
109
+ */
110
+ function sanitizeHtml(html) {
111
+ // First, escape all HTML
112
+ const escaped = html
113
+ .replace(/&/g, '&amp;')
114
+ .replace(/</g, '&lt;')
115
+ .replace(/>/g, '&gt;')
116
+ .replace(/"/g, '&quot;')
117
+ .replace(/'/g, '&#039;');
118
+ // Then, restore only <mark> and </mark> tags
119
+ return escaped
120
+ .replace(/&lt;mark&gt;/g, '<mark>')
121
+ .replace(/&lt;\/mark&gt;/g, '</mark>')
122
+ // Also handle ais-highlight format (Algolia-style)
123
+ .replace(/&lt;ais-highlight&gt;/g, '<mark>')
124
+ .replace(/&lt;\/ais-highlight&gt;/g, '</mark>')
125
+ // Handle em tags too
126
+ .replace(/&lt;em&gt;/g, '<mark>')
127
+ .replace(/&lt;\/em&gt;/g, '</mark>');
128
+ }
129
+ /**
130
+ * Truncate content and highlight the matched portion.
131
+ */
132
+ function truncateAroundMatch(content, maxLength = 150) {
133
+ // If content contains a mark tag, try to center around it
134
+ const markIndex = content.indexOf('<mark>');
135
+ if (markIndex === -1 || content.length <= maxLength) {
136
+ // No highlight or short enough, just truncate from start
137
+ if (content.length <= maxLength) {
138
+ return content;
139
+ }
140
+ return content.slice(0, maxLength) + '...';
141
+ }
142
+ // Center around the first match
143
+ const halfLength = Math.floor(maxLength / 2);
144
+ let start = Math.max(0, markIndex - halfLength);
145
+ let end = Math.min(content.length, markIndex + halfLength);
146
+ // Adjust to not cut in the middle of a word
147
+ if (start > 0) {
148
+ const spaceIndex = content.indexOf(' ', start);
149
+ if (spaceIndex !== -1 && spaceIndex < markIndex) {
150
+ start = spaceIndex + 1;
151
+ }
152
+ }
153
+ if (end < content.length) {
154
+ const spaceIndex = content.lastIndexOf(' ', end);
155
+ if (spaceIndex !== -1 && spaceIndex > markIndex) {
156
+ end = spaceIndex;
157
+ }
158
+ }
159
+ let result = content.slice(start, end);
160
+ if (start > 0) {
161
+ result = '...' + result;
162
+ }
163
+ if (end < content.length) {
164
+ result = result + '...';
165
+ }
166
+ return result;
167
+ }
168
+
169
+ function Hit({ hit, isSelected, onClick, onMouseEnter, openInNewTab, isChild, isLastChild, hierarchyType }) {
170
+ // Determine if this is a full hit or a suggestion
171
+ const isFullHit = 'objectID' in hit;
172
+ // Get suggestion for type checking
173
+ const suggestion = hit;
174
+ // Get the type from prop or from the suggestion itself
175
+ const hitType = hierarchyType || suggestion.type;
176
+ // For admin pages, use category and parentTitle; for docs, breadcrumb is not needed
177
+ // since we group by lvl0 which already shows the path
178
+ const breadcrumb = suggestion.parentTitle
179
+ ? `${suggestion.category || ''} › ${suggestion.parentTitle}`.replace(/^› /, '')
180
+ : suggestion.category
181
+ ? suggestion.category
182
+ : ''; // Don't show breadcrumb for docs - the group header already shows lvl0
183
+ // Get title based on the type field
184
+ // - type=lvl1: use lvl1 as title (page-level)
185
+ // - type=lvl2: use lvl2 as title (section)
186
+ // - type=lvl3: use lvl3 as title, etc.
187
+ const title = getTitleForType(hit, hitType);
188
+ // Get highlighted values
189
+ let highlightedTitle = title;
190
+ let highlightedContent = hit.content || suggestion.description || '';
191
+ if (isFullHit) {
192
+ const fullHit = hit;
193
+ if (fullHit._highlightResult) {
194
+ highlightedTitle = fullHit._highlightResult.title?.value || title;
195
+ highlightedContent = fullHit._highlightResult.content?.value || hit.content || '';
196
+ }
197
+ }
198
+ else {
199
+ if (suggestion.highlight) {
200
+ highlightedTitle = suggestion.highlight.title || title;
201
+ highlightedContent = suggestion.highlight.content || hit.content || suggestion.description || '';
202
+ }
203
+ }
204
+ // Truncate content around match
205
+ const displayContent = highlightedContent
206
+ ? truncateAroundMatch(highlightedContent, 120)
207
+ : '';
208
+ // Determine URL - use route for admin pages if url not set
209
+ const url = hit.url || suggestion.route || '#';
210
+ // Build class names
211
+ const classNames = ['seekora-docsearch-hit'];
212
+ if (isSelected)
213
+ classNames.push('seekora-docsearch-hit--selected');
214
+ if (isChild)
215
+ classNames.push('seekora-docsearch-hit--child');
216
+ if (isLastChild)
217
+ classNames.push('seekora-docsearch-hit--last-child');
218
+ return (jsxs("a", { href: url, className: classNames.join(' '), onClick: (e) => {
219
+ e.preventDefault();
220
+ onClick();
221
+ }, onMouseEnter: onMouseEnter, role: "option", "aria-selected": isSelected, target: openInNewTab ? '_blank' : undefined, rel: openInNewTab ? 'noopener noreferrer' : undefined, children: [isChild && (jsx("div", { className: "seekora-docsearch-hit-tree", children: jsx(TreeConnector, { isLast: isLastChild }) })), jsx("div", { className: "seekora-docsearch-hit-icon", children: jsx(HitIcon, { type: getHitTypeFromLevel(hitType) }) }), jsxs("div", { className: "seekora-docsearch-hit-content", children: [!isChild && breadcrumb && (jsx("span", { className: "seekora-docsearch-hit-breadcrumb", children: breadcrumb })), jsx("span", { className: "seekora-docsearch-hit-title", children: jsx(Highlight, { value: title, highlightedValue: highlightedTitle }) }), displayContent && (jsx("span", { className: "seekora-docsearch-hit-description", children: jsx(Highlight, { value: hit.content || '', highlightedValue: displayContent }) }))] }), jsx("div", { className: "seekora-docsearch-hit-action", children: openInNewTab ? (jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M6 3H3v10h10v-3M9 3h4v4M14 2L7 9", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) : (jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M6.75 3.25L11.5 8L6.75 12.75", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) })] }));
222
+ }
223
+ /**
224
+ * Get the title based on the type field from API.
225
+ * - type=lvl1: use lvl1 as title (page-level result)
226
+ * - type=lvl2: use lvl2 as title (section)
227
+ * - type=lvl3: use lvl3 as title, etc.
228
+ */
229
+ function getTitleForType(hit, type) {
230
+ const hierarchy = hit.hierarchy || {};
231
+ if (!type) {
232
+ // Fallback to existing logic if no type
233
+ return hit.title || hierarchy.lvl1 || hierarchy.lvl0 || 'Untitled';
234
+ }
235
+ // Extract level number from type (e.g., "lvl2" -> 2)
236
+ const match = type.match(/^lvl(\d+)$/);
237
+ if (match) {
238
+ const level = parseInt(match[1], 10);
239
+ // Try to get the title from the matching hierarchy level
240
+ const levelKey = `lvl${level}`;
241
+ const levelTitle = hierarchy[levelKey];
242
+ if (levelTitle) {
243
+ return levelTitle;
244
+ }
245
+ }
246
+ // Fallback
247
+ return hit.title || hierarchy.lvl1 || hierarchy.lvl0 || 'Untitled';
248
+ }
249
+ /**
250
+ * Get the icon type based on the hierarchy type from API.
251
+ * - type=lvl1: page icon (page-level result)
252
+ * - type=lvl2, lvl3, etc.: section icon (child sections)
253
+ */
254
+ function getHitTypeFromLevel(type) {
255
+ if (!type) {
256
+ return 'page';
257
+ }
258
+ // Extract level number from type (e.g., "lvl2" -> 2)
259
+ const match = type.match(/^lvl(\d+)$/);
260
+ if (match) {
261
+ const level = parseInt(match[1], 10);
262
+ if (level === 1)
263
+ return 'page';
264
+ if (level <= 3)
265
+ return 'section';
266
+ return 'content';
267
+ }
268
+ return 'page';
269
+ }
270
+ function HitIcon({ type }) {
271
+ switch (type) {
272
+ case 'page':
273
+ return (jsxs("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: [jsx("path", { d: "M4.5 3.5h11a1 1 0 011 1v11a1 1 0 01-1 1h-11a1 1 0 01-1-1v-11a1 1 0 011-1z", stroke: "currentColor", strokeWidth: "1.5", fill: "none" }), jsx("path", { d: "M7 7h6M7 10h6M7 13h4", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" })] }));
274
+ case 'section':
275
+ return (jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M4 5.5h12M4 10h12M4 14.5h8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }));
276
+ case 'content':
277
+ return (jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: jsx("path", { d: "M4 6h12M4 10h8M4 14h10", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }));
278
+ }
279
+ }
280
+ // Tree connector for hierarchical display
281
+ function TreeConnector({ isLast }) {
282
+ return (jsx("svg", { width: "16", height: "20", viewBox: "0 0 16 20", fill: "none", "aria-hidden": "true", className: "seekora-docsearch-hit-tree-icon", children: isLast ? (
283
+ // └─ Last child connector
284
+ jsx("path", { d: "M8 0V10H14", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })) : (
285
+ // ├─ Middle child connector
286
+ jsxs(Fragment, { children: [jsx("path", { d: "M8 0V20", stroke: "currentColor", strokeWidth: "1.5" }), jsx("path", { d: "M8 10H14", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" })] })) }));
287
+ }
288
+
289
+ function Results({ hits, groupedHits, selectedIndex, onSelect, onHover, query, isLoading, error, translations = {}, sources: _sources = [], }) {
290
+ const listRef = useRef(null);
291
+ // Scroll selected item into view
292
+ useEffect(() => {
293
+ if (listRef.current && hits.length > 0) {
294
+ const selectedItem = listRef.current.children[selectedIndex];
295
+ if (selectedItem) {
296
+ selectedItem.scrollIntoView({ block: 'nearest' });
297
+ }
298
+ }
299
+ }, [selectedIndex, hits.length]);
300
+ // Empty state - no query
301
+ if (!query) {
302
+ return (jsx("div", { className: "seekora-docsearch-empty", children: jsx("p", { className: "seekora-docsearch-empty-text", children: translations.searchPlaceholder || 'Type to start searching...' }) }));
303
+ }
304
+ // Loading state
305
+ if (isLoading && hits.length === 0) {
306
+ return (jsxs("div", { className: "seekora-docsearch-loading", children: [jsx("div", { className: "seekora-docsearch-loading-spinner", children: jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", "aria-hidden": "true", children: jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2", fill: "none", strokeDasharray: "50", strokeDashoffset: "15", children: jsx("animateTransform", { attributeName: "transform", type: "rotate", from: "0 12 12", to: "360 12 12", dur: "0.8s", repeatCount: "indefinite" }) }) }) }), jsx("p", { className: "seekora-docsearch-loading-text", children: translations.loadingText || 'Searching...' })] }));
307
+ }
308
+ // Error state
309
+ if (error) {
310
+ return (jsx("div", { className: "seekora-docsearch-error", children: jsx("p", { className: "seekora-docsearch-error-text", children: translations.errorText || error }) }));
311
+ }
312
+ // No results
313
+ if (hits.length === 0 && query) {
314
+ return (jsx("div", { className: "seekora-docsearch-no-results", children: jsx("p", { className: "seekora-docsearch-no-results-text", children: translations.noResultsText || `No results found for "${query}"` }) }));
315
+ }
316
+ // Use multi-source grouped hits if available, otherwise group by category
317
+ const displayGroups = groupedHits && groupedHits.length > 0
318
+ ? groupedHits.map(g => ({
319
+ category: g.source.name,
320
+ sourceId: g.source.id,
321
+ openInNewTab: g.source.openInNewTab,
322
+ hits: g.items,
323
+ }))
324
+ : groupHitsByCategory(hits).map(g => ({
325
+ category: g.category,
326
+ sourceId: 'default',
327
+ openInNewTab: false,
328
+ hits: g.hits,
329
+ }));
330
+ return (jsx("div", { className: "seekora-docsearch-results", children: jsx("ul", { ref: listRef, id: "seekora-docsearch-results", className: "seekora-docsearch-results-list", role: "listbox", children: displayGroups.map((group, groupIndex) => (jsxs("li", { className: "seekora-docsearch-results-group", children: [group.category && (jsx("div", { className: "seekora-docsearch-results-group-header", children: group.category })), jsx("ul", { className: "seekora-docsearch-results-group-items", children: group.hits.map((hit, hitIndex) => {
331
+ const globalIndex = getGlobalIndexMulti(displayGroups, groupIndex, hitIndex);
332
+ const extHit = hit;
333
+ // Use computed isChild/isLastChild values (derived from type field)
334
+ const isChild = extHit.isChild === true;
335
+ const isLastChild = extHit.isLastChild === true;
336
+ // Pass the type field to Hit for icon determination
337
+ const hitType = extHit.type;
338
+ return (jsx("li", { children: jsx(Hit, { hit: hit, isSelected: globalIndex === selectedIndex, onClick: () => onSelect(hit), onMouseEnter: () => onHover(globalIndex), openInNewTab: group.openInNewTab, isChild: isChild, isLastChild: isLastChild, hierarchyType: hitType }) }, getHitKey(hit, hitIndex)));
339
+ }) })] }, group.sourceId + '-' + groupIndex))) }) }));
340
+ }
341
+ /**
342
+ * Helper to get the hierarchy level number from a type string (e.g., "lvl1" -> 1, "lvl2" -> 2)
343
+ * Returns 1 as default for parent-level items
344
+ */
345
+ function getTypeLevel(type) {
346
+ if (!type)
347
+ return 1;
348
+ const match = type.match(/^lvl(\d+)$/);
349
+ return match ? parseInt(match[1], 10) : 1;
350
+ }
351
+ /**
352
+ * Check if a hit is a child (type is lvl2, lvl3, etc.)
353
+ * type="lvl1" = parent (page-level result)
354
+ * type="lvl2", "lvl3", etc. = children (sections)
355
+ */
356
+ function isChildType(type) {
357
+ const level = getTypeLevel(type);
358
+ return level >= 2;
359
+ }
360
+ /**
361
+ * Groups hits by hierarchy.lvl0 and marks parent/child relationships based on type field.
362
+ * - Groups are keyed by lvl0 (which contains the full path like "Guides > SDK > Search")
363
+ * - type="lvl1" = parent (page-level result)
364
+ * - type="lvl2", "lvl3", etc. = children (sections)
365
+ */
366
+ function groupHitsByHierarchy(hits) {
367
+ const groups = new Map();
368
+ for (const hit of hits) {
369
+ // Group by lvl0 - this is the source of truth for grouping
370
+ const lvl0 = hit.hierarchy?.lvl0 || '';
371
+ if (!groups.has(lvl0)) {
372
+ groups.set(lvl0, []);
373
+ }
374
+ groups.get(lvl0).push(hit);
375
+ }
376
+ const result = [];
377
+ for (const [lvl0Key, groupHits] of groups.entries()) {
378
+ // Sort hits within each group: parents (lvl1) first, then children by type level
379
+ const sortedHits = [...groupHits].sort((a, b) => {
380
+ const aType = a.type;
381
+ const bType = b.type;
382
+ const aLevel = getTypeLevel(aType);
383
+ const bLevel = getTypeLevel(bType);
384
+ // Sort by level: lvl1 first, then lvl2, lvl3, etc.
385
+ return aLevel - bLevel;
386
+ });
387
+ // Mark isChild and isLastChild based on type field
388
+ const markedHits = sortedHits.map((hit, index) => {
389
+ const suggestion = hit;
390
+ const hitType = suggestion.type;
391
+ const isChild = isChildType(hitType);
392
+ // Determine if this is the last child in the group
393
+ // Check if this is a child and the next item is either not a child or doesn't exist
394
+ let isLastChild = false;
395
+ if (isChild) {
396
+ const nextHit = sortedHits[index + 1];
397
+ if (!nextHit || !isChildType(nextHit.type)) {
398
+ isLastChild = true;
399
+ }
400
+ }
401
+ return {
402
+ ...hit,
403
+ isChild,
404
+ isLastChild,
405
+ };
406
+ });
407
+ result.push({
408
+ category: lvl0Key || null,
409
+ hits: markedHits,
410
+ });
411
+ }
412
+ return result;
413
+ }
414
+ // Legacy function for backward compatibility
415
+ function groupHitsByCategory(hits) {
416
+ return groupHitsByHierarchy(hits);
417
+ }
418
+ function getGlobalIndexMulti(groups, groupIndex, hitIndex) {
419
+ let index = 0;
420
+ for (let i = 0; i < groupIndex; i++) {
421
+ index += groups[i].hits.length;
422
+ }
423
+ return index + hitIndex;
424
+ }
425
+ function getHitKey(hit, index) {
426
+ if ('objectID' in hit) {
427
+ return hit.objectID;
428
+ }
429
+ return `suggestion-${hit.url}-${index}`;
430
+ }
431
+
432
+ function Footer({ translations = {} }) {
433
+ return (jsxs("footer", { className: "seekora-docsearch-footer", children: [jsx("div", { className: "seekora-docsearch-footer-commands", children: jsxs("ul", { className: "seekora-docsearch-footer-commands-list", children: [jsx("li", { children: jsxs("span", { className: "seekora-docsearch-footer-command", children: [jsx("kbd", { className: "seekora-docsearch-key", children: "\u21B5" }), jsx("span", { children: "to select" })] }) }), jsx("li", { children: jsxs("span", { className: "seekora-docsearch-footer-command", children: [jsx("kbd", { className: "seekora-docsearch-key", children: "\u2191" }), jsx("kbd", { className: "seekora-docsearch-key", children: "\u2193" }), jsx("span", { children: "to navigate" })] }) }), jsx("li", { children: jsxs("span", { className: "seekora-docsearch-footer-command", children: [jsx("kbd", { className: "seekora-docsearch-key", children: "esc" }), jsx("span", { children: translations.closeText || 'to close' })] }) })] }) }), jsxs("div", { className: "seekora-docsearch-footer-logo", children: [jsx("span", { className: "seekora-docsearch-footer-logo-text", children: translations.searchByText || 'Search by' }), jsx("a", { href: "https://seekora.ai", target: "_blank", rel: "noopener noreferrer", className: "seekora-docsearch-footer-logo-link", children: jsx(SekoraLogo, {}) })] })] }));
434
+ }
435
+ function SekoraLogo() {
436
+ return (jsx("svg", { width: "77", height: "19", viewBox: "0 0 77 19", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-label": "Seekora", className: "seekora-docsearch-logo", children: jsx("text", { x: "0", y: "15", fontFamily: "system-ui, -apple-system, sans-serif", fontSize: "14", fontWeight: "600", fill: "currentColor", children: "Seekora" }) }));
437
+ }
438
+
439
+ function useKeyboard(options) {
440
+ const { isOpen, onOpen, onClose, onSelectNext, onSelectPrev, onEnter, disableShortcut = false, shortcutKey = 'k', } = options;
441
+ const handleGlobalKeyDown = useCallback((event) => {
442
+ // Open modal with Cmd/Ctrl + K
443
+ if (!disableShortcut && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcutKey) {
444
+ event.preventDefault();
445
+ if (isOpen) {
446
+ onClose();
447
+ }
448
+ else {
449
+ onOpen();
450
+ }
451
+ return;
452
+ }
453
+ // Forward slash to open (when not in an input)
454
+ if (!disableShortcut && event.key === '/' && !isOpen) {
455
+ const target = event.target;
456
+ const isInput = target.tagName === 'INPUT' ||
457
+ target.tagName === 'TEXTAREA' ||
458
+ target.isContentEditable;
459
+ if (!isInput) {
460
+ event.preventDefault();
461
+ onOpen();
462
+ }
463
+ }
464
+ }, [isOpen, onOpen, onClose, disableShortcut, shortcutKey]);
465
+ const handleModalKeyDown = useCallback((event) => {
466
+ switch (event.key) {
467
+ case 'Escape':
468
+ event.preventDefault();
469
+ onClose();
470
+ break;
471
+ case 'ArrowDown':
472
+ event.preventDefault();
473
+ onSelectNext();
474
+ break;
475
+ case 'ArrowUp':
476
+ event.preventDefault();
477
+ onSelectPrev();
478
+ break;
479
+ case 'Enter':
480
+ event.preventDefault();
481
+ onEnter();
482
+ break;
483
+ case 'Tab':
484
+ // Allow Tab but prevent default to keep focus in modal
485
+ if (event.shiftKey) {
486
+ onSelectPrev();
487
+ }
488
+ else {
489
+ onSelectNext();
490
+ }
491
+ event.preventDefault();
492
+ break;
493
+ }
494
+ }, [onClose, onSelectNext, onSelectPrev, onEnter]);
495
+ // Global keyboard listener
496
+ useEffect(() => {
497
+ document.addEventListener('keydown', handleGlobalKeyDown);
498
+ return () => {
499
+ document.removeEventListener('keydown', handleGlobalKeyDown);
500
+ };
501
+ }, [handleGlobalKeyDown]);
502
+ return {
503
+ handleModalKeyDown,
504
+ };
505
+ }
506
+ /**
507
+ * Get the keyboard shortcut display text based on platform
508
+ */
509
+ function getShortcutText(key = 'K') {
510
+ if (typeof navigator === 'undefined') {
511
+ return `⌘${key}`;
512
+ }
513
+ const isMac = navigator.platform.toLowerCase().includes('mac');
514
+ return isMac ? `⌘${key}` : `Ctrl+${key}`;
515
+ }
516
+
517
+ function DocSearchButton({ onClick, placeholder = 'Search documentation...', }) {
518
+ const shortcutText = getShortcutText('K');
519
+ return (jsxs("button", { type: "button", className: "seekora-docsearch-button", onClick: onClick, "aria-label": "Search documentation", children: [jsx("span", { className: "seekora-docsearch-button-icon", children: jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", children: jsx("path", { d: "M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z", fill: "currentColor" }) }) }), jsx("span", { className: "seekora-docsearch-button-placeholder", children: placeholder }), jsx("span", { className: "seekora-docsearch-button-keys", children: jsx("kbd", { className: "seekora-docsearch-button-key", children: shortcutText }) })] }));
520
+ }
521
+
522
+ const initialState = {
523
+ query: '',
524
+ results: [],
525
+ suggestions: [],
526
+ groupedSuggestions: [],
527
+ isLoading: false,
528
+ error: null,
529
+ selectedIndex: 0,
530
+ mode: 'suggestions',
531
+ };
532
+ function reducer(state, action) {
533
+ switch (action.type) {
534
+ case 'SET_QUERY':
535
+ return { ...state, query: action.payload, selectedIndex: 0 };
536
+ case 'SET_RESULTS':
537
+ return { ...state, results: action.payload, mode: 'results' };
538
+ case 'SET_SUGGESTIONS':
539
+ return { ...state, suggestions: action.payload, mode: 'suggestions' };
540
+ case 'SET_GROUPED_SUGGESTIONS':
541
+ // Flatten grouped suggestions for navigation while keeping groups for display
542
+ const flatSuggestions = action.payload.flatMap(group => group.items.map(item => ({ ...item, _source: group.source.id })));
543
+ return {
544
+ ...state,
545
+ groupedSuggestions: action.payload,
546
+ suggestions: flatSuggestions,
547
+ mode: 'suggestions'
548
+ };
549
+ case 'SET_LOADING':
550
+ return { ...state, isLoading: action.payload };
551
+ case 'SET_ERROR':
552
+ return { ...state, error: action.payload };
553
+ case 'SET_SELECTED_INDEX':
554
+ return { ...state, selectedIndex: action.payload };
555
+ case 'SELECT_NEXT': {
556
+ const items = state.mode === 'results' ? state.results : state.suggestions;
557
+ const maxIndex = items.length - 1;
558
+ return {
559
+ ...state,
560
+ selectedIndex: state.selectedIndex >= maxIndex ? 0 : state.selectedIndex + 1,
561
+ };
562
+ }
563
+ case 'SELECT_PREV': {
564
+ const items = state.mode === 'results' ? state.results : state.suggestions;
565
+ const maxIndex = items.length - 1;
566
+ return {
567
+ ...state,
568
+ selectedIndex: state.selectedIndex <= 0 ? maxIndex : state.selectedIndex - 1,
569
+ };
570
+ }
571
+ case 'SET_MODE':
572
+ return { ...state, mode: action.payload, selectedIndex: 0 };
573
+ case 'RESET':
574
+ return initialState;
575
+ default:
576
+ return state;
577
+ }
578
+ }
579
+ function useDocSearch(options) {
580
+ const { apiEndpoint, apiKey, sources, maxResults = 10, debounceMs = 200 } = options;
581
+ // Build sources array from either explicit sources or legacy single endpoint
582
+ const searchSources = sources || (apiEndpoint ? [{
583
+ id: 'default',
584
+ name: 'Results',
585
+ endpoint: apiEndpoint,
586
+ apiKey,
587
+ maxResults,
588
+ }] : []);
589
+ const [state, dispatch] = useReducer(reducer, initialState);
590
+ const abortControllersRef = useRef(new Map());
591
+ const debounceTimerRef = useRef(null);
592
+ // Default transform function for API responses
593
+ const defaultTransform = (data, sourceId) => {
594
+ const items = data.data?.suggestions || data.data?.results || data.suggestions || data.results || data.hits || [];
595
+ return items.map((item) => ({
596
+ url: item.url || item.route || '',
597
+ title: item.title?.replace?.(/<\/?mark>/g, '') || item.title || '',
598
+ content: item.content?.replace?.(/<\/?mark>/g, '')?.substring?.(0, 100) || item.description || '',
599
+ description: item.description || item.content?.substring?.(0, 100) || '',
600
+ category: item.category || item.hierarchy?.lvl0 || '',
601
+ hierarchy: item.hierarchy,
602
+ route: item.route,
603
+ parentTitle: item.parent_title || item.parentTitle,
604
+ _source: sourceId,
605
+ }));
606
+ };
607
+ const fetchFromSource = useCallback(async (source, query, signal) => {
608
+ const minLength = source.minQueryLength ?? 1;
609
+ if (query.length < minLength) {
610
+ return [];
611
+ }
612
+ try {
613
+ const url = new URL(source.endpoint);
614
+ url.searchParams.set('query', query);
615
+ url.searchParams.set('limit', String(source.maxResults || 8));
616
+ const headers = {
617
+ 'Content-Type': 'application/json',
618
+ };
619
+ if (source.apiKey) {
620
+ headers['X-Docs-API-Key'] = source.apiKey;
621
+ }
622
+ const response = await fetch(url.toString(), {
623
+ method: 'GET',
624
+ headers,
625
+ signal,
626
+ });
627
+ if (!response.ok) {
628
+ console.warn(`Search source ${source.id} failed: ${response.statusText}`);
629
+ return [];
630
+ }
631
+ const data = await response.json();
632
+ // Use custom transform or default
633
+ if (source.transformResults) {
634
+ return source.transformResults(data).map(item => ({ ...item, _source: source.id }));
635
+ }
636
+ return defaultTransform(data, source.id);
637
+ }
638
+ catch (error) {
639
+ if (error instanceof Error && error.name === 'AbortError') {
640
+ throw error; // Re-throw abort errors
641
+ }
642
+ console.warn(`Search source ${source.id} error:`, error);
643
+ return [];
644
+ }
645
+ }, []);
646
+ const fetchSuggestions = useCallback(async (query) => {
647
+ if (!query.trim()) {
648
+ dispatch({ type: 'SET_GROUPED_SUGGESTIONS', payload: [] });
649
+ return;
650
+ }
651
+ // Cancel all previous requests
652
+ abortControllersRef.current.forEach(controller => controller.abort());
653
+ abortControllersRef.current.clear();
654
+ dispatch({ type: 'SET_LOADING', payload: true });
655
+ dispatch({ type: 'SET_ERROR', payload: null });
656
+ try {
657
+ // Fetch from all sources in parallel
658
+ const results = await Promise.all(searchSources.map(async (source) => {
659
+ const controller = new AbortController();
660
+ abortControllersRef.current.set(source.id, controller);
661
+ const items = await fetchFromSource(source, query, controller.signal);
662
+ return { source, items };
663
+ }));
664
+ // Filter out empty results and dispatch grouped
665
+ const groupedResults = results.filter(r => r.items.length > 0);
666
+ dispatch({ type: 'SET_GROUPED_SUGGESTIONS', payload: groupedResults });
667
+ }
668
+ catch (error) {
669
+ if (error instanceof Error && error.name === 'AbortError') {
670
+ return; // Request was cancelled
671
+ }
672
+ dispatch({
673
+ type: 'SET_ERROR',
674
+ payload: error instanceof Error ? error.message : 'Search failed',
675
+ });
676
+ }
677
+ finally {
678
+ dispatch({ type: 'SET_LOADING', payload: false });
679
+ }
680
+ }, [searchSources, fetchFromSource]);
681
+ const search = useCallback(async (query) => {
682
+ // For now, search behaves same as fetchSuggestions
683
+ await fetchSuggestions(query);
684
+ }, [fetchSuggestions]);
685
+ const setQuery = useCallback((query) => {
686
+ dispatch({ type: 'SET_QUERY', payload: query });
687
+ // Clear existing debounce timer
688
+ if (debounceTimerRef.current) {
689
+ clearTimeout(debounceTimerRef.current);
690
+ }
691
+ // Debounce the search
692
+ debounceTimerRef.current = setTimeout(() => {
693
+ fetchSuggestions(query);
694
+ }, debounceMs);
695
+ }, [fetchSuggestions, debounceMs]);
696
+ const selectNext = useCallback(() => {
697
+ dispatch({ type: 'SELECT_NEXT' });
698
+ }, []);
699
+ const selectPrev = useCallback(() => {
700
+ dispatch({ type: 'SELECT_PREV' });
701
+ }, []);
702
+ const setSelectedIndex = useCallback((index) => {
703
+ dispatch({ type: 'SET_SELECTED_INDEX', payload: index });
704
+ }, []);
705
+ const reset = useCallback(() => {
706
+ abortControllersRef.current.forEach(controller => controller.abort());
707
+ abortControllersRef.current.clear();
708
+ if (debounceTimerRef.current) {
709
+ clearTimeout(debounceTimerRef.current);
710
+ }
711
+ dispatch({ type: 'RESET' });
712
+ }, []);
713
+ const getSelectedItem = useCallback(() => {
714
+ const items = state.mode === 'results' ? state.results : state.suggestions;
715
+ return items[state.selectedIndex] || null;
716
+ }, [state.mode, state.results, state.suggestions, state.selectedIndex]);
717
+ // Cleanup on unmount
718
+ useEffect(() => {
719
+ return () => {
720
+ abortControllersRef.current.forEach(controller => controller.abort());
721
+ abortControllersRef.current.clear();
722
+ if (debounceTimerRef.current) {
723
+ clearTimeout(debounceTimerRef.current);
724
+ }
725
+ };
726
+ }, []);
727
+ return {
728
+ ...state,
729
+ sources: searchSources,
730
+ setQuery,
731
+ search,
732
+ fetchSuggestions,
733
+ selectNext,
734
+ selectPrev,
735
+ setSelectedIndex,
736
+ reset,
737
+ getSelectedItem,
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Transform Seekora SDK search results to DocSearch format
743
+ */
744
+ function transformResults(results) {
745
+ return results.map((result) => {
746
+ // Handle different field name conventions
747
+ const url = result.url || result.route || result.link || '';
748
+ const title = result.title || result.name || '';
749
+ const content = result.content || result.description || result.snippet || '';
750
+ const description = result.description || result.content?.substring?.(0, 150) || '';
751
+ // Build hierarchy from available fields
752
+ const hierarchy = {};
753
+ if (result.hierarchy) {
754
+ hierarchy.lvl0 = result.hierarchy.lvl0;
755
+ hierarchy.lvl1 = result.hierarchy.lvl1;
756
+ hierarchy.lvl2 = result.hierarchy.lvl2;
757
+ }
758
+ else {
759
+ // Try to extract hierarchy from category or breadcrumb fields
760
+ if (result.category) {
761
+ hierarchy.lvl0 = result.category;
762
+ }
763
+ if (result.parent_title || result.parentTitle) {
764
+ hierarchy.lvl1 = result.parent_title || result.parentTitle;
765
+ }
766
+ }
767
+ return {
768
+ url,
769
+ title: title?.replace?.(/<\/?mark>/g, '') || title,
770
+ content: content?.replace?.(/<\/?mark>/g, '')?.substring?.(0, 200) || content,
771
+ description: description?.replace?.(/<\/?mark>/g, '') || description,
772
+ category: result.category || hierarchy.lvl0 || '',
773
+ hierarchy,
774
+ route: result.route,
775
+ parentTitle: result.parent_title || result.parentTitle,
776
+ type: result.type || '',
777
+ anchor: result.anchor || '',
778
+ _source: 'seekora',
779
+ };
780
+ });
781
+ }
782
+ /**
783
+ * Hook for searching using the Seekora SDK
784
+ *
785
+ * This hook provides a seamless integration with the Seekora Search SDK,
786
+ * handling client initialization, debouncing, and result transformation.
787
+ */
788
+ function useSeekoraSearch(options) {
789
+ const { storeId, storeSecret, apiEndpoint, maxResults = 20, debounceMs = 200, analyticsTags = ['docsearch'], groupField, groupSize, } = options;
790
+ const [query, setQueryState] = useState('');
791
+ const [suggestions, setSuggestions] = useState([]);
792
+ const [isLoading, setIsLoading] = useState(false);
793
+ const [error, setError] = useState(null);
794
+ const [selectedIndex, setSelectedIndex] = useState(0);
795
+ const clientRef = useRef(null);
796
+ const debounceTimerRef = useRef(null);
797
+ const abortControllerRef = useRef(null);
798
+ // Initialize the Seekora client
799
+ useEffect(() => {
800
+ if (!storeId) {
801
+ return;
802
+ }
803
+ const config = {
804
+ storeId,
805
+ readSecret: storeSecret,
806
+ logLevel: 'warn', // Keep logging quiet in the widget
807
+ };
808
+ // Handle endpoint configuration
809
+ if (apiEndpoint) {
810
+ if (['local', 'stage', 'production'].includes(apiEndpoint)) {
811
+ config.environment = apiEndpoint;
812
+ }
813
+ else {
814
+ config.baseUrl = apiEndpoint;
815
+ }
816
+ }
817
+ try {
818
+ clientRef.current = new SeekoraClient(config);
819
+ }
820
+ catch (err) {
821
+ console.error('Failed to initialize SeekoraClient:', err);
822
+ setError('Failed to initialize search client');
823
+ }
824
+ return () => {
825
+ clientRef.current = null;
826
+ };
827
+ }, [storeId, storeSecret, apiEndpoint]);
828
+ // Search function
829
+ const performSearch = useCallback(async (searchQuery) => {
830
+ if (!clientRef.current) {
831
+ setError('Search client not initialized');
832
+ return;
833
+ }
834
+ if (!searchQuery.trim()) {
835
+ setSuggestions([]);
836
+ return;
837
+ }
838
+ // Cancel any pending request
839
+ if (abortControllerRef.current) {
840
+ abortControllerRef.current.abort();
841
+ }
842
+ abortControllerRef.current = new AbortController();
843
+ setIsLoading(true);
844
+ setError(null);
845
+ try {
846
+ const response = await clientRef.current.search(searchQuery, {
847
+ per_page: maxResults,
848
+ analytics_tags: analyticsTags,
849
+ // Doc search specific parameters (Algolia-style)
850
+ return_fields: [
851
+ 'hierarchy.lvl0', 'hierarchy.lvl1', 'hierarchy.lvl2', 'hierarchy.lvl3',
852
+ 'hierarchy.lvl4', 'hierarchy.lvl5', 'hierarchy.lvl6',
853
+ 'content', 'type', 'url', 'title', 'anchor'
854
+ ],
855
+ snippet_fields: [
856
+ 'hierarchy.lvl1', 'hierarchy.lvl2', 'hierarchy.lvl3',
857
+ 'hierarchy.lvl4', 'hierarchy.lvl5', 'hierarchy.lvl6', 'content'
858
+ ],
859
+ snippet_prefix: '<mark>',
860
+ snippet_suffix: '</mark>',
861
+ include_snippets: true,
862
+ // Grouping for hierarchy display
863
+ group_field: groupField,
864
+ group_size: groupSize,
865
+ });
866
+ // Check if this request was aborted
867
+ if (abortControllerRef.current?.signal.aborted) {
868
+ return;
869
+ }
870
+ const transformedResults = transformResults(response.results || []);
871
+ setSuggestions(transformedResults);
872
+ setSelectedIndex(0);
873
+ }
874
+ catch (err) {
875
+ // Ignore abort errors
876
+ if (err instanceof Error && err.name === 'AbortError') {
877
+ return;
878
+ }
879
+ console.error('Search failed:', err);
880
+ setError(err instanceof Error ? err.message : 'Search failed');
881
+ setSuggestions([]);
882
+ }
883
+ finally {
884
+ setIsLoading(false);
885
+ }
886
+ }, [maxResults, analyticsTags, groupField, groupSize]);
887
+ // Set query with debouncing
888
+ const setQuery = useCallback((newQuery) => {
889
+ setQueryState(newQuery);
890
+ setSelectedIndex(0);
891
+ // Clear existing debounce timer
892
+ if (debounceTimerRef.current) {
893
+ clearTimeout(debounceTimerRef.current);
894
+ }
895
+ // Debounce the search
896
+ debounceTimerRef.current = setTimeout(() => {
897
+ performSearch(newQuery);
898
+ }, debounceMs);
899
+ }, [performSearch, debounceMs]);
900
+ // Navigation functions
901
+ const selectNext = useCallback(() => {
902
+ setSelectedIndex((prev) => (prev >= suggestions.length - 1 ? 0 : prev + 1));
903
+ }, [suggestions.length]);
904
+ const selectPrev = useCallback(() => {
905
+ setSelectedIndex((prev) => (prev <= 0 ? suggestions.length - 1 : prev - 1));
906
+ }, [suggestions.length]);
907
+ // Reset function
908
+ const reset = useCallback(() => {
909
+ if (abortControllerRef.current) {
910
+ abortControllerRef.current.abort();
911
+ }
912
+ if (debounceTimerRef.current) {
913
+ clearTimeout(debounceTimerRef.current);
914
+ }
915
+ setQueryState('');
916
+ setSuggestions([]);
917
+ setIsLoading(false);
918
+ setError(null);
919
+ setSelectedIndex(0);
920
+ }, []);
921
+ // Get selected item
922
+ const getSelectedItem = useCallback(() => {
923
+ return suggestions[selectedIndex] || null;
924
+ }, [suggestions, selectedIndex]);
925
+ // Cleanup on unmount
926
+ useEffect(() => {
927
+ return () => {
928
+ if (abortControllerRef.current) {
929
+ abortControllerRef.current.abort();
930
+ }
931
+ if (debounceTimerRef.current) {
932
+ clearTimeout(debounceTimerRef.current);
933
+ }
934
+ };
935
+ }, []);
936
+ return {
937
+ query,
938
+ suggestions,
939
+ isLoading,
940
+ error,
941
+ selectedIndex,
942
+ setQuery,
943
+ selectNext,
944
+ selectPrev,
945
+ setSelectedIndex,
946
+ reset,
947
+ getSelectedItem,
948
+ };
949
+ }
950
+
951
+ function DocSearch({
952
+ // New Seekora SDK props
953
+ storeId, storeSecret, seekoraApiEndpoint,
954
+ // Legacy props (deprecated when using storeId)
955
+ apiEndpoint, apiKey, sources,
956
+ // indexName is accepted but not used (kept for backward compatibility)
957
+ placeholder = 'Search documentation...', maxResults = 10, debounceMs = 200, onSelect, onClose, translations = {}, renderButton = true, buttonComponent: ButtonComponent = DocSearchButton, initialOpen = false, disableShortcut = false, shortcutKey = 'k', }) {
958
+ const [isOpen, setIsOpen] = useState(initialOpen);
959
+ // Determine if we should use the Seekora SDK
960
+ const useSeekoraSDK = !!storeId;
961
+ // Seekora SDK-based search (when storeId is provided)
962
+ const seekoraSearch = useSeekoraSearch({
963
+ storeId: storeId || '',
964
+ storeSecret,
965
+ apiEndpoint: seekoraApiEndpoint,
966
+ maxResults,
967
+ debounceMs,
968
+ analyticsTags: ['docsearch'],
969
+ });
970
+ // Legacy fetch-based search (when using apiEndpoint/sources)
971
+ const legacySearch = useDocSearch({
972
+ apiEndpoint,
973
+ apiKey,
974
+ sources,
975
+ maxResults,
976
+ debounceMs,
977
+ });
978
+ // Select the appropriate search implementation
979
+ const { query, suggestions, isLoading, error, selectedIndex, setQuery, selectNext, selectPrev, setSelectedIndex, reset, getSelectedItem, } = useSeekoraSDK ? seekoraSearch : legacySearch;
980
+ // For legacy mode, get additional fields
981
+ const groupedSuggestions = useSeekoraSDK ? undefined : legacySearch.groupedSuggestions;
982
+ const results = useSeekoraSDK ? suggestions : legacySearch.results;
983
+ const mode = useSeekoraSDK ? 'suggestions' : legacySearch.mode;
984
+ const searchSources = useSeekoraSDK
985
+ ? [{ id: 'seekora', name: 'Results', endpoint: '' }]
986
+ : legacySearch.sources;
987
+ const handleOpen = useCallback(() => {
988
+ setIsOpen(true);
989
+ }, []);
990
+ const handleClose = useCallback(() => {
991
+ setIsOpen(false);
992
+ reset();
993
+ onClose?.();
994
+ }, [reset, onClose]);
995
+ const handleSelect = useCallback((hit) => {
996
+ if (onSelect) {
997
+ onSelect(hit);
998
+ }
999
+ else {
1000
+ // Default behavior: navigate to URL
1001
+ window.location.href = hit.url;
1002
+ }
1003
+ handleClose();
1004
+ }, [onSelect, handleClose]);
1005
+ const handleEnter = useCallback(() => {
1006
+ const selectedItem = getSelectedItem();
1007
+ if (selectedItem) {
1008
+ handleSelect(selectedItem);
1009
+ }
1010
+ }, [getSelectedItem, handleSelect]);
1011
+ const { handleModalKeyDown } = useKeyboard({
1012
+ isOpen,
1013
+ onOpen: handleOpen,
1014
+ onClose: handleClose,
1015
+ onSelectNext: selectNext,
1016
+ onSelectPrev: selectPrev,
1017
+ onEnter: handleEnter,
1018
+ disableShortcut,
1019
+ shortcutKey,
1020
+ });
1021
+ const handleKeyDown = useCallback((event) => {
1022
+ handleModalKeyDown(event);
1023
+ }, [handleModalKeyDown]);
1024
+ // Get the items to display (suggestions or full results)
1025
+ const displayHits = mode === 'results' ? results : suggestions;
1026
+ return (jsxs(Fragment, { children: [renderButton && (jsx(ButtonComponent, { onClick: handleOpen, placeholder: translations.buttonText || placeholder })), jsx(Modal, { isOpen: isOpen, onClose: handleClose, children: jsxs("div", { className: "seekora-docsearch-modal", onKeyDown: handleKeyDown, children: [jsxs("header", { className: "seekora-docsearch-header", children: [jsx(SearchBox, { value: query, onChange: setQuery, onKeyDown: handleKeyDown, placeholder: placeholder, isLoading: isLoading, onClear: reset }), jsx("button", { type: "button", className: "seekora-docsearch-close", onClick: handleClose, "aria-label": "Close search", children: jsx("span", { className: "seekora-docsearch-close-text", children: "esc" }) })] }), jsx("div", { className: "seekora-docsearch-body", children: jsx(Results, { hits: displayHits, groupedHits: groupedSuggestions, selectedIndex: selectedIndex, onSelect: handleSelect, onHover: setSelectedIndex, query: query, isLoading: isLoading, error: error, translations: translations, sources: searchSources }) }), jsx(Footer, { translations: translations })] }) })] }));
1027
+ }
1028
+
1029
+ export { DocSearch, DocSearchButton, Footer, Highlight, Hit, Modal, Results, SearchBox, getShortcutText, truncateAroundMatch, useDocSearch, useKeyboard, useSeekoraSearch };
1030
+ //# sourceMappingURL=index.esm.js.map