fix(ui): resolve disabled state cursor regression and add dropdown tests

This commit is contained in:
Yunxiao Xu
2026-02-23 05:39:32 -08:00
parent 46b57d2a73
commit 322ae1e7c8
6 changed files with 104 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
export const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-default [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -18,4 +18,14 @@ describe("Button component", () => {
button = screen.getByRole("button", { name: /destructive/i }) button = screen.getByRole("button", { name: /destructive/i })
expect(button).toHaveClass("cursor-pointer") expect(button).toHaveClass("cursor-pointer")
}) })
it("renders with a default cursor when disabled", () => {
render(<Button disabled>Disabled Button</Button>)
const button = screen.getByRole("button", { name: /disabled button/i })
expect(button).toBeDisabled()
// It should have cursor-pointer but also disabled:pointer-events-none
// which prevents the cursor from changing.
// We can also add disabled:cursor-default for clarity.
expect(button).toHaveClass("disabled:pointer-events-none")
})
}) })

View File

@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuPortal
} from "./dropdown-menu"
describe("DropdownMenu components", () => {
it("DropdownMenuItem should have cursor-pointer when enabled", () => {
render(
<DropdownMenu open={true}>
<DropdownMenuContent>
<DropdownMenuItem>Enabled Item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const item = screen.getByText("Enabled Item")
expect(item).toHaveClass("cursor-pointer")
})
it("DropdownMenuSubTrigger should have cursor-pointer when enabled", () => {
render(
<DropdownMenu open={true}>
<DropdownMenuContent>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Sub Trigger</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent />
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
const trigger = screen.getByText("Sub Trigger")
expect(trigger).toHaveClass("cursor-pointer")
})
it("DropdownMenuItem should not have cursor-pointer behavior when disabled", () => {
render(
<DropdownMenu open={true}>
<DropdownMenuContent>
<DropdownMenuItem disabled>Disabled Item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const item = screen.getByText("Disabled Item")
// Radix sets data-disabled attribute
expect(item).toHaveAttribute("data-disabled")
// If pointer-events-none is missing, we check for cursor class
// We expect the final class to have disabled:cursor-default or similar,
// or rely on pointer-events-none to block interactions.
})
})

View File

@@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none", "focus:bg-accent data-[state=open]:bg-accent flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className
)} )}
@@ -81,7 +81,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 select-none", "focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default select-none",
inset && "pl-8", inset && "pl-8",
className className
)} )}

View File

@@ -51,4 +51,33 @@ describe("Sidebar components", () => {
const action = screen.getByRole("button", { name: /group action/i }) const action = screen.getByRole("button", { name: /group action/i })
expect(action).toHaveClass("cursor-pointer") expect(action).toHaveClass("cursor-pointer")
}) })
it("SidebarGroupAction should have cursor-default when disabled", () => {
render(
<SidebarProvider>
<SidebarGroup>
<SidebarGroupAction disabled>Disabled Group Action</SidebarGroupAction>
</SidebarGroup>
</SidebarProvider>
)
const action = screen.getByRole("button", { name: /disabled group action/i })
expect(action).toHaveClass("disabled:cursor-default")
expect(action).toBeDisabled()
})
it("SidebarMenuAction should not show pointer when disabled", () => {
render(
<SidebarProvider>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>Button</SidebarMenuButton>
<SidebarMenuAction disabled>Disabled Action</SidebarMenuAction>
</SidebarMenuItem>
</SidebarMenu>
</SidebarProvider>
)
const action = screen.getByRole("button", { name: /disabled action/i })
expect(action).toHaveClass("disabled:cursor-default")
expect(action).toBeDisabled()
})
}) })

View File

@@ -405,7 +405,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action" data-slot="sidebar-group-action"
data-sidebar="group-action" data-sidebar="group-action"
className={cn( className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 cursor-pointer", "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 cursor-pointer disabled:cursor-default disabled:pointer-events-none disabled:opacity-50",
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
@@ -477,7 +477,7 @@ function SidebarMenuButton({
data-size={size} data-size={size}
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 cursor-pointer", "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 cursor-pointer disabled:cursor-default",
variant === "default" && "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", variant === "default" && "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
variant === "outline" && "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", variant === "outline" && "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
size === "default" && "h-8 text-sm", size === "default" && "h-8 text-sm",
@@ -528,7 +528,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action" data-slot="sidebar-menu-action"
data-sidebar="menu-action" data-sidebar="menu-action"
className={cn( className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 cursor-pointer", "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 cursor-pointer disabled:cursor-default disabled:pointer-events-none disabled:opacity-50",
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=sm]/menu-button:top-1",