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:
1 parent
19c5d5ba36
commit
fa7c94b4cc
3209 files changed
+32188
-31018
No files matched your search
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"docs"
|
||||
],
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "docs-ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/cjs/index.js",
|
||||
"types": "./dist/cjs/index.d.ts",
|
||||
"module": "./dist/esm/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": {
|
||||
"types": "./dist/cjs/index.d.ts",
|
||||
"default": "./dist/cjs/index.js"
|
||||
},
|
||||
"import": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "yarn clean && yarn build:js:cjs && yarn build:js:esm",
|
||||
"build:js:cjs": "tsc --project tsconfig.cjs.json && tsc-alias -p tsconfig.cjs.json",
|
||||
"build:js:esm": "tsc --project tsconfig.esm.json && tsc-alias -p tsconfig.esm.json",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "yarn build:js:cjs && yarn build:js:esm"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^17.0.1",
|
||||
"@types/react-dom": "^17.0.1",
|
||||
"clsx": "^2.0.0",
|
||||
"cpy-cli": "^5.0.0",
|
||||
"eslint-config-docs": "*",
|
||||
"next": "latest",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"tailwind": "*",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tsc-alias": "^1.8.7",
|
||||
"tsup": "^5.10.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"next": "*",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/icons": "^1.0.0",
|
||||
"@medusajs/ui": "^1.0.0",
|
||||
"@octokit/request": "^8.1.1",
|
||||
"@segment/analytics-next": "^1.55.0",
|
||||
"algoliasearch": "^4.20.0",
|
||||
"prism-react-renderer": "^2.0.6",
|
||||
"react-instantsearch": "^7.0.3",
|
||||
"react-tooltip": "^5.21.3",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-uuid": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("tailwind/postcss.config")
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
export const GITHUB_ISSUES_PREFIX = `https://github.com/medusajs/medusa/issues/new?assignees=&labels=type%3A+docs&template=docs.yml`
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./use-keyboard-shortcut"
|
||||
export * from "./use-scroll-utils"
|
||||
export * from "./use-select"
|
||||
export * from "./use-tabs"
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
export type useKeyboardShortcutOptions = {
|
||||
metakey?: boolean
|
||||
shortcutKeys: string[]
|
||||
action: (e: KeyboardEvent) => void
|
||||
checkEditing?: boolean
|
||||
preventDefault?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const useKeyboardShortcut = ({
|
||||
metakey = true,
|
||||
shortcutKeys,
|
||||
action,
|
||||
checkEditing = true,
|
||||
preventDefault = true,
|
||||
isLoading = false,
|
||||
}: useKeyboardShortcutOptions) => {
|
||||
function isEditingContent(event: KeyboardEvent) {
|
||||
const element = event.target as HTMLElement
|
||||
const tagName = element.tagName
|
||||
return (
|
||||
element.isContentEditable ||
|
||||
tagName === "INPUT" ||
|
||||
tagName === "SELECT" ||
|
||||
tagName === "TEXTAREA"
|
||||
)
|
||||
}
|
||||
|
||||
const checkKeysPressed = useCallback(
|
||||
(pressedKey: string) => {
|
||||
const lowerPressedKey = pressedKey.toLowerCase()
|
||||
return shortcutKeys.some(
|
||||
(value) => lowerPressedKey === value.toLowerCase()
|
||||
)
|
||||
},
|
||||
[shortcutKeys]
|
||||
)
|
||||
|
||||
const sidebarShortcut = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// the event is triggered when an input
|
||||
// autocompletes, and in that case
|
||||
// e.key will be empty
|
||||
if (isLoading || !e.key) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
(!metakey || e.metaKey || e.ctrlKey) &&
|
||||
checkKeysPressed(e.key) &&
|
||||
(!checkEditing || !isEditingContent(e))
|
||||
) {
|
||||
if (preventDefault) {
|
||||
e.preventDefault()
|
||||
}
|
||||
action(e)
|
||||
}
|
||||
},
|
||||
[isLoading, metakey, checkKeysPressed, checkEditing, action, preventDefault]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", sidebarShortcut)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", sidebarShortcut)
|
||||
}
|
||||
}, [sidebarShortcut])
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client"
|
||||
|
||||
/* Copied from Docusaurus to maintain scroll position on re-renders. Useful when content of the page is updated dynamically */
|
||||
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
|
||||
type EventFunc = (...args: never[]) => unknown
|
||||
|
||||
export function useEvent<T extends EventFunc>(callback: T): T {
|
||||
const ref = useRef<T>(callback)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = callback
|
||||
}, [callback])
|
||||
|
||||
// @ts-expect-error: TS is right that this callback may be a supertype of T,
|
||||
// but good enough for our use
|
||||
return useCallback<T>((...args) => ref.current(...args), [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets `value` from the last render.
|
||||
*/
|
||||
export function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
type ScrollController = {
|
||||
/** A boolean ref tracking whether scroll events are enabled. */
|
||||
scrollEventsEnabledRef: React.MutableRefObject<boolean>
|
||||
/** Enable scroll events in `useScrollPosition`. */
|
||||
enableScrollEvents: () => void
|
||||
/** Disable scroll events in `useScrollPosition`. */
|
||||
disableScrollEvents: () => void
|
||||
}
|
||||
|
||||
function useScrollControllerContextValue(): ScrollController {
|
||||
const scrollEventsEnabledRef = useRef(true)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
scrollEventsEnabledRef,
|
||||
enableScrollEvents: () => {
|
||||
scrollEventsEnabledRef.current = true
|
||||
},
|
||||
disableScrollEvents: () => {
|
||||
scrollEventsEnabledRef.current = false
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
const ScrollMonitorContext = React.createContext<ScrollController | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export function ScrollControllerProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}): JSX.Element {
|
||||
const value = useScrollControllerContextValue()
|
||||
return (
|
||||
<ScrollMonitorContext.Provider value={value}>
|
||||
{children}
|
||||
</ScrollMonitorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* We need a way to update the scroll position while ignoring scroll events
|
||||
* so as not to toggle Navbar/BackToTop visibility.
|
||||
*
|
||||
* This API permits to temporarily disable/ignore scroll events. Motivated by
|
||||
* https://github.com/facebook/docusaurus/pull/5618
|
||||
*/
|
||||
export function useScrollController(): ScrollController {
|
||||
const context = useContext(ScrollMonitorContext)
|
||||
if (context == null) {
|
||||
throw new Error(
|
||||
`useScrollController must be used by elements in ScrollControllerProvider`
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type ScrollPosition = { scrollX: number; scrollY: number }
|
||||
|
||||
const getScrollPosition = (): ScrollPosition | null => ({
|
||||
scrollX: window.pageXOffset,
|
||||
scrollY: window.pageYOffset,
|
||||
})
|
||||
|
||||
/**
|
||||
* This hook fires an effect when the scroll position changes. The effect will
|
||||
* be provided with the before/after scroll positions. Note that the effect may
|
||||
* not be always run: if scrolling is disabled through `useScrollController`, it
|
||||
* will be a no-op.
|
||||
*
|
||||
* @see {@link useScrollController}
|
||||
*/
|
||||
export function useScrollPosition(
|
||||
effect: (
|
||||
position: ScrollPosition,
|
||||
lastPosition: ScrollPosition | null
|
||||
) => void,
|
||||
deps: unknown[] = []
|
||||
): void {
|
||||
const { scrollEventsEnabledRef } = useScrollController()
|
||||
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition())
|
||||
|
||||
const dynamicEffect = useEvent(effect)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!scrollEventsEnabledRef.current) {
|
||||
return
|
||||
}
|
||||
const currentPosition = getScrollPosition()!
|
||||
dynamicEffect(currentPosition, lastPositionRef.current)
|
||||
lastPositionRef.current = currentPosition
|
||||
}
|
||||
|
||||
const opts: AddEventListenerOptions & EventListenerOptions = {
|
||||
passive: true,
|
||||
}
|
||||
|
||||
handleScroll()
|
||||
window.addEventListener("scroll", handleScroll, opts)
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll, opts)
|
||||
}, [dynamicEffect, scrollEventsEnabledRef, ...deps])
|
||||
}
|
||||
|
||||
type UseScrollPositionSaver = {
|
||||
/** Measure the top of an element, and store the details. */
|
||||
save: (elem: HTMLElement) => void
|
||||
/**
|
||||
* Restore the page position to keep the stored element's position from
|
||||
* the top of the viewport, and remove the stored details.
|
||||
*/
|
||||
restore: () => { restored: boolean }
|
||||
}
|
||||
|
||||
function useScrollPositionSaver(): UseScrollPositionSaver {
|
||||
const lastElementRef = useRef<{ elem: HTMLElement | null; top: number }>({
|
||||
elem: null,
|
||||
top: 0,
|
||||
})
|
||||
|
||||
const save = useCallback((elem: HTMLElement) => {
|
||||
lastElementRef.current = {
|
||||
elem,
|
||||
top: elem.getBoundingClientRect().top,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const restore = useCallback(() => {
|
||||
const {
|
||||
current: { elem, top },
|
||||
} = lastElementRef
|
||||
if (!elem) {
|
||||
return { restored: false }
|
||||
}
|
||||
const newTop = elem.getBoundingClientRect().top
|
||||
const heightDiff = newTop - top
|
||||
if (heightDiff) {
|
||||
window.scrollBy({ left: 0, top: heightDiff })
|
||||
}
|
||||
lastElementRef.current = { elem: null, top: 0 }
|
||||
|
||||
return { restored: heightDiff !== 0 }
|
||||
}, [])
|
||||
|
||||
return useMemo(() => ({ save, restore }), [restore, save])
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook permits to "block" the scroll position of a DOM element.
|
||||
* The idea is that we should be able to update DOM content above this element
|
||||
* but the screen position of this element should not change.
|
||||
*
|
||||
* Feature motivated by the Tabs groups: clicking on a tab may affect tabs of
|
||||
* the same group upper in the tree, yet to avoid a bad UX, the clicked tab must
|
||||
* remain under the user mouse.
|
||||
*
|
||||
* @see https://github.com/facebook/docusaurus/pull/5618
|
||||
*/
|
||||
export function useScrollPositionBlocker(): {
|
||||
/**
|
||||
* Takes an element, and keeps its screen position no matter what's getting
|
||||
* rendered above it, until the next render.
|
||||
*/
|
||||
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void
|
||||
} {
|
||||
const scrollController = useScrollController()
|
||||
const scrollPositionSaver = useScrollPositionSaver()
|
||||
|
||||
const nextLayoutEffectCallbackRef = useRef<(() => void) | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const blockElementScrollPositionUntilNextRender = useCallback(
|
||||
(el: HTMLElement) => {
|
||||
scrollPositionSaver.save(el)
|
||||
scrollController.disableScrollEvents()
|
||||
nextLayoutEffectCallbackRef.current = () => {
|
||||
const { restored } = scrollPositionSaver.restore()
|
||||
nextLayoutEffectCallbackRef.current = undefined
|
||||
|
||||
// Restoring the former scroll position will trigger a scroll event. We
|
||||
// need to wait for next scroll event to happen before enabling the
|
||||
// scrollController events again.
|
||||
if (restored) {
|
||||
const handleScrollRestoreEvent = () => {
|
||||
scrollController.enableScrollEvents()
|
||||
window.removeEventListener("scroll", handleScrollRestoreEvent)
|
||||
}
|
||||
window.addEventListener("scroll", handleScrollRestoreEvent)
|
||||
} else {
|
||||
scrollController.enableScrollEvents()
|
||||
}
|
||||
}
|
||||
},
|
||||
[scrollController, scrollPositionSaver]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Queuing permits to restore scroll position after all useLayoutEffect
|
||||
// have run, and yet preserve the sync nature of the scroll restoration
|
||||
// See https://github.com/facebook/docusaurus/issues/8625
|
||||
queueMicrotask(() => nextLayoutEffectCallbackRef.current?.())
|
||||
})
|
||||
|
||||
return {
|
||||
blockElementScrollPositionUntilNextRender,
|
||||
}
|
||||
}
|
||||
|
||||
type CancelScrollTop = () => void
|
||||
|
||||
function smoothScrollNative(top: number): CancelScrollTop {
|
||||
window.scrollTo({ top, behavior: "smooth" })
|
||||
return () => {
|
||||
// Nothing to cancel, it's natively cancelled if user tries to scroll down
|
||||
}
|
||||
}
|
||||
|
||||
function smoothScrollPolyfill(top: number): CancelScrollTop {
|
||||
let raf: number | null = null
|
||||
const isUpScroll = document.documentElement.scrollTop > top
|
||||
function rafRecursion() {
|
||||
const currentScroll = document.documentElement.scrollTop
|
||||
if (
|
||||
(isUpScroll && currentScroll > top) ||
|
||||
(!isUpScroll && currentScroll < top)
|
||||
) {
|
||||
raf = requestAnimationFrame(rafRecursion)
|
||||
window.scrollTo(0, Math.floor((currentScroll - top) * 0.85) + top)
|
||||
}
|
||||
}
|
||||
rafRecursion()
|
||||
|
||||
// Break the recursion. Prevents the user from "fighting" against that
|
||||
// recursion producing a weird UX
|
||||
return () => raf && cancelAnimationFrame(raf)
|
||||
}
|
||||
|
||||
/**
|
||||
* A "smart polyfill" of `window.scrollTo({ top, behavior: "smooth" })`.
|
||||
* This currently always uses a polyfilled implementation unless
|
||||
* `scroll-behavior: smooth` has been set in CSS, because native support
|
||||
* detection for scroll behavior seems unreliable.
|
||||
*
|
||||
* This hook does not do anything by itself: it returns a start and a stop
|
||||
* handle. You can execute either handle at any time.
|
||||
*/
|
||||
export function useSmoothScrollTo(): {
|
||||
/**
|
||||
* Start the scroll.
|
||||
*
|
||||
* @param top The final scroll top position.
|
||||
*/
|
||||
startScroll: (top: number) => void
|
||||
/**
|
||||
* A cancel function, because the non-native smooth scroll-top
|
||||
* implementation must be interrupted if user scrolls down. If there's no
|
||||
* existing animation or the scroll is using native behavior, this is a no-op.
|
||||
*/
|
||||
cancelScroll: CancelScrollTop
|
||||
} {
|
||||
const cancelRef = useRef<CancelScrollTop | null>(null)
|
||||
// Not all have support for smooth scrolling (particularly Safari mobile iOS)
|
||||
// TODO proper detection is currently unreliable!
|
||||
// see https://github.com/wessberg/scroll-behavior-polyfill/issues/16
|
||||
// For now, we only use native scroll behavior if smooth is already set,
|
||||
// because otherwise the polyfill produces a weird UX when both CSS and JS try
|
||||
// to scroll a page, and they cancel each other.
|
||||
const supportsNativeSmoothScrolling =
|
||||
getComputedStyle(document.documentElement).scrollBehavior === "smooth"
|
||||
return {
|
||||
startScroll: (top: number) => {
|
||||
cancelRef.current = supportsNativeSmoothScrolling
|
||||
? smoothScrollNative(top)
|
||||
: smoothScrollPolyfill(top)
|
||||
},
|
||||
cancelScroll: () => cancelRef.current?.(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
|
||||
export type OptionType = {
|
||||
value: string
|
||||
label: string
|
||||
index?: string
|
||||
isAllOption?: boolean
|
||||
}
|
||||
|
||||
export type SelectOptions = {
|
||||
value: string | string[]
|
||||
multiple?: boolean
|
||||
options: OptionType[]
|
||||
setSelected?: (value: string | string[]) => void
|
||||
addSelected?: (value: string) => void
|
||||
removeSelected?: (value: string) => void
|
||||
handleAddAll?: (isAllSelected: boolean) => void
|
||||
}
|
||||
|
||||
export const useSelect = ({
|
||||
value,
|
||||
options,
|
||||
multiple = false,
|
||||
setSelected,
|
||||
addSelected,
|
||||
removeSelected,
|
||||
handleAddAll,
|
||||
}: SelectOptions) => {
|
||||
const isValueSelected = useCallback(
|
||||
(val: string) => {
|
||||
return (
|
||||
(typeof value === "string" && val === value) ||
|
||||
(Array.isArray(value) && value.includes(val))
|
||||
)
|
||||
},
|
||||
[value]
|
||||
)
|
||||
|
||||
// checks if there are multiple selected values
|
||||
const hasSelectedValues = useMemo(() => {
|
||||
return multiple && Array.isArray(value) && value.length > 0
|
||||
}, [value, multiple])
|
||||
|
||||
// checks if there are any selected values,
|
||||
// whether multiple or one
|
||||
const hasSelectedValue = useMemo(() => {
|
||||
return hasSelectedValues || (typeof value === "string" && value.length)
|
||||
}, [hasSelectedValues, value])
|
||||
|
||||
const selectedValues: OptionType[] = useMemo(() => {
|
||||
if (typeof value === "string") {
|
||||
const selectedValue = options.find((option) => option.value === value)
|
||||
return selectedValue ? [selectedValue] : []
|
||||
} else if (Array.isArray(value)) {
|
||||
return options.filter((option) => value.includes(option.value))
|
||||
}
|
||||
return []
|
||||
}, [options, value])
|
||||
|
||||
const isAllSelected = useMemo(() => {
|
||||
return Array.isArray(value) && value.length === options.length
|
||||
}, [options, value])
|
||||
|
||||
const handleChange = (selectedValue: string, wasSelected: boolean) => {
|
||||
if (multiple) {
|
||||
wasSelected
|
||||
? removeSelected?.(selectedValue)
|
||||
: addSelected?.(selectedValue)
|
||||
} else {
|
||||
setSelected?.(selectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (handleAddAll) {
|
||||
handleAddAll(isAllSelected)
|
||||
} else {
|
||||
setSelected?.(options.map((option) => option.value))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValueSelected,
|
||||
hasSelectedValue,
|
||||
hasSelectedValues,
|
||||
selectedValues,
|
||||
isAllSelected,
|
||||
handleChange,
|
||||
handleSelectAll,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
export type BaseTabType = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type EventData = {
|
||||
storageValue: string
|
||||
}
|
||||
|
||||
export type TabProps<T> = {
|
||||
tabs: T[]
|
||||
group?: string
|
||||
}
|
||||
|
||||
export function useTabs<T extends BaseTabType>({ tabs, group }: TabProps<T>) {
|
||||
const [selectedTab, setSelectedTab] = useState<T | null>(null)
|
||||
const storageKey = useMemo(() => `tab_${group}`, [group])
|
||||
const eventKey = useMemo(() => `tab_${group}_changed`, [group])
|
||||
const scrollPosition = useRef<number>(0)
|
||||
|
||||
const changeSelectedTab = (tab: T) => {
|
||||
scrollPosition.current = window.scrollY
|
||||
setSelectedTab(tab)
|
||||
localStorage.setItem(storageKey, tab.value)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EventData>(eventKey, {
|
||||
detail: {
|
||||
storageValue: tab.value,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const findTabItem = useCallback(
|
||||
(val: string) => {
|
||||
const lowerVal = val.toLowerCase()
|
||||
return tabs.find((t) => t.value.toLowerCase() === lowerVal)
|
||||
},
|
||||
[tabs]
|
||||
)
|
||||
|
||||
const handleStorageChange = useCallback(
|
||||
(e: CustomEvent<EventData>) => {
|
||||
if (e.detail.storageValue !== selectedTab?.value) {
|
||||
// check if tab exists
|
||||
const tab = findTabItem(e.detail.storageValue)
|
||||
if (tab) {
|
||||
setSelectedTab(tab)
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedTab, findTabItem]
|
||||
) as EventListener
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTab) {
|
||||
const storedSelectedTabValue = localStorage.getItem(storageKey)
|
||||
setSelectedTab(
|
||||
storedSelectedTabValue
|
||||
? findTabItem(storedSelectedTabValue) || tabs[0]
|
||||
: tabs[0]
|
||||
)
|
||||
}
|
||||
}, [selectedTab, storageKey, tabs, findTabItem])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(eventKey, handleStorageChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(eventKey, handleStorageChange)
|
||||
}
|
||||
}, [handleStorageChange, eventKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollPosition.current && window.scrollY !== scrollPosition.current) {
|
||||
window.scrollTo(0, scrollPosition.current)
|
||||
}
|
||||
}, [selectedTab])
|
||||
|
||||
return { selectedTab, changeSelectedTab }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./constants"
|
||||
|
||||
export * from "./components"
|
||||
export * from "./hooks"
|
||||
export * from "./providers"
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
Whitespace-only changes.
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react"
|
||||
import { Analytics, AnalyticsBrowser } from "@segment/analytics-next"
|
||||
|
||||
export type ExtraData = {
|
||||
section?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type AnalyticsContextType = {
|
||||
loaded: boolean
|
||||
analytics: Analytics | null
|
||||
track: (
|
||||
event: string,
|
||||
options?: Record<string, any>,
|
||||
callback?: () => void
|
||||
) => void
|
||||
}
|
||||
|
||||
const AnalyticsContext = createContext<AnalyticsContextType | null>(null)
|
||||
|
||||
export type AnalyticsProviderProps = {
|
||||
writeKey?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = "ajs_anonymous_id"
|
||||
|
||||
export const AnalyticsProvider = ({
|
||||
writeKey = "temp",
|
||||
children,
|
||||
}: AnalyticsProviderProps) => {
|
||||
// loaded is used to ensure that a connection has been made to segment
|
||||
// even if it failed. This is to ensure that the connection isn't
|
||||
// continuously retried
|
||||
const [loaded, setLoaded] = useState<boolean>(false)
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null)
|
||||
const analyticsBrowser = new AnalyticsBrowser()
|
||||
|
||||
const init = () => {
|
||||
if (!loaded) {
|
||||
analyticsBrowser
|
||||
.load(
|
||||
{ writeKey },
|
||||
{
|
||||
initialPageview: true,
|
||||
user: {
|
||||
localStorage: {
|
||||
key: LOCAL_STORAGE_KEY,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((instance) => {
|
||||
setAnalytics(instance[0])
|
||||
})
|
||||
.catch((e) =>
|
||||
console.error(`Could not connect to Segment. Error: ${e}`)
|
||||
)
|
||||
.finally(() => setLoaded(true))
|
||||
}
|
||||
}
|
||||
|
||||
const track = async (
|
||||
event: string,
|
||||
options?: Record<string, any>,
|
||||
callback?: () => void
|
||||
) => {
|
||||
if (analytics) {
|
||||
void analytics.track(
|
||||
event,
|
||||
{
|
||||
...options,
|
||||
uuid: analytics.user().anonymousId(),
|
||||
},
|
||||
callback
|
||||
)
|
||||
} else if (callback) {
|
||||
console.warn(
|
||||
"Segment is either not installed or not configured. Simulating success..."
|
||||
)
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AnalyticsContext.Provider
|
||||
value={{
|
||||
analytics,
|
||||
track,
|
||||
loaded,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAnalytics = () => {
|
||||
const context = useContext(AnalyticsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAnalytics must be used within a AnalyticsProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
export type ColorMode = "light" | "dark"
|
||||
|
||||
export type ColorModeContextType = {
|
||||
colorMode: ColorMode
|
||||
setColorMode: (value: ColorMode) => void
|
||||
toggleColorMode: () => void
|
||||
}
|
||||
|
||||
const ColorModeContext = createContext<ColorModeContextType | null>(null)
|
||||
|
||||
export type ColorModeProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const ColorModeProvider = ({ children }: ColorModeProviderProps) => {
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("light")
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const toggleColorMode = () =>
|
||||
setColorMode(colorMode === "light" ? "dark" : "light")
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
const theme = localStorage.getItem("theme")
|
||||
if (theme && (theme === "light" || theme === "dark")) {
|
||||
setColorMode(theme)
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector("html")?.setAttribute("data-theme", colorMode)
|
||||
}, [colorMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
const theme = localStorage.getItem("theme")
|
||||
if (theme !== colorMode) {
|
||||
localStorage.setItem("theme", colorMode)
|
||||
}
|
||||
}, [loaded, colorMode])
|
||||
|
||||
return (
|
||||
<ColorModeContext.Provider
|
||||
value={{
|
||||
colorMode,
|
||||
setColorMode,
|
||||
toggleColorMode,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ColorModeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useColorMode = (): ColorModeContextType => {
|
||||
const context = useContext(ColorModeContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useColorMode must be used inside a ColorModeProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
export type MobileContextType = {
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const MobileContext = createContext<MobileContextType | null>(null)
|
||||
|
||||
export type MobileProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const MobileProvider = ({ children }: MobileProviderProps) => {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (window.innerWidth < 1025 && !isMobile) {
|
||||
setIsMobile(true)
|
||||
} else if (window.innerWidth >= 1025 && isMobile) {
|
||||
setIsMobile(false)
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
}
|
||||
}, [handleResize])
|
||||
|
||||
useEffect(() => {
|
||||
handleResize()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MobileContext.Provider
|
||||
value={{
|
||||
isMobile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MobileContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useMobile = () => {
|
||||
const context = useContext(MobileContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useMobile must be used within a MobileProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import React, { useContext, useState } from "react"
|
||||
import { createContext } from "react"
|
||||
import { Modal, type ModalProps } from "@/components"
|
||||
|
||||
export type ModalContextType = {
|
||||
modalProps: ModalProps | null
|
||||
setModalProps: (value: ModalProps | null) => void
|
||||
closeModal: () => void
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextType | null>(null)
|
||||
|
||||
export type ModalProviderProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ModalProvider = ({ children }: ModalProviderProps) => {
|
||||
const [modalProps, setModalProps] = useState<ModalProps | null>(null)
|
||||
|
||||
const closeModal = () => {
|
||||
setModalProps(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContext.Provider
|
||||
value={{
|
||||
modalProps,
|
||||
setModalProps,
|
||||
closeModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{modalProps && (
|
||||
<>
|
||||
<div className="bg-medusa-bg-overlay dark:bg-medusa-bg-overlay-dark fixed top-0 left-0 z-[499] h-screen w-screen"></div>
|
||||
<Modal {...modalProps} onClose={closeModal} />
|
||||
</>
|
||||
)}
|
||||
</ModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useModal = () => {
|
||||
const context = useContext(ModalContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useModal must be used within a ModalProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export type NavbarContextType = {
|
||||
activeItem: string | null
|
||||
setActiveItem: (value: string) => void
|
||||
}
|
||||
|
||||
const NavbarContext = createContext<NavbarContextType | null>(null)
|
||||
|
||||
export type NavbarProviderProps = {
|
||||
children: React.ReactNode
|
||||
basePath?: string
|
||||
}
|
||||
|
||||
export const NavbarProvider = ({
|
||||
children,
|
||||
basePath = "",
|
||||
}: NavbarProviderProps) => {
|
||||
const [activeItem, setActiveItem] = useState<string | null>(null)
|
||||
const pathname = usePathname()
|
||||
|
||||
const assemblePathName = (path: string) =>
|
||||
`${basePath}/${path.charAt(0) === "/" ? path.substring(1) : path}`
|
||||
|
||||
useEffect(() => {
|
||||
const newPath = assemblePathName(pathname)
|
||||
if (activeItem !== newPath) {
|
||||
setActiveItem(newPath)
|
||||
}
|
||||
}, [pathname, activeItem])
|
||||
|
||||
return (
|
||||
<NavbarContext.Provider
|
||||
value={{
|
||||
activeItem,
|
||||
setActiveItem,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NavbarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useNavbar = (): NavbarContextType => {
|
||||
const context = useContext(NavbarContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useNavbar must be used inside a NavbarProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useReducer } from "react"
|
||||
import { NotificationItemProps, NotificationContainer } from "@/components"
|
||||
import uuid from "react-uuid"
|
||||
|
||||
export type NotificationItemType = {
|
||||
id?: string
|
||||
} & NotificationItemProps
|
||||
|
||||
export type NotificationContextType = {
|
||||
notifications: NotificationItemType[]
|
||||
addNotification: (value: NotificationItemType) => void
|
||||
generateId: () => string
|
||||
removeNotification: (id: string) => void
|
||||
updateNotification: (
|
||||
id: string,
|
||||
updatedData: Partial<Omit<NotificationItemType, "id">>
|
||||
) => void
|
||||
}
|
||||
|
||||
export enum NotificationReducerActionTypes {
|
||||
ADD = "add",
|
||||
REMOVE = "remove",
|
||||
UPDATE = "update",
|
||||
}
|
||||
|
||||
export type NotificationReducerAction =
|
||||
| {
|
||||
type: NotificationReducerActionTypes.ADD
|
||||
notification: NotificationItemType
|
||||
}
|
||||
| {
|
||||
type: NotificationReducerActionTypes.REMOVE
|
||||
id: string
|
||||
}
|
||||
| {
|
||||
type: NotificationReducerActionTypes.UPDATE
|
||||
id: string
|
||||
updatedData: Partial<Omit<NotificationItemType, "id">>
|
||||
}
|
||||
|
||||
const notificationReducer = (
|
||||
state: NotificationItemType[],
|
||||
action: NotificationReducerAction
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case NotificationReducerActionTypes.ADD:
|
||||
return [...state, action.notification]
|
||||
case NotificationReducerActionTypes.REMOVE:
|
||||
return state.filter((notification) => notification.id !== action.id)
|
||||
case NotificationReducerActionTypes.UPDATE:
|
||||
return state.map((notification) => {
|
||||
if (notification.id === action.id) {
|
||||
return {
|
||||
...notification,
|
||||
...action.updatedData,
|
||||
}
|
||||
}
|
||||
|
||||
return notification
|
||||
})
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | null>(null)
|
||||
|
||||
export type NotificationProviderProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const NotificationProvider = ({
|
||||
children,
|
||||
}: NotificationProviderProps) => {
|
||||
const [notifications, dispatch] = useReducer(notificationReducer, [])
|
||||
|
||||
const generateId = () => uuid()
|
||||
|
||||
const addNotification = (notification: NotificationItemType) => {
|
||||
if (!notification.id) {
|
||||
notification.id = generateId()
|
||||
}
|
||||
dispatch({
|
||||
type: NotificationReducerActionTypes.ADD,
|
||||
notification,
|
||||
})
|
||||
}
|
||||
|
||||
const updateNotification = (
|
||||
id: string,
|
||||
updatedData: Partial<Omit<NotificationItemType, "id">>
|
||||
) => {
|
||||
dispatch({
|
||||
type: NotificationReducerActionTypes.UPDATE,
|
||||
id,
|
||||
updatedData,
|
||||
})
|
||||
}
|
||||
|
||||
const removeNotification = (id: string) => {
|
||||
dispatch({
|
||||
type: NotificationReducerActionTypes.REMOVE,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider
|
||||
value={{
|
||||
notifications,
|
||||
addNotification,
|
||||
generateId,
|
||||
removeNotification,
|
||||
updateNotification,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<NotificationContainer />
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useNotifications = (): NotificationContextType => {
|
||||
const context = useContext(NotificationContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useNotifications must be used within a NotificationProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useState } from "react"
|
||||
|
||||
export type PageLoadingContextType = {
|
||||
isLoading: boolean
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const PageLoadingContext = createContext<PageLoadingContextType | null>(null)
|
||||
|
||||
export type PageLoadingProviderProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const PageLoadingProvider = ({ children }: PageLoadingProviderProps) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
return (
|
||||
<PageLoadingContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PageLoadingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const usePageLoading = (): PageLoadingContextType => {
|
||||
const context = useContext(PageLoadingContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("usePageLoading must be used inside a PageLoadingProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import { SearchModal, SearchModalProps } from "@/components"
|
||||
import { checkArraySameElms } from "../../utils"
|
||||
import algoliasearch, { SearchClient } from "algoliasearch/lite"
|
||||
|
||||
export type SearchContextType = {
|
||||
isOpen: boolean
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
defaultFilters: string[]
|
||||
setDefaultFilters: (value: string[]) => void
|
||||
searchClient: SearchClient
|
||||
}
|
||||
|
||||
const SearchContext = createContext<SearchContextType | null>(null)
|
||||
|
||||
export type AlgoliaProps = {
|
||||
appId: string
|
||||
apiKey: string
|
||||
mainIndexName: string
|
||||
indices: string[]
|
||||
}
|
||||
|
||||
export type SearchProviderProps = {
|
||||
children: React.ReactNode
|
||||
initialDefaultFilters?: string[]
|
||||
algolia: AlgoliaProps
|
||||
searchProps: Omit<SearchModalProps, "algolia">
|
||||
}
|
||||
|
||||
export const SearchProvider = ({
|
||||
children,
|
||||
initialDefaultFilters = [],
|
||||
searchProps,
|
||||
algolia,
|
||||
}: SearchProviderProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [defaultFilters, setDefaultFilters] = useState<string[]>(
|
||||
initialDefaultFilters
|
||||
)
|
||||
|
||||
const searchClient: SearchClient = useMemo(() => {
|
||||
const algoliaClient = algoliasearch(algolia.appId, algolia.apiKey)
|
||||
return {
|
||||
...algoliaClient,
|
||||
async search(requests) {
|
||||
if (requests.every(({ params }) => !params?.query)) {
|
||||
return Promise.resolve({
|
||||
results: requests.map(() => ({
|
||||
hits: [],
|
||||
nbHits: 0,
|
||||
nbPages: 0,
|
||||
page: 0,
|
||||
processingTimeMS: 0,
|
||||
hitsPerPage: 0,
|
||||
exhaustiveNbHits: false,
|
||||
query: "",
|
||||
params: "",
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return algoliaClient.search(requests)
|
||||
},
|
||||
}
|
||||
}, [algolia.appId, algolia.apiKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialDefaultFilters.length &&
|
||||
!checkArraySameElms(defaultFilters, initialDefaultFilters)
|
||||
) {
|
||||
setDefaultFilters(initialDefaultFilters)
|
||||
}
|
||||
}, [initialDefaultFilters])
|
||||
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
defaultFilters,
|
||||
setDefaultFilters,
|
||||
searchClient,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SearchModal {...searchProps} algolia={algolia} />
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSearch = (): SearchContextType => {
|
||||
const context = useContext(SearchContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useSearch must be used inside a SearchProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useState,
|
||||
} from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export enum SidebarItemSections {
|
||||
TOP = "top",
|
||||
BOTTOM = "bottom",
|
||||
MOBILE = "mobile",
|
||||
}
|
||||
|
||||
export type SidebarItemType = {
|
||||
path?: string
|
||||
title: string
|
||||
additionalElms?: React.ReactNode
|
||||
children?: SidebarItemType[]
|
||||
loaded?: boolean
|
||||
isPathHref?: boolean
|
||||
}
|
||||
|
||||
export type SidebarSectionItemsType = {
|
||||
[k in SidebarItemSections]: SidebarItemType[]
|
||||
}
|
||||
|
||||
export type SidebarContextType = {
|
||||
items: SidebarSectionItemsType
|
||||
activePath: string | null
|
||||
getActiveItem: () => SidebarItemType | undefined
|
||||
setActivePath: (path: string | null) => void
|
||||
isItemActive: (item: SidebarItemType, checkChildren?: boolean) => boolean
|
||||
addItems: (
|
||||
item: SidebarItemType[],
|
||||
options?: {
|
||||
section?: SidebarItemSections
|
||||
parent?: {
|
||||
path: string
|
||||
changeLoaded?: boolean
|
||||
}
|
||||
indexPosition?: number
|
||||
ignoreExisting?: boolean
|
||||
}
|
||||
) => void
|
||||
findItemInSection: (
|
||||
section: SidebarItemType[],
|
||||
item: Partial<SidebarItemType>,
|
||||
checkChildren?: boolean
|
||||
) => SidebarItemType | undefined
|
||||
mobileSidebarOpen: boolean
|
||||
setMobileSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
isSidebarEmpty: () => boolean
|
||||
desktopSidebarOpen: boolean
|
||||
setDesktopSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const SidebarContext = createContext<SidebarContextType | null>(null)
|
||||
|
||||
export type ActionOptionsType = {
|
||||
section?: SidebarItemSections
|
||||
parent?: {
|
||||
path: string
|
||||
changeLoaded?: boolean
|
||||
}
|
||||
indexPosition?: number
|
||||
ignoreExisting?: boolean
|
||||
}
|
||||
|
||||
export type ActionType = {
|
||||
type: "add" | "update"
|
||||
items: SidebarItemType[]
|
||||
options?: ActionOptionsType
|
||||
}
|
||||
|
||||
export const reducer = (
|
||||
state: SidebarSectionItemsType,
|
||||
{ type, items, options }: ActionType
|
||||
) => {
|
||||
const {
|
||||
section = SidebarItemSections.TOP,
|
||||
parent,
|
||||
indexPosition,
|
||||
} = options || {}
|
||||
|
||||
switch (type) {
|
||||
case "add":
|
||||
return {
|
||||
...state,
|
||||
[section]:
|
||||
indexPosition !== undefined
|
||||
? [
|
||||
...state[section].slice(0, indexPosition),
|
||||
...items,
|
||||
...state[section].slice(indexPosition),
|
||||
]
|
||||
: [...state[section], ...items],
|
||||
}
|
||||
case "update":
|
||||
// find item index
|
||||
return {
|
||||
...state,
|
||||
[section]: state[section].map((i) => {
|
||||
if (i.path && parent?.path && i.path === parent?.path) {
|
||||
return {
|
||||
...i,
|
||||
children: [...(i.children || []), ...items],
|
||||
loaded: parent.changeLoaded ? true : i.loaded,
|
||||
}
|
||||
}
|
||||
return i
|
||||
}),
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export type SidebarProviderProps = {
|
||||
children?: ReactNode
|
||||
isLoading?: boolean
|
||||
setIsLoading?: React.Dispatch<React.SetStateAction<boolean>>
|
||||
initialItems?: SidebarSectionItemsType
|
||||
shouldHandleHashChange?: boolean
|
||||
shouldHandlePathChange?: boolean
|
||||
}
|
||||
|
||||
export const SidebarProvider = ({
|
||||
children,
|
||||
isLoading = false,
|
||||
setIsLoading,
|
||||
initialItems,
|
||||
shouldHandleHashChange = false,
|
||||
shouldHandlePathChange = false,
|
||||
}: SidebarProviderProps) => {
|
||||
const [items, dispatch] = useReducer(reducer, {
|
||||
top: initialItems?.top || [],
|
||||
bottom: initialItems?.bottom || [],
|
||||
mobile: initialItems?.mobile || [],
|
||||
})
|
||||
const [activePath, setActivePath] = useState<string | null>("")
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
|
||||
const pathname = usePathname()
|
||||
|
||||
const findItemInSection = useCallback(
|
||||
(
|
||||
section: SidebarItemType[],
|
||||
item: Partial<SidebarItemType>,
|
||||
checkChildren = true
|
||||
): SidebarItemType | undefined => {
|
||||
return section.find((i) => {
|
||||
if (!item.path) {
|
||||
return !i.path && i.title === item.title
|
||||
} else {
|
||||
return (
|
||||
i.path === item.path ||
|
||||
(checkChildren && i.children && findItemInSection(i.children, item))
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const getActiveItem = useCallback(() => {
|
||||
if (activePath === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
findItemInSection(items.mobile, { path: activePath }) ||
|
||||
findItemInSection(items.top, { path: activePath }) ||
|
||||
findItemInSection(items.bottom, { path: activePath })
|
||||
)
|
||||
}, [activePath, items, findItemInSection])
|
||||
|
||||
const addItems = (
|
||||
newItems: SidebarItemType[],
|
||||
options?: {
|
||||
section?: SidebarItemSections
|
||||
parent?: {
|
||||
path: string
|
||||
changeLoaded?: boolean
|
||||
}
|
||||
indexPosition?: number
|
||||
ignoreExisting?: boolean
|
||||
}
|
||||
) => {
|
||||
const {
|
||||
section = SidebarItemSections.TOP,
|
||||
parent,
|
||||
ignoreExisting = false,
|
||||
} = options || {}
|
||||
|
||||
if (!ignoreExisting) {
|
||||
const selectedSection =
|
||||
section === SidebarItemSections.BOTTOM ? items.bottom : items.top
|
||||
newItems = newItems.filter(
|
||||
(item) => !findItemInSection(selectedSection, item)
|
||||
)
|
||||
}
|
||||
|
||||
if (!newItems.length) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: parent ? "update" : "add",
|
||||
items: newItems,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
const isItemActive = useCallback(
|
||||
(item: SidebarItemType, checkChildren = false): boolean => {
|
||||
return (
|
||||
item.path === activePath ||
|
||||
(checkChildren && activePath?.split("_")[0] === item.path)
|
||||
)
|
||||
},
|
||||
[activePath]
|
||||
)
|
||||
|
||||
const isSidebarEmpty = useCallback((): boolean => {
|
||||
return Object.values(items).every(
|
||||
(sectionItems) => sectionItems.length === 0
|
||||
)
|
||||
}, [items])
|
||||
|
||||
const init = () => {
|
||||
const currentPath = location.hash.replace("#", "")
|
||||
if (currentPath) {
|
||||
setActivePath(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
// this is mainly triggered by Algolia
|
||||
const handleHashChange = useCallback(() => {
|
||||
const currentPath = location.hash.replace("#", "")
|
||||
if (currentPath !== activePath) {
|
||||
setActivePath(currentPath)
|
||||
}
|
||||
}, [activePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldHandleHashChange) {
|
||||
return
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
const handleScroll = () => {
|
||||
if (window.scrollY === 0) {
|
||||
setActivePath("")
|
||||
// can't use next router as it doesn't support
|
||||
// changing url without scrolling
|
||||
history.replaceState({}, "", location.pathname)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll)
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll)
|
||||
window.removeEventListener("hashchange", handleHashChange)
|
||||
}
|
||||
}, [handleHashChange, shouldHandleHashChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading && items.top.length && items.bottom.length) {
|
||||
setIsLoading?.(false)
|
||||
}
|
||||
}, [items, isLoading, setIsLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldHandlePathChange && pathname !== activePath) {
|
||||
setActivePath(pathname)
|
||||
}
|
||||
}, [shouldHandlePathChange, pathname])
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
items,
|
||||
addItems,
|
||||
activePath,
|
||||
setActivePath,
|
||||
isItemActive,
|
||||
findItemInSection,
|
||||
mobileSidebarOpen,
|
||||
setMobileSidebarOpen,
|
||||
isSidebarEmpty,
|
||||
getActiveItem,
|
||||
desktopSidebarOpen,
|
||||
setDesktopSidebarOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSidebar = (): SidebarContextType => {
|
||||
const context = useContext(SidebarContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used inside a SidebarProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./Analytics"
|
||||
export * from "./ColorMode"
|
||||
export * from "./Mobile"
|
||||
export * from "./Modal"
|
||||
export * from "./Navbar"
|
||||
export * from "./Notification"
|
||||
export * from "./PageLoading"
|
||||
export * from "./Search"
|
||||
export * from "./Sidebar"
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Ref<T> =
|
||||
| React.MutableRefObject<T | null>
|
||||
| ((instance: T | null) => void)
|
||||
@@ -0,0 +1,10 @@
|
||||
export function checkArraySameElms(
|
||||
arr1: Array<any>,
|
||||
arr2: Array<any>
|
||||
): boolean {
|
||||
if (arr1.length !== arr2.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return arr1.every((value, index) => value === arr2[index])
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function checkSidebarItemVisibility(
|
||||
item: Element,
|
||||
options?: {
|
||||
topMargin?: number
|
||||
}
|
||||
) {
|
||||
const sidebar = document.getElementById("sidebar")
|
||||
if (!sidebar) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { topMargin = 0 } = options || {}
|
||||
const sidebarBoundingRect = sidebar.getBoundingClientRect()
|
||||
const activeItemBoundingRect = item.getBoundingClientRect()
|
||||
|
||||
return (
|
||||
activeItemBoundingRect.top >= topMargin &&
|
||||
activeItemBoundingRect.top - sidebarBoundingRect.height + topMargin < 0 &&
|
||||
activeItemBoundingRect.bottom > 0
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function findPrevSibling(
|
||||
element: HTMLElement,
|
||||
selector: string
|
||||
): HTMLElement | null {
|
||||
let prevElement = element.previousElementSibling
|
||||
while (prevElement !== null) {
|
||||
if (prevElement.matches(selector)) {
|
||||
return prevElement as HTMLElement
|
||||
}
|
||||
prevElement = prevElement.previousElementSibling
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findNextSibling(
|
||||
element: HTMLElement,
|
||||
selector: string
|
||||
): HTMLElement | null {
|
||||
let nextElement = element.nextElementSibling
|
||||
while (nextElement !== null) {
|
||||
if (nextElement.matches(selector)) {
|
||||
return nextElement as HTMLElement
|
||||
}
|
||||
nextElement = nextElement.nextElementSibling
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { GITHUB_ISSUES_PREFIX } from "../constants"
|
||||
|
||||
export function formatReportLink(title: string, sectionTitle: string) {
|
||||
return `${GITHUB_ISSUES_PREFIX}&title=${encodeURI(
|
||||
title
|
||||
)}%3A%20Issue%20in%20${encodeURI(sectionTitle)}`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./array-same-elms"
|
||||
export * from "./capitalize"
|
||||
export * from "./check-sidebar-item-visibility"
|
||||
export * from "./dom-utils"
|
||||
export * from "./format-report-link"
|
||||
@@ -0,0 +1,7 @@
|
||||
import coreConfig from "tailwind/modified.tailwind.config"
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
...coreConfig,
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/cjs",
|
||||
"module": "CommonJS"
|
||||
},
|
||||
"exclude": [
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/esm",
|
||||
"module": "ES2020"
|
||||
},
|
||||
"exclude": [
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"docs-ui": ["./src/index.ts"],
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo-client",
|
||||
"noEmit": false,
|
||||
"jsx": "react",
|
||||
"lib": ["dom", "ES2015"],
|
||||
"module": "ESNext",
|
||||
"target": "es6",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user