import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { DotsSix, Eye, EyeSlash } from "@medusajs/icons"
import { IconButton, Kbd, Text, clx } from "@medusajs/ui"
import { Collapsible as RadixCollapsible } from "radix-ui"
import {
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { NavLink, To, useLocation } from "react-router-dom"
import { useLayoutEdit } from "../../../hooks/use-layout-edit"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { ConditionalTooltip } from "../../common/conditional-tooltip"
type ItemType = "core" | "extension" | "setting"
type NestedItemProps = {
label: string
to: string
translationNs?: string
}
export type INavItem = {
icon?: ReactNode
label: string
to: string
items?: NestedItemProps[]
type?: ItemType
from?: string
nested?: string
translationNs?: string
}
const BASE_NAV_LINK_CLASSES =
"text-ui-fg-subtle transition-fg hover:bg-ui-bg-subtle-hover flex items-center gap-x-2 rounded-md py-0.5 pl-0.5 pr-2 outline-none [&>svg]:text-ui-fg-subtle focus-visible:shadow-borders-focus"
const ACTIVE_NAV_LINK_CLASSES =
"bg-ui-bg-base shadow-elevation-card-rest text-ui-fg-base hover:bg-ui-bg-base"
const NESTED_NAV_LINK_CLASSES = "pl-[34px] pr-2 py-1 w-full text-ui-fg-muted"
const SETTING_NAV_LINK_CLASSES = "pl-2 py-1"
// Prefix match that respects path-segment boundaries, so that a route like
// "/inventory-sources" is not considered a match for "/inventory". This keeps
// nested-route highlighting (e.g. "/inventory/123") while avoiding false
// positives for sibling routes that merely share a string prefix.
const isPathActive = (pathname: string, to: string) => {
return pathname === to || pathname.startsWith(to + "/")
}
const getIsOpen = (
to: string,
items: NestedItemProps[] | undefined,
pathname: string
) => {
return [to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
isPathActive(pathname, p)
)
}
const NavItemTooltip = ({
to,
children,
}: PropsWithChildren<{ to: string }>) => {
const { t } = useTranslation()
const globalShortcuts = useGlobalShortcuts()
const shortcut = globalShortcuts.find((s) => s.to === to)
return (
{shortcut?.label}
{shortcut?.keys.Mac?.map((key, index) => (
{key}
{index < (shortcut.keys.Mac?.length || 0) - 1 && (
{t("app.keyboardShortcuts.then")}
)}
))}
}
side="right"
delayDuration={1500}
>
{children}
)
}
type SubItemLinkProps = {
item: NestedItemProps
isSetting: boolean
navLinkClassNames: (props: {
to: string
isActive: boolean
isNested?: boolean
isSetting?: boolean
}) => string
getLinkTarget: (target: string) => To
}
const SubItemLink = ({
item,
isSetting,
navLinkClassNames,
getLinkTarget,
}: SubItemLinkProps) => {
const { t } = useTranslation(item.translationNs as any)
const itemLabel: string = item.translationNs ? t(item.label) : item.label
return (
{
return clx(
navLinkClassNames({
to: item.to,
isActive,
isSetting,
isNested: true,
})
)
}}
>
{itemLabel}
)
}
const NavItemSubItem = (props: SubItemLinkProps) => {
return (
)
}
const SortableSubItem = ({
id,
hidden,
onToggleHidden,
...props
}: SubItemLinkProps & {
id: string
hidden: boolean
onToggleHidden: () => void
}) => {
const { t } = useTranslation()
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Translate.toString(transform),
transition,
}
return (
{hidden ? : }
)
}
/**
* Renders a route's nested children, applying the saved order. In layout edit
* mode they become a self-contained vertical sortable (its own `DndContext`, so
* it doesn't interfere with the composer's entry-level drag), scoped to this
* route — child ids embed the parent path, so moves never cross routes.
*/
const NavSubItems = ({
parentTo,
items,
isSetting,
navLinkClassNames,
getLinkTarget,
}: {
parentTo: string
items: NestedItemProps[]
isSetting: boolean
navLinkClassNames: SubItemLinkProps["navLinkClassNames"]
getLinkTarget: SubItemLinkProps["getLinkTarget"]
}) => {
const { editMode, orderChildren, setChildrenOrder, isHidden, toggleHidden } =
useLayoutEdit()
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
)
const childId = useCallback(
(to: string) => `nav-child:${parentTo}:${to}`,
[parentTo]
)
const ordered = orderChildren(items, (it) => childId(it.to))
const shared = { isSetting, navLinkClassNames, getLinkTarget }
if (!editMode) {
return (
<>
{ordered
.filter((item) => !isHidden(childId(item.to)))
.map((item) => (
))}
>
)
}
const ids = ordered.map((it) => childId(it.to))
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
const oldIndex = ids.indexOf(active.id as string)
const newIndex = ids.indexOf(over.id as string)
if (oldIndex < 0 || newIndex < 0) {
return
}
setChildrenOrder(arrayMove(ids, oldIndex, newIndex))
}
return (
{ordered.map((item) => (
toggleHidden(childId(item.to))}
{...shared}
/>
))}
)
}
export const NavItem = ({
icon,
label,
to,
items,
type = "core",
from,
translationNs,
}: INavItem) => {
const { t } = useTranslation(translationNs as any)
const { pathname, search } = useLocation()
const [open, setOpen] = useState(getIsOpen(to, items, pathname))
// While the layout is being edited, force the nested list open so every
// route's children are visible and reorderable, not just the active route's.
const { editMode } = useLayoutEdit()
// Use translation if translationNs is provided, otherwise use label as-is
const displayLabel: string = translationNs ? t(label) : label
useEffect(() => {
setOpen(getIsOpen(to, items, pathname))
}, [pathname, to, items])
const navLinkClassNames = useCallback(
({
to,
isActive,
isNested = false,
isSetting = false,
}: {
to: string
isActive: boolean
isNested?: boolean
isSetting?: boolean
}) => {
if (["core", "setting"].includes(type)) {
isActive = isPathActive(pathname, to)
}
return clx(BASE_NAV_LINK_CLASSES, {
[NESTED_NAV_LINK_CLASSES]: isNested,
[ACTIVE_NAV_LINK_CLASSES]: isActive && !editMode,
[SETTING_NAV_LINK_CLASSES]: isSetting,
})
},
[type, pathname, editMode]
)
const getLinkTarget = useCallback(
(target: string) => {
if (pathname === target && search) {
return { pathname: target, search }
}
return target
},
[pathname, search]
)
const isSetting = type === "setting"
return (
i.to === pathname)}
state={
from
? {
from,
}
: undefined
}
className={({ isActive }) => {
return clx(navLinkClassNames({ isActive, isSetting, to }), {
"max-lg:hidden": !!items?.length,
})
}}
>
{type !== "setting" && (
)}
{displayLabel}
{items && items.length > 0 && (
{displayLabel}
-
{
return clx(
navLinkClassNames({
to,
isActive,
isSetting,
isNested: true,
})
)
}}
>
{displayLabel}
)}
)
}
const Icon = ({ icon, type }: { icon?: ReactNode; type: ItemType }) => {
if (!icon) {
return null
}
return type === "extension" ? (
) : (
icon
)
}