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:
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
29
www/docs/src/components/Modal/Footer/index.tsx
Normal file
29
www/docs/src/components/Modal/Footer/index.tsx
Normal 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
|
||||
39
www/docs/src/components/Modal/Header/index.tsx
Normal file
39
www/docs/src/components/Modal/Header/index.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
www/docs/src/components/Search/EmptyQueryBoundary/index.tsx
Normal file
22
www/docs/src/components/Search/EmptyQueryBoundary/index.tsx
Normal 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
|
||||
22
www/docs/src/components/Search/Hits/GroupName/index.tsx
Normal file
22
www/docs/src/components/Search/Hits/GroupName/index.tsx
Normal 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
|
||||
190
www/docs/src/components/Search/Hits/index.tsx
Normal file
190
www/docs/src/components/Search/Hits/index.tsx
Normal 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
|
||||
312
www/docs/src/components/Search/Modal/index.tsx
Normal file
312
www/docs/src/components/Search/Modal/index.tsx
Normal 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
|
||||
78
www/docs/src/components/Search/ModalOpener/index.tsx
Normal file
78
www/docs/src/components/Search/ModalOpener/index.tsx
Normal 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
|
||||
15
www/docs/src/components/Search/NoResults/index.tsx
Normal file
15
www/docs/src/components/Search/NoResults/index.tsx
Normal 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
|
||||
69
www/docs/src/components/Search/Suggestions/index.tsx
Normal file
69
www/docs/src/components/Search/Suggestions/index.tsx
Normal 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
|
||||
136
www/docs/src/components/Select/Badge/index.tsx
Normal file
136
www/docs/src/components/Select/Badge/index.tsx
Normal 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
|
||||
152
www/docs/src/components/Select/Dropdown/index.tsx
Normal file
152
www/docs/src/components/Select/Dropdown/index.tsx
Normal 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
|
||||
128
www/docs/src/components/Select/Input/index.tsx
Normal file
128
www/docs/src/components/Select/Input/index.tsx
Normal 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
|
||||
9
www/docs/src/components/Select/types.ts
Normal file
9
www/docs/src/components/Select/types.ts
Normal 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">
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user