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:
Shahed Nasser
2023-08-24 18:36:06 +03:00
committed by GitHub
parent f4bf9ee169
commit f07dc0384f
109 changed files with 4555 additions and 1648 deletions

View File

@@ -3,14 +3,24 @@ import clsx from "clsx"
export type BadgeProps = {
className?: string
variant: string
variant:
| "purple"
| "purple-dark"
| "orange"
| "orange-dark"
| "green"
| "green-dark"
| "blue"
| "blue-dark"
| "red"
| "neutral"
} & React.HTMLAttributes<HTMLSpanElement>
const Badge: React.FC<BadgeProps> = ({ className, variant, children }) => {
return (
<span
className={clsx(
"text-compact-x-small-plus py-px px-0.4 rounded-sm border border-solid text-center",
"text-compact-x-small-plus px-0.4 rounded-sm border border-solid py-px text-center",
variant === "purple" &&
"bg-medusa-tag-purple-bg dark:bg-medusa-tag-purple-bg-dark text-medusa-tag-purple-text dark:text-medusa-tag-purple-text-dark border-medusa-tag-purple-border dark:border-medusa-tag-purple-border-dark",
variant === "purple-dark" &&
@@ -27,6 +37,10 @@ const Badge: React.FC<BadgeProps> = ({ className, variant, children }) => {
"bg-medusa-tag-blue-bg dark:bg-medusa-tag-blue-bg-dark text-medusa-tag-blue-text dark:text-medusa-tag-blue-text-dark border-medusa-tag-blue-border dark:border-medusa-tag-blue-border-dark",
variant === "blue-dark" &&
"bg-medusa-tag-blue-bg-dark text-medusa-tag-blue-text-dark border-medusa-tag-blue-border-dark",
variant === "red" &&
"bg-medusa-tag-red-bg dark:bg-medusa-tag-red-bg-dark text-medusa-tag-red-text dark:text-medusa-tag-red-text-dark border-medusa-tag-red-border dark:border-medusa-tag-red-border-dark",
variant === "neutral" &&
"bg-medusa-tag-neutral-bg dark:bg-medusa-tag-neutral-bg-dark text-medusa-tag-neutral-text dark:text-medusa-tag-neutral-text-dark border-medusa-tag-neutral-border dark:border-medusa-tag-neutral-border-dark",
"badge",
className
)}

View File

@@ -2,6 +2,7 @@ import React from "react"
import clsx from "clsx"
export type ButtonProps = {
variant?: "secondary" | "primary" | "clear"
btnTypeClassName?: string
className?: string
onClick?: React.MouseEventHandler<HTMLButtonElement>
@@ -9,6 +10,7 @@ export type ButtonProps = {
} & React.HTMLAttributes<HTMLButtonElement>
const Button: React.FC<ButtonProps> = ({
variant,
className = "",
btnTypeClassName,
onClick,
@@ -18,7 +20,10 @@ const Button: React.FC<ButtonProps> = ({
return (
<button
className={clsx(
!btnTypeClassName?.length && "btn-secondary",
!variant && "btn-secondary",
variant === "primary" && "btn-primary",
variant === "secondary" && "btn-secondary",
variant === "clear" && "btn-clear",
btnTypeClassName,
className
)}

View File

@@ -13,7 +13,7 @@ const InputText: React.FC<InputTextProps> = (props) => {
{...props}
className={clsx(
"bg-medusa-bg-field dark:bg-medusa-bg-field-dark shadow-button-secondary dark:shadow-button-secondary-dark",
"rounded border-medusa-border-loud-muted dark:border-medusa-border-loud-muted-dark border border-solid",
"rounded border-medusa-border-base dark:border-medusa-border-base-dark border border-solid",
"px-0.75 py-[9px]",
"hover:bg-medusa-bg-field-hover dark:hover:bg-medusa-bg-field-hover-dark",
"focus:border-medusa-border-interactive dark:focus:border-medusa-border-interactive-dark",

View File

@@ -14,7 +14,7 @@ const LearningPathIcon: React.FC<LearningPathIconProps> = ({
return (
<div
className={clsx(
"rounded-full shadow-card-rest dark:shadow-card-rest-dark w-3 h-3 bg-docs-button-neutral dark:bg-docs-button-neutral-dark",
"rounded-full shadow-card-rest dark:shadow-card-rest-dark w-3 h-3 bg-medusa-bg-base dark:bg-medusa-bg-base-dark",
"flex justify-center items-center flex-none",
className
)}

View File

@@ -25,12 +25,12 @@ const LearningPathStepActions: React.FC<LearningPathStepActionsType> = ({
<div className="flex gap-0.5 p-1 justify-end items-center">
<Button onClick={onClose}>Close</Button>
{hasNextStep() && (
<Button onClick={nextStep} btnTypeClassName="btn-primary">
<Button onClick={nextStep} variant="primary">
Next
</Button>
)}
{!hasNextStep() && (
<Button onClick={handleFinish} btnTypeClassName="btn-primary">
<Button onClick={handleFinish} variant="primary">
Finish
</Button>
)}

View File

@@ -0,0 +1,29 @@
import React from "react"
import clsx from "clsx"
import Button, { ButtonProps } from "../../Button"
type ModalFooterProps = {
actions?: ButtonProps[]
children?: React.ReactNode
className?: string
}
const ModalFooter = ({ actions, className, children }: ModalFooterProps) => {
return (
<div
className={clsx(
"py-1.5 pl-0 pr-2",
"border-medusa-border-base dark:border-medusa-border-base-dark border-0 border-t border-solid",
"flex justify-end gap-0.5",
className
)}
>
{actions?.map((action, index) => (
<Button {...action} key={index} />
))}
{children}
</div>
)
}
export default ModalFooter

View File

@@ -0,0 +1,39 @@
import React from "react"
import clsx from "clsx"
import { useModal } from "../../../providers/Modal"
import Button from "../../Button"
import IconXMark from "../../../theme/Icon/XMark"
type ModalHeaderProps = {
title?: string
}
const ModalHeader = ({ title }: ModalHeaderProps) => {
const { closeModal } = useModal()
return (
<div
className={clsx(
"border-medusa-border-base dark:border-medusa-border-base-dark border-0 border-b border-solid py-1.5 px-2",
"flex items-center justify-between"
)}
>
<span
className={clsx(
"text-medusa-fg-base dark:text-medusa-fg-base-dark text-h2"
)}
>
{title}
</span>
<Button
variant="clear"
className="cursor-pointer"
onClick={() => closeModal()}
>
<IconXMark />
</Button>
</div>
)
}
export default ModalHeader

View File

@@ -1,73 +1,117 @@
import clsx from "clsx"
import React from "react"
import Button, { ButtonProps } from "../Button"
import IconXMark from "../../theme/Icon/XMark"
import React, { forwardRef, useCallback, useEffect, useRef } from "react"
import { ButtonProps } from "../Button"
import ModalHeader from "./Header"
import ModalFooter from "./Footer"
import useKeyboardShortcut from "../../hooks/use-keyboard-shortcut"
import { useModal } from "../../providers/Modal"
export type ModalProps = {
className?: string
title?: string
actions?: ButtonProps[]
} & React.DetailedHTMLProps<
React.DialogHTMLAttributes<HTMLDialogElement>,
HTMLDialogElement
>
modalContainerClassName?: string
contentClassName?: string
onClose?: React.ReactEventHandler<HTMLDialogElement>
open?: boolean
footerContent?: React.ReactNode
} & Omit<React.ComponentProps<"dialog">, "ref">
const Modal = forwardRef<HTMLDialogElement, ModalProps>(function Modal(
{
className,
title,
actions,
children,
contentClassName,
modalContainerClassName,
onClose,
open = true,
footerContent,
...props
},
passedRef
) {
const { closeModal } = useModal()
const ref = useRef<HTMLDialogElement | null>(null)
const setRefs = useCallback(
(node: HTMLDialogElement) => {
// Ref's from useRef needs to have the node assigned to `current`
ref.current = node
if (typeof passedRef === "function") {
passedRef(node)
} else if (passedRef && "current" in passedRef) {
passedRef.current = node
}
},
[passedRef]
)
useKeyboardShortcut({
metakey: false,
checkEditing: false,
shortcutKeys: ["escape"],
action: () => {
if (open) {
ref.current?.close()
}
},
})
const handleClick = (e: React.MouseEvent<HTMLDialogElement, MouseEvent>) => {
// close modal when the user clicks outside the content
if (e.target === ref.current) {
closeModal()
onClose?.(e)
}
}
const handleClose = (e: React.SyntheticEvent<HTMLDialogElement, Event>) => {
onClose?.(e)
closeModal()
}
useEffect(() => {
if (open) {
document.body.setAttribute("data-modal", "opened")
} else {
document.body.removeAttribute("data-modal")
}
}, [open])
const Modal: React.FC<ModalProps> = ({
className,
title,
actions,
children,
...props
}) => {
return (
<dialog
{...props}
className={clsx(
"absolute top-0 left-0 flex w-screen h-screen justify-center items-center",
"",
"fixed top-0 left-0 flex h-screen w-screen items-center justify-center",
"bg-medusa-bg-overlay dark:bg-medusa-bg-overlay-dark z-[500]",
"hidden open:flex border-0 p-0",
className
)}
onClick={handleClick}
ref={setRefs}
onClose={handleClose}
open={open}
>
<div
className={clsx(
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark rounded",
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark rounded-sm",
"border-medusa-border-base dark:border-medusa-border-base-dark border border-solid",
"shadow-modal dark:shadow-modal-dark",
"w-[90%] md:w-[75%] lg:w-1/2"
"w-[90%] md:h-auto md:w-[75%] lg:w-[560px]",
modalContainerClassName
)}
>
<div
className={clsx(
"py-1.5 px-2 border-0 border-solid border-b border-medusa-border-base dark:border-medusa-border-base-dark",
"flex justify-between items-center"
)}
>
<span
className={clsx(
"text-h1 text-medusa-fg-base dark:text-medusa-fg-base-dark"
)}
>
{title}
</span>
<IconXMark />
{title && <ModalHeader title={title} />}
<div className={clsx("overflow-auto py-1.5 px-2", contentClassName)}>
{children}
</div>
<div>{children}</div>
{actions && actions?.length > 0 && (
<div
className={clsx(
"pl-0 pr-2 px-1.5",
"border-0 border-solid border-t border-medusa-border-base dark:border-medusa-border-base-dark",
"flex justify-end gap-0.5"
)}
>
{actions.map((action, index) => (
<Button {...action} key={index} />
))}
</div>
)}
{actions && actions?.length > 0 && <ModalFooter actions={actions} />}
{footerContent && <ModalFooter>{footerContent}</ModalFooter>}
</div>
</dialog>
)
}
})
export default Modal

View File

@@ -47,7 +47,7 @@ const Rating: React.FC<RatingProps> = ({
(hoverRating !== 0 && hoverRating - 1 >= i)
return (
<button
className="transparent-button"
className="btn-clear"
ref={(element) => {
if (starElms.current.length - 1 < i) {
starElms.current.push(element)

View File

@@ -0,0 +1,22 @@
import React from "react"
import { useInstantSearch } from "react-instantsearch"
type SearchEmptyQueryBoundaryProps = {
children: React.ReactElement
fallback: React.ReactElement
}
const SearchEmptyQueryBoundary = ({
children,
fallback,
}: SearchEmptyQueryBoundaryProps) => {
const { indexUiState } = useInstantSearch()
if (!indexUiState.query) {
return fallback
}
return children
}
export default SearchEmptyQueryBoundary

View File

@@ -0,0 +1,22 @@
import React from "react"
import clsx from "clsx"
type SearchHitGroupNameProps = {
name: string
}
const SearchHitGroupName = ({ name }: SearchHitGroupNameProps) => {
return (
<span
className={clsx(
"pb-0.25 flex px-0.5 pt-1",
"text-medusa-fg-muted dark:text-medusa-fg-muted-dark",
"text-compact-x-small-plus"
)}
>
{name}
</span>
)
}
export default SearchHitGroupName

View File

@@ -0,0 +1,190 @@
import React from "react"
import clsx from "clsx"
import { Fragment, useEffect, useMemo, useState } from "react"
import {
Configure,
ConfigureProps,
Index,
Snippet,
useHits,
useInstantSearch,
} from "react-instantsearch"
import SearchNoResult from "../NoResults"
import SearchHitGroupName from "./GroupName"
import Link from "@docusaurus/Link"
import { useThemeConfig } from "@docusaurus/theme-common"
import { ThemeConfig } from "@medusajs/docs"
type Hierarchy = "lvl0" | "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5"
export type HitType = {
hierarchy: {
lvl0: string | null
lvl1: string | null
lvl2: string | null
lvl3: string | null
lvl4: string | null
lvl5: string | null
}
_tags: string[]
url: string
type?: "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5" | "content"
content?: string
__position: number
__queryID?: string
objectID: string
}
type GroupedHitType = {
[k: string]: HitType[]
}
type SearchHitWrapperProps = {
configureProps: ConfigureProps
}
type IndexResults = {
[k: string]: boolean
}
const SearchHitsWrapper = ({ configureProps }: SearchHitWrapperProps) => {
const { status } = useInstantSearch()
const { algoliaConfig: algolia } = useThemeConfig() as ThemeConfig
const indices = useMemo(() => Object.values(algolia.indexNames), [])
const [hasNoResults, setHashNoResults] = useState<IndexResults>({
[indices[0]]: false,
[indices[1]]: false,
})
const showNoResults = useMemo(() => {
return Object.values(hasNoResults).every((value) => value === true)
}, [hasNoResults])
const setNoResults = (index: string, value: boolean) => {
setHashNoResults((prev: IndexResults) => ({
...prev,
[index]: value,
}))
}
return (
<div className="h-full overflow-auto">
{status !== "loading" && showNoResults && <SearchNoResult />}
{indices.map((indexName, index) => (
<Index indexName={indexName} key={index}>
<SearchHits indexName={indexName} setNoResults={setNoResults} />
<Configure {...configureProps} />
</Index>
))}
</div>
)
}
type SearchHitsProps = {
indexName: string
setNoResults: (index: string, value: boolean) => void
}
const SearchHits = ({ indexName, setNoResults }: SearchHitsProps) => {
const { hits } = useHits<HitType>()
const { status } = useInstantSearch()
// group by lvl0
const grouped = useMemo(() => {
const grouped: GroupedHitType = {}
hits.forEach((hit) => {
if (hit.hierarchy.lvl0) {
if (!grouped[hit.hierarchy.lvl0]) {
grouped[hit.hierarchy.lvl0] = []
}
grouped[hit.hierarchy.lvl0].push(hit)
}
})
return grouped
}, [hits])
useEffect(() => {
if (status !== "loading" && status !== "stalled") {
setNoResults(indexName, hits.length === 0)
}
}, [hits, status])
const getLastAvailableHeirarchy = (item: HitType) => {
return (
Object.keys(item.hierarchy)
.reverse()
.find((key) => item.hierarchy[key as Hierarchy] !== null) || ""
)
}
return (
<div className="overflow-auto">
{Object.keys(grouped).map((groupName, index) => (
<Fragment key={index}>
<SearchHitGroupName name={groupName} />
{grouped[groupName].map((item, index) => (
<div
className={clsx(
"gap-0.25 relative flex flex-1 flex-col p-0.5",
"overflow-x-hidden text-ellipsis whitespace-nowrap break-words",
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"focus:bg-medusa-bg-base-hover dark:focus:bg-medusa-bg-base-hover-dark",
"focus:outline-none"
)}
key={index}
tabIndex={index}
data-hit
onClick={(e) => {
const target = e.target as Element
if (target.tagName.toLowerCase() === "div") {
target.querySelector("a")?.click()
}
}}
>
<span
className={clsx(
"text-compact-small-plus text-medusa-fg-base dark:text-medusa-fg-base-dark",
"max-w-full"
)}
>
<Snippet
attribute={[
"hierarchy",
item.type && item.type !== "content"
? item.type
: item.hierarchy.lvl1
? "lvl1"
: getLastAvailableHeirarchy(item),
]}
hit={item}
/>
</span>
{item.type !== "lvl1" && (
<span className="text-compact-small text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark">
<Snippet
attribute={
item.content
? "content"
: [
"hierarchy",
item.type || getLastAvailableHeirarchy(item),
]
}
hit={item}
/>
</span>
)}
<Link
href={item.url}
className="absolute top-0 left-0 h-full w-full"
target="_self"
/>
</div>
))}
</Fragment>
))}
</div>
)
}
export default SearchHitsWrapper

View File

@@ -0,0 +1,312 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import algoliasearch, { SearchClient } from "algoliasearch/lite"
import { InstantSearch, SearchBox } from "react-instantsearch"
import Modal from "../../Modal"
import clsx from "clsx"
import SearchEmptyQueryBoundary from "../EmptyQueryBoundary"
import SearchSuggestions from "../Suggestions"
import checkArraySameElms from "../../../utils/array-same-elms"
import SearchHitsWrapper from "../Hits"
import Button from "../../Button"
import useKeyboardShortcut from "../../../hooks/use-keyboard-shortcut"
import { OptionType } from "@medusajs/docs"
import { useSearch } from "../../../providers/Search"
import { findNextSibling, findPrevSibling } from "../../../utils/dom-utils"
import IconMagnifyingGlass from "../../../theme/Icon/MagnifyingGlass"
import IconXMark from "../../../theme/Icon/XMark"
import SelectBadge from "../../Select/Badge"
import Kbd from "../../../theme/MDXComponents/Kbd"
import { useThemeConfig } from "@docusaurus/theme-common"
import { ThemeConfig } from "@medusajs/docs"
const SearchModal = () => {
const modalRef = useRef<HTMLDialogElement | null>(null)
const { algoliaConfig: algolia } = useThemeConfig() as ThemeConfig
if (!algolia) {
throw new Error("Algolia configuration is not defined")
}
const algoliaClient = useMemo(() => {
return algoliasearch(algolia.appId, algolia.apiKey)
}, [])
const searchClient: SearchClient = useMemo(() => {
return {
...algoliaClient,
async search(requests) {
if (requests.every(({ params }) => !params?.query)) {
return Promise.resolve({
results: requests.map(() => ({
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
hitsPerPage: 0,
exhaustiveNbHits: false,
query: "",
params: "",
})),
})
}
return algoliaClient.search(requests)
},
}
}, [])
const options: OptionType[] = algolia.filters
const { isOpen, setIsOpen, defaultFilters } = useSearch()
const [filters, setFilters] = useState<string[]>(defaultFilters)
const formattedFilters: string = useMemo(() => {
let formatted = ""
filters.forEach((filter) => {
const split = filter.split("_")
split.forEach((f) => {
if (formatted.length) {
formatted += " OR "
}
formatted += `_tags:${f}`
})
})
return formatted
}, [filters])
const searchBoxRef = useRef<HTMLFormElement>(null)
const focusSearchInput = () =>
searchBoxRef.current?.querySelector("input")?.focus()
useEffect(() => {
if (!checkArraySameElms(defaultFilters, filters)) {
setFilters(defaultFilters)
}
}, [defaultFilters])
useEffect(() => {
if (isOpen && searchBoxRef.current) {
focusSearchInput()
}
}, [isOpen])
const handleKeyAction = (e: KeyboardEvent) => {
if (!isOpen) {
return
}
e.preventDefault()
const focusedItem = modalRef.current?.querySelector(":focus") as HTMLElement
if (!focusedItem) {
// focus the first data-hit
const nextItem = modalRef.current?.querySelector(
"[data-hit]"
) as HTMLElement
nextItem?.focus()
return
}
const isHit = focusedItem.hasAttribute("data-hit")
const isInput = focusedItem.tagName.toLowerCase() === "input"
if (!isHit && !isInput) {
// ignore if focused items aren't input/data-hit
return
}
const lowerPressedKey = e.key.toLowerCase()
if (lowerPressedKey === "enter") {
if (isHit) {
// trigger click event of the focused element
focusedItem.click()
}
return
}
if (lowerPressedKey === "arrowup") {
// only hit items has action on arrow up
if (isHit) {
// find if there's a data-hit item before this one
const beforeItem = findPrevSibling(focusedItem, "[data-hit]")
if (!beforeItem) {
// focus the input
focusSearchInput()
} else {
// focus the previous item
beforeItem.focus()
}
}
} else if (lowerPressedKey === "arrowdown") {
// check if item is input or hit
if (isInput) {
// go to the first data-hit item
const nextItem = modalRef.current?.querySelector(
"[data-hit]"
) as HTMLElement
nextItem?.focus()
} else {
// handle go down for hit items
// find if there's a data-hit item after this one
const afterItem = findNextSibling(focusedItem, "[data-hit]")
if (afterItem) {
// focus the next item
afterItem.focus()
}
}
}
}
useKeyboardShortcut({
metakey: false,
shortcutKeys: ["ArrowUp", "ArrowDown", "Enter"],
action: handleKeyAction,
checkEditing: false,
preventDefault: false,
})
return (
<Modal
contentClassName={clsx(
"!p-0 overflow-hidden relative h-full",
"rounded-none md:rounded-lg flex flex-col justify-between"
)}
modalContainerClassName="w-screen h-screen !rounded-none md:!rounded-lg"
open={isOpen}
onClose={() => setIsOpen(false)}
ref={modalRef}
>
<InstantSearch
indexName={algolia.indexNames.docs}
searchClient={searchClient}
>
<div
className={clsx("bg-medusa-bg-base dark:bg-medusa-bg-base-dark flex")}
>
<SearchBox
classNames={{
root: clsx(
"h-[56px] w-full md:rounded-t-xl relative border-0 border-solid",
"border-b border-medusa-border-base dark:border-medusa-border-base-dark",
"bg-transparent"
),
form: clsx("h-full md:rounded-t-xl bg-transparent"),
input: clsx(
"w-full h-full pl-3 text-medusa-fg-base dark:text-medusa-fg-base-dark",
"placeholder:text-medusa-fg-muted dark:placeholder:text-medusa-fg-muted-dark",
"md:rounded-t-xl text-compact-medium bg-transparent",
"appearance-none search-cancel:hidden border-0 active:outline-none focus:outline-none"
),
submit: clsx("absolute top-[18px] left-1 btn-clear p-0"),
reset: clsx(
"absolute top-0.75 right-1 hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"p-[5px] md:rounded btn-clear"
),
loadingIndicator: clsx("absolute top-[18px] right-1"),
}}
submitIconComponent={() => (
<IconMagnifyingGlass iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
)}
resetIconComponent={() => (
<IconXMark
iconColorClassName="stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
className="hidden md:block"
/>
)}
placeholder="Find something..."
autoFocus
formRef={searchBoxRef}
/>
<Button
variant="clear"
className={clsx(
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark block md:hidden",
"border-0 border-solid",
"border-medusa-border-base dark:border-medusa-border-base-dark border-b",
"pr-1"
)}
onClick={() => setIsOpen(false)}
>
<IconXMark iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
</Button>
</div>
<div className="mx-0.5 h-[calc(100%-120px)] md:h-[332px] md:flex-initial lg:max-h-[332px] lg:min-h-[332px]">
<SearchEmptyQueryBoundary fallback={<SearchSuggestions />}>
<SearchHitsWrapper
configureProps={{
filters: formattedFilters,
attributesToSnippet: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
],
attributesToHighlight: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
],
}}
/>
</SearchEmptyQueryBoundary>
</div>
</InstantSearch>
<div
className={clsx(
"py-0.75 flex items-center justify-between px-1",
"border-0 border-solid",
"border-medusa-border-base dark:border-medusa-border-base-dark border-t",
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark"
)}
>
<SelectBadge
multiple
options={options}
value={filters}
setSelected={(value) =>
setFilters(Array.isArray(value) ? [...value] : [value])
}
addSelected={(value) => setFilters((prev) => [...prev, value])}
removeSelected={(value) =>
setFilters((prev) => prev.filter((v) => v !== value))
}
showClearButton={false}
placeholder="Filters"
handleAddAll={(isAllSelected: boolean) => {
if (isAllSelected) {
setFilters(defaultFilters)
} else {
setFilters(options.map((option) => option.value))
}
}}
/>
<div className="hidden items-center gap-1 md:flex">
<div className="flex items-center gap-0.5">
<span
className={clsx(
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
"text-compact-x-small"
)}
>
Navigation
</span>
<span className="gap-0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</div>
<div className="flex items-center gap-0.5">
<span
className={clsx(
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
"text-compact-x-small"
)}
>
Open Result
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</Modal>
)
}
export default SearchModal

View File

@@ -0,0 +1,78 @@
import React from "react"
import clsx from "clsx"
import InputText from "../../Input/Text"
import { MouseEvent, useMemo } from "react"
import { useSearch } from "../../../providers/Search"
import { useWindowSize } from "@docusaurus/theme-common"
import Button from "../../Button"
import IconMagnifyingGlass from "../../../theme/Icon/MagnifyingGlass"
import Kbd from "../../../theme/MDXComponents/Kbd"
import useKeyboardShortcut from "../../../hooks/use-keyboard-shortcut"
const SearchModalOpener = () => {
const { setIsOpen } = useSearch()
const windowSize = useWindowSize()
const isApple = useMemo(() => {
return typeof navigator !== "undefined"
? navigator.userAgent.toLowerCase().indexOf("mac") !== 0
: true
}, [])
useKeyboardShortcut({
shortcutKeys: ["k"],
action: () => setIsOpen((prev) => !prev),
})
const handleOpen = (
e:
| MouseEvent<HTMLDivElement, globalThis.MouseEvent>
| MouseEvent<HTMLInputElement, globalThis.MouseEvent>
| MouseEvent<HTMLButtonElement, globalThis.MouseEvent>
) => {
e.preventDefault()
if ("blur" in e.target && typeof e.target.blur === "function") {
e.target.blur()
}
setIsOpen(true)
}
return (
<>
{windowSize !== "desktop" && (
<Button variant="clear" onClick={handleOpen}>
<IconMagnifyingGlass iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
</Button>
)}
{windowSize === "desktop" && (
<div
className={clsx("relative w-min hover:cursor-pointer")}
onClick={handleOpen}
>
<IconMagnifyingGlass
iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark"
className={clsx("absolute left-0.5 top-[5px]")}
/>
<InputText
type="search"
className={clsx(
"placeholder:text-compact-small",
"!py-[5px] !pl-[36px] !pr-[8px]",
"cursor-pointer select-none"
)}
placeholder="Find something"
onClick={handleOpen}
onFocus={(e) => e.target.blur()}
tabIndex={-1}
/>
<span
className={clsx("gap-0.25 flex", "absolute right-0.5 top-[5px]")}
>
<Kbd>{isApple ? "⌘" : "Ctrl"}</Kbd>
<Kbd>K</Kbd>
</span>
</div>
)}
</>
)
}
export default SearchModalOpener

View File

@@ -0,0 +1,15 @@
import React from "react"
import IconExclamationCircleSolid from "../../../theme/Icon/ExclamationCircleSolid"
const SearchNoResult = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<IconExclamationCircleSolid iconColorClassName="fill-medusa-fg-muted dark:fill-medusa-fg-muted-dark" />
<span className="text-compact-small text-medusa-fg-muted dark:text-medusa-fg-muted-dark">
No results found. Try changing selected filters.
</span>
</div>
)
}
export default SearchNoResult

View File

@@ -0,0 +1,69 @@
import React from "react"
import clsx from "clsx"
import { useInstantSearch } from "react-instantsearch"
import SearchHitGroupName from "../Hits/GroupName"
const SearchSuggestions = () => {
const { setIndexUiState } = useInstantSearch()
const suggestions = [
{
title: "Getting started? Try one of the following terms.",
items: [
"Install Medusa with create-medusa-app",
"Next.js quickstart",
"Admin dashboard quickstart",
"Commerce modules",
"Medusa architecture",
],
},
{
title: "Developing with Medusa",
items: [
"Recipes",
"How to create endpoints",
"How to create an entity",
"How to create a plugin",
"How to create an admin widget",
],
},
]
return (
<div className="h-full overflow-auto">
{suggestions.map((suggestion, index) => (
<React.Fragment key={index}>
<SearchHitGroupName name={suggestion.title} />
{suggestion.items.map((item, itemIndex) => (
<div
className={clsx(
"flex items-center justify-between",
"cursor-pointer rounded-sm p-0.5",
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"focus:bg-medusa-bg-base-hover dark:focus:bg-medusa-bg-base-hover-dark",
"focus:outline-none"
)}
onClick={() =>
setIndexUiState({
query: item,
})
}
key={itemIndex}
tabIndex={itemIndex}
data-hit
>
<span
className={clsx(
"text-medusa-fg-base dark:text-medusa-fg-base-dark",
"text-compact-small"
)}
>
{item}
</span>
</div>
))}
</React.Fragment>
))}
</div>
)
}
export default SearchSuggestions

View File

@@ -0,0 +1,136 @@
import React from "react"
import { useCallback, useRef, useState } from "react"
import useSelect from "../../../hooks/use-select"
import clsx from "clsx"
import SelectDropdown from "../Dropdown"
import { SelectProps } from "../types"
const SelectBadge = ({
value,
options,
setSelected,
addSelected,
removeSelected,
multiple,
className,
addAll = multiple,
handleAddAll,
...props
}: SelectProps) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const { isValueSelected, isAllSelected, handleChange, handleSelectAll } =
useSelect({
value,
options,
multiple,
setSelected,
removeSelected,
addSelected,
handleAddAll,
})
const getSelectedText = useCallback(() => {
let str = ""
const selectedOptions = options.filter((option) =>
value.includes(option.value)
)
if (isAllSelected) {
str = "All Areas"
} else {
if (
(!Array.isArray(value) && !value) ||
(Array.isArray(value) && !value.length)
) {
str = "None selected"
} else {
str = selectedOptions[0].label
}
}
return (
<>
<span
className={clsx(
"text-medusa-fg-base dark:text-medusa-fg-base-dark",
"text-compact-x-small-plus",
"inline-block max-w-[60px] overflow-hidden text-ellipsis"
)}
>
{str}
</span>
{!isAllSelected && selectedOptions.length > 1 && (
<span
className={clsx(
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
"text-compact-x-small"
)}
>
{" "}
+ {selectedOptions.length}
</span>
)}
</>
)
}, [isAllSelected, options, value])
return (
<div className={clsx("relative", className)}>
<div
className={clsx(
"border-medusa-border-base dark:border-medusa-border-base-dark rounded-sm border border-solid",
"hover:bg-medusa-bg-subtle-hover dark:hover:bg-medusa-bg-subtle-hover-dark",
"py-0.25 h-fit cursor-pointer px-0.5",
"flex items-center gap-[6px] whitespace-nowrap",
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
!open && "bg-medusa-bg-subtle dark:bg-medusa-bg-subtle-dark",
open &&
"bg-medusa-bg-subtle-hover dark:bg-medusa-bg-subtle-hover-dark",
className
)}
ref={ref}
onClick={(e) => {
if (!dropdownRef.current?.contains(e.target as Element)) {
setOpen((prev) => !prev)
}
}}
>
<span
className={clsx(
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
"text-compact-x-small"
)}
>
Show results from:{" "}
</span>
{getSelectedText()}
</div>
<input
type="hidden"
name={props.name}
value={Array.isArray(value) ? value.join(",") : value}
/>
<SelectDropdown
options={options}
open={open}
setOpen={setOpen}
addAll={addAll}
multiple={multiple}
isAllSelected={isAllSelected}
isValueSelected={isValueSelected}
handleSelectAll={handleSelectAll}
handleChange={handleChange}
parentRef={ref}
ref={dropdownRef}
className={clsx(
"!top-[unset] !bottom-full",
open && "!-translate-y-0.5"
)}
/>
</div>
)
}
export default SelectBadge

View File

@@ -0,0 +1,152 @@
import React from "react"
import clsx from "clsx"
import { OptionType } from "../../../hooks/use-select"
import { forwardRef, useCallback, useEffect, useRef } from "react"
import IconCheckMini from "../../../theme/Icon/CheckMini"
import IconEllipseMiniSolid from "../../../theme/Icon/EllipseMiniSolid"
type SelectDropdownProps = {
options: OptionType[]
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
addAll?: boolean
multiple?: boolean
isAllSelected: boolean
isValueSelected: (val: string) => boolean
handleSelectAll: () => void
handleChange?: (selectedValue: string, wasSelected: boolean) => void
parentRef?: React.RefObject<HTMLDivElement>
className?: string
}
const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
function SelectDropdown(
{
open,
setOpen,
options,
addAll,
multiple = false,
isAllSelected,
isValueSelected,
handleSelectAll,
handleChange: handleSelectChange,
parentRef,
className,
},
passedRef
) {
const ref = useRef<HTMLDivElement | null>(null)
const setRefs = useCallback(
(node: HTMLDivElement) => {
// Ref's from useRef needs to have the node assigned to `current`
ref.current = node
if (typeof passedRef === "function") {
passedRef(node)
} else if (passedRef && "current" in passedRef) {
passedRef.current = node
}
},
[passedRef]
)
const handleChange = (clickedValue: string, wasSelected: boolean) => {
handleSelectChange?.(clickedValue, wasSelected)
if (!multiple) {
setOpen(false)
}
}
const handleOutsideClick = useCallback(
(e: MouseEvent) => {
if (
open &&
!ref.current?.contains(e.target as Element) &&
!parentRef?.current?.contains(e.target as Element)
) {
setOpen(false)
}
},
[open, parentRef, setOpen]
)
useEffect(() => {
document.body.addEventListener("click", handleOutsideClick)
return () => {
document.body.removeEventListener("click", handleOutsideClick)
}
}, [handleOutsideClick])
const getSelectOption = (option: OptionType, index: number) => {
const isSelected = option.isAllOption
? isAllSelected
: isValueSelected(option.value)
return (
<li
key={index}
className={clsx(
"pr-0.75 relative rounded-sm py-0.5 pl-2.5",
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"[&>svg]:left-0.75 cursor-pointer [&>svg]:absolute [&>svg]:top-0.5",
!isSelected && "text-compact-small",
isSelected && "text-compact-small-plus"
)}
onClick={() => {
if (option.isAllOption) {
handleSelectAll()
} else {
handleChange(option.value, isSelected)
}
}}
>
{isSelected && (
<>
{multiple && (
<IconCheckMini className="stroke-medusa-fg-base dark:stroke-medusa-fg-base-dark" />
)}
{!multiple && (
<IconEllipseMiniSolid className="fill-medusa-fg-base dark:fill-medusa-fg-base-dark" />
)}
</>
)}
{option.label}
</li>
)
}
return (
<div
className={clsx(
"absolute top-full left-0 w-full",
"z-10 h-0 translate-y-0 overflow-hidden transition-transform",
open && "h-auto translate-y-0.5 !overflow-visible",
className
)}
ref={setRefs}
>
<ul
className={clsx(
"p-0.25 mb-0 w-full overflow-auto rounded",
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark text-medusa-fg-base dark:text-medusa-fg-base-dark",
"shadow-flyout dark:shadow-flyout-dark list-none"
)}
>
{addAll &&
getSelectOption(
{
value: "all",
label: "All Areas",
isAllOption: true,
},
-1
)}
{options.map(getSelectOption)}
</ul>
</div>
)
}
)
export default SelectDropdown

View File

@@ -0,0 +1,128 @@
import React from "react"
import clsx from "clsx"
import { useRef, useState } from "react"
import Badge from "../../Badge"
import useSelect from "../../../hooks/use-select"
import SelectDropdown from "../Dropdown"
import { SelectProps } from "../types"
import IconChevronUpDown from "../../../theme/Icon/ChevronUpDown"
import IconXMarkMini from "../../../theme/Icon/XMarkMini"
const SelectInput = ({
value,
options,
setSelected,
addSelected,
removeSelected,
multiple,
className,
addAll = multiple,
handleAddAll,
showClearButton = true,
...props
}: SelectProps) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const {
isValueSelected,
hasSelectedValue,
hasSelectedValues,
selectedValues,
isAllSelected,
handleChange,
handleSelectAll,
} = useSelect({
value,
options,
multiple,
setSelected,
removeSelected,
addSelected,
handleAddAll,
})
return (
<div
className={clsx(
"px-0.75 relative py-[9px]",
"border-medusa-border-base dark:border-medusa-border-base-dark rounded-sm border border-solid",
"bg-medusa-bg-field dark:bg-medusa-bg-field-dark shadow-button-neutral dark:shadow-button-neutral-dark",
"hover:bg-medusa-bg-field-hover dark:hover:bg-medusa-bg-field-hover-dark",
"active:shadow-active dark:active:shadow-active-dark",
"focus:shadow-active dark:focus:shadow-active-dark",
"text-medusa-fg-base dark:text-medusa-fg-base-dark text-compact-medium",
"disabled:bg-medusa-bg-disabled dark:disabled:bg-medusa-bg-disabled-dark",
"disabled:text-medusa-fg-disabled dark:disabled:text-medusa-fg-disabled-dark",
"flex items-center gap-0.5",
!hasSelectedValues &&
"placeholder:text-medusa-fg-muted dark:placeholder:text-medusa-fg-muted-dark",
hasSelectedValues &&
"placeholder:text-medusa-fg-base dark:placeholder:text-medusa-fg-base-dark",
className
)}
ref={ref}
onClick={(e) => {
if (!dropdownRef.current?.contains(e.target as Element)) {
setOpen((prev) => !prev)
}
}}
>
{hasSelectedValues && (
<Badge
variant="neutral"
className={clsx("flex", showClearButton && "flex-1")}
>
<span
className={clsx(
"text-compact-medium-plus inline-block",
showClearButton && "mr-0.125"
)}
>
{(value as string[]).length}
</span>
{showClearButton && (
<IconXMarkMini
iconColorClassName="stroke-medusa-tag-neutral-icon dark:stroke-medusa-tag-neutral-icon-dark"
onClick={(e) => {
e.stopPropagation()
setSelected?.([])
}}
/>
)}
</Badge>
)}
<span
className={clsx(
"inline-block flex-1 select-none overflow-ellipsis whitespace-nowrap break-words",
hasSelectedValues && "max-w-1/3"
)}
>
{!multiple && hasSelectedValue && selectedValues.length
? selectedValues[0].label
: props.placeholder}
</span>
<IconChevronUpDown iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
<input
type="hidden"
name={props.name}
value={Array.isArray(value) ? value.join(",") : value}
/>
<SelectDropdown
options={options}
open={open}
setOpen={setOpen}
addAll={addAll}
multiple={multiple}
isAllSelected={isAllSelected}
isValueSelected={isValueSelected}
handleSelectAll={handleSelectAll}
handleChange={handleChange}
parentRef={ref}
ref={dropdownRef}
/>
</div>
)
}
export default SelectInput

View File

@@ -0,0 +1,9 @@
import { OptionType, SelectOptions } from "../../hooks/use-select"
export type SelectProps = {
options: OptionType[]
multiple?: boolean
addAll?: boolean
showClearButton?: boolean
} & SelectOptions &
React.ComponentProps<"input">

View File

@@ -1,33 +0,0 @@
import React from "react"
import useDocusaurusContext from "@docusaurus/useDocusaurusContext"
import Head from "@docusaurus/Head"
type StructuredDataSearchboxProps = React.HTMLAttributes<HTMLScriptElement>
const StructuredDataSearchbox: React.FC<StructuredDataSearchboxProps> = () => {
const {
siteConfig: { url },
} = useDocusaurusContext()
return (
<Head>
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
url,
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${url}/search?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
})}
</script>
</Head>
)
}
export default StructuredDataSearchbox

View File

@@ -14,7 +14,7 @@ const TextArea: React.FC<TextAreaProps> = (props) => {
{...props}
className={clsx(
"bg-medusa-bg-field dark:bg-medusa-bg-field-dark shadow-button-secondary dark:shadow-button-secondary-dark",
"rounded border-medusa-border-loud-muted dark:border-medusa-border-loud-muted-dark border border-solid",
"rounded border-medusa-border-base dark:border-medusa-border-base-dark border border-solid",
"px-0.75 pt-0.4 pb-[9px]",
"hover:bg-medusa-bg-field-hover dark:hover:bg-medusa-bg-field-hover-dark",
"focus:border-medusa-border-interactive dark:focus:border-medusa-border-interactive-dark",