docs,api-ref: added search filters (#4830)
* initial implementation of search modal * added hit and search suggestions * added support for multiple indices * updated sample env * added close when click outside dropdown * test for mobile * added mobile design * added shortcut * dark mode fixes * added search to docs * added plugins filter * added React import * moved filters to configurations * handled error on page load * change suggestion text * removed hits limit * handle select all * open link in current tab * change highlight colors * added support for shortcuts + auto focus * change header and footer * redesigned search ui
This commit is contained in:
@@ -20,6 +20,7 @@ import SidebarProvider from "@site/src/providers/Sidebar"
|
||||
import NotificationProvider from "@site/src/providers/Notification"
|
||||
import UserProvider from "@site/src/providers/User"
|
||||
import ModalProvider from "../../providers/Modal"
|
||||
import SearchProvider from "../../providers/Search"
|
||||
|
||||
function DocPageMetadata(props: Props): JSX.Element {
|
||||
const { versionMetadata } = props
|
||||
@@ -67,9 +68,7 @@ export default function DocPage(props: Props): JSX.Element {
|
||||
<LearningPathProvider>
|
||||
<SidebarProvider sidebarName={sidebarName}>
|
||||
<NotificationProvider>
|
||||
<ModalProvider>
|
||||
<DocPageLayout>{docElement}</DocPageLayout>
|
||||
</ModalProvider>
|
||||
<DocPageLayout>{docElement}</DocPageLayout>
|
||||
</NotificationProvider>
|
||||
</SidebarProvider>
|
||||
</LearningPathProvider>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react"
|
||||
import { IconProps } from ".."
|
||||
|
||||
const IconArrowDownLeftMini: React.FC<IconProps> = ({
|
||||
iconColorClassName,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={props.width || 20}
|
||||
height={props.height || 20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8.00002 8.66675L4.66669 12.0001L8.00002 15.3334"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.3334 4.66675V9.33341C15.3334 10.0407 15.0524 10.7189 14.5523 11.219C14.0522 11.7191 13.3739 12.0001 12.6667 12.0001H4.66669"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconArrowDownLeftMini
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import { IconProps } from ".."
|
||||
|
||||
const IconCheckMini: React.FC<IconProps> = ({
|
||||
iconColorClassName,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={props.width || 20}
|
||||
height={props.height || 20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5.83334 10.4167L9.16668 13.75L14.1667 6.25"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconCheckMini
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import { IconProps } from ".."
|
||||
|
||||
const IconChevronUpDown: React.FC<IconProps> = ({
|
||||
iconColorClassName,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={props.width || 20}
|
||||
height={props.height || 20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6 12.75L9.75 16.5L13.5 12.75M6 6.75L9.75 3L13.5 6.75"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconChevronUpDown
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import { IconProps } from ".."
|
||||
|
||||
const IconEllipseMiniSolid: React.FC<IconProps> = ({
|
||||
iconColorClassName,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={props.width || 20}
|
||||
height={props.height || 20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="2"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"fill-medusa-fg-subtle dark:fill-medusa-fg-subtle-dark"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconEllipseMiniSolid
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import { IconProps } from ".."
|
||||
|
||||
const IconMagnifyingGlass: React.FC<IconProps> = ({
|
||||
iconColorClassName,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={props.width || 20}
|
||||
height={props.height || 20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M17.4999 17.8713L13.1691 13.5404M13.1691 13.5404C14.3412 12.3683 14.9997 10.7785 14.9997 9.12084C14.9997 7.46317 14.3412 5.8734 13.1691 4.70126C11.9969 3.52911 10.4072 2.87061 8.7495 2.87061C7.09184 2.87061 5.50207 3.52911 4.32992 4.70126C3.15777 5.8734 2.49927 7.46317 2.49927 9.12084C2.49927 10.7785 3.15777 12.3683 4.32992 13.5404C5.50207 14.7126 7.09184 15.3711 8.7495 15.3711C10.4072 15.3711 11.9969 14.7126 13.1691 13.5404V13.5404Z"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconMagnifyingGlass
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import { IconProps } from ".."
|
||||
|
||||
const IconXMarkMini: React.FC<IconProps> = ({
|
||||
iconColorClassName,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={props.width || 20}
|
||||
height={props.height || 20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6 14L14 6M6 6L14 14"
|
||||
className={
|
||||
iconColorClassName ||
|
||||
"stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconXMarkMini
|
||||
@@ -1,6 +1,7 @@
|
||||
import IconAcademicCapSolid from "./AcademicCapSolid"
|
||||
import IconAdjustments from "./Adjustments"
|
||||
import IconAlert from "./Alert"
|
||||
import IconArrowDownLeftMini from "./ArrowDownLeftMini"
|
||||
import IconArrowDownTray from "./ArrowDownTray"
|
||||
import IconBackArrow from "./BackArrow"
|
||||
import IconBarsThree from "./BarsThree"
|
||||
@@ -19,7 +20,9 @@ import IconCashSolid from "./CashSolid"
|
||||
import IconChannels from "./Channels"
|
||||
import IconChannelsSolid from "./ChannelsSolid"
|
||||
import IconCheckCircleSolid from "./CheckCircleSolid"
|
||||
import IconCheckMini from "./CheckMini"
|
||||
import IconChevronDoubleLeftMiniSolid from "./ChevronDoubleLeftMiniSolid"
|
||||
import IconChevronUpDown from "./ChevronUpDown"
|
||||
import IconCircleDottedLine from "./CircleDottedLine"
|
||||
import IconCircleMiniSolid from "./CircleMiniSolid"
|
||||
import IconCircleStack from "./CircleStack"
|
||||
@@ -43,6 +46,7 @@ import IconDarkMode from "./DarkMode"
|
||||
import IconDiscord from "./Discord"
|
||||
import IconDocumentText from "./DocumentText"
|
||||
import IconDocumentTextSolid from "./DocumentTextSolid"
|
||||
import IconEllipseMiniSolid from "./EllipseMiniSolid"
|
||||
import IconExclamationCircleSolid from "./ExclamationCircleSolid"
|
||||
import IconExternalLink from "./ExternalLink"
|
||||
import IconFlyingBox from "./FlyingBox"
|
||||
@@ -61,6 +65,7 @@ import IconLightBulb from "./LightBulb"
|
||||
import IconLightBulbSolid from "./LightBulbSolid"
|
||||
import IconLightMode from "./LightMode"
|
||||
import IconLinkedIn from "./LinkedIn"
|
||||
import IconMagnifyingGlass from "./MagnifyingGlass"
|
||||
import IconMap from "./Map"
|
||||
import IconNewspaper from "./Newspaper"
|
||||
import IconNextjs from "./Nextjs"
|
||||
@@ -93,6 +98,8 @@ import IconTwitter from "./Twitter"
|
||||
import IconUser from "./User"
|
||||
import IconUsersSolid from "./UsersSolid"
|
||||
import IconXCircleSolid from "./XCircleSolid"
|
||||
import IconXMark from "./XMark"
|
||||
import IconXMarkMini from "./XMarkMini"
|
||||
|
||||
export type IconProps = {
|
||||
width?: number
|
||||
@@ -105,6 +112,7 @@ export default {
|
||||
"academic-cap-solid": IconAcademicCapSolid,
|
||||
adjustments: IconAdjustments,
|
||||
alert: IconAlert,
|
||||
"arrow-down-left-mini": IconArrowDownLeftMini,
|
||||
"arrow-down-tray": IconArrowDownTray,
|
||||
"back-arrow": IconBackArrow,
|
||||
"bars-three": IconBarsThree,
|
||||
@@ -123,7 +131,9 @@ export default {
|
||||
"channels-solid": IconChannelsSolid,
|
||||
channels: IconChannels,
|
||||
"check-circle-solid": IconCheckCircleSolid,
|
||||
"check-mini": IconCheckMini,
|
||||
"chevron-double-left-mini-solid": IconChevronDoubleLeftMiniSolid,
|
||||
"chevron-up-down": IconChevronUpDown,
|
||||
"circle-dotted-line": IconCircleDottedLine,
|
||||
"circle-mini-solid": IconCircleMiniSolid,
|
||||
"circle-stack": IconCircleStack,
|
||||
@@ -147,6 +157,7 @@ export default {
|
||||
discord: IconDiscord,
|
||||
"document-text": IconDocumentText,
|
||||
"document-text-solid": IconDocumentTextSolid,
|
||||
"ellipse-mini-solid": IconEllipseMiniSolid,
|
||||
"exclamation-circle-solid": IconExclamationCircleSolid,
|
||||
"external-link": IconExternalLink,
|
||||
"flying-box": IconFlyingBox,
|
||||
@@ -165,6 +176,7 @@ export default {
|
||||
"light-bulb-solid": IconLightBulbSolid,
|
||||
"light-mode": IconLightMode,
|
||||
linkedin: IconLinkedIn,
|
||||
"magnifying-glass": IconMagnifyingGlass,
|
||||
map: IconMap,
|
||||
newspaper: IconNewspaper,
|
||||
nextjs: IconNextjs,
|
||||
@@ -197,4 +209,6 @@ export default {
|
||||
user: IconUser,
|
||||
"users-solid": IconUsersSolid,
|
||||
"x-circle-solid": IconXCircleSolid,
|
||||
"x-mark": IconXMark,
|
||||
"x-mark-mini": IconXMarkMini,
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ import type { Props } from "@theme/Layout"
|
||||
import useIsBrowser from "@docusaurus/useIsBrowser"
|
||||
import { useLocation } from "@docusaurus/router"
|
||||
import "animate.css"
|
||||
import StructuredDataSearchbox from "@site/src/components/StructuredData/Searchbox"
|
||||
import { useUser } from "@site/src/providers/User"
|
||||
import SearchProvider from "../../providers/Search"
|
||||
import ModalProvider from "../../providers/Modal"
|
||||
|
||||
export default function Layout(props: Props): JSX.Element {
|
||||
const {
|
||||
@@ -54,24 +55,29 @@ export default function Layout(props: Props): JSX.Element {
|
||||
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<PageMetadata title={title} description={description} />
|
||||
{isBrowser && location.pathname === "/" && <StructuredDataSearchbox />}
|
||||
<SkipToContent />
|
||||
<ModalProvider>
|
||||
<SearchProvider>
|
||||
<PageMetadata title={title} description={description} />
|
||||
<SkipToContent />
|
||||
|
||||
<Navbar />
|
||||
<Navbar />
|
||||
|
||||
<div
|
||||
id={SkipToContentFallbackId}
|
||||
className={clsx(
|
||||
ThemeClassNames.wrapper.main,
|
||||
"flex-auto flex-grow flex-shrink-0",
|
||||
wrapperClassName
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary fallback={(params) => <ErrorPageContent {...params} />}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div
|
||||
id={SkipToContentFallbackId}
|
||||
className={clsx(
|
||||
ThemeClassNames.wrapper.main,
|
||||
"flex-auto flex-grow flex-shrink-0",
|
||||
wrapperClassName
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback={(params) => <ErrorPageContent {...params} />}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</SearchProvider>
|
||||
</ModalProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
type KbdProps = {
|
||||
className?: string
|
||||
} & React.ComponentProps<"kbd">
|
||||
|
||||
const Kbd: React.FC<KbdProps> = ({ children, className, ...props }) => {
|
||||
return (
|
||||
<kbd
|
||||
className={clsx(
|
||||
"h-[22px] w-[22px] rounded-sm p-0 border-solid",
|
||||
"inline-flex items-center justify-center",
|
||||
"border-medusa-tag-neutral-border dark:border-medusa-tag-neutral-border-dark border",
|
||||
"bg-medusa-tag-neutral-bg dark:bg-medusa-tag-neutral-bg-dark",
|
||||
"text-medusa-tag-neutral-text dark:text-medusa-tag-neutral-text-dark",
|
||||
"text-compact-x-small-plus shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export default Kbd
|
||||
@@ -5,6 +5,7 @@ import CloudinaryImage from "@site/src/components/CloudinaryImage"
|
||||
import MDXDetails from "./Details"
|
||||
import MDXSummary from "./Summary"
|
||||
import MDXA from "./A"
|
||||
import Kbd from "./Kbd"
|
||||
|
||||
export default {
|
||||
// Re-use the default mapping
|
||||
@@ -14,4 +15,5 @@ export default {
|
||||
details: MDXDetails,
|
||||
summary: MDXSummary,
|
||||
a: MDXA,
|
||||
kbd: Kbd,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import type { Props } from "@theme/Navbar/Search"
|
||||
|
||||
export default function NavbarSearch({
|
||||
children,
|
||||
className,
|
||||
}: Props): JSX.Element {
|
||||
return <div className={clsx("flex", className)}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react"
|
||||
import NavbarSearch from "@theme/Navbar/Search"
|
||||
import type { Props } from "@theme/NavbarItem/SearchNavbarItem"
|
||||
import SearchModalOpener from "../../components/Search/ModalOpener"
|
||||
|
||||
export default function SearchNavbarItem({
|
||||
mobile,
|
||||
}: Props): JSX.Element | null {
|
||||
if (mobile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NavbarSearch>
|
||||
<SearchModalOpener />
|
||||
</NavbarSearch>
|
||||
)
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
import React, { useEffect, useReducer, useRef, useState } from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
import algoliaSearchHelper from "algoliasearch-helper"
|
||||
import algoliaSearch from "algoliasearch/lite"
|
||||
|
||||
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment"
|
||||
import Head from "@docusaurus/Head"
|
||||
import Link from "@docusaurus/Link"
|
||||
import { useAllDocsData } from "@docusaurus/plugin-content-docs/client"
|
||||
import {
|
||||
HtmlClassNameProvider,
|
||||
useEvent,
|
||||
usePluralForm,
|
||||
useSearchQueryString,
|
||||
} from "@docusaurus/theme-common"
|
||||
import { useTitleFormatter } from "@docusaurus/theme-common/internal"
|
||||
import Translate, { translate } from "@docusaurus/Translate"
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext"
|
||||
import { useSearchResultUrlProcessor } from "@docusaurus/theme-search-algolia/client"
|
||||
import Layout from "@theme/Layout"
|
||||
import { ThemeConfig } from "@medusajs/docs"
|
||||
import UserProvider from "@site/src/providers/User"
|
||||
|
||||
// Very simple pluralization: probably good enough for now
|
||||
function useDocumentsFoundPlural() {
|
||||
const { selectMessage } = usePluralForm()
|
||||
return (count: number) =>
|
||||
selectMessage(
|
||||
count,
|
||||
translate(
|
||||
{
|
||||
id: "theme.SearchPage.documentsFound.plurals",
|
||||
description: `Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)`,
|
||||
message: "One document found|{count} documents found",
|
||||
},
|
||||
{ count }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function useDocsSearchVersionsHelpers() {
|
||||
const allDocsData = useAllDocsData()
|
||||
|
||||
// State of the version select menus / algolia facet filters
|
||||
// docsPluginId -> versionName map
|
||||
const [searchVersions, setSearchVersions] = useState<{
|
||||
[pluginId: string]: string
|
||||
}>(() =>
|
||||
Object.entries(allDocsData).reduce(
|
||||
(acc, [pluginId, pluginData]) => ({
|
||||
...acc,
|
||||
[pluginId]: pluginData.versions[0]!.name,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
// Set the value of a single select menu
|
||||
const setSearchVersion = (pluginId: string, searchVersion: string) =>
|
||||
setSearchVersions((s) => ({ ...s, [pluginId]: searchVersion }))
|
||||
|
||||
const versioningEnabled = Object.values(allDocsData).some(
|
||||
(docsData) => docsData.versions.length > 1
|
||||
)
|
||||
|
||||
return {
|
||||
allDocsData,
|
||||
versioningEnabled,
|
||||
searchVersions,
|
||||
setSearchVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// We want to display one select per versioned docs plugin instance
|
||||
function SearchVersionSelectList({
|
||||
docsSearchVersionsHelpers,
|
||||
}: {
|
||||
docsSearchVersionsHelpers: ReturnType<typeof useDocsSearchVersionsHelpers>
|
||||
}) {
|
||||
const versionedPluginEntries = Object.entries(
|
||||
docsSearchVersionsHelpers.allDocsData
|
||||
)
|
||||
// Do not show a version select for unversioned docs plugin instances
|
||||
.filter(([, docsData]) => docsData.versions.length > 1)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"col",
|
||||
"col--3",
|
||||
"pl-0",
|
||||
"lg:!max-w-[unset] xs:!max-w-[40%] !max-w-full",
|
||||
"xs:pl-0 pl-1"
|
||||
)}
|
||||
>
|
||||
{versionedPluginEntries.map(([pluginId, docsData]) => {
|
||||
const labelPrefix =
|
||||
versionedPluginEntries.length > 1 ? `${pluginId}: ` : ""
|
||||
return (
|
||||
<select
|
||||
key={pluginId}
|
||||
onChange={(e) =>
|
||||
docsSearchVersionsHelpers.setSearchVersion(
|
||||
pluginId,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
defaultValue={docsSearchVersionsHelpers.searchVersions[pluginId]}
|
||||
className={clsx("search-page-input")}
|
||||
>
|
||||
{docsData.versions.map((version, i) => (
|
||||
<option
|
||||
key={i}
|
||||
label={`${labelPrefix}${version.label}`}
|
||||
value={version.name}
|
||||
/>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ResultDispatcherState = {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
summary: string
|
||||
breadcrumbs: string[]
|
||||
}[]
|
||||
query: string | null
|
||||
totalResults: number | null
|
||||
totalPages: number | null
|
||||
lastPage: number | null
|
||||
hasMore: boolean | null
|
||||
loading: boolean | null
|
||||
}
|
||||
|
||||
type ResultDispatcher =
|
||||
| { type: "reset"; value?: undefined }
|
||||
| { type: "loading"; value?: undefined }
|
||||
| { type: "update"; value: ResultDispatcherState }
|
||||
| { type: "advance"; value?: undefined }
|
||||
|
||||
function SearchPageContent(): JSX.Element {
|
||||
const {
|
||||
siteConfig: { themeConfig },
|
||||
} = useDocusaurusContext()
|
||||
const {
|
||||
algolia: { appId, apiKey, indexName },
|
||||
} = themeConfig as ThemeConfig
|
||||
const processSearchResultUrl = useSearchResultUrlProcessor()
|
||||
const documentsFoundPlural = useDocumentsFoundPlural()
|
||||
|
||||
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers()
|
||||
const [searchQuery, setSearchQuery] = useSearchQueryString()
|
||||
const initialSearchResultState: ResultDispatcherState = {
|
||||
items: [],
|
||||
query: null,
|
||||
totalResults: null,
|
||||
totalPages: null,
|
||||
lastPage: null,
|
||||
hasMore: null,
|
||||
loading: null,
|
||||
}
|
||||
const [searchResultState, searchResultStateDispatcher] = useReducer(
|
||||
(prevState: ResultDispatcherState, data: ResultDispatcher) => {
|
||||
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.lastPage === 0
|
||||
? data.value.items
|
||||
: prevState.items.concat(data.value.items),
|
||||
}
|
||||
}
|
||||
case "advance": {
|
||||
const hasMore = prevState.totalPages! > prevState.lastPage! + 1
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage,
|
||||
hasMore,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return prevState
|
||||
}
|
||||
},
|
||||
initialSearchResultState
|
||||
)
|
||||
|
||||
const algoliaClient = algoliaSearch(appId, apiKey)
|
||||
const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, {
|
||||
hitsPerPage: 15,
|
||||
advancedSyntax: true,
|
||||
disjunctiveFacets: ["language", "docusaurus_tag"],
|
||||
})
|
||||
|
||||
algoliaHelper.on(
|
||||
"result",
|
||||
({ results: { query, hits, page, nbHits, nbPages } }) => {
|
||||
if (query === "" || !Array.isArray(hits)) {
|
||||
searchResultStateDispatcher({ type: "reset" })
|
||||
return
|
||||
}
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value.replace(
|
||||
/algolia-docsearch-suggestion--highlight/g,
|
||||
"search-result-match"
|
||||
)
|
||||
|
||||
const items = hits.map(
|
||||
({
|
||||
url,
|
||||
_highlightResult: { hierarchy },
|
||||
_snippetResult: snippet = {},
|
||||
}: {
|
||||
url: string
|
||||
_highlightResult: { hierarchy: { [key: string]: { value: string } } }
|
||||
_snippetResult: { content?: { value: string } }
|
||||
}) => {
|
||||
const titles = Object.keys(hierarchy).map((key) =>
|
||||
sanitizeValue(hierarchy[key]!.value)
|
||||
)
|
||||
return {
|
||||
title: titles.pop()!,
|
||||
url: processSearchResultUrl(url),
|
||||
summary: snippet.content
|
||||
? `${sanitizeValue(snippet.content.value)}...`
|
||||
: "",
|
||||
breadcrumbs: titles,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
searchResultStateDispatcher({
|
||||
type: "update",
|
||||
value: {
|
||||
items,
|
||||
query,
|
||||
totalResults: nbHits,
|
||||
totalPages: nbPages,
|
||||
lastPage: page,
|
||||
hasMore: nbPages > page + 1,
|
||||
loading: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(null)
|
||||
const prevY = useRef(0)
|
||||
const observer = useRef(
|
||||
ExecutionEnvironment.canUseIntersectionObserver &&
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const {
|
||||
isIntersecting,
|
||||
boundingClientRect: { y: currentY },
|
||||
} = entries[0]!
|
||||
|
||||
if (isIntersecting && prevY.current > currentY) {
|
||||
searchResultStateDispatcher({ type: "advance" })
|
||||
}
|
||||
|
||||
prevY.current = currentY
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
)
|
||||
|
||||
const getTitle = () => {
|
||||
return searchQuery
|
||||
? translate(
|
||||
{
|
||||
id: "theme.SearchPage.existingResultsTitle",
|
||||
message: `Search results for "{query}"`,
|
||||
description: "The search page title for non-empty query",
|
||||
},
|
||||
{
|
||||
query: searchQuery,
|
||||
}
|
||||
)
|
||||
: translate({
|
||||
id: "theme.SearchPage.emptyResultsTitle",
|
||||
message: "Search the documentation",
|
||||
description: "The search page title for empty query",
|
||||
})
|
||||
}
|
||||
|
||||
const makeSearch = useEvent((page?: number) => {
|
||||
// These commented out line are from algolia's implementation
|
||||
// we might need them in the future
|
||||
// algoliaHelper.addDisjunctiveFacetRefinement("docusaurus_tag", "default")
|
||||
// algoliaHelper.addDisjunctiveFacetRefinement("language", currentLocale)
|
||||
|
||||
// Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(
|
||||
// ([pluginId, searchVersion]) => {
|
||||
// algoliaHelper.addDisjunctiveFacetRefinement(
|
||||
// "docusaurus_tag",
|
||||
// `docs-${pluginId}-${searchVersion}`
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
|
||||
algoliaHelper
|
||||
.setQuery(searchQuery)
|
||||
.setPage(page || 0)
|
||||
.search()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaderRef) {
|
||||
return undefined
|
||||
}
|
||||
const currentObserver = observer.current
|
||||
if (currentObserver) {
|
||||
currentObserver.observe(loaderRef)
|
||||
return () => currentObserver.unobserve(loaderRef)
|
||||
}
|
||||
return () => true
|
||||
}, [loaderRef])
|
||||
|
||||
useEffect(() => {
|
||||
searchResultStateDispatcher({ type: "reset" })
|
||||
|
||||
if (searchQuery) {
|
||||
searchResultStateDispatcher({ type: "loading" })
|
||||
|
||||
setTimeout(() => {
|
||||
makeSearch()
|
||||
}, 300)
|
||||
}
|
||||
}, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchResultState.lastPage || searchResultState.lastPage === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
makeSearch(searchResultState.lastPage)
|
||||
}, [makeSearch, searchResultState.lastPage])
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>{useTitleFormatter(getTitle())}</title>
|
||||
{/*
|
||||
We should not index search pages
|
||||
See https://github.com/facebook/docusaurus/pull/3233
|
||||
*/}
|
||||
<meta property="robots" content="noindex, follow" />
|
||||
</Head>
|
||||
|
||||
<div className={clsx("container", "mt-2")}>
|
||||
<h1>{getTitle()}</h1>
|
||||
|
||||
<form className="row" onSubmit={(e) => e.preventDefault()}>
|
||||
<div
|
||||
className={clsx(
|
||||
"col",
|
||||
"lg:max-w-[unset] xs:max-w-[60%] max-w-full",
|
||||
{
|
||||
"col--9": docsSearchVersionsHelpers.versioningEnabled,
|
||||
"col--12": !docsSearchVersionsHelpers.versioningEnabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
className={clsx(
|
||||
"search-page-input",
|
||||
"placeholder:text-medusa-fg-subtle dark:placeholder:text-medusa-fg-subtle-dark"
|
||||
)}
|
||||
placeholder={translate({
|
||||
id: "theme.SearchPage.inputPlaceholder",
|
||||
message: "Type your search here",
|
||||
description: "The placeholder for search page input",
|
||||
})}
|
||||
aria-label={translate({
|
||||
id: "theme.SearchPage.inputLabel",
|
||||
message: "Search",
|
||||
description: "The ARIA label for search page input",
|
||||
})}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={searchQuery}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{docsSearchVersionsHelpers.versioningEnabled && (
|
||||
<SearchVersionSelectList
|
||||
docsSearchVersionsHelpers={docsSearchVersionsHelpers}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="row">
|
||||
<div className={clsx("col", "col--8", "!text-compact-small-plus")}>
|
||||
{!!searchResultState.totalResults &&
|
||||
documentsFoundPlural(searchResultState.totalResults)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchResultState.items.length > 0 ? (
|
||||
<main>
|
||||
{searchResultState.items.map(
|
||||
({ title, url, summary, breadcrumbs }, i) => (
|
||||
<article
|
||||
key={i}
|
||||
className={clsx(
|
||||
"py-1 px-0 border-b border-t-0 border-x-0 border-solid",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark",
|
||||
"!max-w-[unset]"
|
||||
)}
|
||||
>
|
||||
<h2 className={clsx("font-normal mb-0.5")}>
|
||||
<Link
|
||||
to={url}
|
||||
dangerouslySetInnerHTML={{ __html: title }}
|
||||
className={clsx(
|
||||
"text-medusa-fg-base dark:text-medusa-fg-base-dark"
|
||||
)}
|
||||
/>
|
||||
</h2>
|
||||
|
||||
{breadcrumbs.length > 0 && (
|
||||
<nav aria-label="breadcrumbs">
|
||||
<ul
|
||||
className={clsx(
|
||||
"mb-0 pl-0",
|
||||
"!text-compact-x-small-plus text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark"
|
||||
)}
|
||||
>
|
||||
{breadcrumbs.map((html, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="breadcrumbs__item"
|
||||
// Developer provided the HTML, so assume it's safe.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<p
|
||||
className={clsx("mt-0.5 mb-0 mx-0")}
|
||||
// Developer provided the HTML, so assume it's safe.
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: summary }}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
)}
|
||||
</main>
|
||||
) : (
|
||||
[
|
||||
searchQuery && !searchResultState.loading && (
|
||||
<p key="no-results">
|
||||
<Translate
|
||||
id="theme.SearchPage.noResultsText"
|
||||
description="The paragraph for empty search result"
|
||||
>
|
||||
No results were found
|
||||
</Translate>
|
||||
</p>
|
||||
),
|
||||
!!searchResultState.loading && (
|
||||
<div
|
||||
key="spinner"
|
||||
className={clsx(
|
||||
"w-3 h-3 border-[7px] border-solid border-[#eee] border-t-medusa-fg-base dark:border-t-medusa-fg-base-dark",
|
||||
"rounded-[50%] animate-spin my-0 mx-auto"
|
||||
)}
|
||||
/>
|
||||
),
|
||||
]
|
||||
)}
|
||||
|
||||
{searchResultState.hasMore && (
|
||||
<div className={clsx("mt-2")} ref={setLoaderRef}>
|
||||
<Translate
|
||||
id="theme.SearchPage.fetchingNewResults"
|
||||
description="The paragraph for fetching new search results"
|
||||
>
|
||||
Fetching new results...
|
||||
</Translate>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchPage(): JSX.Element {
|
||||
return (
|
||||
<HtmlClassNameProvider className="">
|
||||
<UserProvider>
|
||||
<SearchPageContent />
|
||||
</UserProvider>
|
||||
</HtmlClassNameProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user