From b2a7392e06a215c67a1187a2516b014f43ac70bb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 8 Oct 2026 13:56:16 -0700 Subject: [PATCH] fix(sidebar): render SidebarItem parts inside the asChild element Slot accepts one child, so asChild threw React.Children.only whenever an icon or count was set. The icon, label and count now go inside the consumer's element, which keeps the markup identical to the anchor case. --- src/ui/sidebar.test.tsx | 26 +++++++++++++++++++ src/ui/sidebar.tsx | 55 +++++++++++++++++++++++++++++------------ 2 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 src/ui/sidebar.test.tsx diff --git a/src/ui/sidebar.test.tsx b/src/ui/sidebar.test.tsx new file mode 100644 index 00000000..2c86e95f --- /dev/null +++ b/src/ui/sidebar.test.tsx @@ -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( + } count={3}> + Inbox + , + ); + }); + 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(); +}); diff --git a/src/ui/sidebar.tsx b/src/ui/sidebar.tsx index a160c75b..ef6aef0e 100644 --- a/src/ui/sidebar.tsx +++ b/src/ui/sidebar.tsx @@ -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"; @@ -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 : ( + + {icon} + + )} + + {label} + + {count === undefined ? null : ( + + {count} + + )} + + ); +} + export function SidebarItem({ active = false, icon, @@ -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 (
  • - {icon === undefined ? null : ( - - {icon} - - )} - - {children} - - {count === undefined ? null : ( - - {count} - - )} + {content}
  • );