Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";

import { SidebarItem } from "./sidebar.js";

test("asChild renders the consumer's link with the icon, label and count inside it", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<SidebarItem asChild active icon={<svg />} count={3}>
<a href="/inbox">Inbox</a>
</SidebarItem>,
);
});
const link = host.querySelector("a");
expect(link?.getAttribute("href")).toBe("/inbox");
expect(link?.getAttribute("aria-current")).toBe("page");
expect(link?.getAttribute("data-slot")).toBe("sidebar-item");
expect(link?.querySelector("svg")).not.toBeNull();
expect(link?.textContent).toBe("Inbox3");
act(() => root.unmount());
host.remove();
});
55 changes: 39 additions & 16 deletions src/ui/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Slot } from "@radix-ui/react-slot";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { cloneElement, isValidElement } from "react";
import type * as React from "react";

import { cn } from "../lib/utils.js";
Expand Down Expand Up @@ -114,6 +115,33 @@ export type SidebarItemProps = React.ComponentProps<"a"> & {
asChild?: boolean;
};

function sidebarItemParts(
icon: React.ReactNode,
label: React.ReactNode,
count: number | undefined,
) {
return (
<>
{icon === undefined ? null : (
<span
className="grid size-4 shrink-0 place-items-center [&_svg]:size-4"
aria-hidden
>
{icon}
</span>
)}
<span className="min-w-0 flex-1 truncate group-data-[collapsed=true]/sidebar:sr-only">
{label}
</span>
{count === undefined ? null : (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground group-data-[collapsed=true]/sidebar:sr-only">
{count}
</span>
)}
</>
);
}

export function SidebarItem({
active = false,
icon,
Expand All @@ -124,6 +152,16 @@ export function SidebarItem({
...props
}: SidebarItemProps) {
const Comp = asChild ? Slot : "a";
// Slot accepts one child, so with `asChild` the icon, label and count go
// inside the consumer's element (a router link) rather than beside it.
const content =
asChild && isValidElement<{ children?: React.ReactNode }>(children)
? cloneElement(
children,
undefined,
sidebarItemParts(icon, children.props.children, count),
)
: sidebarItemParts(icon, children, count);
return (
<li>
<Comp
Expand All @@ -139,22 +177,7 @@ export function SidebarItem({
)}
{...props}
>
{icon === undefined ? null : (
<span
className="grid size-4 shrink-0 place-items-center [&_svg]:size-4"
aria-hidden
>
{icon}
</span>
)}
<span className="min-w-0 flex-1 truncate group-data-[collapsed=true]/sidebar:sr-only">
{children}
</span>
{count === undefined ? null : (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground group-data-[collapsed=true]/sidebar:sr-only">
{count}
</span>
)}
{content}
</Comp>
</li>
);
Expand Down
Loading