diff --git a/.gitignore b/.gitignore
index dd90024f53..ebb4447229 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,8 @@
# Generated files
.docusaurus
+.playwright-mcp
+.claude
.cache-loader
build
claude_logs
diff --git a/kb_allowlist.json b/kb_allowlist.json
index 817c8c4088..0ce970bb8f 100644
--- a/kb_allowlist.json
+++ b/kb_allowlist.json
@@ -32,9 +32,6 @@
"11.0",
"11.1"
],
- "endpointpolicymanager": [
- "current"
- ],
"endpointprotector": [
"current"
],
diff --git a/package-lock.json b/package-lock.json
index 9bf5fc9af2..9fe815d919 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -174,7 +174,6 @@
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.34.1.tgz",
"integrity": "sha512-bt5hC9vvjaKvdvsgzfXJ42Sl3qjQqoi/FD8V7HOQgtNFhwSauZOlgLwFoUiw67sM+r7ehF7QDk5WRDgY7fAkIg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@algolia/client-common": "5.34.1",
"@algolia/requester-browser-xhr": "5.34.1",
@@ -335,7 +334,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -2202,7 +2200,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -2225,7 +2222,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -2306,7 +2302,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2670,7 +2665,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3438,7 +3432,6 @@
"resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.8.1.tgz",
"integrity": "sha512-XYrj3qnTm+o2d5ih5drCq9s63GJoM8vZ26WbLG5FZhURsNxTSXgHJcx11Qo7nWPUStCQkuqk1HvItzscCUnd4A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@docusaurus/types": "3.8.1",
"@rspack/core": "^1.3.15",
@@ -3591,7 +3584,6 @@
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz",
"integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@docusaurus/core": "3.8.1",
"@docusaurus/logger": "3.8.1",
@@ -4259,7 +4251,6 @@
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
"integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@@ -4763,7 +4754,6 @@
"resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.4.10.tgz",
"integrity": "sha512-eK3H328pihiM1323OlaClKJ9WlqgGBZpcR5AqFoWsG0KD01tKCJOeZEgtCY6paRLrsQrEJwBrLntkG0fE7WNGg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@module-federation/runtime-tools": "0.17.0",
"@rspack/binding": "1.4.10",
@@ -5005,7 +4995,6 @@
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -5110,7 +5099,6 @@
"integrity": "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.23"
@@ -6084,7 +6072,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6416,7 +6403,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6494,7 +6480,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -6540,7 +6525,6 @@
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.34.1.tgz",
"integrity": "sha512-s70HlfBgswgEdmCYkUJG8i/ULYhbkk8N9+N8JsWUwszcp7eauPEr5tIX4BY0qDGeKWQ/qZvmt4mxwTusYY23sg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@algolia/client-abtesting": "5.34.1",
"@algolia/client-analytics": "5.34.1",
@@ -7032,7 +7016,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -7316,7 +7299,6 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -8244,7 +8226,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8576,7 +8557,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.1.tgz",
"integrity": "sha512-dbeqFTLYEwlFg7UGtcZhCCG/2WayX72zK3Sq323CEX29CY81tYfVhw1MIdduCtpstB0cTOhJswWlM/OEB3Xp+Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10"
}
@@ -8998,7 +8978,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -10326,7 +10305,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -15376,7 +15354,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -16093,7 +16070,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -17009,7 +16985,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -17814,7 +17789,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -17827,7 +17801,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -17884,7 +17857,6 @@
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/react": "*"
},
@@ -17913,7 +17885,6 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -20865,7 +20836,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -21222,7 +21192,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.2.tgz",
"integrity": "sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
diff --git a/src/theme/SearchBar/index.js b/src/theme/SearchBar/index.js
new file mode 100644
index 0000000000..74df84ade4
--- /dev/null
+++ b/src/theme/SearchBar/index.js
@@ -0,0 +1,552 @@
+import React, {useCallback, useMemo, useRef, useState, useEffect} from 'react';
+import {createPortal} from 'react-dom';
+import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react';
+import Head from '@docusaurus/Head';
+import Link from '@docusaurus/Link';
+import {useHistory} from '@docusaurus/router';
+import {
+ isRegexpStringMatch,
+ useSearchLinkCreator,
+} from '@docusaurus/theme-common';
+import {
+ useAlgoliaContextualFacetFilters,
+ useSearchResultUrlProcessor,
+} from '@docusaurus/theme-search-algolia/client';
+import Translate from '@docusaurus/Translate';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import translations from '@theme/SearchTranslations';
+import {PRODUCTS} from '../../config/products';
+
+let DocSearchModal = null;
+
+function importDocSearchModalIfNeeded() {
+ if (DocSearchModal) {
+ return Promise.resolve();
+ }
+ return Promise.all([
+ import('@docsearch/react/modal'),
+ import('@docsearch/react/style'),
+ import('./styles.css'),
+ ]).then(([{DocSearchModal: Modal}]) => {
+ DocSearchModal = Modal;
+ });
+}
+
+function useNavigator({externalUrlRegex, selectedProducts}) {
+ const history = useHistory();
+ const createSearchLink = useSearchLinkCreator();
+
+ // Use useMemo instead of useState to ensure we get fresh values
+ const navigator = useMemo(() => {
+ return {
+ navigate(params) {
+ // Get the current search query directly from the DOM at navigation time
+ const input =
+ document.querySelector('.DocSearch-Input') ||
+ document.querySelector('input[type="search"]');
+ const currentQuery = input ? input.value : '';
+
+ // If we have a search query, redirect to full search results page instead
+ if (currentQuery) {
+ const baseLink = createSearchLink(currentQuery);
+ const urlParams = new URLSearchParams();
+ if (selectedProducts && selectedProducts.length > 0) {
+ urlParams.set('products', selectedProducts.join(','));
+ }
+ const searchPageUrl = urlParams.toString()
+ ? `${baseLink}&${urlParams.toString()}`
+ : baseLink;
+ history.push(searchPageUrl);
+ } else if (isRegexpStringMatch(externalUrlRegex, params.itemUrl)) {
+ window.location.href = params.itemUrl;
+ } else {
+ history.push(params.itemUrl);
+ }
+ },
+ };
+ }, [externalUrlRegex, history, createSearchLink, selectedProducts]);
+
+ return navigator;
+}
+
+function useTransformSearchClient() {
+ const {
+ siteMetadata: {docusaurusVersion},
+ } = useDocusaurusContext();
+ return useCallback(
+ (searchClient) => {
+ searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion);
+ return searchClient;
+ },
+ [docusaurusVersion],
+ );
+}
+
+function useTransformItems(props) {
+ const processSearchResultUrl = useSearchResultUrlProcessor();
+ const [transformItems] = useState(() => {
+ return (items) =>
+ props.transformItems
+ ? props.transformItems(items)
+ : items.map((item) => ({
+ ...item,
+ url: processSearchResultUrl(item.url),
+ }));
+ });
+ return transformItems;
+}
+
+function useResultsFooterComponent({closeModal, selectedProducts}) {
+ return useMemo(
+ () =>
+ ({state}) =>
+ ,
+ [closeModal, selectedProducts],
+ );
+}
+
+function Hit({hit, children}) {
+ return {children};
+}
+
+function ResultsFooter({state, onClose, selectedProducts = []}) {
+ const createSearchLink = useSearchLinkCreator();
+ const baseLink = createSearchLink(state.query);
+
+ // Add product filters as URL parameters
+ const params = new URLSearchParams();
+ if (selectedProducts.length > 0) {
+ params.set('products', selectedProducts.join(','));
+ }
+
+ const linkWithFilters = params.toString()
+ ? `${baseLink}&${params.toString()}`
+ : baseLink;
+
+ return (
+
+
+ {'See all {count} results'}
+
+
+ );
+}
+
+function useSearchParameters({contextualSearch, productFacetFilters = [], ...props}) {
+ function mergeFacetFilters(f1, f2) {
+ const normalize = (f) => (typeof f === 'string' ? [f] : f);
+ return [...normalize(f1), ...normalize(f2)];
+ }
+
+ const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters();
+
+ const configFacetFilters = props.searchParameters?.facetFilters ?? [];
+ const combinedConfigFacetFilters =
+ productFacetFilters.length > 0
+ ? mergeFacetFilters(configFacetFilters, productFacetFilters)
+ : configFacetFilters;
+
+ let facetFilters;
+ if (contextualSearch) {
+ // Use contextual filters (includes language and doc tags)
+ facetFilters = mergeFacetFilters(contextualSearchFacetFilters, combinedConfigFacetFilters);
+ } else {
+ // When contextualSearch is disabled, still include language filter but not doc tags
+ const languageFilter = contextualSearchFacetFilters.find(f => typeof f === 'string' && f.startsWith('language:'));
+ if (languageFilter && productFacetFilters.length > 0) {
+ // IMPORTANT: Don't spread productFacetFilters - it's already an array of arrays
+ // We want: ['language:en', ['product1', 'product2']] not ['language:en', 'product1', 'product2']
+ facetFilters = [languageFilter].concat(productFacetFilters);
+ } else {
+ facetFilters = combinedConfigFacetFilters;
+ }
+ }
+
+ return {
+ ...props.searchParameters,
+ facetFilters,
+ };
+}
+
+// Generate product options dynamically from PRODUCTS constant
+// Values MUST match Algolia facet values exactly
+const PRODUCT_OPTIONS = [
+ {label: 'All products', value: '__all__'},
+ ...PRODUCTS.map(product => ({
+ label: product.name,
+ value: product.name,
+ }))
+].sort((a, b) => {
+ // Keep "All products" first
+ if (a.value === '__all__') return -1;
+ if (b.value === '__all__') return 1;
+ return a.label.localeCompare(b.label);
+});
+
+// Helper to get versions for selected products
+// Multi-select dropdown component with checkboxes
+function MultiSelectDropdown({label, options, selectedValues, onChange, placeholder}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleToggle = (value) => {
+ let newSelection;
+ if (value === '__all__') {
+ newSelection = [];
+ } else {
+ if (selectedValues.includes(value)) {
+ newSelection = selectedValues.filter(v => v !== value);
+ } else {
+ newSelection = [...selectedValues, value];
+ }
+ }
+ onChange(newSelection);
+ };
+
+ const displayText = selectedValues.length === 0
+ ? placeholder
+ : `${selectedValues.length} selected`;
+
+ return (
+
+
+ {isOpen && (
+
+ {options.map((opt) => {
+ const isSelected = opt.value === '__all__'
+ ? selectedValues.length === 0
+ : selectedValues.includes(opt.value);
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function DocSearch({externalUrlRegex, onModalOpen, selectedProducts, ...props}) {
+ const navigator = useNavigator({externalUrlRegex, selectedProducts});
+ const searchParameters = useSearchParameters({...props});
+ const transformItems = useTransformItems(props);
+ const transformSearchClient = useTransformSearchClient();
+ const searchContainer = useRef(null);
+ const searchButtonRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [initialQuery, setInitialQuery] = useState(undefined);
+
+ const prepareSearchContainer = useCallback(() => {
+ if (!searchContainer.current) {
+ const divElement = document.createElement('div');
+ searchContainer.current = divElement;
+ document.body.insertBefore(divElement, document.body.firstChild);
+ }
+ }, []);
+
+ const openModal = useCallback(() => {
+ prepareSearchContainer();
+ importDocSearchModalIfNeeded().then(() => {
+ setIsOpen(true);
+ // Let React render the modal, then caller can locate DOM nodes
+ setTimeout(() => onModalOpen?.(), 0);
+ });
+ }, [prepareSearchContainer, onModalOpen]);
+
+ const closeModal = useCallback(() => {
+ setIsOpen(false);
+ searchButtonRef.current?.focus();
+ setInitialQuery(undefined);
+ }, []);
+
+ const handleInput = useCallback(
+ (event) => {
+ if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
+ return;
+ }
+ event.preventDefault();
+ setInitialQuery(event.key);
+ openModal();
+ },
+ [openModal],
+ );
+
+ const resultsFooterComponent = useResultsFooterComponent({
+ closeModal,
+ selectedProducts,
+ });
+
+ useDocSearchKeyboardEvents({
+ isOpen,
+ onOpen: openModal,
+ onClose: closeModal,
+ onInput: handleInput,
+ searchButtonRef,
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+ {isOpen &&
+ DocSearchModal &&
+ searchContainer.current &&
+ createPortal(
+ ,
+ searchContainer.current,
+ )}
+ >
+ );
+}
+
+export default function SearchBar() {
+ const {siteConfig} = useDocusaurusContext();
+
+ // Store the current search query to preserve it across filter changes
+ const searchQueryRef = useRef('');
+
+ // Multi-select state for products
+ const [selectedProducts, setSelectedProducts] = useState(() => {
+ if (typeof window === 'undefined') return [];
+ const saved = localStorage.getItem('docs_product_filter');
+ try {
+ return saved ? JSON.parse(saved) : [];
+ } catch {
+ return [];
+ }
+ });
+
+ // Generate facetFilters for products
+ const productFacetFilters = useMemo(() => {
+ const filters = [];
+
+ // Add product filters (OR logic - any of the selected products)
+ if (selectedProducts.length > 0) {
+ const productFilters = selectedProducts.map(p => `product_name:${p}`);
+ filters.push(productFilters); // Array within array = OR logic
+ }
+
+ return filters;
+ }, [selectedProducts]);
+
+ // Keep track of the search input value
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const input =
+ document.querySelector('.DocSearch-Input') ||
+ document.querySelector('input[type="search"]');
+ if (input) {
+ searchQueryRef.current = input.value;
+ }
+ }, 100);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // Helper to refresh search - preserve query and wait for user action
+ const refreshSearch = useCallback(() => {
+ const input =
+ document.querySelector('.DocSearch-Input') ||
+ document.querySelector('input[type="search"]');
+
+ if (input && input.value) {
+ searchQueryRef.current = input.value;
+
+ // Try multiple approaches to trigger search
+ const query = input.value;
+
+ // Approach 1: Simulate typing by adding/removing character
+ setTimeout(() => {
+ if (input) {
+ input.value = query + ' ';
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+
+ setTimeout(() => {
+ input.value = query;
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ }, 50);
+ }
+ }, 50);
+ }
+ }, []);
+
+ const onChangeProducts = useCallback((newProducts) => {
+ setSelectedProducts(newProducts);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('docs_product_filter', JSON.stringify(newProducts));
+ }
+ // Don't auto-refresh - let user retype or press enter
+ }, []);
+
+ // This is where we will portal the filters into the modal DOM.
+ const [modalHeaderEl, setModalHeaderEl] = useState(null);
+
+ const onModalOpen = useCallback(() => {
+ // Try to locate the header/searchbar area in the DocSearch modal.
+ // DocSearch v3 uses these classnames.
+ const el =
+ document.querySelector('.DocSearch-SearchBar') ||
+ document.querySelector('.DocSearch-Form') ||
+ document.querySelector('.DocSearch-Modal');
+
+ setModalHeaderEl(el || null);
+ }, []);
+
+ // When modalHeaderEl exists, insert filters BEFORE the form input if possible.
+ // We target .DocSearch-Form when present, otherwise the container itself.
+ const portalTarget = useMemo(() => {
+ if (!modalHeaderEl) return null;
+ return (
+ modalHeaderEl.querySelector('.DocSearch-Form') ||
+ modalHeaderEl
+ );
+ }, [modalHeaderEl]);
+
+ // Disable contextualSearch when products are selected to allow cross-product searching
+ const contextualSearch = selectedProducts.length === 0;
+
+ return (
+ <>
+
+
+ {portalTarget &&
+ createPortal(
+ // Wrapper to align with DocSearch input
+
+
+
,
+ portalTarget,
+ )}
+ >
+ );
+}
diff --git a/src/theme/SearchBar/styles.css b/src/theme/SearchBar/styles.css
new file mode 100644
index 0000000000..fdf8dff9a4
--- /dev/null
+++ b/src/theme/SearchBar/styles.css
@@ -0,0 +1,14 @@
+:root {
+ --docsearch-primary-color: var(--ifm-color-primary);
+ --docsearch-text-color: var(--ifm-font-color-base);
+}
+
+.DocSearch-Button {
+ margin: 0;
+ transition: all var(--ifm-transition-fast)
+ var(--ifm-transition-timing-default);
+}
+
+.DocSearch-Container {
+ z-index: calc(var(--ifm-z-index-fixed) + 1);
+}
diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js
new file mode 100644
index 0000000000..b6a59552af
--- /dev/null
+++ b/src/theme/SearchPage/index.js
@@ -0,0 +1,1009 @@
+/**
+ * Custom SearchPage that respects product and version filters from URL parameters
+ */
+
+import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
+import {useHistory, useLocation} from '@docusaurus/router';
+import clsx from 'clsx';
+import algoliaSearchHelper from 'algoliasearch-helper';
+import {liteClient} from 'algoliasearch/lite';
+import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
+import Head from '@docusaurus/Head';
+import Link from '@docusaurus/Link';
+import {HtmlClassNameProvider, PageMetadata, usePluralForm} from '@docusaurus/theme-common';
+import Translate, {translate} from '@docusaurus/Translate';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import {useAlgoliaThemeConfig, useSearchResultUrlProcessor} from '@docusaurus/theme-search-algolia/client';
+import Layout from '@theme/Layout';
+import Heading from '@theme/Heading';
+import {PRODUCTS} from '../../config/products';
+import styles from './styles.module.css';
+
+// Safely strip HTML tags to plain text
+function stripHtmlTagsToText(input) {
+ if (!input) {
+ return '';
+ }
+ if (ExecutionEnvironment.canUseDOM) {
+ const container = document.createElement('div');
+ container.innerHTML = input;
+ return container.textContent || container.innerText || '';
+ }
+ // Fallback for non-DOM environments (SSR): remove all angle brackets to prevent tags
+ // This avoids multi-character tag patterns and ensures no HTML elements can be formed.
+ return input.replace(/[<>]/g, '');
+}
+
+// Generate product options from PRODUCTS config
+const PRODUCT_OPTIONS = [
+ {label: 'All products', value: '__all__'},
+ ...PRODUCTS.map(product => ({
+ label: product.name,
+ value: product.name,
+ }))
+].sort((a, b) => {
+ if (a.value === '__all__') return -1;
+ if (b.value === '__all__') return 1;
+ return a.label.localeCompare(b.label);
+});
+
+// Checkbox-based multi-select component
+function MultiSelect({label, options, selectedValues, onChange}) {
+ // options[0] is the "All products" sentinel — skip it for the checkbox list
+ const productOptions = options.filter(o => o.value !== '__all__');
+ const noneSelected = selectedValues.length === 1 && selectedValues[0] === '__none__';
+ const allSelected = selectedValues.length === 0 || selectedValues.includes('__all__');
+ const someSelected = !allSelected && !noneSelected && selectedValues.length > 0;
+
+ const selectAllRef = useRef(null);
+
+ useEffect(() => {
+ if (selectAllRef.current) {
+ selectAllRef.current.indeterminate = someSelected;
+ if (!someSelected) selectAllRef.current.indeterminate = false;
+ }
+ }, [someSelected]);
+
+ function handleSelectAll() {
+ if (allSelected && !someSelected) {
+ onChange(['__none__']); // All checked → uncheck all
+ } else {
+ onChange([]); // Indeterminate or none → check all
+ }
+ }
+
+ function handleOption(value, checked) {
+ // Resolve current explicit selection
+ const current = allSelected
+ ? productOptions.map(o => o.value) // [] means all
+ : noneSelected
+ ? [] // __none__ means none
+ : selectedValues.filter(v => v !== '__all__');
+ if (checked) {
+ const next = current.concat(value);
+ onChange(next.length === productOptions.length ? [] : next);
+ } else {
+ onChange(current.filter(v => v !== value));
+ }
+ }
+
+ const containerStyle = {
+ width: '100%',
+ border: '2px solid var(--ifm-color-emphasis-300)',
+ borderRadius: '8px',
+ overflowY: 'auto',
+ background: 'var(--ifm-background-color)',
+ flex: 1,
+ minHeight: 0,
+ };
+
+ const rowStyle = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ padding: '4px 10px',
+ cursor: 'pointer',
+ fontSize: '13px',
+ lineHeight: '1.3',
+ userSelect: 'none',
+ };
+
+ const checkboxStyle = {
+ width: '16px',
+ height: '16px',
+ flexShrink: 0,
+ cursor: 'pointer',
+ accentColor: 'var(--ifm-color-primary)',
+ };
+
+ const dividerStyle = {
+ height: '1px',
+ background: 'var(--ifm-color-emphasis-200)',
+ margin: '0',
+ };
+
+ return (
+
+ );
+}
+
+function useDocumentsFoundPlural() {
+ const {selectMessage} = usePluralForm();
+ return (count) =>
+ selectMessage(
+ count,
+ translate(
+ {
+ id: 'theme.SearchPage.documentsFound.plurals',
+ message: 'One document found|{count} documents found',
+ },
+ {count},
+ ),
+ );
+}
+
+function SearchPageContent() {
+ const {i18n: {currentLocale}} = useDocusaurusContext();
+ const {algolia: {appId, apiKey, indexName}} = useAlgoliaThemeConfig();
+ const processSearchResultUrl = useSearchResultUrlProcessor();
+ const documentsFoundPlural = useDocumentsFoundPlural();
+ const location = useLocation();
+ const history = useHistory();
+
+ // Back to top button visibility
+ const [showBackToTop, setShowBackToTop] = useState(false);
+ const [showJumpToBottom, setShowJumpToBottom] = useState(false);
+
+ // Results per page dropdown state
+ const [rppOpen, setRppOpen] = useState(false);
+ const rppRef = useRef(null);
+
+ // Close results-per-page dropdown when clicking outside
+ useEffect(() => {
+ if (!rppOpen) return;
+ const handler = (e) => {
+ if (rppRef.current && !rppRef.current.contains(e.target)) setRppOpen(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [rppOpen]);
+
+ // Page jump input state
+ const [pageInputValue, setPageInputValue] = useState('');
+ const [pageInputFocused, setPageInputFocused] = useState(false);
+
+ // Mobile breakpoint
+ const [isMobile, setIsMobile] = useState(false);
+ const [isSmall, setIsSmall] = useState(false);
+ useEffect(() => {
+ if (!ExecutionEnvironment.canUseDOM) return;
+ const mqMobile = window.matchMedia('(max-width: 768px)');
+ const mqSmall = window.matchMedia('(max-width: 480px)');
+ setIsMobile(mqMobile.matches);
+ setIsSmall(mqSmall.matches);
+ const mobileHandler = (e) => setIsMobile(e.matches);
+ const smallHandler = (e) => setIsSmall(e.matches);
+ mqMobile.addEventListener('change', mobileHandler);
+ mqSmall.addEventListener('change', smallHandler);
+ return () => {
+ mqMobile.removeEventListener('change', mobileHandler);
+ mqSmall.removeEventListener('change', smallHandler);
+ };
+ }, []);
+
+ // Parse URL parameters
+ const urlParams = new URLSearchParams(location.search);
+ const queryFromUrl = urlParams.get('q') || '';
+ const productsFromUrl = urlParams.get('products')?.split(',').filter(Boolean) || [];
+ const resultsPerPageFromUrl = parseInt(urlParams.get('resultsPerPage'), 10) || 25;
+ const pageFromUrl = parseInt(urlParams.get('page'), 10) || 1;
+
+ const [searchQuery, setSearchQuery] = useState(queryFromUrl);
+ const [selectedProducts, setSelectedProducts] = useState(productsFromUrl);
+ const [resultsPerPage, setResultsPerPage] = useState(resultsPerPageFromUrl);
+
+ // Track if we're restoring from URL (e.g., browser back button)
+ const restoringFromUrl = useRef(false);
+ const targetPageRef = useRef(null);
+ const isInternalNavigation = useRef(false);
+
+ // Update state when URL changes (e.g., when navigating from search modal or browser back)
+ useEffect(() => {
+ // Skip if this was an internal navigation (we triggered the URL change ourselves)
+ if (isInternalNavigation.current) {
+ isInternalNavigation.current = false;
+ return;
+ }
+
+ // External navigation (browser back/forward, or coming from search modal)
+ const urlParams = new URLSearchParams(location.search);
+ const newQuery = urlParams.get('q') || '';
+ const newProducts = urlParams.get('products')?.split(',').filter(Boolean) || [];
+ const newResultsPerPage = parseInt(urlParams.get('resultsPerPage'), 10) || 25;
+ const newPage = parseInt(urlParams.get('page'), 10) || 1;
+
+ setSearchQuery(newQuery);
+ setSelectedProducts(newProducts);
+ setResultsPerPage(newResultsPerPage);
+
+ // Store target page for restoration
+ if (newPage > 1) {
+ restoringFromUrl.current = true;
+ targetPageRef.current = newPage - 1;
+ } else {
+ restoringFromUrl.current = false;
+ targetPageRef.current = null;
+ }
+ }, [location.search]);
+
+ const initialSearchResultState = {
+ items: [],
+ query: null,
+ totalResults: null,
+ totalPages: null,
+ lastPage: null,
+ hasMore: null,
+ loading: null,
+ };
+
+ const [searchResultState, searchResultStateDispatcher] = useReducer(
+ (prevState, data) => {
+ switch (data.type) {
+ case 'reset':
+ return initialSearchResultState;
+ case 'loading':
+ return {...prevState, loading: true};
+ case 'update':
+ if (searchQuery !== data.value.query) {
+ return prevState;
+ }
+ return {
+ ...data.value,
+ items: data.value.items, // Show only current page
+ };
+ case 'nextPage':
+ if (!prevState.hasMore || prevState.loading) {
+ return prevState;
+ }
+ return {
+ ...prevState,
+ lastPage: prevState.lastPage + 1,
+ loading: true,
+ };
+ case 'prevPage':
+ if (prevState.lastPage === 0 || prevState.loading) {
+ return prevState;
+ }
+ return {
+ ...prevState,
+ lastPage: prevState.lastPage - 1,
+ loading: true,
+ };
+ default:
+ return prevState;
+ }
+ },
+ initialSearchResultState,
+ );
+
+ const algoliaClient = useMemo(() => liteClient(appId, apiKey), [appId, apiKey]);
+ const algoliaHelper = useMemo(
+ () =>
+ algoliaSearchHelper(algoliaClient, indexName, {
+ hitsPerPage: resultsPerPage,
+ advancedSyntax: true,
+ disjunctiveFacets: ['language'], // Only language facet exists in index
+ }),
+ [algoliaClient, indexName, resultsPerPage],
+ );
+
+ algoliaHelper.on('result', ({results: {query, hits, page, nbHits, nbPages, facets}}) => {
+ if (query === '' || !Array.isArray(hits)) {
+ searchResultStateDispatcher({type: 'reset'});
+ return;
+ }
+
+
+ const sanitizeValue = (value) =>
+ value.replace(/algolia-docsearch-suggestion--highlight/g, 'search-result-match');
+
+ // Extract product and version from URL and filter client-side
+ const allItems = hits.map(({url, _highlightResult: {hierarchy}, _snippetResult: snippet = {}, product_name}) => {
+ const titles = Object.keys(hierarchy).map((key) => sanitizeValue(hierarchy[key].value));
+ const breadcrumbs = [...titles];
+ const title = titles.pop();
+
+ // Extract product and version from URL path like /docs/auditor/10.8/...
+ let product = product_name;
+ let version = null;
+ let productId = null;
+
+ // Try to match: /docs/{product}/{version}/ or /docs/kb/{product}/
+ const urlMatch = url.match(/\/docs\/(?:kb\/)?([^/]+)(?:\/([^/]+))?/);
+ if (urlMatch) {
+ productId = urlMatch[1];
+ const versionPart = urlMatch[2];
+
+ // Map product ID to display name
+ const productConfig = PRODUCTS.find(p => p.id === productId);
+ if (productConfig) {
+ product = productConfig.name;
+
+ // Convert version from URL format (10_8) to display format (10.8)
+ if (versionPart && versionPart !== 'kb') {
+ version = versionPart.replace(/_/g, '.');
+ }
+ }
+ }
+
+ // Fallback: try to extract product from breadcrumbs
+ if (!product && breadcrumbs.length > 0) {
+ product = stripHtmlTagsToText(breadcrumbs[0]);
+ }
+ if (!product) {
+ product = 'Unknown';
+ }
+
+ return {
+ title,
+ url: processSearchResultUrl(url),
+ summary: snippet.content ? `${sanitizeValue(snippet.content.value)}...` : '',
+ breadcrumbs,
+ product,
+ version,
+ productId,
+ originalUrl: url,
+ };
+ });
+
+ // Algolia handles filtering via facetFilters, so no client-side filtering needed
+ const items = allItems;
+
+ searchResultStateDispatcher({
+ type: 'update',
+ value: {
+ items,
+ query,
+ totalResults: nbHits,
+ totalPages: nbPages,
+ lastPage: page,
+ hasMore: nbPages > page + 1,
+ loading: false,
+ },
+ });
+ });
+
+ // Pagination is now controlled by Previous/Next buttons instead of infinite scroll
+
+ const makeSearch = useCallback(
+ (page = 0) => {
+ // Build facetFilters with product filters (matching SearchBar logic)
+ const facetFilters = [`language:${currentLocale}`];
+
+ // Add product filters (OR logic - any of the selected products)
+ const hasProductFilter = selectedProducts.length > 0 && !selectedProducts.includes('__all__');
+ if (hasProductFilter) {
+ const productFilters = selectedProducts.map(p => `product_name:${p}`);
+ facetFilters.push(productFilters); // Array within array = OR logic
+ }
+
+ algoliaHelper
+ .setQuery(searchQuery)
+ .setQueryParameter('facetFilters', facetFilters)
+ .setPage(page)
+ .search();
+ },
+ [searchQuery, algoliaHelper, currentLocale, selectedProducts],
+ );
+
+ // Update URL when filters or pagination change
+ const prevFiltersRef = useRef({searchQuery: '', selectedProducts: [], resultsPerPage: 25, page: 1});
+
+ useEffect(() => {
+ // Only update URL if values actually changed
+ const prev = prevFiltersRef.current;
+ const currentPage = (searchResultState.lastPage || 0) + 1;
+
+ if (
+ prev.searchQuery !== searchQuery ||
+ JSON.stringify(prev.selectedProducts) !== JSON.stringify(selectedProducts) ||
+ prev.resultsPerPage !== resultsPerPage ||
+ prev.page !== currentPage
+ ) {
+ const params = new URLSearchParams();
+ if (searchQuery) params.set('q', searchQuery);
+ if (selectedProducts.length > 0) params.set('products', selectedProducts.join(','));
+ if (resultsPerPage !== 25) params.set('resultsPerPage', String(resultsPerPage));
+ if (currentPage > 1) params.set('page', String(currentPage));
+
+ // Mark this as an internal navigation so location.search effect ignores it
+ isInternalNavigation.current = true;
+ history.replace({search: params.toString()});
+
+ prevFiltersRef.current = {searchQuery, selectedProducts, resultsPerPage, page: currentPage};
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchQuery, selectedProducts, resultsPerPage, searchResultState.lastPage]);
+
+ // IntersectionObserver removed - using pagination buttons instead
+
+ useEffect(() => {
+ searchResultStateDispatcher({type: 'reset'});
+ if (searchQuery) {
+ searchResultStateDispatcher({type: 'loading'});
+ // If restoring from URL, use the target page; otherwise start at page 0
+ const startPage = (restoringFromUrl.current && targetPageRef.current !== null)
+ ? targetPageRef.current
+ : 0;
+ setTimeout(() => {
+ makeSearch(startPage);
+ // Clear restoration flags after search
+ restoringFromUrl.current = false;
+ targetPageRef.current = null;
+ }, 300);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchQuery, selectedProducts, resultsPerPage]);
+
+ useEffect(() => {
+ // Only trigger pagination search if lastPage has been set by nextPage/prevPage actions
+ // (not during initial render where lastPage is null)
+ if (searchResultState.lastPage === null) {
+ return;
+ }
+ makeSearch(searchResultState.lastPage);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchResultState.lastPage]);
+
+ // Scroll to top when page changes (except initial load)
+ const isInitialLoad = useRef(true);
+ useEffect(() => {
+ if (isInitialLoad.current) {
+ isInitialLoad.current = false;
+ return;
+ }
+ if (searchResultState.items.length > 0 && !searchResultState.loading) {
+ window.scrollTo({top: 0, behavior: 'smooth'});
+ }
+ }, [searchResultState.lastPage, searchResultState.items.length, searchResultState.loading]);
+
+ // Show/hide back to top and jump to bottom buttons based on window scroll
+ useEffect(() => {
+ const handleScroll = () => {
+ const scrolledDown = window.scrollY > 300;
+ const nearBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 300;
+
+ setShowBackToTop(scrolledDown);
+ setShowJumpToBottom(!scrolledDown && !nearBottom && searchResultState.items.length > 0);
+ };
+
+ handleScroll();
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, [searchResultState.items.length]);
+
+ const scrollToTop = () => {
+ window.scrollTo({top: 0, behavior: 'smooth'});
+ };
+
+ const scrollToBottom = () => {
+ window.scrollTo({top: document.documentElement.scrollHeight, behavior: 'smooth'});
+ };
+
+ const pageTitle = searchQuery
+ ? `Search results for "${searchQuery}"`
+ : 'Search the documentation';
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Controls — heading + search + results-per-page + doc count */}
+
+
{pageTitle}
+
+
+
+ setSearchQuery(e.target.value)}
+ value={searchQuery}
+ autoComplete="off"
+ autoFocus
+ style={{
+ width: '100%',
+ padding: '14px 16px',
+ fontSize: '16px',
+ borderRadius: '8px',
+ border: '2px solid var(--ifm-color-emphasis-300)',
+ marginBottom: '0',
+ transition: 'border-color 0.2s',
+ }}
+ onFocus={(e) => {
+ e.target.style.borderColor = 'var(--ifm-color-primary)';
+ e.target.style.outline = 'none';
+ }}
+ onBlur={(e) => {
+ e.target.style.borderColor = 'var(--ifm-color-emphasis-300)';
+ }}
+ />
+
+
+
+
+ {rppOpen && (
+
+ {[25, 50, 100, 150, 200].map(n => (
+
{ setResultsPerPage(n); setRppOpen(false); }}
+ style={{
+ padding: '8px 16px',
+ fontSize: '15px',
+ cursor: 'pointer',
+ background: n === resultsPerPage ? 'var(--ifm-color-primary)' : 'transparent',
+ color: n === resultsPerPage ? 'white' : 'var(--ifm-font-color-base)',
+ }}
+ onMouseEnter={(e) => {
+ if (n !== resultsPerPage) e.currentTarget.style.background = 'var(--ifm-color-emphasis-100)';
+ }}
+ onMouseLeave={(e) => {
+ if (n !== resultsPerPage) e.currentTarget.style.background = 'transparent';
+ }}
+ >
+ {n}
+
+ ))}
+
+ )}
+
+
+
+ {!!searchResultState.totalResults && (
+
+ {documentsFoundPlural(searchResultState.totalResults)}
+
+ )}
+
{/* closes controls div */}
+
+ {/* Filters sidebar — product filters */}
+
+
+
{/* closes filters div */}
+
+ {/* Results area */}
+
+
+ {searchResultState.items.length > 0 ? (
+
+ {(() => {
+ // Group results by product
+ const groupedByProduct = searchResultState.items.reduce((acc, item) => {
+ const product = item.product || 'Unknown';
+ if (!acc[product]) acc[product] = [];
+ acc[product].push(item);
+ return acc;
+ }, {});
+
+ // Sort products alphabetically
+ const sortedProducts = Object.keys(groupedByProduct).sort();
+
+ return sortedProducts.map((product) => {
+ const productResults = groupedByProduct[product];
+
+ return (
+
+
+ {product} ({productResults.length} {productResults.length === 1 ? 'result' : 'results'} on this page)
+
+
+ {productResults.map(({title, url, summary, breadcrumbs}, i) => {
+ return (
+
+
+
+
+
+ {breadcrumbs.length > 0 && (
+
+ )}
+
+ {summary && (
+
+ )}
+
+ );
+ })}
+
+ );
+ });
+ })()}
+
+ ) : (
+ [
+ searchQuery && !searchResultState.loading && (
+
+ No results were found
+
+ ),
+ !!searchResultState.loading && (
+
+ Loading...
+
+ ),
+ ]
+ )}
+
+ {/* Pagination controls */}
+ {searchResultState.items.length > 0 && (
+
+
+
+
+
+ Page
+
+ {
+ const value = e.target.value.replace(/\D/g, '');
+ setPageInputValue(value);
+ }}
+ onFocus={(e) => {
+ setPageInputFocused(true);
+ setPageInputValue(String(searchResultState.lastPage + 1));
+ setTimeout(() => e.target.select(), 0);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ const pageNum = parseInt(pageInputValue, 10);
+ if (pageNum >= 1 && pageNum <= searchResultState.totalPages) {
+ searchResultStateDispatcher({
+ type: 'update',
+ value: {
+ ...searchResultState,
+ lastPage: pageNum - 1,
+ loading: true,
+ }
+ });
+ }
+ setPageInputValue('');
+ setPageInputFocused(false);
+ e.target.blur();
+ } else if (e.key === 'Escape') {
+ setPageInputValue('');
+ setPageInputFocused(false);
+ e.target.blur();
+ }
+ }}
+ onBlur={() => {
+ setPageInputValue('');
+ setPageInputFocused(false);
+ }}
+ style={{
+ width: '60px',
+ padding: '6px 8px',
+ fontSize: '16px',
+ fontWeight: '500',
+ border: '1px solid var(--ifm-color-emphasis-300)',
+ borderRadius: '4px',
+ textAlign: 'center',
+ }}
+ />
+
+ of {searchResultState.totalPages}
+
+
+
+
+
+ )}
+
{/* closes results div */}
+
{/* closes outer grid/flex container */}
+
+
+ {/* Back to top / jump to bottom buttons — mutually exclusive, same position */}
+ {(showBackToTop || showJumpToBottom) && (
+
+ )}
+
+ );
+}
+
+export default function SearchPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/theme/SearchPage/styles.module.css b/src/theme/SearchPage/styles.module.css
new file mode 100644
index 0000000000..649c45478c
--- /dev/null
+++ b/src/theme/SearchPage/styles.module.css
@@ -0,0 +1,41 @@
+/* Custom styles for search page */
+
+.searchQueryColumn {
+ flex-grow: 1;
+}
+
+.searchResultsColumn {
+ margin-top: 1rem;
+}
+
+.searchResultItem {
+ margin-bottom: 2rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+}
+
+.searchResultItemHeading {
+ font-size: 1.25rem;
+ margin-bottom: 0.5rem;
+}
+
+.searchResultItemPath {
+ font-size: 0.875rem;
+ margin-bottom: 0.5rem;
+}
+
+.searchResultItemSummary {
+ color: var(--ifm-color-emphasis-700);
+ margin: 0;
+}
+
+.loadingSpinner {
+ text-align: center;
+ padding: 2rem;
+}
+
+.loader {
+ text-align: center;
+ padding: 1rem;
+ color: var(--ifm-color-emphasis-600);
+}