docs: create docs workspace (#5174)
* docs: migrate ui docs to docs universe * created yarn workspace * added eslint and tsconfig configurations * fix eslint configurations * fixed eslint configurations * shared tailwind configurations * added shared ui package * added more shared components * migrating more components * made details components shared * move InlineCode component * moved InputText * moved Loading component * Moved Modal component * moved Select components * Moved Tooltip component * moved Search components * moved ColorMode provider * Moved Notification components and providers * used icons package * use UI colors in api-reference * moved Navbar component * used Navbar and Search in UI docs * added Feedback to UI docs * general enhancements * fix color mode * added copy colors file from ui-preset * added features and enhancements to UI docs * move Sidebar component and provider * general fixes and preparations for deployment * update docusaurus version * adjusted versions * fix output directory * remove rootDirectory property * fix yarn.lock * moved code component * added vale for all docs MD and MDX * fix tests * fix vale error * fix deployment errors * change ignore commands * add output directory * fix docs test * general fixes * content fixes * fix announcement script * added changeset * fix vale checks * added nofilter option * fix vale error
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type BadgeVariant =
|
||||
| "purple"
|
||||
| "purple-dark"
|
||||
| "orange"
|
||||
| "orange-dark"
|
||||
| "green"
|
||||
| "green-dark"
|
||||
| "blue"
|
||||
| "blue-dark"
|
||||
| "red"
|
||||
| "neutral"
|
||||
|
||||
export type BadgeProps = {
|
||||
className?: string
|
||||
variant: BadgeVariant
|
||||
} & React.HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const Badge = ({ className, variant, children }: BadgeProps) => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-compact-x-small-plus px-docs_0.4 rounded-docs_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" &&
|
||||
"bg-medusa-tag-purple-bg-dark text-medusa-tag-purple-text-dark border-medusa-tag-purple-border-dark",
|
||||
variant === "orange" &&
|
||||
"bg-medusa-tag-orange-bg dark:bg-medusa-tag-orange-bg-dark text-medusa-tag-orange-text dark:text-medusa-tag-orange-text-dark border-medusa-tag-orange-border dark:border-medusa-tag-orange-border-dark",
|
||||
variant === "orange-dark" &&
|
||||
"bg-medusa-tag-orange-bg-dark text-medusa-tag-orange-text-dark border-medusa-tag-orange-border-dark",
|
||||
variant === "green" &&
|
||||
"bg-medusa-tag-green-bg dark:bg-medusa-tag-green-bg-dark text-medusa-tag-green-text dark:text-medusa-tag-green-text-dark border-medusa-tag-green-border dark:border-medusa-tag-green-border-dark",
|
||||
variant === "green-dark" &&
|
||||
"bg-medusa-tag-green-bg-dark text-medusa-tag-green-text-dark border-medusa-tag-green-border-dark",
|
||||
variant === "blue" &&
|
||||
"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
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
|
||||
export type BorderedProps = {
|
||||
wrapperClassName?: string
|
||||
} & React.HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const Bordered = ({ wrapperClassName, children }: BorderedProps) => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"border-medusa-border-strong dark:border-medusa-border-strong-dark bg-docs-bg",
|
||||
"dark:bg-docs-bg-dark mr-docs_1 inline-flex w-fit items-center justify-center rounded-docs_DEFAULT border border-solid p-[3px]",
|
||||
"no-zoom-img",
|
||||
wrapperClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react"
|
||||
import { Bordered } from "@/components/Bordered"
|
||||
import clsx from "clsx"
|
||||
import { IconProps } from "@medusajs/icons/dist/types"
|
||||
|
||||
export type BorderedIconProps = {
|
||||
icon?: string
|
||||
IconComponent?: React.FC<IconProps> | null
|
||||
wrapperClassName?: string
|
||||
iconWrapperClassName?: string
|
||||
iconClassName?: string
|
||||
iconColorClassName?: string
|
||||
} & React.HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const BorderedIcon = ({
|
||||
icon = "",
|
||||
IconComponent = null,
|
||||
wrapperClassName,
|
||||
iconWrapperClassName,
|
||||
iconClassName,
|
||||
iconColorClassName = "",
|
||||
}: BorderedIconProps) => {
|
||||
return (
|
||||
<Bordered wrapperClassName={wrapperClassName}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-docs_xs p-docs_0.125 bg-medusa-bg-component dark:bg-medusa-bg-component-dark inline-flex items-center justify-center",
|
||||
iconWrapperClassName
|
||||
)}
|
||||
>
|
||||
{!IconComponent && (
|
||||
<img
|
||||
src={icon || ""}
|
||||
className={clsx(iconClassName, "bordered-icon")}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={clsx(
|
||||
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
|
||||
iconClassName,
|
||||
"bordered-icon",
|
||||
iconColorClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Bordered>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
|
||||
export type ButtonVariants = "primary" | "secondary" | "clear"
|
||||
|
||||
export type ButtonType = "default" | "icon"
|
||||
|
||||
export type ButtonProps = {
|
||||
isSelected?: boolean
|
||||
disabled?: boolean
|
||||
variant?: ButtonVariants
|
||||
className?: string
|
||||
buttonType?: ButtonType
|
||||
buttonRef?: React.LegacyRef<HTMLButtonElement>
|
||||
} & React.HTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export const Button = ({
|
||||
className,
|
||||
children,
|
||||
variant = "primary",
|
||||
buttonType = "default",
|
||||
buttonRef,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const variantClasses = {
|
||||
primary: [
|
||||
"inline-flex flex-row justify-center items-center",
|
||||
"py-[5px] px-docs_0.75 rounded-docs_sm cursor-pointer",
|
||||
"bg-button-inverted bg-medusa-button-inverted dark:bg-button-inverted-dark dark:bg-medusa-button-inverted-dark",
|
||||
"hover:bg-medusa-button-inverted-hover hover:bg-no-image dark:hover:bg-medusa-button-inverted-hover-dark hover:no-underline",
|
||||
"active:bg-medusa-button-inverted-pressed active:bg-no-image dark:active:bg-medusa-button-inverted-pressed-dark",
|
||||
"focus:bg-medusa-button-inverted-pressed focus:bg-no-image dark:focus:bg-medusa-button-inverted-pressed-dark",
|
||||
"shadow-button-colored active:shadow-button-colored-focused focus:shadow-button-colored-focused transition-shadow",
|
||||
"dark:shadow-button-colored-dark dark:active:shadow-button-colored-focused-dark dark:focus:shadow-button-colored-focused-dark",
|
||||
"disabled:!bg-no-image disabled:bg-medusa-bg-disabled dark:disabled:bg-medusa-bg-disabled-dark",
|
||||
"disabled:cursor-not-allowed disabled:border-medusa-border-base dark:disabled:border-medusa-border-base-dark",
|
||||
"text-compact-small-plus text-medusa-fg-on-inverted dark:text-medusa-fg-on-inverted-dark",
|
||||
"[&_a]:text-medusa-fg-on-inverted dark:[&_a]:text-medusa-fg-on-inverted-dark",
|
||||
"disabled:text-medusa-fg-disabled dark:disabled:text-medusa-fg-disabled-dark",
|
||||
"[&_a]:disabled:text-medusa-fg-disabled dark:[&_a]:disabled:text-medusa-fg-disabled-dark",
|
||||
"border border-medusa-border-loud dark:border-medusa-border-loud-dark",
|
||||
"select-none",
|
||||
],
|
||||
secondary: [
|
||||
"inline-flex flex-row justify-center items-center",
|
||||
"py-[5px] px-docs_0.75 rounded-docs_sm cursor-pointer",
|
||||
"bg-button-neutral bg-medusa-button-neutral dark:bg-button-neutral-dark dark:bg-medusa-button-neutral-dark",
|
||||
"hover:bg-medusa-button-neutral-hover hover:bg-no-image dark:hover:bg-medusa-button-neutral-hover-dark hover:no-underline",
|
||||
"active:bg-medusa-button-neutral-pressed active:bg-no-image dark:active:bg-medusa-button-neutral-pressed-dark",
|
||||
"focus:bg-medusa-button-neutral-pressed focus:bg-no-image dark:focus:bg-medusa-button-neutral-pressed-dark",
|
||||
"disabled:!bg-no-image disabled:bg-medusa-bg-disabled dark:disabled:bg-medusa-bg-disabled-dark",
|
||||
"disabled:cursor-not-allowed",
|
||||
"border border-solid border-medusa-border-base dark:border-medusa-border-base-dark",
|
||||
"text-compact-small-plus text-medusa-fg-base dark:text-medusa-fg-base-dark",
|
||||
"[&_a]:text-medusa-fg-base dark:[&_a]:text-medusa-fg-base-dark",
|
||||
"shadow-button-neutral focus:shadow-button-neutral-focused active:shadow-button-neutral-focused transition-shadow",
|
||||
"dark:shadow-button-neutral dark:focus:shadow-button-neutral-focused dark:active:shadow-button-neutral-focused",
|
||||
"select-none",
|
||||
],
|
||||
clear: ["bg-transparent shadow-none border-0 outline-none cursor-pointer"],
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
variant === "primary" && variantClasses.primary,
|
||||
variant === "secondary" && variantClasses.secondary,
|
||||
variant === "clear" && variantClasses.clear,
|
||||
buttonType === "icon" && "!px-docs_0.25",
|
||||
className
|
||||
)}
|
||||
ref={buttonRef}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react"
|
||||
import { ArrowUpRightOnBox } from "@medusajs/icons"
|
||||
import clsx from "clsx"
|
||||
import { Link } from "@/components"
|
||||
|
||||
export type CardProps = {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
text?: string
|
||||
href?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Card = ({ icon, title, text, href, className }: CardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-medusa-bg-subtle dark:bg-medusa-bg-subtle-dark w-full rounded",
|
||||
"shadow-card-rest dark:shadow-card-rest-dark py-docs_0.75 relative px-docs_1",
|
||||
"flex items-center gap-docs_1 transition-shadow",
|
||||
href && "hover:shadow-card-hover dark:hover:shadow-card-hover-dark",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex items-center gap-docs_1 justify-between flex-1">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-compact-medium-plus text-medusa-fg-base dark:text-medusa-fg-base-dark">
|
||||
{title}
|
||||
</span>
|
||||
{text && (
|
||||
<span className="text-compact-medium text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{href && (
|
||||
<>
|
||||
<ArrowUpRightOnBox className="text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark" />
|
||||
<Link
|
||||
href={href}
|
||||
className="absolute left-0 top-0 h-full w-full rounded"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { HighlightProps, Highlight, themes } from "prism-react-renderer"
|
||||
import { CopyButton, useColorMode } from "docs-ui"
|
||||
import { SquareTwoStackSolid } from "@medusajs/icons"
|
||||
|
||||
export type CodeBlockProps = {
|
||||
source: string
|
||||
lang?: string
|
||||
className?: string
|
||||
collapsed?: boolean
|
||||
} & Omit<HighlightProps, "code" | "language" | "children">
|
||||
|
||||
export const CodeBlock = ({
|
||||
source,
|
||||
lang = "",
|
||||
className,
|
||||
collapsed = false,
|
||||
...rest
|
||||
}: CodeBlockProps) => {
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-medusa-code-bg-base dark:bg-medusa-code-bg-base-dark relative mb-docs_1 rounded-docs_DEFAULT",
|
||||
"border-medusa-code-border dark:border-medusa-code-border-dark w-full max-w-full border",
|
||||
"xs:after:content-[''] xs:after:rounded xs:after:absolute xs:after:right-0 xs:after:top-0 xs:after:w-[calc(10%+24px)] xs:after:h-full xs:after:bg-code-fade xs:dark:after:bg-code-fade-dark",
|
||||
collapsed && "max-h-[400px] overflow-auto",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Highlight
|
||||
theme={{
|
||||
...themes.vsDark,
|
||||
plain: {
|
||||
...themes.vsDark.plain,
|
||||
backgroundColor: colorMode === "light" ? "#111827" : "#1B1B1F",
|
||||
},
|
||||
}}
|
||||
code={source.trim()}
|
||||
language={lang}
|
||||
{...rest}
|
||||
>
|
||||
{({
|
||||
className: preClassName,
|
||||
style,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<>
|
||||
<pre
|
||||
style={{ ...style, fontStretch: "100%" }}
|
||||
className={clsx(
|
||||
"xs:max-w-[90%] relative !mt-0 break-words bg-transparent !outline-none",
|
||||
"overflow-auto break-words rounded-docs_DEFAULT",
|
||||
preClassName
|
||||
)}
|
||||
>
|
||||
<code
|
||||
className={clsx(
|
||||
"text-code-body font-monospace table min-w-full pb-docs_1.5 print:whitespace-pre-wrap",
|
||||
tokens.length > 1 && "pt-docs_1 pr-docs_1",
|
||||
tokens.length <= 1 && "py-docs_0.5 px-docs_1"
|
||||
)}
|
||||
>
|
||||
{tokens.map((line, i) => {
|
||||
const lineProps = getLineProps({ line })
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
{...lineProps}
|
||||
className={clsx("table-row", lineProps.className)}
|
||||
>
|
||||
{tokens.length > 1 && (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-medusa-code-text-subtle dark:text-medusa-code-text-subtle-dark mr-docs_1 table-cell select-none",
|
||||
"bg-medusa-code-bg-base dark:bg-medusa-code-bg-base-dark sticky left-0 w-[1%] px-docs_1 text-right"
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute z-50 hidden gap-docs_1 md:flex",
|
||||
tokens.length === 1 && "right-docs_0.75 top-[10px]",
|
||||
tokens.length > 1 && "right-docs_1 top-docs_1"
|
||||
)}
|
||||
>
|
||||
<CopyButton text={source} tooltipClassName="font-base">
|
||||
<SquareTwoStackSolid className="text-medusa-code-icon dark:text-medusa-code-icon-dark" />
|
||||
</CopyButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react"
|
||||
import { CodeBlock, InlineCode } from "@/components"
|
||||
|
||||
export type CodeMdxProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
// due to how mdx handles code blocks
|
||||
// it is required that a code block specify a language
|
||||
// to be considered a block. Otherwise, it will be
|
||||
// considered as inline code
|
||||
export const CodeMdx = ({ className, children }: CodeMdxProps) => {
|
||||
if (!children) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
|
||||
if (match) {
|
||||
return <CodeBlock source={children as string} lang={match[1]} />
|
||||
}
|
||||
|
||||
return <InlineCode>{children}</InlineCode>
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import clsx from "clsx"
|
||||
import { CodeBlock, CodeBlockProps } from "@/components"
|
||||
import { useTabs, BaseTabType, useScrollPositionBlocker } from "@/hooks"
|
||||
|
||||
export type TabType = {
|
||||
code?: CodeBlockProps
|
||||
codeBlock?: React.ReactNode
|
||||
} & BaseTabType
|
||||
|
||||
export type CodeTabsProps = {
|
||||
tabs: TabType[]
|
||||
className?: string
|
||||
group?: string
|
||||
}
|
||||
|
||||
export const CodeTabs = ({
|
||||
tabs,
|
||||
className,
|
||||
group = "client",
|
||||
}: CodeTabsProps) => {
|
||||
const { selectedTab, changeSelectedTab } = useTabs<TabType>({
|
||||
tabs,
|
||||
group,
|
||||
})
|
||||
const tabRefs: (HTMLButtonElement | null)[] = useMemo(() => [], [])
|
||||
const codeTabSelectorRef = useRef<HTMLSpanElement | null>(null)
|
||||
const codeTabsWrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const { blockElementScrollPositionUntilNextRender } =
|
||||
useScrollPositionBlocker()
|
||||
|
||||
const changeTabSelectorCoordinates = useCallback(
|
||||
(selectedTabElm: HTMLElement) => {
|
||||
if (!codeTabSelectorRef?.current || !codeTabsWrapperRef?.current) {
|
||||
return
|
||||
}
|
||||
const selectedTabsCoordinates = selectedTabElm.getBoundingClientRect()
|
||||
const tabsWrapperCoordinates =
|
||||
codeTabsWrapperRef.current.getBoundingClientRect()
|
||||
codeTabSelectorRef.current.style.left = `${
|
||||
selectedTabsCoordinates.left - tabsWrapperCoordinates.left
|
||||
}px`
|
||||
codeTabSelectorRef.current.style.width = `${selectedTabsCoordinates.width}px`
|
||||
codeTabSelectorRef.current.style.height = `${selectedTabsCoordinates.height}px`
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (codeTabSelectorRef?.current && tabRefs.length) {
|
||||
const selectedTabElm = tabRefs.find(
|
||||
(tab) => tab?.getAttribute("aria-selected") === "true"
|
||||
)
|
||||
if (selectedTabElm) {
|
||||
changeTabSelectorCoordinates(
|
||||
selectedTabElm.parentElement || selectedTabElm
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [codeTabSelectorRef, tabRefs, changeTabSelectorCoordinates, selectedTab])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative my-docs_1 w-full max-w-full overflow-auto",
|
||||
className
|
||||
)}
|
||||
ref={codeTabsWrapperRef}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"xs:absolute xs:border xs:border-solid xs:border-medusa-code-border dark:xs:border-medusa-code-border-dark xs:bg-medusa-code-bg-base dark:xs:bg-medusa-code-bg-base-dark ",
|
||||
"xs:transition-all xs:duration-200 xs:ease-ease xs:top-[13px] xs:z-[1] xs:rounded-full"
|
||||
)}
|
||||
ref={codeTabSelectorRef}
|
||||
></span>
|
||||
<ul
|
||||
className={clsx(
|
||||
"bg-medusa-code-bg-header dark:bg-medusa-code-bg-header-dark py-docs_0.75 flex !list-none rounded-t-docs_DEFAULT px-docs_1",
|
||||
"border-medusa-code-border dark:border-medusa-code-border-dark border border-b-0",
|
||||
"gap-docs_0.25 mb-0"
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
className={clsx(
|
||||
"text-compact-small-plus xs:border-0 py-docs_0.25 px-docs_0.75 relative z-[2] rounded-full border",
|
||||
(!selectedTab || selectedTab.value !== tab.value) && [
|
||||
"text-medusa-code-text-subtle dark:text-medusa-code-text-subtle-dark border-transparent",
|
||||
"hover:bg-medusa-code-bg-base dark:hover:bg-medusa-code-bg-base-dark",
|
||||
],
|
||||
selectedTab?.value === tab.value && [
|
||||
"text-medusa-code-text-base bg-medusa-code-bg-base xs:!bg-transparent",
|
||||
"dark:text-medusa-code-text-base-dark dark:bg-medusa-code-bg-base-dark xs:!bg-transparent",
|
||||
]
|
||||
)}
|
||||
ref={(tabControl) => tabRefs.push(tabControl)}
|
||||
onClick={(e) => {
|
||||
blockElementScrollPositionUntilNextRender(
|
||||
e.target as HTMLButtonElement
|
||||
)
|
||||
changeSelectedTab(tab)
|
||||
}}
|
||||
aria-selected={selectedTab?.value === tab.value}
|
||||
role="tab"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
{selectedTab?.code && (
|
||||
<CodeBlock
|
||||
{...selectedTab?.code}
|
||||
className={clsx(
|
||||
"!mt-0 !rounded-t-none",
|
||||
selectedTab.code.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{selectedTab?.codeBlock && <>{selectedTab.codeBlock}</>}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react"
|
||||
import copy from "copy-text-to-clipboard"
|
||||
import clsx from "clsx"
|
||||
import { Tooltip } from "@/components/Tooltip"
|
||||
|
||||
export type CopyButtonProps = {
|
||||
text: string
|
||||
buttonClassName?: string
|
||||
tooltipClassName?: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CopyButton = ({
|
||||
text,
|
||||
buttonClassName = "",
|
||||
tooltipClassName = "",
|
||||
children,
|
||||
}: CopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const copyTimeout = useRef<number>(0)
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(text)
|
||||
setIsCopied(true)
|
||||
copyTimeout.current = window.setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 1000)
|
||||
}, [text])
|
||||
|
||||
useEffect(() => () => window.clearTimeout(copyTimeout.current), [])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={isCopied ? `Copied!` : `Copy to Clipboard`}
|
||||
tooltipClassName={tooltipClassName}
|
||||
>
|
||||
<span
|
||||
className={clsx("cursor-pointer", buttonClassName)}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { PlusMini } from "@medusajs/icons"
|
||||
|
||||
export type DetailsSummaryProps = {
|
||||
title: React.ReactNode
|
||||
subtitle?: string
|
||||
badge?: React.ReactNode
|
||||
expandable?: boolean
|
||||
open?: boolean
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
} & Omit<React.HTMLAttributes<HTMLElement>, "title">
|
||||
|
||||
export const DetailsSummary = ({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
expandable = true,
|
||||
open = false,
|
||||
className,
|
||||
titleClassName,
|
||||
...rest
|
||||
}: DetailsSummaryProps) => {
|
||||
return (
|
||||
<summary
|
||||
className={clsx(
|
||||
"py-docs_0.75 flex items-center justify-between",
|
||||
expandable && "cursor-pointer",
|
||||
!expandable &&
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark border-y",
|
||||
"no-marker",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span className="gap-docs_0.25 flex flex-col">
|
||||
<span
|
||||
className={clsx(
|
||||
"text-compact-medium-plus text-medusa-fg-base dark:text-medusa-fg-base-dark",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-compact-medium text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{(badge || expandable) && (
|
||||
<span className="flex gap-docs_0.5">
|
||||
{badge}
|
||||
{expandable && (
|
||||
<PlusMini
|
||||
className={clsx("transition-transform", open && "rotate-45")}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import React, { Suspense, cloneElement, useRef, useState } from "react"
|
||||
import { Loading } from "@/components/Loading"
|
||||
import clsx from "clsx"
|
||||
import { CSSTransition } from "react-transition-group"
|
||||
import { DetailsSummary } from "./Summary"
|
||||
|
||||
export type DetailsProps = {
|
||||
openInitial?: boolean
|
||||
summaryContent?: React.ReactNode
|
||||
summaryElm?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDetailsElement>
|
||||
|
||||
export const Details = ({
|
||||
openInitial = false,
|
||||
summaryContent,
|
||||
summaryElm,
|
||||
children,
|
||||
...props
|
||||
}: DetailsProps) => {
|
||||
const [open, setOpen] = useState(openInitial)
|
||||
const [showContent, setShowContent] = useState(openInitial)
|
||||
const ref = useRef<HTMLDetailsElement>(null)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (open) {
|
||||
setShowContent(false)
|
||||
} else {
|
||||
setOpen(true)
|
||||
setShowContent(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<details
|
||||
{...props}
|
||||
ref={ref}
|
||||
open={open}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
onToggle={(event) => {
|
||||
// this is to avoid event propagation
|
||||
// when details are nested, which is a bug
|
||||
// in react. Learn more here:
|
||||
// https://github.com/facebook/react/issues/22718
|
||||
event.stopPropagation()
|
||||
}}
|
||||
className={clsx(
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark border-y border-solid border-x-0",
|
||||
"overflow-hidden [&>summary]:relative [&>summary]:z-[398]",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{summaryContent && (
|
||||
<DetailsSummary
|
||||
onClick={handleToggle}
|
||||
className="cursor-pointer"
|
||||
title={summaryContent}
|
||||
/>
|
||||
)}
|
||||
{summaryElm &&
|
||||
cloneElement(summaryElm as React.ReactElement, {
|
||||
open,
|
||||
onClick: handleToggle,
|
||||
})}
|
||||
<CSSTransition
|
||||
unmountOnExit
|
||||
in={showContent}
|
||||
timeout={150}
|
||||
onEnter={(node: HTMLElement) => {
|
||||
node.classList.add(
|
||||
"!mb-docs_2",
|
||||
"!mt-0",
|
||||
"translate-y-docs_1",
|
||||
"transition-transform"
|
||||
)
|
||||
}}
|
||||
onExit={(node: HTMLElement) => {
|
||||
node.classList.add("transition-transform", "!-translate-y-docs_1")
|
||||
setTimeout(() => {
|
||||
setOpen(false)
|
||||
}, 100)
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<Loading className="!mb-docs_2 !mt-0" />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</CSSTransition>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { request } from "@octokit/request"
|
||||
import { Link } from "@/components/Link"
|
||||
|
||||
export type SolutionsProps = {
|
||||
feedback: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type GitHubSearchItem = {
|
||||
url: string
|
||||
html_url: string
|
||||
title: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export const Solutions = ({ feedback, message }: SolutionsProps) => {
|
||||
const [possibleSolutionsQuery, setPossibleSolutionsQuery] =
|
||||
useState<string>("")
|
||||
const [possibleSolutions, setPossibleSolutions] = useState<
|
||||
GitHubSearchItem[]
|
||||
>([])
|
||||
|
||||
function constructQuery(searchQuery: string) {
|
||||
return `${searchQuery} repo:medusajs/medusa is:closed is:issue`
|
||||
}
|
||||
|
||||
async function searchGitHub(query: string) {
|
||||
return request(`GET /search/issues`, {
|
||||
q: query,
|
||||
sort: "updated",
|
||||
per_page: 3,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedback) {
|
||||
let query = constructQuery(
|
||||
// Github does not allow queries longer than 256 characters
|
||||
message ? message.substring(0, 256) : document.title
|
||||
)
|
||||
searchGitHub(query)
|
||||
.then(async (result) => {
|
||||
if (!result.data.items.length && message) {
|
||||
query = constructQuery(document.title)
|
||||
result = await searchGitHub(query)
|
||||
}
|
||||
|
||||
setPossibleSolutionsQuery(query)
|
||||
setPossibleSolutions(result.data.items)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
} else {
|
||||
setPossibleSolutionsQuery("")
|
||||
setPossibleSolutions([])
|
||||
}
|
||||
}, [feedback, message])
|
||||
|
||||
return (
|
||||
<>
|
||||
{possibleSolutions.length > 0 && (
|
||||
<div className="text-compact-large-plus font-normal">
|
||||
<span className="my-docs_1 mx-0 inline-block">
|
||||
If you faced a problem, here are some possible solutions from
|
||||
GitHub:
|
||||
</span>
|
||||
<ul>
|
||||
{possibleSolutions.map((solution) => (
|
||||
<li key={solution.url} className="mb-docs_0.5 last:mb-0">
|
||||
<Link href={solution.html_url} target="_blank" rel="noreferrer">
|
||||
{solution.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span>
|
||||
Explore more issues in{" "}
|
||||
<a
|
||||
href={`https://github.com/medusajs/medusa/issues?q=${possibleSolutionsQuery}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
the GitHub repository
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import React, { useRef, useState } from "react"
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group"
|
||||
import { Solutions } from "./Solutions"
|
||||
import { ExtraData, useAnalytics } from "@/providers/Analytics"
|
||||
import clsx from "clsx"
|
||||
import { TextArea } from "@/components/TextArea"
|
||||
import { Label } from "@/components/Label"
|
||||
import { Button } from "docs-ui"
|
||||
import { Details } from "@/components/Details"
|
||||
import { InputText } from "@/components/Input/Text"
|
||||
|
||||
export type FeedbackProps = {
|
||||
event: string
|
||||
pathName: string
|
||||
reportLink?: string
|
||||
question?: string
|
||||
positiveBtn?: string
|
||||
negativeBtn?: string
|
||||
positiveQuestion?: string
|
||||
negativeQuestion?: string
|
||||
submitBtn?: string
|
||||
submitMessage?: string
|
||||
showPossibleSolutions?: boolean
|
||||
className?: string
|
||||
extraData?: ExtraData
|
||||
vertical?: boolean
|
||||
showLongForm?: boolean
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Feedback = ({
|
||||
event,
|
||||
pathName,
|
||||
reportLink,
|
||||
question = "Was this section helpful?",
|
||||
positiveBtn = "Yes",
|
||||
negativeBtn = "No",
|
||||
positiveQuestion = "What was most helpful?",
|
||||
negativeQuestion = "What can we improve?",
|
||||
submitBtn = "Submit",
|
||||
submitMessage = "Thank you for helping improve our documentation!",
|
||||
showPossibleSolutions = true,
|
||||
className = "",
|
||||
extraData = {},
|
||||
vertical = false,
|
||||
showLongForm = false,
|
||||
}: FeedbackProps) => {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [submittedFeedback, setSubmittedFeedback] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inlineFeedbackRef = useRef<HTMLDivElement>(null)
|
||||
const inlineQuestionRef = useRef<HTMLDivElement>(null)
|
||||
const inlineMessageRef = useRef<HTMLDivElement>(null)
|
||||
const [positiveFeedback, setPositiveFeedback] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [steps, setSteps] = useState("")
|
||||
const [medusaVersion, setMedusaVersion] = useState("")
|
||||
const [errorFix, setErrorFix] = useState("")
|
||||
const [contactInfo, setContactInfo] = useState("")
|
||||
const nodeRef: React.RefObject<HTMLDivElement> = submittedFeedback
|
||||
? inlineMessageRef
|
||||
: showForm
|
||||
? inlineQuestionRef
|
||||
: inlineFeedbackRef
|
||||
const { loaded, track } = useAnalytics()
|
||||
|
||||
function handleFeedback(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
||||
if (!loaded) {
|
||||
return
|
||||
}
|
||||
const feedback = (e.target as Element).classList.contains("positive")
|
||||
setPositiveFeedback(feedback)
|
||||
setShowForm(true)
|
||||
submitFeedback(e, feedback)
|
||||
}
|
||||
|
||||
function submitFeedback(
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
feedback = false
|
||||
) {
|
||||
if (showForm) {
|
||||
setLoading(true)
|
||||
}
|
||||
track(
|
||||
event,
|
||||
{
|
||||
url: pathName,
|
||||
label: document.title,
|
||||
feedback:
|
||||
(feedback !== null && feedback) ||
|
||||
(feedback === null && positiveFeedback)
|
||||
? "yes"
|
||||
: "no",
|
||||
message: message?.length ? message : null,
|
||||
os: window.navigator.userAgent,
|
||||
...extraData,
|
||||
},
|
||||
function () {
|
||||
if (showForm) {
|
||||
setLoading(false)
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setShowForm(false)
|
||||
setSubmittedFeedback(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("mt-docs_3", className)}>
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={
|
||||
showForm
|
||||
? "show_form"
|
||||
: !submittedFeedback
|
||||
? "feedback"
|
||||
: "submitted_feedback"
|
||||
}
|
||||
nodeRef={nodeRef}
|
||||
timeout={300}
|
||||
addEndListener={(done) => {
|
||||
nodeRef.current?.addEventListener("transitionend", done, false)
|
||||
}}
|
||||
classNames={{
|
||||
enter: "animate-fadeIn animation-fill-forwards animate-fast",
|
||||
exit: "animate-fadeOut animation-fill-forwards animate-fast",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{!showForm && !submittedFeedback && (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex",
|
||||
!vertical && "flex-row items-center",
|
||||
vertical && "flex-col justify-center gap-docs_1"
|
||||
)}
|
||||
ref={inlineFeedbackRef}
|
||||
>
|
||||
<Label className="mr-docs_1.5">{question}</Label>
|
||||
<div
|
||||
className={clsx("flex flex-row items-center gap-docs_0.5")}
|
||||
>
|
||||
<Button
|
||||
onClick={handleFeedback}
|
||||
className="positive w-fit"
|
||||
variant="secondary"
|
||||
>
|
||||
{positiveBtn}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFeedback}
|
||||
className="w-fit"
|
||||
variant="secondary"
|
||||
>
|
||||
{negativeBtn}
|
||||
</Button>
|
||||
{reportLink && (
|
||||
<Button variant="secondary">
|
||||
<a href={reportLink}>Report Issue</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showForm && !submittedFeedback && (
|
||||
<div className="flex flex-col gap-docs_1" ref={inlineQuestionRef}>
|
||||
<Label>
|
||||
{positiveFeedback ? positiveQuestion : negativeQuestion}
|
||||
</Label>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
{showLongForm && !positiveFeedback && (
|
||||
<Details summaryContent="More Details" className="mt-docs_1">
|
||||
<div className="flex flex-col gap-docs_0.5">
|
||||
<div className="flex flex-col gap-docs_0.5">
|
||||
<Label>
|
||||
Can you provide the exact steps you took before
|
||||
receiving the error? For example, the commands you
|
||||
ran.
|
||||
</Label>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(e.target.value)}
|
||||
placeholder="1. I ran npm dev..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-docs_0.5">
|
||||
<Label>
|
||||
If applicable, what version of Medusa are you using?
|
||||
If a plugin is related to the error, please provide a
|
||||
version of that as well.
|
||||
</Label>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={medusaVersion}
|
||||
onChange={(e) => setMedusaVersion(e.target.value)}
|
||||
placeholder="@medusajs/medusa: vX"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-docs_0.5">
|
||||
<Label>
|
||||
Were you able to fix the error? If so, what steps did
|
||||
you follow?
|
||||
</Label>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={errorFix}
|
||||
onChange={(e) => setErrorFix(e.target.value)}
|
||||
placeholder="@medusajs/medusa: vX"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-docs_0.5">
|
||||
<Label>
|
||||
Can you provide your email or discord username? This
|
||||
would allow us to contact you for further info or
|
||||
assist you with your issue.
|
||||
</Label>
|
||||
<InputText
|
||||
value={contactInfo}
|
||||
onChange={(e) => setContactInfo(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Details>
|
||||
)}
|
||||
<Button
|
||||
onClick={submitFeedback}
|
||||
disabled={loading}
|
||||
className="w-fit"
|
||||
variant="secondary"
|
||||
>
|
||||
{submitBtn}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{submittedFeedback && (
|
||||
<div>
|
||||
<div
|
||||
className="text-compact-large-plus flex flex-col"
|
||||
ref={inlineMessageRef}
|
||||
>
|
||||
<span>{submitMessage}</span>
|
||||
{showPossibleSolutions && (
|
||||
<Solutions message={message} feedback={positiveFeedback} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { CopyButton } from "@/components"
|
||||
|
||||
export type InlineCodeProps = React.ComponentProps<"code">
|
||||
|
||||
export const InlineCode = (props: InlineCodeProps) => {
|
||||
return (
|
||||
<CopyButton
|
||||
text={props.children as string}
|
||||
buttonClassName={clsx(
|
||||
"bg-transparent border-0 p-0 inline text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
|
||||
"active:[&>code]:bg-medusa-bg-subtle-pressed dark:active:[&>code]:bg-medusa-bg-subtle-pressed-dark",
|
||||
"focus:[&>code]:bg-medusa-bg-subtle-pressed dark:focus:[&>code]:bg-medusa-bg-subtle-pressed-dark",
|
||||
"hover:[&>code]:bg-medusa-bg-subtle-hover dark:hover:[&>code]:bg-medusa-bg-base-hover-dark"
|
||||
)}
|
||||
>
|
||||
<code
|
||||
{...props}
|
||||
className={clsx(
|
||||
"border-medusa-tag-neutral-border dark:border-medusa-tag-neutral-border-dark border",
|
||||
"text-medusa-tag-neutral-text dark:text-medusa-tag-neutral-text-dark",
|
||||
"bg-medusa-tag-neutral-bg dark:bg-medusa-tag-neutral-bg-dark font-monospace text-code-label rounded-docs_sm py-0 px-[6px]",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
</CopyButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type InputTextProps = {
|
||||
className?: string
|
||||
} & React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>
|
||||
|
||||
export const InputText = (props: InputTextProps) => {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={clsx(
|
||||
"bg-medusa-bg-field dark:bg-medusa-bg-field-dark shadow-button-secondary dark:shadow-button-secondary-dark",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark rounded-docs_sm border border-solid",
|
||||
"px-docs_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",
|
||||
"active:border-medusa-border-interactive dark:active:border-medusa-border-interactive-dark",
|
||||
"disabled:bg-medusa-bg-disabled dark:disabled:bg-medusa-bg-disabled-dark",
|
||||
"disabled:border-medusa-border-base dark:disabled:border-medusa-border-base-dark",
|
||||
"placeholder:text-medusa-fg-muted dark:placeholder:text-medusa-fg-muted-dark",
|
||||
"disabled:placeholder:text-medusa-fg-disabled dark:disabled:placeholder:text-medusa-fg-disabled-dark",
|
||||
"text-compact-medium font-base",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type KbdProps = React.ComponentProps<"kbd">
|
||||
|
||||
export const Kbd = ({ children, className, ...props }: KbdProps) => {
|
||||
return (
|
||||
<kbd
|
||||
className={clsx(
|
||||
"rounded-docs_sm border-solid py-0 px-[6px]",
|
||||
"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 font-monospace shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
|
||||
export type LabelProps = {
|
||||
className?: string
|
||||
} & React.HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const Label = ({ children, className }: LabelProps) => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-medusa-fg-base dark:text-medusa-fg-base-dark text-compact-medium-plus",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type LinkProps = {
|
||||
href?: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
} & React.AllHTMLAttributes<HTMLAnchorElement>
|
||||
|
||||
export const Link = ({ href, children, className, ...rest }: LinkProps) => {
|
||||
return (
|
||||
<a
|
||||
href={href || ""}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"text-medusa-fg-interactive hover:text-medusa-fg-interactive-hover",
|
||||
"dark:text-medusa-fg-interactive-dark dark:hover:text-medusa-fg-interactive-hover-dark",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
import { IconProps } from "@medusajs/icons/dist/types"
|
||||
|
||||
export type SpinnerLoadingProps = {
|
||||
iconProps?: IconProps
|
||||
}
|
||||
|
||||
export const SpinnerLoading = ({ iconProps }: SpinnerLoadingProps) => {
|
||||
return (
|
||||
<span role="status">
|
||||
<Spinner
|
||||
{...iconProps}
|
||||
className={clsx("animate-spin", iconProps?.className)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type LoadingProps = {
|
||||
className?: string
|
||||
barClassName?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export const Loading = ({
|
||||
className,
|
||||
count = 6,
|
||||
barClassName,
|
||||
}: LoadingProps) => {
|
||||
const getLoadingBars = () => {
|
||||
const bars = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
bars.push(
|
||||
<span
|
||||
className={clsx(
|
||||
"bg-medusa-bg-subtle-pressed dark:bg-medusa-bg-subtle-pressed-dark h-docs_1 w-full rounded-full",
|
||||
barClassName
|
||||
)}
|
||||
key={i}
|
||||
></span>
|
||||
)
|
||||
}
|
||||
|
||||
return bars
|
||||
}
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
className={clsx(
|
||||
"my-docs_1 flex w-full animate-pulse flex-col gap-docs_1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{getLoadingBars()}
|
||||
<span className="sr-only">Loading...</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { Button, ButtonProps } from "@/components"
|
||||
|
||||
export type ModalFooterProps = {
|
||||
actions?: ButtonProps[]
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ModalFooter = ({
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: ModalFooterProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"py-docs_1.5 pl-0 pr-docs_2",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark border-0 border-t border-solid",
|
||||
"flex justify-end gap-docs_0.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<Button {...action} key={index} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { useModal } from "@/providers"
|
||||
import { Button } from "@/components"
|
||||
import { XMark } from "@medusajs/icons"
|
||||
|
||||
export type ModalHeaderProps = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export 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-docs_1.5 px-docs_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()}
|
||||
>
|
||||
<XMark />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import clsx from "clsx"
|
||||
import React, { useCallback, useEffect, useRef } from "react"
|
||||
import { useModal } from "@/providers"
|
||||
import { ModalHeader } from "./Header"
|
||||
import { ModalFooter } from "./Footer"
|
||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"
|
||||
import { ButtonProps } from "@/components"
|
||||
import { Ref } from "@/types"
|
||||
|
||||
export type ModalProps = {
|
||||
className?: string
|
||||
title?: string
|
||||
actions?: ButtonProps[]
|
||||
modalContainerClassName?: string
|
||||
contentClassName?: string
|
||||
onClose?: React.ReactEventHandler<HTMLDialogElement>
|
||||
open?: boolean
|
||||
footerContent?: React.ReactNode
|
||||
passedRef?: Ref<HTMLDialogElement>
|
||||
} & Omit<React.ComponentProps<"dialog">, "ref">
|
||||
|
||||
export const Modal = ({
|
||||
className,
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
contentClassName,
|
||||
modalContainerClassName,
|
||||
onClose,
|
||||
open = true,
|
||||
footerContent,
|
||||
passedRef,
|
||||
...props
|
||||
}: ModalProps) => {
|
||||
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])
|
||||
|
||||
return (
|
||||
<dialog
|
||||
{...props}
|
||||
className={clsx(
|
||||
"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-docs_sm",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark border border-solid",
|
||||
"shadow-modal dark:shadow-modal-dark",
|
||||
"w-[90%] md:h-auto md:w-[75%] lg:w-[560px]",
|
||||
modalContainerClassName
|
||||
)}
|
||||
>
|
||||
{title && <ModalHeader title={title} />}
|
||||
<div
|
||||
className={clsx(
|
||||
"overflow-auto py-docs_1.5 px-docs_2",
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{actions && actions?.length > 0 && <ModalFooter actions={actions} />}
|
||||
{footerContent && <ModalFooter>{footerContent}</ModalFooter>}
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { NavbarIconButton, NavbarIconButtonProps } from "../IconButton"
|
||||
import { useColorMode } from "@/providers"
|
||||
import { Moon, Sun } from "@medusajs/icons"
|
||||
|
||||
export type NavbarColorModeToggleProps = {
|
||||
buttonProps?: NavbarIconButtonProps
|
||||
}
|
||||
|
||||
export const NavbarColorModeToggle = ({
|
||||
buttonProps,
|
||||
}: NavbarColorModeToggleProps) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<NavbarIconButton {...buttonProps} onClick={() => toggleColorMode()}>
|
||||
{colorMode === "light" && (
|
||||
<Sun className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
)}
|
||||
{colorMode === "dark" && (
|
||||
<Moon className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
)}
|
||||
</NavbarIconButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { Button, ButtonProps } from "@/components"
|
||||
|
||||
export type NavbarIconButtonProps = ButtonProps
|
||||
|
||||
export const NavbarIconButton = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: NavbarIconButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
className={clsx(
|
||||
"[&>svg]:h-[22px] [&>svg]:w-[22px] btn-secondary-icon",
|
||||
className
|
||||
)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import clsx from "clsx"
|
||||
import { useNavbar } from "@/providers"
|
||||
import { NextLink, NextLinkProps } from "@/components"
|
||||
|
||||
export type NavbarLinkProps = {
|
||||
href: string
|
||||
label: string
|
||||
className?: string
|
||||
activeValuePattern?: RegExp
|
||||
} & NextLinkProps
|
||||
|
||||
export const NavbarLink = ({
|
||||
href,
|
||||
label,
|
||||
className,
|
||||
activeValuePattern,
|
||||
}: NavbarLinkProps) => {
|
||||
const { activeItem } = useNavbar()
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
return activeItem
|
||||
? activeValuePattern
|
||||
? activeValuePattern.test(activeItem)
|
||||
: href === activeItem
|
||||
: false
|
||||
}, [activeItem, href, activeValuePattern])
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={href}
|
||||
className={clsx(
|
||||
isActive && "!text-medusa-fg-base dark:!text-medusa-fg-base-dark",
|
||||
!isActive && "!text-medusa-fg-subtle dark:!text-medusa-fg-subtle-dark",
|
||||
"text-compact-small-plus inline-block",
|
||||
"hover:!text-medusa-fg-base dark:hover:!text-medusa-fg-base-dark",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useColorMode } from "@/providers"
|
||||
import Link from "next/link"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type NavbarLogoProps = {
|
||||
light: string
|
||||
dark?: string
|
||||
className?: string
|
||||
imageClassName?: string
|
||||
}
|
||||
|
||||
export const NavbarLogo = ({
|
||||
light,
|
||||
dark,
|
||||
className,
|
||||
imageClassName,
|
||||
}: NavbarLogoProps) => {
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<Link href={`/`} className={clsx("flex-1", className)}>
|
||||
<img
|
||||
src={colorMode === "light" ? light : dark || light}
|
||||
alt="Medusa Logo"
|
||||
height={20}
|
||||
width={20}
|
||||
className={clsx("align-middle", imageClassName)}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { NavbarIconButton, NavbarIconButtonProps } from "../../IconButton"
|
||||
import clsx from "clsx"
|
||||
import { Sidebar, XMark } from "@medusajs/icons"
|
||||
|
||||
export type NavbarMobileMenuButtonProps = {
|
||||
buttonProps?: NavbarIconButtonProps
|
||||
mobileSidebarOpen: boolean
|
||||
setMobileSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const NavbarMobileMenuButton = ({
|
||||
buttonProps,
|
||||
mobileSidebarOpen,
|
||||
setMobileSidebarOpen,
|
||||
isLoading = false,
|
||||
}: NavbarMobileMenuButtonProps) => {
|
||||
return (
|
||||
<NavbarIconButton
|
||||
{...buttonProps}
|
||||
className={clsx("mr-docs_1 lg:!hidden", buttonProps?.className)}
|
||||
onClick={() => {
|
||||
if (!isLoading) {
|
||||
setMobileSidebarOpen((prevValue) => !prevValue)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!mobileSidebarOpen && (
|
||||
<Sidebar className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
)}
|
||||
{mobileSidebarOpen && (
|
||||
<XMark className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
)}
|
||||
</NavbarIconButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { NavbarMobileMenuButton, NavbarMobileMenuButtonProps } from "./Button"
|
||||
import { NavbarColorModeToggle } from "../ColorModeToggle"
|
||||
import { NavbarSearchModalOpener } from "../SearchModalOpener"
|
||||
import { useMobile } from "@/providers"
|
||||
import clsx from "clsx"
|
||||
import { NavbarLogo, NavbarLogoProps } from "../Logo"
|
||||
|
||||
export type NavbarMobileMenuProps = {
|
||||
menuButton: NavbarMobileMenuButtonProps
|
||||
logo: NavbarLogoProps
|
||||
}
|
||||
|
||||
export const NavbarMobileMenu = ({
|
||||
menuButton,
|
||||
logo,
|
||||
}: NavbarMobileMenuProps) => {
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between lg:hidden">
|
||||
{isMobile && (
|
||||
<>
|
||||
<NavbarMobileMenuButton
|
||||
{...menuButton}
|
||||
buttonProps={{
|
||||
...(menuButton.buttonProps || {}),
|
||||
className: clsx(
|
||||
menuButton.buttonProps?.className,
|
||||
"!border-none !bg-transparent !bg-no-image !shadow-none"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<NavbarLogo
|
||||
{...logo}
|
||||
className="lg:hidden"
|
||||
imageClassName="mx-auto"
|
||||
/>
|
||||
<div className="flex">
|
||||
<NavbarSearchModalOpener />
|
||||
<NavbarColorModeToggle
|
||||
buttonProps={{
|
||||
className:
|
||||
"!border-none !bg-transparent !bg-no-image !shadow-none ml-docs_1",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { SearchModalOpener } from "@/components"
|
||||
import { useMobile } from "@/providers"
|
||||
|
||||
export type NavbarSearchModalOpenerProps = {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const NavbarSearchModalOpener = ({
|
||||
isLoading,
|
||||
}: NavbarSearchModalOpenerProps) => {
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
return <SearchModalOpener isMobile={isMobile} isLoading={isLoading} />
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { NavbarLink, NavbarLinkProps } from "./Link"
|
||||
import { NavbarColorModeToggle } from "./ColorModeToggle"
|
||||
import { NavbarLogo, NavbarLogoProps } from "./Logo"
|
||||
import { NavbarMobileMenu } from "./MobileMenu"
|
||||
import { NavbarSearchModalOpener } from "./SearchModalOpener"
|
||||
import { NavbarMobileMenuButtonProps } from "./MobileMenu/Button"
|
||||
|
||||
export type NavbarProps = {
|
||||
logo: NavbarLogoProps
|
||||
items: NavbarLinkProps[]
|
||||
showSearchOpener?: boolean
|
||||
showColorModeToggle?: boolean
|
||||
additionalActions?: React.ReactNode
|
||||
mobileMenuButton: NavbarMobileMenuButtonProps
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Navbar = ({
|
||||
logo,
|
||||
items,
|
||||
showSearchOpener = true,
|
||||
showColorModeToggle = true,
|
||||
additionalActions,
|
||||
mobileMenuButton,
|
||||
isLoading,
|
||||
className,
|
||||
}: NavbarProps) => {
|
||||
return (
|
||||
<nav
|
||||
className={clsx(
|
||||
"h-navbar sticky top-0 w-full justify-between",
|
||||
"bg-docs-bg dark:bg-docs-bg-dark border-medusa-border-base dark:border-medusa-border-base-dark z-[400] border-b",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"h-navbar max-w-xxl py-docs_0.75 sticky top-0 mx-auto flex w-full justify-between px-docs_1 lg:px-docs_3"
|
||||
)}
|
||||
>
|
||||
<div className="hidden w-full items-center gap-docs_0.5 lg:flex lg:w-auto lg:gap-docs_1.5">
|
||||
<NavbarLogo {...logo} />
|
||||
{items.map((item, index) => (
|
||||
<NavbarLink key={index} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden min-w-0 flex-1 items-center justify-end gap-docs_0.5 lg:flex">
|
||||
{showSearchOpener && (
|
||||
<NavbarSearchModalOpener isLoading={isLoading} />
|
||||
)}
|
||||
{showColorModeToggle && <NavbarColorModeToggle />}
|
||||
{additionalActions}
|
||||
</div>
|
||||
<NavbarMobileMenu
|
||||
logo={logo}
|
||||
menuButton={{
|
||||
...mobileMenuButton,
|
||||
isLoading,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
import NextLink from "next/link"
|
||||
import type { LinkProps as NextLinkProps } from "next/link"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type LinkProps = {
|
||||
href?: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
} & Partial<NextLinkProps> &
|
||||
React.AllHTMLAttributes<HTMLAnchorElement>
|
||||
|
||||
export const Link = ({ href, children, className, ...rest }: LinkProps) => {
|
||||
return (
|
||||
<NextLink
|
||||
href={href || ""}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"text-medusa-fg-interactive hover:text-medusa-fg-interactive-hover",
|
||||
"dark:text-medusa-fg-interactive-dark dark:hover:text-medusa-fg-interactive-hover-dark",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react"
|
||||
import { NotificationItemProps } from "../.."
|
||||
import clsx from "clsx"
|
||||
import {
|
||||
CheckCircleSolid,
|
||||
ExclamationCircleSolid,
|
||||
InformationCircleSolid,
|
||||
XCircleSolid,
|
||||
} from "@medusajs/icons"
|
||||
import { Button } from "@/components"
|
||||
|
||||
export type NotificationItemLayoutDefaultProps = NotificationItemProps & {
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const NotificationItemLayoutDefault: React.FC<
|
||||
NotificationItemLayoutDefaultProps
|
||||
> = ({
|
||||
type = "info",
|
||||
title = "",
|
||||
text = "",
|
||||
children,
|
||||
isClosable = true,
|
||||
handleClose,
|
||||
CustomIcon,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={clsx("flex gap-docs_1 p-docs_1")}>
|
||||
{type !== "none" && (
|
||||
<div
|
||||
className={clsx(
|
||||
type !== "custom" && "w-docs_2 flex justify-center items-center"
|
||||
)}
|
||||
>
|
||||
{type === "info" && (
|
||||
<InformationCircleSolid className="text-medusa-fg-interactive-dark" />
|
||||
)}
|
||||
{type === "error" && (
|
||||
<XCircleSolid className="text-medusa-tag-red-icon dark:text-medusa-tag-red-icon-dark" />
|
||||
)}
|
||||
{type === "warning" && (
|
||||
<ExclamationCircleSolid className="text-medusa-tag-orange-icon dark:text-medusa-tag-orange-icon-dark" />
|
||||
)}
|
||||
{type === "success" && (
|
||||
<CheckCircleSolid className="text-medusa-tag-green-icon dark:text-medusa-tag-green-icon-dark" />
|
||||
)}
|
||||
{type === "custom" && CustomIcon}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
"text-compact-medium-plus",
|
||||
"text-medusa-fg-base dark:text-medusa-fg-base-dark"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{(text || children) && (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex pt-0 pr-docs_1 pb-docs_1.5 pl-docs_1 gap-docs_1",
|
||||
"border-0 border-b border-solid border-medusa-border-base dark:border-medusa-border-base-dark"
|
||||
)}
|
||||
>
|
||||
<div className="w-docs_2 flex-none"></div>
|
||||
<div className={clsx("flex flex-col", children && "gap-docs_1")}>
|
||||
{text && (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-medium text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isClosable && (
|
||||
<div className={clsx("p-docs_1 flex justify-end items-center")}>
|
||||
<Button onClick={handleClose}>Close</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import clsx from "clsx"
|
||||
import React, { Children, ReactElement } from "react"
|
||||
import { NotificationItemLayoutDefault } from "./Layout/Default"
|
||||
|
||||
export type NotificationItemProps = {
|
||||
layout?: "default" | "empty"
|
||||
type?: "info" | "error" | "warning" | "success" | "custom" | "none"
|
||||
CustomIcon?: React.ReactNode
|
||||
title?: string
|
||||
text?: string
|
||||
className?: string
|
||||
children?: ReactElement
|
||||
isClosable?: boolean
|
||||
placement?: "top" | "bottom"
|
||||
show?: boolean
|
||||
setShow?: (value: boolean) => void
|
||||
onClose?: () => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const NotificationItem = ({
|
||||
className = "",
|
||||
placement = "bottom",
|
||||
show = true,
|
||||
layout = "default",
|
||||
setShow,
|
||||
onClose,
|
||||
children,
|
||||
...rest
|
||||
}: NotificationItemProps) => {
|
||||
const handleClose = () => {
|
||||
setShow?.(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"md:max-w-[320px] md:w-[320px] w-full bg-medusa-bg-base dark:bg-medusa-bg-base-dark rounded-docs_DEFAULT",
|
||||
"shadow-flyout dark:shadow-flyout-dark max-h-[calc(100vh-90px)]",
|
||||
"fixed md:right-docs_1 left-0 z-[400] md:m-docs_1",
|
||||
placement === "bottom" && "md:bottom-docs_1 bottom-0",
|
||||
placement === "top" && "md:top-docs_1 top-0",
|
||||
"opacity-100 transition-opacity duration-200 ease-ease",
|
||||
!show && "!opacity-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{layout === "default" && (
|
||||
<NotificationItemLayoutDefault {...rest} handleClose={handleClose}>
|
||||
{children}
|
||||
</NotificationItemLayoutDefault>
|
||||
)}
|
||||
{layout === "empty" &&
|
||||
Children.map(children, (child) => {
|
||||
if (child) {
|
||||
return React.cloneElement(child, {
|
||||
onClose: handleClose,
|
||||
})
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NotificationItemType, useNotifications } from "@/providers"
|
||||
import React from "react"
|
||||
import { NotificationItem } from "./Item"
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group"
|
||||
import clsx from "clsx"
|
||||
|
||||
export const NotificationContainer = () => {
|
||||
const { notifications, removeNotification } = useNotifications()
|
||||
|
||||
const handleClose = (notification: NotificationItemType) => {
|
||||
notification.onClose?.()
|
||||
if (notification.id) {
|
||||
removeNotification(notification.id)
|
||||
}
|
||||
}
|
||||
|
||||
const renderFilteredNotifications = (
|
||||
condition: (notificaiton: NotificationItemType) => boolean,
|
||||
className?: string
|
||||
) => {
|
||||
return (
|
||||
<TransitionGroup className={className}>
|
||||
{notifications.filter(condition).map((notification) => (
|
||||
<CSSTransition
|
||||
key={notification.id}
|
||||
timeout={200}
|
||||
classNames={{
|
||||
enter: "animate__animated animate__slideInRight animate__fastest",
|
||||
exit: "animate__animated animate__slideOutRight animate__fastest",
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
{...notification}
|
||||
onClose={() => handleClose(notification)}
|
||||
className={clsx(
|
||||
notification.className,
|
||||
"!relative !top-0 !bottom-0 !right-0"
|
||||
)}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderFilteredNotifications(
|
||||
(notification) => notification.placement === "top",
|
||||
"flex fixed flex-col gap-docs_0.5 right-0 top-0 z-[400] md:w-auto w-full max-h-[calc(100vh-57px)] overflow-y-auto"
|
||||
)}
|
||||
{renderFilteredNotifications(
|
||||
(notification) => notification.placement !== "top",
|
||||
"flex flex-col gap-docs_0.5 fixed right-0 bottom-0 z-[400] md:w-auto w-full max-h-[calc(100vh-57px)] overflow-y-auto"
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import React, { useRef, useState } from "react"
|
||||
import clsx from "clsx"
|
||||
import { Star, StarSolid } from "@medusajs/icons"
|
||||
import { Button } from "@/components"
|
||||
import { useAnalytics } from "@/providers"
|
||||
|
||||
export type RatingProps = {
|
||||
event?: string
|
||||
className?: string
|
||||
onRating?: () => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Rating: React.FC<RatingProps> = ({
|
||||
event = "rating",
|
||||
className = "",
|
||||
onRating,
|
||||
}) => {
|
||||
const [rating, setRating] = useState(0)
|
||||
const [hoverRating, setHoverRating] = useState(0)
|
||||
const starElms = useRef<HTMLElement[]>([])
|
||||
const starArr = Array.from(Array(5).keys())
|
||||
const { track } = useAnalytics()
|
||||
|
||||
const handleRating = (selectedRating: number) => {
|
||||
if (rating) {
|
||||
return
|
||||
}
|
||||
setHoverRating(0)
|
||||
setRating(selectedRating)
|
||||
for (let i = 0; i < selectedRating; i++) {
|
||||
starElms.current[i].classList.add("animate-tada")
|
||||
}
|
||||
track(
|
||||
event,
|
||||
{
|
||||
rating: selectedRating,
|
||||
},
|
||||
() => onRating?.()
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("flex gap-docs_0.5", className)}>
|
||||
{starArr.map((i) => {
|
||||
const isSelected =
|
||||
(rating !== 0 && rating - 1 >= i) ||
|
||||
(hoverRating !== 0 && hoverRating - 1 >= i)
|
||||
return (
|
||||
<Button
|
||||
variant="clear"
|
||||
buttonRef={(element) => {
|
||||
if (starElms.current.length - 1 < i) {
|
||||
starElms.current.push(element as HTMLElement)
|
||||
}
|
||||
}}
|
||||
key={i}
|
||||
onMouseOver={() => {
|
||||
if (!rating) {
|
||||
setHoverRating(i + 1)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!rating) {
|
||||
setHoverRating(0)
|
||||
}
|
||||
}}
|
||||
onClick={() => handleRating(i + 1)}
|
||||
>
|
||||
{!isSelected && <Star />}
|
||||
{isSelected && (
|
||||
<StarSolid className="text-medusa-tag-orange-icon dark:text-medusa-tag-orange-icon-dark" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useInstantSearch } from "react-instantsearch"
|
||||
|
||||
export type SearchEmptyQueryBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
fallback: React.ReactNode
|
||||
}
|
||||
|
||||
export const SearchEmptyQueryBoundary = ({
|
||||
children,
|
||||
fallback,
|
||||
}: SearchEmptyQueryBoundaryProps) => {
|
||||
const { indexUiState } = useInstantSearch()
|
||||
|
||||
if (!indexUiState.query) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type SearchHitGroupNameProps = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SearchHitGroupName = ({ name }: SearchHitGroupNameProps) => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"pb-docs_0.25 flex px-docs_0.5 pt-docs_1",
|
||||
"text-medusa-fg-muted dark:text-medusa-fg-muted-dark",
|
||||
"text-compact-x-small-plus"
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import React, { Fragment, useEffect, useMemo, useState } from "react"
|
||||
import clsx from "clsx"
|
||||
import {
|
||||
Configure,
|
||||
ConfigureProps,
|
||||
Index,
|
||||
Snippet,
|
||||
useHits,
|
||||
useInstantSearch,
|
||||
} from "react-instantsearch"
|
||||
import { SearchNoResult } from "../NoResults"
|
||||
import { SearchHitGroupName } from "./GroupName"
|
||||
import { useSearch } from "@/providers"
|
||||
import { Link } from "@/components/Link"
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export type GroupedHitType = {
|
||||
[k: string]: HitType[]
|
||||
}
|
||||
|
||||
export type SearchHitWrapperProps = {
|
||||
configureProps: ConfigureProps
|
||||
indices: string[]
|
||||
} & Omit<SearchHitsProps, "indexName" | "setNoResults">
|
||||
|
||||
export type IndexResults = {
|
||||
[k: string]: boolean
|
||||
}
|
||||
|
||||
export const SearchHitsWrapper = ({
|
||||
configureProps,
|
||||
indices,
|
||||
...rest
|
||||
}: SearchHitWrapperProps) => {
|
||||
const { status } = useInstantSearch()
|
||||
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}
|
||||
{...rest}
|
||||
/>
|
||||
<Configure {...configureProps} />
|
||||
</Index>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type SearchHitsProps = {
|
||||
indexName: string
|
||||
setNoResults: (index: string, value: boolean) => void
|
||||
checkInternalPattern?: RegExp
|
||||
}
|
||||
|
||||
export const SearchHits = ({
|
||||
indexName,
|
||||
setNoResults,
|
||||
checkInternalPattern,
|
||||
}: SearchHitsProps) => {
|
||||
const { hits } = useHits<HitType>()
|
||||
const { status } = useInstantSearch()
|
||||
const { setIsOpen } = useSearch()
|
||||
|
||||
// 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) || ""
|
||||
)
|
||||
}
|
||||
|
||||
const checkIfInternal = (url: string): boolean => {
|
||||
if (!checkInternalPattern) {
|
||||
return false
|
||||
}
|
||||
return checkInternalPattern.test(url)
|
||||
}
|
||||
|
||||
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-docs_0.25 relative flex flex-1 flex-col p-docs_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",
|
||||
"last:mb-docs_1 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"
|
||||
onClick={(e) => {
|
||||
if (checkIfInternal(item.url)) {
|
||||
e.preventDefault()
|
||||
window.location.href = item.url
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { InstantSearch, SearchBox } from "react-instantsearch"
|
||||
import clsx from "clsx"
|
||||
import { SearchEmptyQueryBoundary } from "../EmptyQueryBoundary"
|
||||
import { SearchSuggestions, type SearchSuggestionType } from "../Suggestions"
|
||||
import { AlgoliaProps, useSearch } from "@/providers"
|
||||
import { checkArraySameElms } from "@/utils"
|
||||
import { SearchHitsWrapper } from "../Hits"
|
||||
import { findNextSibling, findPrevSibling } from "@/utils"
|
||||
import { Button, Kbd, Modal, SelectBadge } from "@/components"
|
||||
import { MagnifyingGlass, XMark } from "@medusajs/icons"
|
||||
import { useKeyboardShortcut, type OptionType } from "@/hooks"
|
||||
|
||||
export type SearchModalProps = {
|
||||
algolia: AlgoliaProps
|
||||
isLoading?: boolean
|
||||
suggestions: SearchSuggestionType[]
|
||||
checkInternalPattern?: RegExp
|
||||
filterOptions?: OptionType[]
|
||||
}
|
||||
|
||||
export const SearchModal = ({
|
||||
algolia,
|
||||
suggestions,
|
||||
isLoading = false,
|
||||
checkInternalPattern,
|
||||
filterOptions = [],
|
||||
}: SearchModalProps) => {
|
||||
const modalRef = useRef<HTMLDialogElement | null>(null)
|
||||
const { isOpen, setIsOpen, defaultFilters, searchClient } = 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()
|
||||
} else if (!isOpen) {
|
||||
const focusedItem = modalRef.current?.querySelector(
|
||||
":focus"
|
||||
) as HTMLElement
|
||||
if (
|
||||
focusedItem &&
|
||||
focusedItem === searchBoxRef.current?.querySelector("input")
|
||||
) {
|
||||
// remove focus
|
||||
focusedItem.blur()
|
||||
}
|
||||
}
|
||||
}, [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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutKeys = useMemo(() => ["ArrowUp", "ArrowDown", "Enter"], [])
|
||||
|
||||
useKeyboardShortcut({
|
||||
metakey: false,
|
||||
shortcutKeys,
|
||||
action: handleKeyAction,
|
||||
checkEditing: false,
|
||||
preventDefault: false,
|
||||
isLoading,
|
||||
})
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
// check if shortcut keys were pressed
|
||||
const lowerPressedKey = e.key.toLowerCase()
|
||||
const pressedShortcut = [...shortcutKeys, "Escape"].some(
|
||||
(s) => s.toLowerCase() === lowerPressedKey
|
||||
)
|
||||
if (pressedShortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
const focusedItem = modalRef.current?.querySelector(
|
||||
":focus"
|
||||
) as HTMLElement
|
||||
const searchInput = searchBoxRef.current?.querySelector(
|
||||
"input"
|
||||
) as HTMLInputElement
|
||||
if (searchInput && focusedItem !== searchInput) {
|
||||
searchInput.focus()
|
||||
}
|
||||
},
|
||||
[shortcutKeys, isOpen]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [handleKeyDown])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
contentClassName={clsx(
|
||||
"!p-0 overflow-hidden relative h-full",
|
||||
"rounded-none md:rounded-docs_lg flex flex-col justify-between"
|
||||
)}
|
||||
modalContainerClassName="w-screen h-screen !rounded-none md:!rounded-docs_lg"
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
passedRef={modalRef}
|
||||
>
|
||||
<InstantSearch
|
||||
indexName={algolia.mainIndexName}
|
||||
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-docs_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-docs_xl bg-transparent"),
|
||||
input: clsx(
|
||||
"w-full h-full pl-docs_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-docs_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-docs_1 btn-clear p-0"),
|
||||
reset: clsx(
|
||||
"absolute top-docs_0.75 right-docs_1 hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
|
||||
"p-[5px] md:rounded-docs_DEFAULT btn-clear"
|
||||
),
|
||||
loadingIndicator: clsx("absolute top-[18px] right-docs_1"),
|
||||
}}
|
||||
submitIconComponent={() => (
|
||||
<MagnifyingGlass className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
)}
|
||||
resetIconComponent={() => (
|
||||
<XMark className="hidden md:block text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark" />
|
||||
)}
|
||||
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-docs_1"
|
||||
)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<XMark className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-docs_0.5 h-[calc(100%-120px)] md:h-[332px] md:flex-initial lg:max-h-[332px] lg:min-h-[332px]">
|
||||
<SearchEmptyQueryBoundary
|
||||
fallback={<SearchSuggestions suggestions={suggestions} />}
|
||||
>
|
||||
<SearchHitsWrapper
|
||||
configureProps={{
|
||||
filters: formattedFilters,
|
||||
attributesToSnippet: [
|
||||
"content",
|
||||
"hierarchy.lvl1",
|
||||
"hierarchy.lvl2",
|
||||
],
|
||||
attributesToHighlight: [
|
||||
"content",
|
||||
"hierarchy.lvl1",
|
||||
"hierarchy.lvl2",
|
||||
],
|
||||
}}
|
||||
indices={algolia.indices}
|
||||
checkInternalPattern={checkInternalPattern}
|
||||
/>
|
||||
</SearchEmptyQueryBoundary>
|
||||
</div>
|
||||
</InstantSearch>
|
||||
<div
|
||||
className={clsx(
|
||||
"py-docs_0.75 flex items-center justify-between px-docs_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"
|
||||
)}
|
||||
>
|
||||
{filterOptions.length && (
|
||||
<SelectBadge
|
||||
multiple
|
||||
options={filterOptions}
|
||||
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(filterOptions.map((option) => option.value))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="hidden items-center gap-docs_1 md:flex">
|
||||
<div className="flex items-center gap-docs_0.5">
|
||||
<span
|
||||
className={clsx(
|
||||
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
|
||||
"text-compact-x-small"
|
||||
)}
|
||||
>
|
||||
Navigation
|
||||
</span>
|
||||
<span className="gap-docs_0.25 flex">
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-docs_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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import React, { MouseEvent, useMemo } from "react"
|
||||
import clsx from "clsx"
|
||||
import { useSearch } from "@/providers"
|
||||
import { Button, InputText, Kbd } from "@/components"
|
||||
import { MagnifyingGlass } from "@medusajs/icons"
|
||||
import { useKeyboardShortcut } from "@/hooks"
|
||||
|
||||
export type SearchModalOpenerProps = {
|
||||
isLoading?: boolean
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const SearchModalOpener = ({
|
||||
isLoading = false,
|
||||
isMobile = false,
|
||||
}: SearchModalOpenerProps) => {
|
||||
const { setIsOpen } = useSearch()
|
||||
const isApple = useMemo(() => {
|
||||
return typeof navigator !== "undefined"
|
||||
? navigator.userAgent.toLowerCase().indexOf("mac") !== 0
|
||||
: true
|
||||
}, [])
|
||||
useKeyboardShortcut({
|
||||
shortcutKeys: ["k"],
|
||||
action: () => setIsOpen((prev) => !prev),
|
||||
isLoading,
|
||||
})
|
||||
|
||||
const handleOpen = (
|
||||
e:
|
||||
| MouseEvent<HTMLDivElement, globalThis.MouseEvent>
|
||||
| MouseEvent<HTMLInputElement, globalThis.MouseEvent>
|
||||
| MouseEvent<HTMLButtonElement, globalThis.MouseEvent>
|
||||
) => {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
if ("blur" in e.target && typeof e.target.blur === "function") {
|
||||
e.target.blur()
|
||||
}
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Button variant="clear" onClick={handleOpen}>
|
||||
<MagnifyingGlass className="text-medusa-fg-muted dark:text-medusa-fg-muted-dark" />
|
||||
</Button>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={clsx("relative w-min hover:cursor-pointer")}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<MagnifyingGlass
|
||||
className={clsx(
|
||||
"absolute left-docs_0.5 top-[5px]",
|
||||
"text-medusa-fg-muted dark:text-medusa-fg-muted-dark"
|
||||
)}
|
||||
/>
|
||||
<InputText
|
||||
type="search"
|
||||
className={clsx(
|
||||
"placeholder:text-compact-small",
|
||||
"!py-[5px] !pl-[36px] !pr-docs_0.5",
|
||||
"cursor-pointer select-none"
|
||||
)}
|
||||
placeholder="Find something"
|
||||
onClick={handleOpen}
|
||||
onFocus={(e) => e.target.blur()}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
"gap-docs_0.25 flex",
|
||||
"absolute right-docs_0.5 top-[5px]"
|
||||
)}
|
||||
>
|
||||
<Kbd>{isApple ? "⌘" : "Ctrl"}</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ExclamationCircleSolid } from "@medusajs/icons"
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
|
||||
export const SearchNoResult = () => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex h-full w-full flex-col items-center justify-center gap-docs_1",
|
||||
"text-medusa-fg-muted dark:text-medusa-fg-muted-dark"
|
||||
)}
|
||||
>
|
||||
<ExclamationCircleSolid />
|
||||
<span className="text-compact-small">
|
||||
No results found. Try changing selected filters.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
import { useInstantSearch } from "react-instantsearch"
|
||||
import { SearchHitGroupName } from "../Hits/GroupName"
|
||||
|
||||
export type SearchSuggestionType = {
|
||||
title: string
|
||||
items: string[]
|
||||
}
|
||||
|
||||
export type SearchSuggestionsProps = {
|
||||
suggestions: SearchSuggestionType[]
|
||||
}
|
||||
|
||||
export const SearchSuggestions = ({ suggestions }: SearchSuggestionsProps) => {
|
||||
const { setIndexUiState } = useInstantSearch()
|
||||
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-docs_sm p-docs_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 last:mb-docs_1"
|
||||
)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./EmptyQueryBoundary"
|
||||
export * from "./Hits"
|
||||
export * from "./Modal"
|
||||
export * from "./ModalOpener"
|
||||
export * from "./NoResults"
|
||||
export * from "./Suggestions"
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useRef, useState } from "react"
|
||||
import { useSelect } from "@/hooks"
|
||||
import clsx from "clsx"
|
||||
import { SelectDropdown, SelectProps } from ".."
|
||||
|
||||
export 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-docs_0.25 h-fit cursor-pointer px-docs_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}
|
||||
passedRef={dropdownRef}
|
||||
className={clsx(
|
||||
"!top-[unset] !bottom-full",
|
||||
open && "!-translate-y-docs_0.5"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectBadge
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from "react"
|
||||
import clsx from "clsx"
|
||||
import { OptionType } from "@/hooks"
|
||||
import { Ref } from "@/types"
|
||||
import { CheckMini, EllipseMiniSolid } from "@medusajs/icons"
|
||||
|
||||
export 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
|
||||
passedRef?: Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const SelectDropdown = ({
|
||||
open,
|
||||
setOpen,
|
||||
options,
|
||||
addAll,
|
||||
multiple = false,
|
||||
isAllSelected,
|
||||
isValueSelected,
|
||||
handleSelectAll,
|
||||
handleChange: handleSelectChange,
|
||||
parentRef,
|
||||
className,
|
||||
passedRef,
|
||||
}: SelectDropdownProps) => {
|
||||
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-docs_0.75 relative rounded-docs_sm py-docs_0.5 pl-docs_2.5",
|
||||
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
|
||||
"[&>svg]:left-docs_0.75 cursor-pointer [&>svg]:absolute [&>svg]:top-docs_0.5",
|
||||
!isSelected && "text-compact-small",
|
||||
isSelected && "text-compact-small-plus"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (option.isAllOption) {
|
||||
handleSelectAll()
|
||||
} else {
|
||||
handleChange(option.value, isSelected)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<>
|
||||
{multiple && (
|
||||
<CheckMini className="text-medusa-fg-base dark:text-medusa-fg-base-dark" />
|
||||
)}
|
||||
{!multiple && (
|
||||
<EllipseMiniSolid className="text-medusa-fg-base dark:text-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-docs_0.5 !overflow-visible",
|
||||
className
|
||||
)}
|
||||
ref={setRefs}
|
||||
>
|
||||
<ul
|
||||
className={clsx(
|
||||
"p-docs_0.25 mb-0 w-full overflow-auto rounded-docs_DEFAULT",
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import React, { useRef, useState } from "react"
|
||||
import clsx from "clsx"
|
||||
import { useSelect } from "@/hooks"
|
||||
import { SelectDropdown, SelectProps } from ".."
|
||||
import { Badge } from "docs-ui"
|
||||
import { ChevronUpDown, XMarkMini } from "@medusajs/icons"
|
||||
|
||||
export 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-docs_0.75 relative py-[9px]",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark rounded-docs_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-docs_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-docs_0.125"
|
||||
)}
|
||||
>
|
||||
{(value as string[]).length}
|
||||
</span>
|
||||
{showClearButton && (
|
||||
<XMarkMini
|
||||
className="text-medusa-tag-neutral-icon dark:text-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>
|
||||
<ChevronUpDown className="text-medusa-fg-muted dark:text-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}
|
||||
passedRef={dropdownRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OptionType, SelectOptions } from "@/hooks"
|
||||
|
||||
export type SelectProps = {
|
||||
options: OptionType[]
|
||||
multiple?: boolean
|
||||
addAll?: boolean
|
||||
showClearButton?: boolean
|
||||
} & SelectOptions &
|
||||
React.ComponentProps<"input">
|
||||
|
||||
export * from "./Badge"
|
||||
export * from "./Dropdown"
|
||||
export * from "./Input"
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { SidebarItemType } from "@/providers"
|
||||
import { useSidebar } from "@/providers"
|
||||
import clsx from "clsx"
|
||||
import Link from "next/link"
|
||||
import { checkSidebarItemVisibility } from "@/utils"
|
||||
import { Loading } from "@/components"
|
||||
|
||||
export type SidebarItemProps = {
|
||||
item: SidebarItemType
|
||||
nested?: boolean
|
||||
expandItems?: boolean
|
||||
} & React.AllHTMLAttributes<HTMLLIElement>
|
||||
|
||||
export const SidebarItem = ({
|
||||
item,
|
||||
nested = false,
|
||||
expandItems = false,
|
||||
className,
|
||||
}: SidebarItemProps) => {
|
||||
const [showLoading, setShowLoading] = useState(false)
|
||||
const { isItemActive, setMobileSidebarOpen: setSidebarOpen } = useSidebar()
|
||||
const active = useMemo(() => {
|
||||
return isItemActive(item, nested)
|
||||
}, [isItemActive, item, nested])
|
||||
const collapsed = !expandItems && !isItemActive(item, true)
|
||||
const ref = useRef<HTMLLIElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (active && ref.current && window.innerWidth >= 1025) {
|
||||
if (
|
||||
!checkSidebarItemVisibility(ref.current, {
|
||||
topMargin: 57,
|
||||
})
|
||||
) {
|
||||
// scroll to element
|
||||
ref.current.scrollIntoView({
|
||||
block: "center",
|
||||
})
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
setShowLoading(true)
|
||||
}
|
||||
}, [active])
|
||||
|
||||
const classNames = useMemo(
|
||||
() =>
|
||||
clsx(
|
||||
"flex items-center justify-between gap-docs_0.5 rounded-docs_sm px-docs_0.5 py-[6px] hover:no-underline",
|
||||
!item.children &&
|
||||
"text-compact-small-plus text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
|
||||
item.children &&
|
||||
"text-compact-x-small-plus text-medusa-fg-muted dark:text-medusa-fg-muted-dark uppercase",
|
||||
item.path !== undefined &&
|
||||
active && [
|
||||
"!text-medusa-fg-base bg-medusa-bg-base-pressed",
|
||||
"dark:!text-medusa-fg-base-dark dark:bg-medusa-bg-base-pressed-dark",
|
||||
],
|
||||
item.path !== undefined &&
|
||||
active &&
|
||||
"border border-medusa-border-base dark:border-medusa-border-base-dark",
|
||||
item.path !== undefined &&
|
||||
!active &&
|
||||
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark border-transparent"
|
||||
),
|
||||
[item.children, active, item.path]
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
item.children && !collapsed && "my-docs_1.5",
|
||||
!item.children && !nested && active && "mt-docs_1.5",
|
||||
!expandItems &&
|
||||
((item.children && !collapsed) ||
|
||||
(!item.children && !nested && active)) &&
|
||||
"-translate-y-docs_1 transition-transform",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{item.path === undefined && (
|
||||
<span className={classNames}>
|
||||
<span>{item.title}</span>
|
||||
{item.additionalElms}
|
||||
</span>
|
||||
)}
|
||||
{item.path !== undefined && (
|
||||
<Link
|
||||
href={item.isPathHref ? item.path : `#${item.path}`}
|
||||
className={classNames}
|
||||
scroll={true}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1025) {
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
}}
|
||||
replace
|
||||
shallow
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
{item.additionalElms}
|
||||
</Link>
|
||||
)}
|
||||
{item.children && (
|
||||
<ul
|
||||
className={clsx("ease-ease overflow-hidden", collapsed && "m-0 h-0")}
|
||||
>
|
||||
{showLoading && !item.loaded && (
|
||||
<Loading
|
||||
count={3}
|
||||
className="!mb-0 !px-docs_0.5"
|
||||
barClassName="h-[20px]"
|
||||
/>
|
||||
)}
|
||||
{item.children?.map((childItem, index) => (
|
||||
<SidebarItem item={childItem} key={index} nested={true} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useSidebar } from "@/providers"
|
||||
import clsx from "clsx"
|
||||
import { Loading } from "@/components"
|
||||
import { SidebarItem } from "./Item"
|
||||
|
||||
export type SidebarProps = {
|
||||
className?: string
|
||||
expandItems?: boolean
|
||||
}
|
||||
|
||||
export const Sidebar = ({
|
||||
className = "",
|
||||
expandItems = false,
|
||||
}: SidebarProps) => {
|
||||
const { items, mobileSidebarOpen, desktopSidebarOpen } = useSidebar()
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
"clip bg-docs-bg dark:bg-docs-bg-dark w-ref-sidebar block",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark border-0 border-r border-solid",
|
||||
"fixed -left-full top-[57px] h-screen transition-[left] lg:relative lg:left-0 lg:top-auto lg:h-auto",
|
||||
"lg:w-sidebar z-[100] w-full lg:z-0",
|
||||
mobileSidebarOpen && "!left-0",
|
||||
!desktopSidebarOpen && "!absolute !-left-full",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
animationFillMode: "forwards",
|
||||
}}
|
||||
>
|
||||
<ul
|
||||
className={clsx(
|
||||
"sticky top-[57px] h-screen max-h-screen w-full list-none overflow-auto p-0",
|
||||
"px-docs_1.5 pb-[57px] pt-docs_1.5"
|
||||
)}
|
||||
id="sidebar"
|
||||
>
|
||||
<div className="mb-docs_1.5 lg:hidden">
|
||||
{!items.mobile.length && <Loading className="px-0" />}
|
||||
{items.mobile.map((item, index) => (
|
||||
<SidebarItem item={item} key={index} expandItems={expandItems} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-docs_1.5">
|
||||
{!items.top.length && <Loading className="px-0" />}
|
||||
{items.top.map((item, index) => (
|
||||
<SidebarItem item={item} key={index} expandItems={expandItems} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-docs_1.5">
|
||||
{!items.bottom.length && <Loading className="px-0" />}
|
||||
{items.bottom.map((item, index) => (
|
||||
<SidebarItem item={item} key={index} expandItems={expandItems} />
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
export type TextAreaProps = {
|
||||
className?: string
|
||||
} & React.DetailedHTMLProps<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
HTMLTextAreaElement
|
||||
>
|
||||
|
||||
export const TextArea = (props: TextAreaProps) => {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={clsx(
|
||||
"bg-medusa-bg-field dark:bg-medusa-bg-field-dark shadow-button-secondary dark:shadow-button-secondary-dark",
|
||||
"border-medusa-border-base dark:border-medusa-border-base-dark rounded-docs_sm border border-solid",
|
||||
"pt-docs_0.4 px-docs_0.75 text-medium font-base 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",
|
||||
"active:border-medusa-border-interactive dark:active:border-medusa-border-interactive-dark",
|
||||
"disabled:bg-medusa-bg-disabled dark:disabled:bg-medusa-bg-disabled-dark",
|
||||
"disabled:border-medusa-border-base dark:disabled:border-medusa-border-base-dark",
|
||||
"placeholder:text-medusa-fg-muted dark:placeholder:text-medusa-fg-muted-dark",
|
||||
"disabled:placeholder:text-medusa-fg-disabled dark:disabled:placeholder:text-medusa-fg-disabled-dark",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Tooltip as ReactTooltip } from "react-tooltip"
|
||||
import type { ITooltip } from "react-tooltip"
|
||||
import clsx from "clsx"
|
||||
import "react-tooltip/dist/react-tooltip.css"
|
||||
import uuid from "react-uuid"
|
||||
|
||||
export type TooltipProps = {
|
||||
text?: string
|
||||
tooltipClassName?: string
|
||||
html?: string
|
||||
tooltipChildren?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLSpanElement> &
|
||||
ITooltip
|
||||
|
||||
export const Tooltip = ({
|
||||
text = "",
|
||||
tooltipClassName = "",
|
||||
children,
|
||||
html = "",
|
||||
tooltipChildren,
|
||||
...tooltipProps
|
||||
}: TooltipProps) => {
|
||||
const [elementId, setElementId] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!elementId) {
|
||||
setElementId(uuid())
|
||||
}
|
||||
}, [elementId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
id={elementId}
|
||||
data-tooltip-content={text}
|
||||
data-tooltip-html={html}
|
||||
data-tooltip-id={elementId}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<ReactTooltip
|
||||
anchorId={elementId}
|
||||
// anchorSelect={elementId ? `#${elementId}` : undefined}
|
||||
className={clsx(
|
||||
"!border-medusa-border-base dark:!border-medusa-border-base-dark !border !border-solid",
|
||||
"!text-compact-x-small-plus !shadow-tooltip dark:!shadow-tooltip-dark !rounded-docs_DEFAULT",
|
||||
"!py-docs_0.4 !z-[399] hidden !px-docs_1 lg:block",
|
||||
"!bg-medusa-bg-base dark:!bg-medusa-bg-base-dark",
|
||||
"!text-medusa-fg-subtle dark:!text-medusa-fg-subtle-dark",
|
||||
tooltipClassName
|
||||
)}
|
||||
wrapper="span"
|
||||
noArrow={true}
|
||||
positionStrategy={"fixed"}
|
||||
{...tooltipProps}
|
||||
>
|
||||
{tooltipChildren}
|
||||
</ReactTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export * from "./Badge"
|
||||
export * from "./Bordered"
|
||||
export * from "./BorderedIcon"
|
||||
export * from "./Button"
|
||||
export * from "./Card"
|
||||
export * from "./CodeBlock"
|
||||
export * from "./CodeMdx"
|
||||
export * from "./CodeTabs"
|
||||
export * from "./CopyButton"
|
||||
export * from "./Details"
|
||||
export * from "./Details/Summary"
|
||||
export * from "./Feedback"
|
||||
export * from "./Feedback/Solutions"
|
||||
export * from "./InlineCode"
|
||||
export * from "./Input/Text"
|
||||
export * from "./Kbd"
|
||||
export * from "./Label"
|
||||
export * from "./Link"
|
||||
export * from "./Loading"
|
||||
export * from "./Loading/Spinner"
|
||||
export * from "./Modal"
|
||||
export * from "./Modal/Header"
|
||||
export * from "./Modal/Footer"
|
||||
export * from "./Navbar"
|
||||
export * from "./Navbar/ColorModeToggle"
|
||||
export * from "./Navbar/IconButton"
|
||||
export * from "./Navbar/Link"
|
||||
export * from "./Navbar/Logo"
|
||||
export * from "./Navbar/MobileMenu"
|
||||
export * from "./Navbar/MobileMenu/Button"
|
||||
export * from "./Navbar/SearchModalOpener"
|
||||
export { Link as NextLink, type LinkProps as NextLinkProps } from "./NextLink"
|
||||
export * from "./Notification"
|
||||
export * from "./Notification/Item"
|
||||
export * from "./Notification/Item/Layout/Default"
|
||||
export * from "./Rating"
|
||||
export * from "./Search"
|
||||
export * from "./Select"
|
||||
export * from "./Sidebar"
|
||||
export * from "./Sidebar/Item"
|
||||
export * from "./TextArea"
|
||||
export * from "./Tooltip"
|
||||
Reference in New Issue
Block a user