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