| 1 |
import React, { useState, useEffect, useRef } from "react"; |
| 2 |
import apiFetch from "@wordpress/api-fetch"; |
| 3 |
import LayoutOne from "./LayoutOne"; |
| 4 |
import LayoutTwo from "./LayoutTwo"; |
| 5 |
import LayoutSidebar from "./LayoutSidebar"; |
| 6 |
import SearchModal from "./SearchModal"; |
| 7 |
|
| 8 |
const SearchField = (props) => { |
| 9 |
const [searchQuery, setSearchQuery] = useState(""); |
| 10 |
const [isModalOpen, setIsModalOpen] = useState(false); |
| 11 |
const [docCategories, setDocCategories] = useState([]); |
| 12 |
const [popularDocs, setPopularDocs] = useState([]); |
| 13 |
const [recentFaqs, setRecentFaqs] = useState([]); |
| 14 |
|
| 15 |
// Extracting attributes from props |
| 16 |
const { |
| 17 |
layout, |
| 18 |
placeholder, |
| 19 |
heading, |
| 20 |
subheading, |
| 21 |
headingTag, |
| 22 |
subheadingTag, |
| 23 |
buttonText, |
| 24 |
numberofdocs, |
| 25 |
numberoffaqs, |
| 26 |
doc_ids, |
| 27 |
doc_categories_ids, |
| 28 |
faq_categories_ids, |
| 29 |
searchButton, |
| 30 |
enable_docs_search, |
| 31 |
enable_faq_search, |
| 32 |
enable_ai_powered_search, |
| 33 |
kbBasedSearch, |
| 34 |
topWrapperRef = null |
| 35 |
} = props; |
| 36 |
|
| 37 |
const modalRef = useRef(null); // Ref to keep track of the modal element |
| 38 |
|
| 39 |
useEffect(() => { |
| 40 |
const handleKeyDown = (e) => { |
| 41 |
if (e.key === "Escape") { |
| 42 |
setIsModalOpen(false); |
| 43 |
} |
| 44 |
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { |
| 45 |
setIsModalOpen(true); |
| 46 |
} |
| 47 |
}; |
| 48 |
|
| 49 |
document.addEventListener("keydown", handleKeyDown); |
| 50 |
return () => { |
| 51 |
document.removeEventListener("keydown", handleKeyDown); |
| 52 |
}; |
| 53 |
}, []); |
| 54 |
|
| 55 |
useEffect(() => { |
| 56 |
const fetchDocCategories = async () => { |
| 57 |
try { |
| 58 |
const namespace = 'betterdocs/v1/'; |
| 59 |
let relativePath = `get-terms?taxonomy=doc_category`; |
| 60 |
|
| 61 |
// Add KB filter if provided |
| 62 |
if (kbBasedSearch) { |
| 63 |
relativePath += `&knowledge_base=${encodeURIComponent(kbBasedSearch)}`; |
| 64 |
} |
| 65 |
|
| 66 |
const categories = await apiFetch({ |
| 67 |
path: `${namespace}${relativePath}`, |
| 68 |
}); |
| 69 |
setDocCategories(categories); |
| 70 |
} catch (error) { |
| 71 |
console.error("Error fetching doc categories:", error); |
| 72 |
} |
| 73 |
}; |
| 74 |
|
| 75 |
fetchDocCategories(); |
| 76 |
}, [kbBasedSearch]); |
| 77 |
|
| 78 |
useEffect(() => { |
| 79 |
const fetchInitialResults = async () => { |
| 80 |
try { |
| 81 |
const popularDocs = await apiFetch({ |
| 82 |
path: `betterdocs/v1/search?initial=true&per_page=${numberofdocs}${ |
| 83 |
doc_ids ? `&doc_ids=${doc_ids}` : "" |
| 84 |
}${ |
| 85 |
doc_categories_ids |
| 86 |
? `&doc_categories_ids=${doc_categories_ids}` |
| 87 |
: "" |
| 88 |
}${ |
| 89 |
kbBasedSearch |
| 90 |
? `&knowledge_base=${encodeURIComponent(kbBasedSearch)}` |
| 91 |
: "" |
| 92 |
}`, |
| 93 |
}); |
| 94 |
setPopularDocs(popularDocs); |
| 95 |
|
| 96 |
const recentFaqs = await apiFetch({ |
| 97 |
path: `betterdocs/v1/search?initial=true&post_type=betterdocs_faq&per_page=${numberoffaqs}${ |
| 98 |
faq_categories_ids |
| 99 |
? `&faq_categories_ids=${faq_categories_ids}` |
| 100 |
: "" |
| 101 |
}`, |
| 102 |
}); |
| 103 |
setRecentFaqs(recentFaqs); |
| 104 |
} catch (error) { |
| 105 |
console.error("Error fetching initial results:", error); |
| 106 |
} |
| 107 |
}; |
| 108 |
|
| 109 |
fetchInitialResults(); |
| 110 |
}, [numberofdocs, numberoffaqs, doc_ids, doc_categories_ids, faq_categories_ids, kbBasedSearch]); |
| 111 |
|
| 112 |
// Use stopPropagation on the trigger so that the click that opens the modal |
| 113 |
// does NOT continue bubbling to document / React's event delegation. |
| 114 |
// Background: React 18 with createRoot still uses event delegation at the |
| 115 |
// root container. setState flushes cause the modal overlay to enter the DOM |
| 116 |
// before the click finishes bubbling, so React's delegation fires the |
| 117 |
// overlay's onMouseDown on the SAME click that opened the modal. |
| 118 |
const handleSearchFieldClick = (e) => { |
| 119 |
if (e && e.stopPropagation) { |
| 120 |
e.stopPropagation(); |
| 121 |
} |
| 122 |
setIsModalOpen(true); |
| 123 |
}; |
| 124 |
|
| 125 |
const closeModal = () => { |
| 126 |
setIsModalOpen(false); |
| 127 |
}; |
| 128 |
|
| 129 |
// Use mousedown (not click) on the document so that it fires BEFORE the |
| 130 |
// click event and is not affected by the trigger's stopPropagation on click. |
| 131 |
// Store the handler in a ref so the same function reference is always used |
| 132 |
// with addEventListener / removeEventListener — prevents stale-handler leak. |
| 133 |
const outsideMouseDownRef = useRef(null); |
| 134 |
|
| 135 |
useEffect(() => { |
| 136 |
outsideMouseDownRef.current = (e) => { |
| 137 |
if (modalRef.current && !modalRef.current.contains(e.target)) { |
| 138 |
setIsModalOpen(false); |
| 139 |
} |
| 140 |
}; |
| 141 |
}); // runs after every render — keeps ref current |
| 142 |
|
| 143 |
useEffect(() => { |
| 144 |
const handler = (e) => { |
| 145 |
if (outsideMouseDownRef.current) { |
| 146 |
outsideMouseDownRef.current(e); |
| 147 |
} |
| 148 |
}; |
| 149 |
|
| 150 |
if (isModalOpen) { |
| 151 |
// Use mousedown instead of click: |
| 152 |
// - fires before click, so it doesn't race with the trigger's click |
| 153 |
// - stopPropagation on the trigger's click doesn't affect mousedown |
| 154 |
document.addEventListener("mousedown", handler); |
| 155 |
} |
| 156 |
|
| 157 |
return () => { |
| 158 |
document.removeEventListener("mousedown", handler); |
| 159 |
}; |
| 160 |
}, [isModalOpen]); |
| 161 |
|
| 162 |
return ( |
| 163 |
<> |
| 164 |
{layout === "betterdocs-search-modal-sidebar" && ( |
| 165 |
<div onClick={handleSearchFieldClick}> |
| 166 |
<LayoutSidebar placeholder={placeholder} /> |
| 167 |
</div> |
| 168 |
)} |
| 169 |
|
| 170 |
{layout === "betterdocs-search-modal-archive" && ( |
| 171 |
<div onClick={handleSearchFieldClick}> |
| 172 |
<LayoutTwo |
| 173 |
placeholder={placeholder} |
| 174 |
buttonText={buttonText} |
| 175 |
searchButton={searchButton} |
| 176 |
/> |
| 177 |
</div> |
| 178 |
)} |
| 179 |
|
| 180 |
{layout === "betterdocs-search-modal-layout-1" && ( |
| 181 |
<LayoutOne |
| 182 |
handleSearchFieldClick={handleSearchFieldClick} |
| 183 |
placeholder={placeholder} |
| 184 |
heading={heading} |
| 185 |
subheading={subheading} |
| 186 |
headingTag={headingTag} |
| 187 |
subheadingTag={subheadingTag} |
| 188 |
buttonText={buttonText} |
| 189 |
searchButton={searchButton} |
| 190 |
/> |
| 191 |
)} |
| 192 |
|
| 193 |
{isModalOpen && ( |
| 194 |
<div ref={modalRef}> |
| 195 |
<SearchModal |
| 196 |
topWrapperRef={topWrapperRef} |
| 197 |
initialSearchQuery={searchQuery} |
| 198 |
closeModal={closeModal} |
| 199 |
docCategories={docCategories} |
| 200 |
popularDocs={popularDocs} |
| 201 |
recentFaqs={recentFaqs} |
| 202 |
placeholder={placeholder} |
| 203 |
enableFaqSearch={enable_faq_search} |
| 204 |
enableDocsSearch={enable_docs_search} |
| 205 |
enableAiSearchSuggestions={enable_ai_powered_search} |
| 206 |
kbBasedSearch={kbBasedSearch} |
| 207 |
/> |
| 208 |
</div> |
| 209 |
)} |
| 210 |
</> |
| 211 |
); |
| 212 |
}; |
| 213 |
|
| 214 |
export default SearchField; |
| 215 |
|