Data display
Table, Pagination, Carousel, Chart, Avatar, Item, ProfilePill.
Table
Semantic table wrappers. Exports: Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption.
Usage:
import {
Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption,
} from "@flow-industries/ui/components/table";
<Table>
<TableCaption>Recent deploys</TableCaption>
<TableHeader>
<TableRow>
<TableHead>App</TableHead>
<TableHead>Server</TableHead>
<TableHead className="text-right">Uptime</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>site</TableCell>
<TableCell>fl-1</TableCell>
<TableCell className="text-right">3d 4h</TableCell>
</TableRow>
</TableBody>
</Table>Source:
"use client";
import type * as React from "react";
import { cn } from "../../utils/cn";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn(
"[&_tr]:border-b-[length:var(--border-width)] [&_tr]:hover:bg-transparent",
className,
)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t-[length:var(--border-width)] bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b-[length:var(--border-width)] transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};Pagination
Exports: Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious.
Usage:
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from "@flow-industries/ui/components/pagination";
<Pagination>
<PaginationContent>
<PaginationItem><PaginationPrevious href="#" /></PaginationItem>
<PaginationItem><PaginationLink href="#">1</PaginationLink></PaginationItem>
<PaginationItem><PaginationLink href="#" isActive>2</PaginationLink></PaginationItem>
<PaginationItem><PaginationLink href="#">3</PaginationLink></PaginationItem>
<PaginationItem><PaginationEllipsis /></PaginationItem>
<PaginationItem><PaginationNext href="#" /></PaginationItem>
</PaginationContent>
</Pagination>Source:
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import type * as React from "react";
import { cn } from "../../utils/cn";
import { Button } from "./button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
);
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
);
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};Carousel
Embla-powered image carousel with keyboard + swipe support. Exports: Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel, CarouselApi type.
Usage:
import {
Carousel, CarouselContent, CarouselItem,
CarouselPrevious, CarouselNext,
} from "@flow-industries/ui/components/carousel";
<Carousel>
<CarouselContent>
<CarouselItem><img src="/1.jpg" /></CarouselItem>
<CarouselItem><img src="/2.jpg" /></CarouselItem>
<CarouselItem><img src="/3.jpg" /></CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>Source:
"use client";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../../utils/cn";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
{/* biome-ignore lint/a11y/useSemanticElements: carousel region with aria-roledescription is the accepted embla/shadcn pattern */}
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
// biome-ignore lint/a11y/useSemanticElements: slide group with aria-roledescription is the accepted embla/shadcn pattern
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2 active:-translate-y-1/2!"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90 active:-translate-x-1/2!",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2 active:-translate-y-1/2!"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90 active:-translate-x-1/2!",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
};Chart
Recharts wrapper with Flow-token theming. Exports: ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, ChartConfig (type).
Usage:
import {
ChartContainer, ChartTooltip, ChartTooltipContent,
} from "@flow-industries/ui/components/chart";
import { Bar, BarChart, XAxis, YAxis } from "recharts";
const chartConfig = {
visits: { label: "Visits", color: "var(--color-blue)" },
};
<ChartContainer config={chartConfig}>
<BarChart data={data}>
<XAxis dataKey="month" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="visits" fill="var(--color-visits)" />
</BarChart>
</ChartContainer>The Chart component is ~390 lines of recharts integration code. For copy-paste usage, view the full source at the link above. Colors resolve against Flow tokens so charts automatically swap palettes when .dark toggles.
Avatar
User image with fallback, badge, and group composition. Exports: Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge, AvatarPlaceholder. Sizes: sm, default, lg. Avatars use the rounded-squircle shape rather than a full circle, and an empty AvatarFallback renders AvatarPlaceholder — an anonymous head-and-shoulders silhouette — by default.
Usage:
import {
Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarBadge,
} from "@flow-industries/ui/components/avatar";
<Avatar>
<AvatarImage src="/alice.jpg" alt="Alice" />
<AvatarFallback>AL</AvatarFallback>
<AvatarBadge />
</Avatar>
<AvatarGroup>
<Avatar><AvatarImage src="/a.jpg" /><AvatarFallback>A</AvatarFallback></Avatar>
<Avatar><AvatarImage src="/b.jpg" /><AvatarFallback>B</AvatarFallback></Avatar>
<Avatar><AvatarImage src="/c.jpg" /><AvatarFallback>C</AvatarFallback></Avatar>
</AvatarGroup>Source:
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import type * as React from "react";
import { cn } from "../../utils/cn";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-squircle select-none after:absolute after:inset-0 after:rounded-squircle after:border-[length:var(--border-width)] after:border-secondary after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-squircle object-cover",
className,
)}
{...props}
/>
);
}
/**
* Anonymous head-and-shoulders silhouette — the stand-in for a user with no
* avatar image. Scales to its box, so it works at any avatar size. The
* shoulders are stroked with a round join so their base corners stay as soft
* as the squircle they sit in.
*/
function AvatarPlaceholder({
className,
...props
}: React.ComponentProps<"svg">) {
return (
<svg
data-slot="avatar-placeholder"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
className={cn("size-full", className)}
{...props}
>
<circle cx="24" cy="18" r="7.7" />
<path
d="M15 41.4a9 6 0 0 1 18 0Z"
stroke="currentColor"
strokeWidth="6"
strokeLinejoin="round"
/>
</svg>
);
}
function AvatarFallback({
children,
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-squircle bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
>
{children ?? <AvatarPlaceholder />}
</AvatarPrimitive.Fallback>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-squircle bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
AvatarPlaceholder,
};ProfilePill
The signed-in user's identity chip — the username followed by their avatar — used wherever an app shows who is signed in (Flow ID's own pill, the game menu, every app header). It is rendering-only: it holds no session state and fetches nothing, so callers pass whatever identity they already have. It renders a button by default, which makes it a drop-in menu trigger; pass render={<span />} for a non-interactive display, and the hover treatment applies only while it is a button or a link. Sizes: sm, default, lg. The username truncates past 12rem, and a missing or broken image falls back to the anonymous silhouette.
Usage:
import { ProfilePill } from "@flow-industries/ui/components/profile-pill";
import { DropdownMenuTrigger } from "@flow-industries/ui/components/dropdown-menu";
<ProfilePill username={user.username} image={user.image} />
<DropdownMenuTrigger render={<ProfilePill username={user.username} />} />Source:
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../utils/cn";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
const profilePillVariants = cva(
// normal-case/tracking-tight are load-bearing: app headers commonly set
// `uppercase tracking-widest` on the nav around it and both inherit, so the
// pill restates the base layer's own defaults rather than accepting them.
"group/profile-pill inline-flex w-fit items-center rounded-full text-foreground normal-case tracking-tight outline-none transition-colors [&:is(a,button):hover]:text-muted-foreground",
{
variants: {
size: {
sm: "gap-2 p-0.5 pl-3 font-medium text-sm",
default: "gap-2.5 p-0.5 pl-3.5 font-medium text-base",
lg: "gap-3 p-1 pl-4 font-semibold text-lg",
},
},
defaultVariants: {
size: "default",
},
},
);
/**
* The signed-in user's identity chip — username followed by their avatar — used
* wherever an app shows who is signed in (Flow ID's own pill, the game menu, and
* every app header). Rendering only: it holds no session state and fetches
* nothing, so callers pass whatever identity they already have.
*
* Renders a `button` by default, which is also what makes it a drop-in trigger:
*
* ```tsx
* <DropdownMenuTrigger render={<ProfilePill username={user.username} />} />
* ```
*
* Pass `render={<span />}` for a non-interactive display; the hover treatment
* only applies while it is a button or a link.
*
* @param username Shown as-is, truncated past 12rem — callers guard the
* signed-out case rather than passing an empty string.
* @param image Avatar URL; falls back to the anonymous silhouette when it is
* absent or fails to load.
*/
function ProfilePill({
username,
image,
size = "default",
className,
render,
...props
}: useRender.ComponentProps<"button"> &
VariantProps<typeof profilePillVariants> & {
username: string;
image?: string | null;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
// Only when we own the element: `type` on a caller-supplied `render`
// element would be an invalid attribute (and the base layer turns any
// [type=button] into a pointer cursor, even on a span).
type: render ? undefined : ("button" as const),
className: cn(profilePillVariants({ size, className })),
},
props,
{
children: (
<>
<span className="max-w-[12rem] truncate">{username}</span>
<Avatar size={size ?? "default"}>
{image ? <AvatarImage src={image} alt="" /> : null}
<AvatarFallback />
</Avatar>
</>
),
},
),
render,
state: {
slot: "profile-pill",
size,
},
});
}
export { ProfilePill, profilePillVariants };Item
Generic list item primitive used for lists, command menus, and rich selection rows. Exports: Item, ItemMedia, ItemContent, ItemActions, ItemGroup, ItemSeparator, ItemTitle, ItemDescription, ItemHeader, ItemFooter.
Usage:
import {
Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions,
} from "@flow-industries/ui/components/item";
<Item>
<ItemMedia variant="icon"><FolderIcon /></ItemMedia>
<ItemContent>
<ItemTitle>flow/docs</ItemTitle>
<ItemDescription>React Router + Fumadocs site</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm">Open</Button>
</ItemActions>
</Item>Variants: default, outline, muted. Sizes: default, sm, xs.
Source:
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../utils/cn";
import { Separator } from "./separator";
const ItemGroupContext = React.createContext(false);
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<ItemGroupContext.Provider value={true}>
{/* biome-ignore lint/a11y/useSemanticElements: role="list" on a styled div avoids ul default list styles */}
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className,
)}
{...props}
/>
</ItemGroupContext.Provider>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
aria-hidden="true"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
);
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border-[length:var(--border-width)] text-sm transition-colors duration-100 outline-none focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-secondary",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
const inGroup = React.useContext(ItemGroupContext);
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
role: inGroup ? "listitem" : undefined,
},
props,
),
render,
state: {
slot: "item",
variant,
size,
},
});
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm font-medium underline-offset-4",
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props}
/>
);
}
export {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemGroup,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle,
};