@seekora-ai/docsearch-react 1.0.0

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,661 @@
1
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
+ import { useRef, useEffect, useCallback, useReducer, useState } from 'react';
3
+
4
+ function Modal({ isOpen, onClose, children }) {
5
+ const overlayRef = useRef(null);
6
+ const containerRef = useRef(null);
7
+ // Handle click outside to close
8
+ useEffect(() => {
9
+ const handleClickOutside = (event) => {
10
+ if (containerRef.current &&
11
+ !containerRef.current.contains(event.target)) {
12
+ onClose();
13
+ }
14
+ };
15
+ if (isOpen) {
16
+ document.addEventListener('mousedown', handleClickOutside);
17
+ }
18
+ return () => {
19
+ document.removeEventListener('mousedown', handleClickOutside);
20
+ };
21
+ }, [isOpen, onClose]);
22
+ // Lock body scroll when open
23
+ useEffect(() => {
24
+ if (isOpen) {
25
+ const originalOverflow = document.body.style.overflow;
26
+ document.body.style.overflow = 'hidden';
27
+ return () => {
28
+ document.body.style.overflow = originalOverflow;
29
+ };
30
+ }
31
+ }, [isOpen]);
32
+ // Focus trap
33
+ useEffect(() => {
34
+ if (!isOpen || !containerRef.current)
35
+ return;
36
+ const container = containerRef.current;
37
+ const focusableElements = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
38
+ const firstElement = focusableElements[0];
39
+ const lastElement = focusableElements[focusableElements.length - 1];
40
+ const handleTabKey = (e) => {
41
+ if (e.key !== 'Tab')
42
+ return;
43
+ if (e.shiftKey) {
44
+ if (document.activeElement === firstElement) {
45
+ e.preventDefault();
46
+ lastElement?.focus();
47
+ }
48
+ }
49
+ else {
50
+ if (document.activeElement === lastElement) {
51
+ e.preventDefault();
52
+ firstElement?.focus();
53
+ }
54
+ }
55
+ };
56
+ container.addEventListener('keydown', handleTabKey);
57
+ return () => container.removeEventListener('keydown', handleTabKey);
58
+ }, [isOpen]);
59
+ if (!isOpen)
60
+ return null;
61
+ return (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 }) }));
62
+ }
63
+
64
+ function SearchBox({ value, onChange, onKeyDown, placeholder = 'Search documentation...', isLoading = false, onClear, }) {
65
+ const inputRef = useRef(null);
66
+ // Focus input when mounted
67
+ useEffect(() => {
68
+ if (inputRef.current) {
69
+ inputRef.current.focus();
70
+ }
71
+ }, []);
72
+ const handleChange = (event) => {
73
+ onChange(event.target.value);
74
+ };
75
+ const handleClear = () => {
76
+ onChange('');
77
+ onClear?.();
78
+ inputRef.current?.focus();
79
+ };
80
+ 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" }) }) }))] }));
81
+ }
82
+
83
+ /**
84
+ * Renders text with highlighted matches.
85
+ * Expects highlightedValue to contain <mark> tags around matches.
86
+ */
87
+ function Highlight({ value, highlightedValue }) {
88
+ // If no highlighted value, render plain text
89
+ if (!highlightedValue) {
90
+ return jsx("span", { children: value });
91
+ }
92
+ // Parse the highlighted value and render with mark tags
93
+ // The API returns text with <mark> tags for highlighting
94
+ return (jsx("span", { className: "seekora-docsearch-highlight", dangerouslySetInnerHTML: { __html: sanitizeHtml(highlightedValue) } }));
95
+ }
96
+ /**
97
+ * Sanitize HTML to only allow <mark> tags for highlighting.
98
+ * This prevents XSS attacks while allowing highlighting.
99
+ */
100
+ function sanitizeHtml(html) {
101
+ // First, escape all HTML
102
+ const escaped = html
103
+ .replace(/&/g, '&amp;')
104
+ .replace(/</g, '&lt;')
105
+ .replace(/>/g, '&gt;')
106
+ .replace(/"/g, '&quot;')
107
+ .replace(/'/g, '&#039;');
108
+ // Then, restore only <mark> and </mark> tags
109
+ return escaped
110
+ .replace(/&lt;mark&gt;/g, '<mark>')
111
+ .replace(/&lt;\/mark&gt;/g, '</mark>')
112
+ // Also handle ais-highlight format (Algolia-style)
113
+ .replace(/&lt;ais-highlight&gt;/g, '<mark>')
114
+ .replace(/&lt;\/ais-highlight&gt;/g, '</mark>')
115
+ // Handle em tags too
116
+ .replace(/&lt;em&gt;/g, '<mark>')
117
+ .replace(/&lt;\/em&gt;/g, '</mark>');
118
+ }
119
+ /**
120
+ * Truncate content and highlight the matched portion.
121
+ */
122
+ function truncateAroundMatch(content, maxLength = 150) {
123
+ // If content contains a mark tag, try to center around it
124
+ const markIndex = content.indexOf('<mark>');
125
+ if (markIndex === -1 || content.length <= maxLength) {
126
+ // No highlight or short enough, just truncate from start
127
+ if (content.length <= maxLength) {
128
+ return content;
129
+ }
130
+ return content.slice(0, maxLength) + '...';
131
+ }
132
+ // Center around the first match
133
+ const halfLength = Math.floor(maxLength / 2);
134
+ let start = Math.max(0, markIndex - halfLength);
135
+ let end = Math.min(content.length, markIndex + halfLength);
136
+ // Adjust to not cut in the middle of a word
137
+ if (start > 0) {
138
+ const spaceIndex = content.indexOf(' ', start);
139
+ if (spaceIndex !== -1 && spaceIndex < markIndex) {
140
+ start = spaceIndex + 1;
141
+ }
142
+ }
143
+ if (end < content.length) {
144
+ const spaceIndex = content.lastIndexOf(' ', end);
145
+ if (spaceIndex !== -1 && spaceIndex > markIndex) {
146
+ end = spaceIndex;
147
+ }
148
+ }
149
+ let result = content.slice(start, end);
150
+ if (start > 0) {
151
+ result = '...' + result;
152
+ }
153
+ if (end < content.length) {
154
+ result = result + '...';
155
+ }
156
+ return result;
157
+ }
158
+
159
+ function Hit({ hit, isSelected, onClick, onMouseEnter, openInNewTab }) {
160
+ // Determine if this is a full hit or a suggestion
161
+ const isFullHit = 'objectID' in hit;
162
+ // Get hierarchy breadcrumb
163
+ const hierarchy = hit.hierarchy || {};
164
+ const suggestion = hit;
165
+ // For admin pages, use category and parentTitle; for docs, use hierarchy
166
+ const breadcrumb = suggestion.parentTitle
167
+ ? `${suggestion.category || ''} › ${suggestion.parentTitle}`.replace(/^› /, '')
168
+ : suggestion.category
169
+ ? suggestion.category
170
+ : [hierarchy.lvl0, hierarchy.lvl1, hierarchy.lvl2]
171
+ .filter(Boolean)
172
+ .join(' › ');
173
+ // Get title
174
+ const title = hit.title || hierarchy.lvl1 || hierarchy.lvl0 || 'Untitled';
175
+ // Get highlighted values
176
+ let highlightedTitle = title;
177
+ let highlightedContent = hit.content || suggestion.description || '';
178
+ if (isFullHit) {
179
+ const fullHit = hit;
180
+ if (fullHit._highlightResult) {
181
+ highlightedTitle = fullHit._highlightResult.title?.value || title;
182
+ highlightedContent = fullHit._highlightResult.content?.value || hit.content || '';
183
+ }
184
+ }
185
+ else {
186
+ if (suggestion.highlight) {
187
+ highlightedTitle = suggestion.highlight.title || title;
188
+ highlightedContent = suggestion.highlight.content || hit.content || suggestion.description || '';
189
+ }
190
+ }
191
+ // Truncate content around match
192
+ const displayContent = highlightedContent
193
+ ? truncateAroundMatch(highlightedContent, 120)
194
+ : '';
195
+ // Determine URL - use route for admin pages if url not set
196
+ const url = hit.url || suggestion.route || '#';
197
+ return (jsxs("a", { href: url, className: `seekora-docsearch-hit ${isSelected ? 'seekora-docsearch-hit--selected' : ''}`, onClick: (e) => {
198
+ e.preventDefault();
199
+ onClick();
200
+ }, onMouseEnter: onMouseEnter, role: "option", "aria-selected": isSelected, target: openInNewTab ? '_blank' : undefined, rel: openInNewTab ? 'noopener noreferrer' : undefined, children: [jsx("div", { className: "seekora-docsearch-hit-icon", children: jsx(HitIcon, { type: getHitType(hit) }) }), jsxs("div", { className: "seekora-docsearch-hit-content", children: [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" }) })) })] }));
201
+ }
202
+ function getHitType(hit) {
203
+ if ('section_level' in hit) {
204
+ const level = hit.section_level;
205
+ if (level === 0)
206
+ return 'page';
207
+ if (level <= 2)
208
+ return 'section';
209
+ return 'content';
210
+ }
211
+ return 'page';
212
+ }
213
+ function HitIcon({ type }) {
214
+ switch (type) {
215
+ case 'page':
216
+ 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" })] }));
217
+ case 'section':
218
+ 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" }) }));
219
+ case 'content':
220
+ 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" }) }));
221
+ }
222
+ }
223
+
224
+ function Results({ hits, groupedHits, selectedIndex, onSelect, onHover, query, isLoading, error, translations = {}, sources = [], }) {
225
+ const listRef = useRef(null);
226
+ // Scroll selected item into view
227
+ useEffect(() => {
228
+ if (listRef.current && hits.length > 0) {
229
+ const selectedItem = listRef.current.children[selectedIndex];
230
+ if (selectedItem) {
231
+ selectedItem.scrollIntoView({ block: 'nearest' });
232
+ }
233
+ }
234
+ }, [selectedIndex, hits.length]);
235
+ // Empty state - no query
236
+ if (!query) {
237
+ return (jsx("div", { className: "seekora-docsearch-empty", children: jsx("p", { className: "seekora-docsearch-empty-text", children: translations.searchPlaceholder || 'Type to start searching...' }) }));
238
+ }
239
+ // Loading state
240
+ if (isLoading && hits.length === 0) {
241
+ 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...' })] }));
242
+ }
243
+ // Error state
244
+ if (error) {
245
+ return (jsx("div", { className: "seekora-docsearch-error", children: jsx("p", { className: "seekora-docsearch-error-text", children: translations.errorText || error }) }));
246
+ }
247
+ // No results
248
+ if (hits.length === 0 && query) {
249
+ 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}"` }) }));
250
+ }
251
+ // Use multi-source grouped hits if available, otherwise group by category
252
+ const displayGroups = groupedHits && groupedHits.length > 0
253
+ ? groupedHits.map(g => ({
254
+ category: g.source.name,
255
+ sourceId: g.source.id,
256
+ openInNewTab: g.source.openInNewTab,
257
+ hits: g.items,
258
+ }))
259
+ : groupHitsByCategory(hits).map(g => ({
260
+ category: g.category,
261
+ sourceId: 'default',
262
+ openInNewTab: false,
263
+ hits: g.hits,
264
+ }));
265
+ 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) => {
266
+ const globalIndex = getGlobalIndexMulti(displayGroups, groupIndex, hitIndex);
267
+ return (jsx("li", { children: jsx(Hit, { hit: hit, isSelected: globalIndex === selectedIndex, onClick: () => onSelect(hit), onMouseEnter: () => onHover(globalIndex), openInNewTab: group.openInNewTab }) }, getHitKey(hit, hitIndex)));
268
+ }) })] }, group.sourceId + '-' + groupIndex))) }) }));
269
+ }
270
+ function groupHitsByCategory(hits) {
271
+ const groups = new Map();
272
+ for (const hit of hits) {
273
+ const category = hit.hierarchy?.lvl0 || '';
274
+ if (!groups.has(category)) {
275
+ groups.set(category, []);
276
+ }
277
+ groups.get(category).push(hit);
278
+ }
279
+ return Array.from(groups.entries()).map(([category, hits]) => ({
280
+ category: category || null,
281
+ hits,
282
+ }));
283
+ }
284
+ function getGlobalIndexMulti(groups, groupIndex, hitIndex) {
285
+ let index = 0;
286
+ for (let i = 0; i < groupIndex; i++) {
287
+ index += groups[i].hits.length;
288
+ }
289
+ return index + hitIndex;
290
+ }
291
+ function getHitKey(hit, index) {
292
+ if ('objectID' in hit) {
293
+ return hit.objectID;
294
+ }
295
+ return `suggestion-${hit.url}-${index}`;
296
+ }
297
+
298
+ function Footer({ translations = {} }) {
299
+ 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, {}) })] })] }));
300
+ }
301
+ function SekoraLogo() {
302
+ 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" }) }));
303
+ }
304
+
305
+ function useKeyboard(options) {
306
+ const { isOpen, onOpen, onClose, onSelectNext, onSelectPrev, onEnter, disableShortcut = false, shortcutKey = 'k', } = options;
307
+ const handleGlobalKeyDown = useCallback((event) => {
308
+ // Open modal with Cmd/Ctrl + K
309
+ if (!disableShortcut && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcutKey) {
310
+ event.preventDefault();
311
+ if (isOpen) {
312
+ onClose();
313
+ }
314
+ else {
315
+ onOpen();
316
+ }
317
+ return;
318
+ }
319
+ // Forward slash to open (when not in an input)
320
+ if (!disableShortcut && event.key === '/' && !isOpen) {
321
+ const target = event.target;
322
+ const isInput = target.tagName === 'INPUT' ||
323
+ target.tagName === 'TEXTAREA' ||
324
+ target.isContentEditable;
325
+ if (!isInput) {
326
+ event.preventDefault();
327
+ onOpen();
328
+ }
329
+ }
330
+ }, [isOpen, onOpen, onClose, disableShortcut, shortcutKey]);
331
+ const handleModalKeyDown = useCallback((event) => {
332
+ switch (event.key) {
333
+ case 'Escape':
334
+ event.preventDefault();
335
+ onClose();
336
+ break;
337
+ case 'ArrowDown':
338
+ event.preventDefault();
339
+ onSelectNext();
340
+ break;
341
+ case 'ArrowUp':
342
+ event.preventDefault();
343
+ onSelectPrev();
344
+ break;
345
+ case 'Enter':
346
+ event.preventDefault();
347
+ onEnter();
348
+ break;
349
+ case 'Tab':
350
+ // Allow Tab but prevent default to keep focus in modal
351
+ if (event.shiftKey) {
352
+ onSelectPrev();
353
+ }
354
+ else {
355
+ onSelectNext();
356
+ }
357
+ event.preventDefault();
358
+ break;
359
+ }
360
+ }, [onClose, onSelectNext, onSelectPrev, onEnter]);
361
+ // Global keyboard listener
362
+ useEffect(() => {
363
+ document.addEventListener('keydown', handleGlobalKeyDown);
364
+ return () => {
365
+ document.removeEventListener('keydown', handleGlobalKeyDown);
366
+ };
367
+ }, [handleGlobalKeyDown]);
368
+ return {
369
+ handleModalKeyDown,
370
+ };
371
+ }
372
+ /**
373
+ * Get the keyboard shortcut display text based on platform
374
+ */
375
+ function getShortcutText(key = 'K') {
376
+ if (typeof navigator === 'undefined') {
377
+ return `⌘${key}`;
378
+ }
379
+ const isMac = navigator.platform.toLowerCase().includes('mac');
380
+ return isMac ? `⌘${key}` : `Ctrl+${key}`;
381
+ }
382
+
383
+ function DocSearchButton({ onClick, placeholder = 'Search documentation...', }) {
384
+ const shortcutText = getShortcutText('K');
385
+ 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 }) })] }));
386
+ }
387
+
388
+ const initialState = {
389
+ query: '',
390
+ results: [],
391
+ suggestions: [],
392
+ groupedSuggestions: [],
393
+ isLoading: false,
394
+ error: null,
395
+ selectedIndex: 0,
396
+ mode: 'suggestions',
397
+ };
398
+ function reducer(state, action) {
399
+ switch (action.type) {
400
+ case 'SET_QUERY':
401
+ return { ...state, query: action.payload, selectedIndex: 0 };
402
+ case 'SET_RESULTS':
403
+ return { ...state, results: action.payload, mode: 'results' };
404
+ case 'SET_SUGGESTIONS':
405
+ return { ...state, suggestions: action.payload, mode: 'suggestions' };
406
+ case 'SET_GROUPED_SUGGESTIONS':
407
+ // Flatten grouped suggestions for navigation while keeping groups for display
408
+ const flatSuggestions = action.payload.flatMap(group => group.items.map(item => ({ ...item, _source: group.source.id })));
409
+ return {
410
+ ...state,
411
+ groupedSuggestions: action.payload,
412
+ suggestions: flatSuggestions,
413
+ mode: 'suggestions'
414
+ };
415
+ case 'SET_LOADING':
416
+ return { ...state, isLoading: action.payload };
417
+ case 'SET_ERROR':
418
+ return { ...state, error: action.payload };
419
+ case 'SET_SELECTED_INDEX':
420
+ return { ...state, selectedIndex: action.payload };
421
+ case 'SELECT_NEXT': {
422
+ const items = state.mode === 'results' ? state.results : state.suggestions;
423
+ const maxIndex = items.length - 1;
424
+ return {
425
+ ...state,
426
+ selectedIndex: state.selectedIndex >= maxIndex ? 0 : state.selectedIndex + 1,
427
+ };
428
+ }
429
+ case 'SELECT_PREV': {
430
+ const items = state.mode === 'results' ? state.results : state.suggestions;
431
+ const maxIndex = items.length - 1;
432
+ return {
433
+ ...state,
434
+ selectedIndex: state.selectedIndex <= 0 ? maxIndex : state.selectedIndex - 1,
435
+ };
436
+ }
437
+ case 'SET_MODE':
438
+ return { ...state, mode: action.payload, selectedIndex: 0 };
439
+ case 'RESET':
440
+ return initialState;
441
+ default:
442
+ return state;
443
+ }
444
+ }
445
+ function useDocSearch(options) {
446
+ const { apiEndpoint, apiKey, sources, maxResults = 10, debounceMs = 200 } = options;
447
+ // Build sources array from either explicit sources or legacy single endpoint
448
+ const searchSources = sources || (apiEndpoint ? [{
449
+ id: 'default',
450
+ name: 'Results',
451
+ endpoint: apiEndpoint,
452
+ apiKey,
453
+ maxResults,
454
+ }] : []);
455
+ const [state, dispatch] = useReducer(reducer, initialState);
456
+ const abortControllersRef = useRef(new Map());
457
+ const debounceTimerRef = useRef(null);
458
+ // Default transform function for API responses
459
+ const defaultTransform = (data, sourceId) => {
460
+ const items = data.data?.suggestions || data.data?.results || data.suggestions || data.results || data.hits || [];
461
+ return items.map((item) => ({
462
+ url: item.url || item.route || '',
463
+ title: item.title?.replace?.(/<\/?mark>/g, '') || item.title || '',
464
+ content: item.content?.replace?.(/<\/?mark>/g, '')?.substring?.(0, 100) || item.description || '',
465
+ description: item.description || item.content?.substring?.(0, 100) || '',
466
+ category: item.category || item.hierarchy?.lvl0 || '',
467
+ hierarchy: item.hierarchy,
468
+ route: item.route,
469
+ parentTitle: item.parent_title || item.parentTitle,
470
+ _source: sourceId,
471
+ }));
472
+ };
473
+ const fetchFromSource = useCallback(async (source, query, signal) => {
474
+ const minLength = source.minQueryLength ?? 1;
475
+ if (query.length < minLength) {
476
+ return [];
477
+ }
478
+ try {
479
+ const url = new URL(source.endpoint);
480
+ url.searchParams.set('query', query);
481
+ url.searchParams.set('limit', String(source.maxResults || 8));
482
+ const headers = {
483
+ 'Content-Type': 'application/json',
484
+ };
485
+ if (source.apiKey) {
486
+ headers['X-Docs-API-Key'] = source.apiKey;
487
+ }
488
+ const response = await fetch(url.toString(), {
489
+ method: 'GET',
490
+ headers,
491
+ signal,
492
+ });
493
+ if (!response.ok) {
494
+ console.warn(`Search source ${source.id} failed: ${response.statusText}`);
495
+ return [];
496
+ }
497
+ const data = await response.json();
498
+ // Use custom transform or default
499
+ if (source.transformResults) {
500
+ return source.transformResults(data).map(item => ({ ...item, _source: source.id }));
501
+ }
502
+ return defaultTransform(data, source.id);
503
+ }
504
+ catch (error) {
505
+ if (error instanceof Error && error.name === 'AbortError') {
506
+ throw error; // Re-throw abort errors
507
+ }
508
+ console.warn(`Search source ${source.id} error:`, error);
509
+ return [];
510
+ }
511
+ }, []);
512
+ const fetchSuggestions = useCallback(async (query) => {
513
+ if (!query.trim()) {
514
+ dispatch({ type: 'SET_GROUPED_SUGGESTIONS', payload: [] });
515
+ return;
516
+ }
517
+ // Cancel all previous requests
518
+ abortControllersRef.current.forEach(controller => controller.abort());
519
+ abortControllersRef.current.clear();
520
+ dispatch({ type: 'SET_LOADING', payload: true });
521
+ dispatch({ type: 'SET_ERROR', payload: null });
522
+ try {
523
+ // Fetch from all sources in parallel
524
+ const results = await Promise.all(searchSources.map(async (source) => {
525
+ const controller = new AbortController();
526
+ abortControllersRef.current.set(source.id, controller);
527
+ const items = await fetchFromSource(source, query, controller.signal);
528
+ return { source, items };
529
+ }));
530
+ // Filter out empty results and dispatch grouped
531
+ const groupedResults = results.filter(r => r.items.length > 0);
532
+ dispatch({ type: 'SET_GROUPED_SUGGESTIONS', payload: groupedResults });
533
+ }
534
+ catch (error) {
535
+ if (error instanceof Error && error.name === 'AbortError') {
536
+ return; // Request was cancelled
537
+ }
538
+ dispatch({
539
+ type: 'SET_ERROR',
540
+ payload: error instanceof Error ? error.message : 'Search failed',
541
+ });
542
+ }
543
+ finally {
544
+ dispatch({ type: 'SET_LOADING', payload: false });
545
+ }
546
+ }, [searchSources, fetchFromSource]);
547
+ const search = useCallback(async (query) => {
548
+ // For now, search behaves same as fetchSuggestions
549
+ await fetchSuggestions(query);
550
+ }, [fetchSuggestions]);
551
+ const setQuery = useCallback((query) => {
552
+ dispatch({ type: 'SET_QUERY', payload: query });
553
+ // Clear existing debounce timer
554
+ if (debounceTimerRef.current) {
555
+ clearTimeout(debounceTimerRef.current);
556
+ }
557
+ // Debounce the search
558
+ debounceTimerRef.current = setTimeout(() => {
559
+ fetchSuggestions(query);
560
+ }, debounceMs);
561
+ }, [fetchSuggestions, debounceMs]);
562
+ const selectNext = useCallback(() => {
563
+ dispatch({ type: 'SELECT_NEXT' });
564
+ }, []);
565
+ const selectPrev = useCallback(() => {
566
+ dispatch({ type: 'SELECT_PREV' });
567
+ }, []);
568
+ const setSelectedIndex = useCallback((index) => {
569
+ dispatch({ type: 'SET_SELECTED_INDEX', payload: index });
570
+ }, []);
571
+ const reset = useCallback(() => {
572
+ if (abortControllerRef.current) {
573
+ abortControllerRef.current.abort();
574
+ }
575
+ if (debounceTimerRef.current) {
576
+ clearTimeout(debounceTimerRef.current);
577
+ }
578
+ dispatch({ type: 'RESET' });
579
+ }, []);
580
+ const getSelectedItem = useCallback(() => {
581
+ const items = state.mode === 'results' ? state.results : state.suggestions;
582
+ return items[state.selectedIndex] || null;
583
+ }, [state.mode, state.results, state.suggestions, state.selectedIndex]);
584
+ // Cleanup on unmount
585
+ useEffect(() => {
586
+ return () => {
587
+ if (abortControllerRef.current) {
588
+ abortControllerRef.current.abort();
589
+ }
590
+ if (debounceTimerRef.current) {
591
+ clearTimeout(debounceTimerRef.current);
592
+ }
593
+ };
594
+ }, []);
595
+ return {
596
+ ...state,
597
+ sources: searchSources,
598
+ setQuery,
599
+ search,
600
+ fetchSuggestions,
601
+ selectNext,
602
+ selectPrev,
603
+ setSelectedIndex,
604
+ reset,
605
+ getSelectedItem,
606
+ };
607
+ }
608
+
609
+ function DocSearch({ apiEndpoint, apiKey, sources, indexName, placeholder = 'Search documentation...', maxResults = 10, debounceMs = 200, onSelect, onClose, translations = {}, renderButton = true, buttonComponent: ButtonComponent = DocSearchButton, initialOpen = false, disableShortcut = false, shortcutKey = 'k', }) {
610
+ const [isOpen, setIsOpen] = useState(initialOpen);
611
+ const { query, suggestions, groupedSuggestions, results, isLoading, error, selectedIndex, mode, sources: searchSources, setQuery, selectNext, selectPrev, setSelectedIndex, reset, getSelectedItem, } = useDocSearch({
612
+ apiEndpoint,
613
+ apiKey,
614
+ sources,
615
+ maxResults,
616
+ debounceMs,
617
+ });
618
+ const handleOpen = useCallback(() => {
619
+ setIsOpen(true);
620
+ }, []);
621
+ const handleClose = useCallback(() => {
622
+ setIsOpen(false);
623
+ reset();
624
+ onClose?.();
625
+ }, [reset, onClose]);
626
+ const handleSelect = useCallback((hit) => {
627
+ if (onSelect) {
628
+ onSelect(hit);
629
+ }
630
+ else {
631
+ // Default behavior: navigate to URL
632
+ window.location.href = hit.url;
633
+ }
634
+ handleClose();
635
+ }, [onSelect, handleClose]);
636
+ const handleEnter = useCallback(() => {
637
+ const selectedItem = getSelectedItem();
638
+ if (selectedItem) {
639
+ handleSelect(selectedItem);
640
+ }
641
+ }, [getSelectedItem, handleSelect]);
642
+ const { handleModalKeyDown } = useKeyboard({
643
+ isOpen,
644
+ onOpen: handleOpen,
645
+ onClose: handleClose,
646
+ onSelectNext: selectNext,
647
+ onSelectPrev: selectPrev,
648
+ onEnter: handleEnter,
649
+ disableShortcut,
650
+ shortcutKey,
651
+ });
652
+ const handleKeyDown = useCallback((event) => {
653
+ handleModalKeyDown(event);
654
+ }, [handleModalKeyDown]);
655
+ // Get the items to display (suggestions or full results)
656
+ const displayHits = mode === 'results' ? results : suggestions;
657
+ 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 })] }) })] }));
658
+ }
659
+
660
+ export { DocSearch, DocSearchButton, Footer, Highlight, Hit, Modal, Results, SearchBox, getShortcutText, truncateAroundMatch, useDocSearch, useKeyboard };
661
+ //# sourceMappingURL=index.esm.js.map