+
{
"!text-medusa-fg-base !text-compact-small-plus"
)}
tabIndex={-1}
+ data-testid="sidebar-child-back-button"
>
-
{title}
+
+ {title}
+
)
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/Category/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/Category/__tests__/index.test.tsx
new file mode 100644
index 0000000000..cedaa2cefa
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/Category/__tests__/index.test.tsx
@@ -0,0 +1,378 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { fireEvent, render, waitFor } from "@testing-library/react"
+import { Sidebar } from "types"
+
+// mock hooks
+const mockIsItemActive = vi.fn()
+const mockUpdatePersistedCategoryState = vi.fn()
+const mockGetPersistedCategoryState = vi.fn()
+
+const defaultUseSidebarReturn = {
+ isItemActive: mockIsItemActive,
+ updatePersistedCategoryState: mockUpdatePersistedCategoryState,
+ getPersistedCategoryState: mockGetPersistedCategoryState,
+ persistCategoryState: false,
+}
+
+const mockUseSidebar = vi.fn(() => defaultUseSidebarReturn)
+
+vi.mock("@/providers/Sidebar", () => ({
+ useSidebar: () => mockUseSidebar(),
+}))
+
+// mock components
+vi.mock("@/components/Badge", () => ({
+ Badge: ({
+ variant,
+ children,
+ }: {
+ variant?: string
+ children: React.ReactNode
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock("@/components/Loading", () => ({
+ Loading: ({
+ count,
+ className,
+ barClassName,
+ }: {
+ count?: number
+ className?: string
+ barClassName?: string
+ }) => (
+
+ Loading
+
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Item", () => ({
+ SidebarItem: ({ item }: { item: Sidebar.SidebarItem }) => (
+
+ {"title" in item ? item.title : item.type}
+
+ ),
+}))
+
+vi.mock("@medusajs/icons", () => ({
+ TriangleDownMini: () =>
,
+ TriangleUpMini: () =>
,
+}))
+
+import { SidebarItemCategory } from "../../Category"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsItemActive.mockReturnValue(false)
+ mockGetPersistedCategoryState.mockReturnValue(undefined)
+ mockUseSidebar.mockReturnValue(defaultUseSidebarReturn)
+})
+
+describe("rendering", () => {
+ test("renders category title", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
)
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toBeInTheDocument()
+ expect(title).toHaveTextContent("Test Category")
+ })
+
+ test("renders badge when provided", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ badge: { text: "New", variant: "blue" },
+ children: [],
+ }
+ const { container } = render(
)
+ const badge = container.querySelector("[data-testid='badge']")
+ expect(badge).toBeInTheDocument()
+ expect(badge).toHaveTextContent("New")
+ })
+
+ test("renders additional elements when provided", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ additionalElms:
Additional,
+ children: [],
+ }
+ const { container } = render(
)
+ const additional = container.querySelector("[data-testid='additional']")
+ expect(additional).toBeInTheDocument()
+ })
+
+ test("renders triangle up icon when closed", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
)
+ const upIcon = container.querySelector("[data-testid='triangle-up-icon']")
+ expect(upIcon).toBeInTheDocument()
+ })
+
+ test("renders triangle down icon when open", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ initialOpen: true,
+ children: [],
+ }
+ const { container } = render(
)
+ const downIcon = container.querySelector(
+ "[data-testid='triangle-down-icon']"
+ )
+ expect(downIcon).toBeInTheDocument()
+ })
+
+ test("does not render triangle icons when additionalElms is provided", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ additionalElms:
Additional,
+ children: [],
+ }
+ const { container } = render(
)
+ const upIcon = container.querySelector("[data-testid='triangle-up-icon']")
+ const downIcon = container.querySelector(
+ "[data-testid='triangle-down-icon']"
+ )
+ expect(upIcon).not.toBeInTheDocument()
+ expect(downIcon).not.toBeInTheDocument()
+ })
+
+ test("renders children when open", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ initialOpen: true,
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render(
)
+ const childItem = container.querySelector("[data-testid='sidebar-item']")
+ expect(childItem).toBeInTheDocument()
+ expect(childItem).toHaveTextContent("Child 1")
+ })
+
+ test("hides children when closed", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ initialOpen: false,
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render(
)
+ const children = container.querySelector(
+ "[data-testid='sidebar-item-category-children']"
+ )
+ expect(children).toHaveClass("overflow-hidden m-0 h-0")
+ })
+
+ test("does not render children when hideChildren is true", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ hideChildren: true,
+ initialOpen: true,
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render(
)
+ const childItem = container.querySelector("[data-testid='sidebar-item']")
+ expect(childItem).not.toBeInTheDocument()
+ })
+
+ test("renders loading when showLoadingIfEmpty is true and no children", async () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ initialOpen: true,
+ loaded: false,
+ showLoadingIfEmpty: true,
+ children: [],
+ }
+ const { container } = render(
)
+ await waitFor(() => {
+ const loading = container.querySelector("[data-testid='loading']")
+ expect(loading).toBeInTheDocument()
+ })
+ })
+
+ test("applies break-words for multi-word titles", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category Title",
+ children: [],
+ }
+ const { container } = render(
)
+ const clickableDiv = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ expect(clickableDiv).toHaveClass("break-words")
+ })
+
+ test("applies truncate for single-word titles", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Category",
+ children: [],
+ }
+ const { container } = render(
)
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toBeInTheDocument()
+ expect(title).toHaveClass("truncate")
+ })
+
+ test("applies custom className", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
+
+ )
+ const category = container.querySelector(
+ "[data-testid='sidebar-item-category-container']"
+ )
+ expect(category).toHaveClass("custom-class")
+ })
+})
+
+describe("interactions", () => {
+ test("toggles open state when clicked", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
)
+ const clickableDiv = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ const upIcon = container.querySelector("[data-testid='triangle-up-icon']")
+ expect(upIcon).toBeInTheDocument()
+ fireEvent.click(clickableDiv!)
+ const downIcon = container.querySelector(
+ "[data-testid='triangle-down-icon']"
+ )
+ expect(downIcon).toBeInTheDocument()
+ })
+
+ test("calls onOpen when opening", () => {
+ const mockOnOpen = vi.fn()
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ onOpen: mockOnOpen,
+ children: [],
+ }
+ const { container } = render(
)
+ const clickableDiv = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ fireEvent.click(clickableDiv!)
+ expect(mockOnOpen).toHaveBeenCalledTimes(1)
+ })
+
+ test("does not call onOpen when closing", () => {
+ const mockOnOpen = vi.fn()
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ initialOpen: true,
+ onOpen: mockOnOpen,
+ children: [],
+ }
+ const { container } = render(
)
+ const clickableDiv = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ fireEvent.click(clickableDiv!)
+ expect(mockOnOpen).not.toHaveBeenCalled()
+ })
+
+ test("updates persisted state when persistCategoryState is true", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ persistCategoryState: true,
+ })
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
)
+ const clickableDiv = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ fireEvent.click(clickableDiv!)
+ expect(mockUpdatePersistedCategoryState).toHaveBeenCalledWith(
+ "Test Category",
+ true
+ )
+ })
+
+ test("opens category when active", async () => {
+ mockIsItemActive.mockReturnValue(true)
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
)
+ await waitFor(() => {
+ const downIcon = container.querySelector(
+ "[data-testid='triangle-down-icon']"
+ )
+ expect(downIcon).toBeInTheDocument()
+ })
+ })
+
+ test("uses persisted state when persistCategoryState is true", () => {
+ mockGetPersistedCategoryState.mockReturnValue(true)
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ persistCategoryState: true,
+ })
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
)
+ const downIcon = container.querySelector(
+ "[data-testid='triangle-down-icon']"
+ )
+ expect(downIcon).toBeInTheDocument()
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/Category/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/Category/index.tsx
index b07f0a4db1..5a32f13422 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Item/Category/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/Category/index.tsx
@@ -4,7 +4,10 @@
import React, { useEffect, useMemo, useState } from "react"
import { Sidebar } from "types"
-import { Badge, Loading, SidebarItem, useSidebar } from "../../../.."
+import { Badge } from "@/components/Badge"
+import { Loading } from "@/components/Loading"
+import { SidebarItem } from "@/components/Sidebar/Item"
+import { useSidebar } from "@/providers/Sidebar"
import clsx from "clsx"
import { TriangleDownMini, TriangleUpMini } from "@medusajs/icons"
@@ -81,6 +84,7 @@ export const SidebarItemCategory = ({
return (
{item.title}
@@ -123,6 +129,7 @@ export const SidebarItemCategory = ({
"z-[1] relative",
!open && "overflow-hidden m-0 h-0"
)}
+ data-testid="sidebar-item-category-children"
>
{item.children?.map((childItem, index) => (
(),
+ sidebarTopHeight: 0,
+}
+
+const defaultUseMobileReturn = {
+ isMobile: false,
+}
+
+const mockUseSidebar = vi.fn(() => defaultUseSidebarReturn)
+const mockUseMobile = vi.fn(() => defaultUseMobileReturn)
+
+vi.mock("@/providers/Sidebar", () => ({
+ useSidebar: () => mockUseSidebar(),
+}))
+
+vi.mock("@/providers/Mobile", () => ({
+ useMobile: () => mockUseMobile(),
+}))
+
+// mock components
+vi.mock("@/components/Badge", () => ({
+ Badge: ({
+ variant,
+ children,
+ }: {
+ variant?: string
+ children: React.ReactNode
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ className,
+ target,
+ rel,
+ }: {
+ href: string
+ children: React.ReactNode
+ className?: string
+ target?: string
+ rel?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Item", () => ({
+ SidebarItem: ({ item }: { item: Sidebar.SidebarItem }) => (
+
+ {"title" in item ? item.title : item.type}
+
+ ),
+}))
+
+vi.mock("@/utils/check-sidebar-item-visibility", () => ({
+ checkSidebarItemVisibility: vi.fn(() => false),
+}))
+
+// Mock scrollIntoView
+beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView
+ window.HTMLElement.prototype.scrollTo = mockScrollTo
+ vi.clearAllMocks()
+ mockIsItemActive.mockReturnValue(false)
+ mockUseSidebar.mockReturnValue(defaultUseSidebarReturn)
+ mockUseMobile.mockReturnValue(defaultUseMobileReturn)
+})
+
+import { SidebarItemLink } from "../../Link"
+
+describe("rendering", () => {
+ test("renders link title", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ const { container } = render()
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toBeInTheDocument()
+ expect(title).toHaveTextContent("Test Link")
+ })
+
+ test("renders link with hash prefix when isPathHref is false", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "test",
+ title: "Test Link",
+ isPathHref: false,
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("href", "#test")
+ })
+
+ test("renders link without hash prefix when isPathHref is true", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ isPathHref: true,
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("href", "/test")
+ })
+
+ test("renders external link with target and rel", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "external",
+ path: "https://example.com",
+ title: "External Link",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("target", "_blank")
+ expect(link).toHaveAttribute("rel", "noopener noreferrer")
+ })
+
+ test("renders badge when provided", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ badge: { text: "New", variant: "blue" },
+ }
+ const { container } = render()
+ const badge = container.querySelector("[data-testid='badge']")
+ expect(badge).toBeInTheDocument()
+ expect(badge).toHaveTextContent("New")
+ })
+
+ test("renders additional elements when provided", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ additionalElms: Additional,
+ }
+ const { container } = render()
+ const additional = container.querySelector("[data-testid='additional']")
+ expect(additional).toBeInTheDocument()
+ })
+
+ test("renders children when provided", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render()
+ const childItem = container.querySelector("[data-testid='sidebar-item']")
+ expect(childItem).toBeInTheDocument()
+ expect(childItem).toHaveTextContent("Child 1")
+ })
+
+ test("does not render children when hideChildren is true", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ hideChildren: true,
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render()
+ const childItem = container.querySelector("[data-testid='sidebar-item']")
+ expect(childItem).not.toBeInTheDocument()
+ })
+
+ test("applies active styles when active", () => {
+ mockIsItemActive.mockReturnValue(true)
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("bg-medusa-bg-base")
+ expect(link).toHaveClass(
+ "shadow-elevation-card-rest dark:shadow-elevation-card-rest-dark"
+ )
+ expect(link).toHaveClass("text-medusa-fg-base")
+ })
+
+ test("applies inactive styles when not active and nested is false", () => {
+ mockIsItemActive.mockReturnValue(false)
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("text-medusa-fg-subtle")
+ })
+
+ test("applies nested styles when not active and nested is true", () => {
+ mockIsItemActive.mockReturnValue(false)
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "test",
+ title: "Test Link",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("text-medusa-fg-muted")
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toHaveClass("pl-docs_1.5")
+ })
+
+ test("applies break-words for multi-word titles", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link Title",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("break-words")
+ })
+
+ test("applies truncate for single-word titles", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Link",
+ }
+ const { container } = render()
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toBeInTheDocument()
+ expect(title).toHaveClass("truncate")
+ })
+
+ test("applies custom className", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ const { container } = render(
+
+ )
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("custom-class")
+ })
+
+ test("passes linkProps to Link component", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ linkProps: { rel: "noopener noreferrer" },
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("rel", "noopener noreferrer")
+ })
+})
+
+describe("interactions", () => {
+ test("closes mobile sidebar when active and mobile", async () => {
+ mockIsItemActive.mockReturnValue(true)
+ mockUseMobile.mockReturnValue({ isMobile: true })
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ render()
+ await waitFor(() => {
+ expect(mockSetMobileSidebarOpen).toHaveBeenCalledWith(false)
+ })
+ })
+
+ test("does not close mobile sidebar when not active", () => {
+ mockIsItemActive.mockReturnValue(false)
+ mockUseMobile.mockReturnValue({ isMobile: true })
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ render()
+ expect(mockSetMobileSidebarOpen).not.toHaveBeenCalled()
+ })
+})
+
+describe("scroll behavior", () => {
+ test("scrolls into view when active and parent category is open", () => {
+ mockIsItemActive.mockReturnValue(true)
+ const sidebarRef = React.createRef()
+ sidebarRef.current = document.createElement("div")
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ sidebarRef,
+ })
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ const { container } = render(
+
+ )
+ const listItem = container.querySelector("li")
+ expect(listItem).toBeInTheDocument()
+ // scrollIntoView should be called via useEffect
+ expect(mockScrollIntoView).toHaveBeenCalledWith({
+ block: "center",
+ })
+ })
+
+ test("doesn't scroll into view when disableActiveTransition is true", () => {
+ mockIsItemActive.mockReturnValue(true)
+ const sidebarRef = React.createRef()
+ sidebarRef.current = document.createElement("div")
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ disableActiveTransition: true,
+ sidebarRef,
+ sidebarTopHeight: 0,
+ })
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ render()
+ expect(mockScrollIntoView).not.toHaveBeenCalled()
+
+ // instead, scrollTo should be called
+ expect(mockScrollTo).toHaveBeenCalledWith({
+ top: -10,
+ })
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx
index 069207f274..e8ecd52dcc 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx
@@ -4,15 +4,13 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react"
import { Sidebar } from "types"
-import {
- Badge,
- checkSidebarItemVisibility,
- SidebarItem,
- useMobile,
- useSidebar,
-} from "../../../.."
+import { Badge } from "@/components/Badge"
+import { checkSidebarItemVisibility } from "@/utils/check-sidebar-item-visibility"
+import { useSidebar } from "@/providers/Sidebar"
+import { SidebarItem } from "@/components/Sidebar/Item"
import clsx from "clsx"
import Link from "next/link"
+import { useMobile } from "@/providers/Mobile"
export type SidebarItemLinkProps = {
item: Sidebar.SidebarItemLink
@@ -144,6 +142,7 @@ export const SidebarItemLink = ({
isTitleOneWord && "truncate",
nested && "inline-block pl-docs_1.5"
)}
+ data-testid="sidebar-item-title"
>
{item.title}
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/__tests__/index.test.tsx
new file mode 100644
index 0000000000..7076dc4a7a
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/__tests__/index.test.tsx
@@ -0,0 +1,291 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { render } from "@testing-library/react"
+import { Sidebar } from "types"
+
+// mock hooks
+const mockGetSidebarFirstLinkChild = vi.fn()
+
+const defaultUseSidebarReturn = {
+ getSidebarFirstLinkChild: mockGetSidebarFirstLinkChild,
+}
+
+const mockUseSidebar = vi.fn(() => defaultUseSidebarReturn)
+
+vi.mock("@/providers/Sidebar", () => ({
+ useSidebar: () => mockUseSidebar(),
+}))
+
+// mock components
+vi.mock("@/components/Badge", () => ({
+ Badge: ({
+ variant,
+ children,
+ }: {
+ variant?: string
+ children: React.ReactNode
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ className,
+ ...props
+ }: {
+ href: string
+ children: React.ReactNode
+ className?: string
+ [key: string]: unknown
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+import { SidebarItemSidebar } from "../../Sidebar"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseSidebar.mockReturnValue(defaultUseSidebarReturn)
+})
+
+describe("rendering", () => {
+ test("renders sidebar title", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toBeInTheDocument()
+ expect(title).toHaveTextContent("Test Sidebar")
+ })
+
+ test("renders link with hash prefix when isPathHref is false", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "test",
+ title: "Test Link",
+ isPathHref: false,
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("href", "#test")
+ })
+
+ test("renders link without hash prefix when isPathHref is true", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ isPathHref: true,
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("href", "/test")
+ })
+
+ test("renders badge when provided", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ badge: { text: "New", variant: "blue" },
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const badge = container.querySelector("[data-testid='badge']")
+ expect(badge).toBeInTheDocument()
+ expect(badge).toHaveTextContent("New")
+ })
+
+ test("renders additional elements when provided", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ additionalElms: Additional,
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const additional = container.querySelector("[data-testid='additional']")
+ expect(additional).toBeInTheDocument()
+ })
+
+ test("applies nested styles when nested is true", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render(
+
+ )
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("text-medusa-fg-muted")
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toHaveClass("pl-docs_1.5")
+ })
+
+ test("applies subtle styles when nested is false", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render(
+
+ )
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("text-medusa-fg-subtle")
+ })
+
+ test("applies break-words for multi-word titles", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar Title",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("break-words")
+ })
+
+ test("applies truncate for single-word titles", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toHaveClass("truncate")
+ })
+
+ test("applies custom className", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render(
+
+ )
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveClass("custom-class")
+ })
+
+ test("passes linkProps to Link component", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ linkProps: { rel: "noopener noreferrer" },
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-link']")
+ expect(link).toHaveAttribute("rel", "noopener noreferrer")
+ })
+})
+
+describe("useSidebar integration", () => {
+ test("calls getSidebarFirstLinkChild with item", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const firstChild: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ mockGetSidebarFirstLinkChild.mockReturnValue(firstChild)
+ render()
+ expect(mockGetSidebarFirstLinkChild).toHaveBeenCalledWith(item)
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/index.tsx
index 130c625af6..1cf08e3b89 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/Sidebar/index.tsx
@@ -4,7 +4,8 @@
import React, { useMemo } from "react"
import { Sidebar } from "types"
-import { Badge, useSidebar } from "../../../.."
+import { Badge } from "@/components/Badge"
+import { useSidebar } from "@/providers/Sidebar"
import clsx from "clsx"
import Link from "next/link"
@@ -52,6 +53,7 @@ export const SidebarItemSidebar = ({
isTitleOneWord && "truncate",
nested && "inline-block pl-docs_1.5"
)}
+ data-testid="sidebar-item-title"
>
{item.title}
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/__tests__/index.test.tsx
new file mode 100644
index 0000000000..31364b1ed3
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/__tests__/index.test.tsx
@@ -0,0 +1,174 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { render } from "@testing-library/react"
+import { Sidebar } from "types"
+
+// mock components
+vi.mock("@/components/Badge", () => ({
+ Badge: ({
+ variant,
+ children,
+ }: {
+ variant?: string
+ children: React.ReactNode
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Item", () => ({
+ SidebarItem: ({ item }: { item: Sidebar.SidebarItem }) => (
+
+ {"title" in item ? item.title : item.type}
+
+ ),
+}))
+
+import { SidebarItemSubCategory } from "../../SubCategory"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe("rendering", () => {
+ test("renders subcategory item", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ }
+ const { container } = render()
+ const listItem = container.querySelector("li")
+ expect(listItem).toBeInTheDocument()
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toBeInTheDocument()
+ expect(title).toHaveTextContent("Sub Category")
+ })
+
+ test("renders badge when provided", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ badge: { text: "New", variant: "blue" },
+ }
+ const { container } = render()
+ const badge = container.querySelector("[data-testid='badge']")
+ expect(badge).toBeInTheDocument()
+ expect(badge).toHaveTextContent("New")
+ expect(badge).toHaveAttribute("data-variant", "blue")
+ })
+
+ test("renders additional elements when provided", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ additionalElms: Additional,
+ }
+ const { container } = render()
+ const additional = container.querySelector("[data-testid='additional']")
+ expect(additional).toBeInTheDocument()
+ })
+
+ test("renders children when provided", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render()
+ const childItem = container.querySelector("[data-testid='sidebar-item']")
+ expect(childItem).toBeInTheDocument()
+ expect(childItem).toHaveTextContent("Child 1")
+ })
+
+ test("does not render children when hideChildren is true", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ hideChildren: true,
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ const { container } = render()
+ const childItem = container.querySelector("[data-testid='sidebar-item']")
+ expect(childItem).not.toBeInTheDocument()
+ })
+
+ test("applies nested styles when nested is true", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ }
+ const { container } = render(
+
+ )
+ const itemContainer = container.querySelector(
+ "[data-testid='sidebar-item-container']"
+ )
+ expect(itemContainer).toHaveClass("text-medusa-fg-muted")
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toHaveClass("pl-docs_1.5")
+ })
+
+ test("applies subtle styles when nested is false", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ }
+ const { container } = render(
+
+ )
+ const itemContainer = container.querySelector(
+ "[data-testid='sidebar-item-container']"
+ )
+ expect(itemContainer).toHaveClass("text-medusa-fg-subtle")
+ })
+
+ test("applies break-words for multi-word titles", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category Title",
+ }
+ const { container } = render()
+ const itemContainer = container.querySelector(
+ "[data-testid='sidebar-item-container']"
+ )
+ expect(itemContainer).toHaveClass("break-words")
+ })
+
+ test("applies truncate for single-word titles", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Subcategory",
+ }
+ const { container } = render()
+ const title = container.querySelector("[data-testid='sidebar-item-title']")
+ expect(title).toHaveClass("truncate")
+ })
+
+ test("applies custom className", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Sub Category",
+ }
+ const { container } = render(
+
+ )
+ const span = container.querySelector(
+ "[data-testid='sidebar-item-container']"
+ )
+ expect(span).toHaveClass("custom-class")
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/index.tsx
index a6b7ba76a0..3aa6f67f00 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/SubCategory/index.tsx
@@ -4,7 +4,8 @@
import React, { useMemo, useRef } from "react"
import { Sidebar } from "types"
-import { Badge, SidebarItem } from "../../../.."
+import { Badge } from "@/components/Badge"
+import { SidebarItem } from "@/components/Sidebar/Item"
import clsx from "clsx"
export type SidebarItemSubCategoryProps = {
@@ -43,12 +44,14 @@ export const SidebarItemSubCategory = ({
"text-compact-small-plus",
className
)}
+ data-testid="sidebar-item-container"
>
{item.title}
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/__tests__/index.test.tsx
new file mode 100644
index 0000000000..8b3dbb60b0
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/__tests__/index.test.tsx
@@ -0,0 +1,177 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { render } from "@testing-library/react"
+import { Sidebar } from "types"
+
+// mock components
+vi.mock("@/components/Sidebar/Item/Link", () => ({
+ SidebarItemLink: ({ item }: { item: Sidebar.SidebarItemLink }) => (
+ {item.title}
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Item/Category", () => ({
+ SidebarItemCategory: ({ item }: { item: Sidebar.SidebarItemCategory }) => (
+ {item.title}
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Item/SubCategory", () => ({
+ SidebarItemSubCategory: ({
+ item,
+ }: {
+ item: Sidebar.SidebarItemSubCategory
+ }) => {item.title}
,
+}))
+
+vi.mock("@/components/Sidebar/Item/Sidebar", () => ({
+ SidebarItemSidebar: ({ item }: { item: Sidebar.SidebarItemSidebar }) => (
+ {item.title}
+ ),
+}))
+
+vi.mock("@/components/DottedSeparator", () => ({
+ DottedSeparator: () => Separator
,
+}))
+
+import { SidebarItem } from "../../Item"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe("rendering", () => {
+ test("renders link item", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-item-link']")
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveTextContent("Test Link")
+ })
+
+ test("renders ref item", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "ref",
+ path: "/test",
+ title: "Test Ref",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-item-link']")
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveTextContent("Test Ref")
+ })
+
+ test("renders external item", () => {
+ const item: Sidebar.SidebarItemLink = {
+ type: "external",
+ path: "https://example.com",
+ title: "External Link",
+ }
+ const { container } = render()
+ const link = container.querySelector("[data-testid='sidebar-item-link']")
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveTextContent("External Link")
+ })
+
+ test("renders category item", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render()
+ const category = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ expect(category).toBeInTheDocument()
+ expect(category).toHaveTextContent("Test Category")
+ })
+
+ test("renders subcategory item", () => {
+ const item: Sidebar.SidebarItemSubCategory = {
+ type: "sub-category",
+ title: "Test SubCategory",
+ }
+ const { container } = render()
+ const subcategory = container.querySelector(
+ "[data-testid='sidebar-item-subcategory']"
+ )
+ expect(subcategory).toBeInTheDocument()
+ expect(subcategory).toHaveTextContent("Test SubCategory")
+ })
+
+ test("renders sidebar item", () => {
+ const item: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ children: [],
+ }
+ const { container } = render()
+ const sidebar = container.querySelector(
+ "[data-testid='sidebar-item-sidebar']"
+ )
+ expect(sidebar).toBeInTheDocument()
+ expect(sidebar).toHaveTextContent("Test Sidebar")
+ })
+
+ test("renders separator item", () => {
+ const item: Sidebar.SidebarItemSeparator = {
+ type: "separator",
+ }
+ const { container } = render()
+ const separator = container.querySelector(
+ "[data-testid='dotted-separator']"
+ )
+ expect(separator).toBeInTheDocument()
+ })
+
+ test("renders dotted separator after category when hasNextItems is true", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
+
+ )
+ const separator = container.querySelector(
+ "[data-testid='dotted-separator']"
+ )
+ expect(separator).toBeInTheDocument()
+ })
+
+ test("does not render dotted separator after category when hasNextItems is false", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
+
+ )
+ const separator = container.querySelector(
+ "[data-testid='dotted-separator']"
+ )
+ expect(separator).not.toBeInTheDocument()
+ })
+
+ test("passes props to nested component", () => {
+ const item: Sidebar.SidebarItemCategory = {
+ type: "category",
+ title: "Test Category",
+ children: [],
+ }
+ const { container } = render(
+
+ )
+ const category = container.querySelector(
+ "[data-testid='sidebar-item-category']"
+ )
+ expect(category).toBeInTheDocument()
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/index.tsx
index dad8e8e663..886f85acd3 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Item/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Item/index.tsx
@@ -4,7 +4,7 @@ import React from "react"
import { Sidebar } from "types"
import { SidebarItemLink } from "./Link"
import { SidebarItemSubCategory } from "./SubCategory"
-import { DottedSeparator } from "../.."
+import { DottedSeparator } from "@/components/DottedSeparator"
import { SidebarItemCategory } from "./Category"
import { SidebarItemSidebar } from "./Sidebar"
diff --git a/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/__tests__/index.test.tsx
new file mode 100644
index 0000000000..3bee859ab2
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/__tests__/index.test.tsx
@@ -0,0 +1,74 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { fireEvent, render } from "@testing-library/react"
+
+// mock hooks
+const mockSetMobileSidebarOpen = vi.fn()
+
+const defaultUseSidebarReturn = {
+ setMobileSidebarOpen: mockSetMobileSidebarOpen,
+}
+
+const mockUseSidebar = vi.fn(() => defaultUseSidebarReturn)
+
+vi.mock("@/providers/Sidebar", () => ({
+ useSidebar: () => mockUseSidebar(),
+}))
+
+// mock components
+vi.mock("@/components/Button", () => ({
+ Button: ({
+ variant,
+ onClick,
+ className,
+ children,
+ }: {
+ variant?: string
+ onClick?: () => void
+ className?: string
+ children: React.ReactNode
+ }) => (
+
+ ),
+}))
+
+vi.mock("@medusajs/icons", () => ({
+ XMarkMini: () => ,
+}))
+
+import { SidebarTopMobileClose } from "../../MobileClose"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseSidebar.mockReturnValue(defaultUseSidebarReturn)
+})
+
+describe("rendering", () => {
+ test("renders mobile close button", () => {
+ const { container } = render()
+ const button = container.querySelector("[data-testid='close-button']")
+ expect(button).toBeInTheDocument()
+ })
+
+ test("renders X mark icon", () => {
+ const { container } = render()
+ const icon = container.querySelector("[data-testid='x-mark-icon']")
+ expect(icon).toBeInTheDocument()
+ })
+})
+
+describe("interactions", () => {
+ test("calls setMobileSidebarOpen when button is clicked", () => {
+ const { container } = render()
+ const button = container.querySelector("[data-testid='close-button']")
+ fireEvent.click(button!)
+ expect(mockSetMobileSidebarOpen).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/index.tsx
index 11a07aa101..08b8e73484 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Top/MobileClose/index.tsx
@@ -1,7 +1,8 @@
"use client"
import React from "react"
-import { Button, useSidebar } from "../../../.."
+import { useSidebar } from "@/providers/Sidebar"
+import { Button } from "@/components/Button"
import { XMarkMini } from "@medusajs/icons"
export const SidebarTopMobileClose = () => {
diff --git a/www/packages/docs-ui/src/components/Sidebar/Top/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/Top/__tests__/index.test.tsx
new file mode 100644
index 0000000000..b504e48fcc
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/Top/__tests__/index.test.tsx
@@ -0,0 +1,87 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { render } from "@testing-library/react"
+
+// mock hooks
+const defaultUseSidebarReturn = {
+ sidebarHistory: [] as string[],
+}
+
+const mockUseSidebar = vi.fn(() => defaultUseSidebarReturn)
+
+vi.mock("@/providers/Sidebar", () => ({
+ useSidebar: () => mockUseSidebar(),
+}))
+
+// mock components
+vi.mock("@/components/Sidebar/Top/MobileClose", () => ({
+ SidebarTopMobileClose: () => (
+ Mobile Close
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Child", () => ({
+ SidebarChild: () => Child
,
+}))
+
+vi.mock("@/components/DottedSeparator", () => ({
+ DottedSeparator: ({ wrapperClassName }: { wrapperClassName?: string }) => (
+
+ Separator
+
+ ),
+}))
+
+import { SidebarTop } from "../../Top"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseSidebar.mockReturnValue(defaultUseSidebarReturn)
+})
+
+describe("rendering", () => {
+ test("renders mobile close button", () => {
+ const ref = React.createRef()
+ const { container } = render()
+ const mobileClose = container.querySelector("[data-testid='mobile-close']")
+ expect(mobileClose).toBeInTheDocument()
+ })
+
+ test("does not render child sidebar when history length is 1 or less", () => {
+ mockUseSidebar.mockReturnValue({
+ sidebarHistory: ["sidebar1"],
+ })
+ const ref = React.createRef()
+ const { container } = render()
+ const child = container.querySelector("[data-testid='sidebar-child']")
+ expect(child).not.toBeInTheDocument()
+ })
+
+ test("renders child sidebar when history length is greater than 1", () => {
+ mockUseSidebar.mockReturnValue({
+ sidebarHistory: ["sidebar1", "sidebar2"],
+ })
+ const ref = React.createRef()
+ const { container } = render()
+ const child = container.querySelector("[data-testid='sidebar-child']")
+ expect(child).toBeInTheDocument()
+ })
+
+ test("renders dotted separator when history length is greater than 1", () => {
+ mockUseSidebar.mockReturnValue({
+ sidebarHistory: ["sidebar1", "sidebar2"],
+ })
+ const ref = React.createRef()
+ const { container } = render()
+ const separator = container.querySelector(
+ "[data-testid='dotted-separator']"
+ )
+ expect(separator).toBeInTheDocument()
+ })
+
+ test("forwards ref correctly", () => {
+ const ref = React.createRef()
+ render()
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/Top/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Top/index.tsx
index 964b39b9ca..1b6b3716df 100644
--- a/www/packages/docs-ui/src/components/Sidebar/Top/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/Top/index.tsx
@@ -3,7 +3,8 @@
import React from "react"
import { SidebarChild } from "../Child"
import { SidebarTopMobileClose } from "./MobileClose"
-import { DottedSeparator, useSidebar } from "../../.."
+import { useSidebar } from "@/providers/Sidebar"
+import { DottedSeparator } from "@/components/DottedSeparator"
import clsx from "clsx"
export const SidebarTop = React.forwardRef(
diff --git a/www/packages/docs-ui/src/components/Sidebar/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Sidebar/__tests__/index.test.tsx
new file mode 100644
index 0000000000..b1290b23fc
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Sidebar/__tests__/index.test.tsx
@@ -0,0 +1,320 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { fireEvent, render } from "@testing-library/react"
+import { Sidebar } from "types"
+
+// mock hooks
+const mockSetMobileSidebarOpen = vi.fn()
+const mockSetDesktopSidebarOpen = vi.fn()
+const mockSetSidebarTopHeight = vi.fn()
+
+const mockSidebar: Sidebar.Sidebar = {
+ sidebar_id: "test-sidebar",
+ title: "Test Sidebar",
+ items: [
+ {
+ type: "link",
+ path: "/test",
+ title: "Test Link",
+ },
+ ],
+}
+
+const defaultUseSidebarReturn = {
+ sidebars: [mockSidebar],
+ shownSidebar: mockSidebar as
+ | Sidebar.Sidebar
+ | Sidebar.SidebarItemSidebar
+ | undefined,
+ mobileSidebarOpen: false,
+ setMobileSidebarOpen: mockSetMobileSidebarOpen,
+ isSidebarStatic: true,
+ sidebarRef: React.createRef(),
+ desktopSidebarOpen: true,
+ setDesktopSidebarOpen: mockSetDesktopSidebarOpen,
+ setSidebarTopHeight: mockSetSidebarTopHeight,
+ sidebarHistory: ["test-sidebar"],
+}
+
+const mockUseSidebar = vi.fn(() => defaultUseSidebarReturn)
+const mockUseKeyboardShortcut = vi.fn()
+
+vi.mock("@/providers/Sidebar", () => ({
+ useSidebar: () => mockUseSidebar(),
+}))
+
+vi.mock("@/hooks/use-keyboard-shortcut", () => ({
+ useKeyboardShortcut: (options: unknown) => mockUseKeyboardShortcut(options),
+}))
+
+// mock components
+vi.mock("@/components/Loading", () => ({
+ Loading: ({
+ count,
+ className,
+ }: {
+ count?: number
+ className?: string
+ barClassName?: string
+ }) => (
+
+ Loading
+
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Item", () => ({
+ SidebarItem: ({
+ item,
+ hasNextItems,
+ }: {
+ item: Sidebar.SidebarItem
+ hasNextItems: boolean
+ }) => (
+
+ {"title" in item ? item.title : item.type}
+
+ ),
+}))
+
+vi.mock("@/components/Sidebar/Top", () => ({
+ SidebarTop: () => Top
,
+}))
+
+vi.mock("react-transition-group", () => ({
+ CSSTransition: ({
+ children,
+ nodeRef,
+ }: {
+ children: React.ReactNode
+ nodeRef: React.RefObject
+ }) => {children}
,
+ SwitchTransition: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+vi.mock("@react-hook/resize-observer", () => ({
+ default: vi.fn(),
+}))
+
+vi.mock("@/providers/BrowserProvider", () => ({
+ useIsBrowser: () => ({
+ isBrowser: true,
+ }),
+}))
+
+import { Sidebar as SidebarComponent } from "../../Sidebar"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseSidebar.mockReturnValue(defaultUseSidebarReturn)
+})
+
+describe("rendering", () => {
+ test("renders sidebar top", () => {
+ const { container } = render()
+ const top = container.querySelector("[data-testid='sidebar-top']")
+ expect(top).toBeInTheDocument()
+ })
+
+ test("renders sidebar items", () => {
+ const { container } = render()
+ const items = container.querySelectorAll("[data-testid='sidebar-item']")
+ expect(items).toHaveLength(1)
+ expect(items[0]).toHaveTextContent("Test Link")
+ })
+
+ test("renders loading when items are empty and not static", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ shownSidebar: { ...mockSidebar, items: [] },
+ isSidebarStatic: false,
+ })
+ const { container } = render()
+ const loading = container.querySelector("[data-testid='loading']")
+ expect(loading).toBeInTheDocument()
+ })
+
+ test("does not render loading when items exist", () => {
+ const { container } = render()
+ const loading = container.querySelector("[data-testid='loading']")
+ expect(loading).not.toBeInTheDocument()
+ })
+
+ test("renders overlay when mobile sidebar is open", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ mobileSidebarOpen: true,
+ })
+ const { container } = render()
+ const overlay = container.querySelector(
+ "[data-testid='mobile-sidebar-overlay']"
+ )
+ expect(overlay).toBeInTheDocument()
+ })
+
+ test("does not render overlay when mobile sidebar is closed", () => {
+ const { container } = render()
+ const overlay = container.querySelector(
+ "[data-testid='mobile-sidebar-overlay']"
+ )
+ expect(overlay).not.toBeInTheDocument()
+ })
+
+ test("applies mobile sidebar open classes", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ mobileSidebarOpen: true,
+ })
+ const { container } = render()
+ const aside = container.querySelector("aside")
+ expect(aside).toHaveClass(
+ "!left-docs_0.5 !top-docs_0.5 z-50 shadow-elevation-modal dark:shadow-elevation-modal-dark"
+ )
+ expect(aside).toHaveClass("rounded")
+ expect(aside).toHaveClass("lg:!left-0 lg:!top-0 lg:shadow-none")
+ })
+
+ test("applies desktop sidebar open classes", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ desktopSidebarOpen: true,
+ })
+ const { container } = render()
+ const aside = container.querySelector("aside")
+ expect(aside).toHaveClass("lg:left-0")
+ })
+
+ test("applies desktop sidebar closed classes", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ desktopSidebarOpen: false,
+ })
+ const { container } = render()
+ const aside = container.querySelector("aside")
+ expect(aside).toHaveClass("lg:!absolute lg:!-left-full")
+ })
+
+ test("applies custom className", () => {
+ const { container } = render()
+ const aside = container.querySelector("aside")
+ expect(aside).toHaveClass("custom-class")
+ })
+
+ test("uses items from shownSidebar when available", () => {
+ const sidebarWithItems: Sidebar.Sidebar = {
+ sidebar_id: "sidebar-with-items",
+ title: "Sidebar With Items",
+ items: [
+ {
+ type: "link",
+ path: "/item1",
+ title: "Item 1",
+ },
+ {
+ type: "link",
+ path: "/item2",
+ title: "Item 2",
+ },
+ ],
+ }
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ shownSidebar: sidebarWithItems,
+ })
+ const { container } = render()
+ const items = container.querySelectorAll("[data-testid='sidebar-item']")
+ expect(items).toHaveLength(2)
+ })
+
+ test("uses children from shownSidebar when items not available", () => {
+ const sidebarWithChildren: Sidebar.SidebarItemSidebar = {
+ type: "sidebar",
+ sidebar_id: "sidebar-with-children",
+ title: "Sidebar With Children",
+ children: [
+ {
+ type: "link",
+ path: "/child1",
+ title: "Child 1",
+ },
+ ],
+ }
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ shownSidebar: sidebarWithChildren,
+ })
+ const { container } = render()
+ const items = container.querySelectorAll("[data-testid='sidebar-item']")
+ expect(items).toHaveLength(1)
+ expect(items[0]).toHaveTextContent("Child 1")
+ })
+
+ test("passes hasNextItems correctly", () => {
+ const sidebarWithMultipleItems: Sidebar.Sidebar = {
+ sidebar_id: "sidebar-multiple",
+ title: "Multiple Items",
+ items: [
+ {
+ type: "link",
+ path: "/item1",
+ title: "Item 1",
+ },
+ {
+ type: "link",
+ path: "/item2",
+ title: "Item 2",
+ },
+ ],
+ }
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ shownSidebar: sidebarWithMultipleItems,
+ })
+ const { container } = render()
+ const items = container.querySelectorAll("[data-testid='sidebar-item']")
+ expect(items[0]).toHaveAttribute("data-has-next", "true")
+ expect(items[1]).toHaveAttribute("data-has-next", "false")
+ })
+})
+
+describe("interactions", () => {
+ test("closes mobile sidebar when clicking outside", () => {
+ mockUseSidebar.mockReturnValue({
+ ...defaultUseSidebarReturn,
+ mobileSidebarOpen: true,
+ })
+ const { container } = render(
+
+ )
+ const outsideElement = container.querySelector(
+ "[data-testid='outside-element']"
+ )
+ fireEvent.click(outsideElement!)
+ expect(mockSetMobileSidebarOpen).toHaveBeenCalledWith(false)
+ })
+
+ test("sets up keyboard shortcut for toggle", () => {
+ render()
+ expect(mockUseKeyboardShortcut).toHaveBeenCalledWith({
+ metakey: true,
+ shortcutKeys: ["\\"],
+ action: expect.any(Function),
+ })
+ })
+
+ test("toggles desktop sidebar when keyboard shortcut is triggered", () => {
+ render()
+ const lastCall =
+ mockUseKeyboardShortcut.mock.calls[
+ mockUseKeyboardShortcut.mock.calls.length - 1
+ ]
+ const action = lastCall[0].action
+ action()
+ expect(mockSetDesktopSidebarOpen).toHaveBeenCalledWith(expect.any(Function))
+ })
+})
diff --git a/www/packages/docs-ui/src/components/Sidebar/index.tsx b/www/packages/docs-ui/src/components/Sidebar/index.tsx
index 70fb094dfa..b1d7de9a44 100644
--- a/www/packages/docs-ui/src/components/Sidebar/index.tsx
+++ b/www/packages/docs-ui/src/components/Sidebar/index.tsx
@@ -1,14 +1,15 @@
"use client"
import React, { Suspense, useMemo, useRef } from "react"
-import { useSidebar } from "@/providers"
+import { useSidebar } from "@/providers/Sidebar"
import clsx from "clsx"
-import { Loading } from "@/components"
+import { Loading } from "@/components/Loading"
import { SidebarItem } from "./Item"
// @ts-expect-error can't install the types package because it doesn't support React v19
import { CSSTransition, SwitchTransition } from "react-transition-group"
import { SidebarTop } from "./Top"
-import { useClickOutside, useKeyboardShortcut } from "@/hooks"
+import { useClickOutside } from "@/hooks/use-click-outside"
+import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"
import useResizeObserver from "@react-hook/resize-observer"
import { isSidebarItemLink } from "../../utils/sidebar-utils"
@@ -65,6 +66,7 @@ export const Sidebar = ({ className = "" }: SidebarProps) => {
"lg:hidden bg-medusa-bg-overlay opacity-70",
"fixed top-0 left-0 w-full h-full z-[45]"
)}
+ data-testid="mobile-sidebar-overlay"
>
)}