@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.
- package/README.md +108 -0
- package/dist/index.d.ts +309 -0
- package/dist/index.esm.js +661 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +674 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
|
@@ -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, '&')
|
|
104
|
+
.replace(/</g, '<')
|
|
105
|
+
.replace(/>/g, '>')
|
|
106
|
+
.replace(/"/g, '"')
|
|
107
|
+
.replace(/'/g, ''');
|
|
108
|
+
// Then, restore only <mark> and </mark> tags
|
|
109
|
+
return escaped
|
|
110
|
+
.replace(/<mark>/g, '<mark>')
|
|
111
|
+
.replace(/<\/mark>/g, '</mark>')
|
|
112
|
+
// Also handle ais-highlight format (Algolia-style)
|
|
113
|
+
.replace(/<ais-highlight>/g, '<mark>')
|
|
114
|
+
.replace(/<\/ais-highlight>/g, '</mark>')
|
|
115
|
+
// Handle em tags too
|
|
116
|
+
.replace(/<em>/g, '<mark>')
|
|
117
|
+
.replace(/<\/em>/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
|