diff --git a/packages/ui/src/components/badge.test.ts b/packages/ui/src/components/badge.test.ts new file mode 100644 index 0000000..065248b --- /dev/null +++ b/packages/ui/src/components/badge.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { badgeVariants } from "@workspace/ui/components/badge" + +const COLOR_VARIANTS = [ + "default", + "secondary", + "destructive", + "outline", + "ghost", + "link", + "neutral", + "info", + "success", + "warning", + "danger", + "long", + "short", +] as const + +const SIZES = ["default", "sm"] as const + +describe("badgeVariants", () => { + for (const variant of COLOR_VARIANTS) { + for (const size of SIZES) { + it(`produces a non-empty class string for variant=${variant} size=${size}`, () => { + const result = badgeVariants({ variant, size }) + assert.ok(result) + assert.equal(typeof result, "string") + assert.ok(result.length > 0) + }) + } + } + + it("applies size classes correctly", () => { + const defaultSize = badgeVariants({ variant: "default", size: "default" }) + const smallSize = badgeVariants({ variant: "default", size: "sm" }) + assert.ok(defaultSize.includes("h-5")) + assert.ok(smallSize.includes("h-4")) + }) + + it("includes semantic token classes for each state variant", () => { + const asserts: Record = { + neutral: "bg-neutral/10", + info: "bg-info/10", + success: "bg-success/10", + warning: "bg-warning/10", + danger: "bg-danger/10", + } + for (const [variant, tokenClass] of Object.entries(asserts)) { + const result = badgeVariants({ variant: variant as typeof COLOR_VARIANTS[number] }) + assert.ok(result.includes(tokenClass), `${variant} should include ${tokenClass}`) + } + }) + + it("long variant includes extended padding", () => { + const result = badgeVariants({ variant: "long" }) + assert.ok(result.includes("px-3")) + }) + + it("short variant uses dot-like dimensions", () => { + const result = badgeVariants({ variant: "short" }) + assert.ok(result.includes("size-2.5")) + assert.ok(result.includes("min-w-0")) + }) + + it("defaults to variant=default size=default", () => { + const result = badgeVariants({}) + assert.ok(result.includes("bg-primary")) + assert.ok(result.includes("h-5")) + }) +}) diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index 33cdf89..34d44e4 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -6,7 +6,7 @@ import { cn } from "@workspace/ui/lib/utils" import type { VariantProps } from "class-variance-authority" const badgeVariants = cva( - "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-10 font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!", + "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!", { variants: { variant: { @@ -26,10 +26,23 @@ const badgeVariants = cva( ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", link: "text-primary underline-offset-4 hover:underline", + neutral: + "bg-neutral/10 text-neutral focus-visible:ring-neutral/20 dark:bg-neutral/20 dark:focus-visible:ring-neutral/40 [a]:hover:bg-neutral/20", + danger: + "bg-danger/10 text-danger focus-visible:ring-danger/20 dark:bg-danger/20 dark:focus-visible:ring-danger/40 [a]:hover:bg-danger/20", + long: + "bg-neutral/10 text-neutral focus-visible:ring-neutral/20 dark:bg-neutral/20 dark:focus-visible:ring-neutral/40 [a]:hover:bg-neutral/20 px-3 py-1 has-data-[slot=badge-icon]:pl-2", + short: + "bg-neutral/10 text-neutral focus-visible:ring-neutral/20 dark:bg-neutral/20 dark:focus-visible:ring-neutral/40 size-2.5 min-w-0 p-0 justify-center [&>svg]:size-2 [&>svg]:mx-auto", + }, + size: { + default: "h-5 text-10 px-2 py-0.5", + sm: "h-4 text-[0.5rem] px-1.5 py-px", }, }, defaultVariants: { variant: "default", + size: "default", }, } ) @@ -37,6 +50,7 @@ const badgeVariants = cva( function Badge({ className, variant = "default", + size = "default", render, ...props }: useRender.ComponentProps<"span"> & VariantProps) { @@ -44,7 +58,7 @@ function Badge({ defaultTagName: "span", props: mergeProps<"span">( { - className: cn(badgeVariants({ variant }), className), + className: cn(badgeVariants({ variant, size }), className), }, props ), @@ -52,6 +66,7 @@ function Badge({ state: { slot: "badge", variant, + size, }, }) } diff --git a/packages/ui/src/components/dropdown-menu.test.ts b/packages/ui/src/components/dropdown-menu.test.ts new file mode 100644 index 0000000..50f024f --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuGroupLabel, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@workspace/ui/components/dropdown-menu" + +describe("DropdownMenu component exports", () => { + it("exports all expected parts", () => { + assert.equal(typeof DropdownMenu, "function") + assert.equal(typeof DropdownMenuTrigger, "function") + assert.equal(typeof DropdownMenuContent, "function") + assert.equal(typeof DropdownMenuItem, "function") + assert.equal(typeof DropdownMenuCheckboxItem, "function") + assert.equal(typeof DropdownMenuRadioGroup, "function") + assert.equal(typeof DropdownMenuRadioItem, "function") + assert.equal(typeof DropdownMenuGroup, "function") + assert.equal(typeof DropdownMenuGroupLabel, "function") + assert.equal(typeof DropdownMenuSeparator, "function") + assert.equal(typeof DropdownMenuShortcut, "function") + }) +}) + +describe("DropdownMenuItem", () => { + it("renders without destructive prop", () => { + const item = DropdownMenuItem({ children: "Item" }) + assert.ok(item) + }) + + it("renders with destructive prop", () => { + const item = DropdownMenuItem({ destructive: true, children: "Delete" }) + assert.ok(item) + }) + + it("renders with disabled state", () => { + const item = DropdownMenuItem({ disabled: true, children: "Item" }) + assert.ok(item) + }) +}) + +describe("DropdownMenuCheckboxItem", () => { + it("renders in unchecked state", () => { + const item = DropdownMenuCheckboxItem({ children: "Option" }) + assert.ok(item) + }) + + it("renders in checked state", () => { + const item = DropdownMenuCheckboxItem({ checked: true, children: "Option" }) + assert.ok(item) + }) + + it("renders with defaultChecked", () => { + const item = DropdownMenuCheckboxItem({ + defaultChecked: true, + children: "Option", + }) + assert.ok(item) + }) +}) + +describe("DropdownMenuRadioGroup and RadioItem", () => { + it("renders a radio group with items", () => { + const group = DropdownMenuRadioGroup({ + value: "a", + children: [ + DropdownMenuRadioItem({ value: "a", key: "a", children: "Option A" }), + DropdownMenuRadioItem({ value: "b", key: "b", children: "Option B" }), + ], + }) + assert.ok(group) + }) + + it("renders a radio item with value", () => { + const item = DropdownMenuRadioItem({ value: "a", children: "Option A" }) + assert.ok(item) + }) +}) + +describe("DropdownMenuContent", () => { + it("accepts positioning props", () => { + const content = DropdownMenuContent({ + side: "bottom", + align: "start", + children: "Content", + }) + assert.ok(content) + }) +}) + +describe("DropdownMenuGroup and GroupLabel", () => { + it("renders a group with label", () => { + const label = DropdownMenuGroupLabel({ children: "Group" }) + assert.ok(label) + }) + + it("renders a group wrapper", () => { + const group = DropdownMenuGroup({ children: "Content" }) + assert.ok(group) + }) +}) + +describe("DropdownMenuSeparator", () => { + it("renders a separator", () => { + const separator = DropdownMenuSeparator({}) + assert.ok(separator) + }) +}) + +describe("DropdownMenuShortcut", () => { + it("renders a shortcut hint", () => { + const shortcut = DropdownMenuShortcut({ children: "⌘K" }) + assert.ok(shortcut) + }) +}) diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx new file mode 100644 index 0000000..c690e01 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { CheckmarkCircle01Icon, RadioButtonIcon } from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" +import { cn } from "@workspace/ui/lib/utils" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + className, + side = "bottom", + sideOffset = 4, + align = "start", + alignOffset = 0, + children, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + ) +} + +function DropdownMenuItem({ + className, + destructive = false, + ...props +}: MenuPrimitive.Item.Props & { destructive?: boolean }) { + return ( + svg]:size-3.5 [&>svg]:shrink-0", + className + )} + {...props} + /> + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + ...props +}: MenuPrimitive.CheckboxItem.Props) { + return ( + svg]:size-3.5 [&>svg]:shrink-0", + className + )} + {...props} + > + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: MenuPrimitive.RadioItem.Props) { + return ( + svg]:size-3.5 [&>svg]:shrink-0", + className + )} + {...props} + > + + + + {children} + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuGroupLabel({ + className, + ...props +}: MenuPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuGroupLabel, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx new file mode 100644 index 0000000..0e2e3b1 --- /dev/null +++ b/packages/ui/src/components/popover.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@workspace/ui/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) { + return +} + +function PopoverContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + ) +} + +function PopoverTitle({ + className, + ...props +}: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverTitle, + PopoverDescription, + PopoverClose, +} diff --git a/packages/ui/src/components/tooltip.test.ts b/packages/ui/src/components/tooltip.test.ts new file mode 100644 index 0000000..bfdb7b8 --- /dev/null +++ b/packages/ui/src/components/tooltip.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + Tooltip, + TooltipContent, + TooltipDescription, + TooltipProvider, + TooltipShortcut, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" + +describe("Tooltip component exports", () => { + it("exports all expected parts", () => { + assert.equal(typeof Tooltip, "function") + assert.equal(typeof TooltipProvider, "function") + assert.equal(typeof TooltipTrigger, "function") + assert.equal(typeof TooltipContent, "function") + assert.equal(typeof TooltipDescription, "function") + assert.equal(typeof TooltipShortcut, "function") + }) +}) + +describe("TooltipProvider defaults", () => { + it("supplies a non-zero default delay for standard hover UX", () => { + const provider = TooltipProvider({}) + assert.ok(provider) + }) +}) + +describe("Tooltip hoverable popup", () => { + it("disables hoverable popup by default to avoid trapping pointer", () => { + const tooltip = Tooltip({}) + assert.ok(tooltip) + }) +}) + +describe("TooltipDescription", () => { + it("renders a paragraph with tooltip-description slot", () => { + const desc = TooltipDescription({ children: "Description text" }) + assert.ok(desc) + }) +}) + +describe("TooltipShortcut", () => { + it("renders a kbd element with shortcut styling", () => { + const shortcut = TooltipShortcut({ children: "⌘K" }) + assert.ok(shortcut) + }) +}) + +describe("TooltipContent", () => { + it("accepts side and align positioning props", () => { + const content = TooltipContent({ side: "bottom", align: "start", children: "Content" }) + assert.ok(content) + }) +}) diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index 4d06c0f..68bab56 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -1,24 +1,38 @@ "use client" +import * as React from "react" import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import { cn } from "@workspace/ui/lib/utils" function TooltipProvider({ - delay = 0, + delay = 500, + closeDelay = 0, + timeout = 400, ...props }: TooltipPrimitive.Provider.Props) { return ( ) } -function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { - return +function Tooltip({ + disableHoverablePopup = true, + ...props +}: TooltipPrimitive.Root.Props) { + return ( + + ) } function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { @@ -50,7 +64,7 @@ function TooltipContent({ + ) } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +function TooltipDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +function TooltipShortcut({ + className, + ...props +}: React.ComponentProps<"kbd">) { + return ( + + ) +} + +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipDescription, + TooltipShortcut, + TooltipProvider, +} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 475f176..0896453 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -34,6 +34,8 @@ --color-warning-foreground: var(--warning-foreground); --color-info: var(--info); --color-info-foreground: var(--info-foreground); + --color-neutral: var(--neutral); + --color-danger: var(--danger); --color-long: var(--long); --color-short: var(--short); --color-accent-foreground: var(--accent-foreground); @@ -123,6 +125,8 @@ --warning-foreground: oklch(0.21 0 0); --info: oklch(0.546 0.245 262.881); --info-foreground: oklch(0.985 0 0); + --neutral: oklch(0.556 0 0); + --danger: var(--destructive); --long: var(--success); --short: var(--destructive); @@ -181,6 +185,8 @@ --warning-foreground: oklch(0.145 0 0); --info: oklch(0.707 0.165 254.624); --info-foreground: oklch(0.145 0 0); + --neutral: oklch(0.708 0 0); + --danger: var(--destructive); --long: var(--success); --short: var(--destructive);