Skip to content

Commit 271ff65

Browse files
Merge pull request #84 from tetrascience/feat/layout-patterns
feat(pattern): SW-1740 Add RichListItem pattern
2 parents 8fbefea + 228e18a commit 271ff65

4 files changed

Lines changed: 245 additions & 1 deletion

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { MoreHorizontal } from "lucide-react";
2+
3+
import { RichListItem, RichListItemAvatar } from "./RichListItem";
4+
5+
import type { Meta, StoryObj } from "@storybook/react-vite";
6+
7+
import { Badge } from "@/components/ui/badge";
8+
import { Button } from "@/components/ui/button";
9+
10+
const meta: Meta<typeof RichListItem> = {
11+
title: "Design Patterns/RichListItem",
12+
component: RichListItem,
13+
parameters: {
14+
layout: "centered",
15+
},
16+
tags: ["autodocs"],
17+
decorators: [
18+
(Story) => (
19+
<div className="w-full max-w-[30rem] rounded-lg border bg-card">
20+
<Story />
21+
</div>
22+
),
23+
],
24+
};
25+
26+
export default meta;
27+
type Story = StoryObj<typeof meta>;
28+
29+
export const Default: Story = {
30+
parameters: {
31+
zephyr: { testCaseId: "SW-T5122" },
32+
},
33+
args: {
34+
leading: <RichListItemAvatar initials="JS" />,
35+
primary: "Dr. Jane Smith",
36+
secondary: "Principal Scientist · Biology Platform",
37+
trailing: (
38+
<>
39+
<Badge variant="positive" className="text-xs">
40+
Active
41+
</Badge>
42+
<span className="text-xs leading-tight text-muted-foreground">Last active 2h ago</span>
43+
</>
44+
),
45+
actions: (
46+
<Button aria-label="More actions" variant="ghost" size="icon-sm">
47+
<MoreHorizontal className="h-4 w-4" />
48+
</Button>
49+
),
50+
},
51+
};
52+
53+
export const TeamList: Story = {
54+
parameters: {
55+
zephyr: { testCaseId: "SW-T3579" },
56+
},
57+
render: () => {
58+
const members = [
59+
{
60+
initials: "JS",
61+
name: "Dr. Jane Smith",
62+
role: "Principal Scientist · Biology Platform",
63+
meta: "Last active 2h ago",
64+
status: "Active",
65+
statusVariant: "positive" as const,
66+
fallbackClassName: "bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-300",
67+
},
68+
{
69+
initials: "MT",
70+
name: "Mark Thompson",
71+
role: "Data Engineer · Informatics",
72+
meta: "Last active 5h ago",
73+
status: "Active",
74+
statusVariant: "positive" as const,
75+
fallbackClassName: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
76+
},
77+
{
78+
initials: "SC",
79+
name: "Sarah Chen",
80+
role: "Research Associate · Chemistry",
81+
meta: "Invited 3 days ago",
82+
status: "Invited",
83+
statusVariant: "warning" as const,
84+
fallbackClassName: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
85+
},
86+
{
87+
initials: "RJ",
88+
name: "Robert Johnson",
89+
role: "Lab Manager · Operations",
90+
meta: "Inactive since Jan 2024",
91+
status: "Inactive",
92+
statusVariant: "outline" as const,
93+
fallbackClassName: "",
94+
},
95+
];
96+
97+
return (
98+
<div className="divide-y divide-border">
99+
{members.map((m, i) => (
100+
<RichListItem
101+
key={i}
102+
leading={<RichListItemAvatar initials={m.initials} fallbackClassName={m.fallbackClassName || undefined} />}
103+
primary={m.name}
104+
secondary={m.role}
105+
trailing={
106+
<>
107+
<Badge variant={m.statusVariant}>{m.status}</Badge>
108+
<span className="text-xs leading-tight text-muted-foreground">{m.meta}</span>
109+
</>
110+
}
111+
actions={
112+
<Button aria-label="More actions" variant="ghost" size="icon-sm">
113+
<MoreHorizontal className="h-4 w-4" />
114+
</Button>
115+
}
116+
/>
117+
))}
118+
</div>
119+
);
120+
},
121+
};
122+
123+
export const MinimalList: Story = {
124+
parameters: {
125+
zephyr: { testCaseId: "SW-T3580" },
126+
},
127+
render: () => (
128+
<div className="divide-y divide-border">
129+
{["Dataset Alpha", "Dataset Beta", "Dataset Gamma"].map((name) => (
130+
<RichListItem
131+
key={name}
132+
primary={name}
133+
secondary="Last modified 1 day ago"
134+
trailing={<Badge variant="secondary">CSV</Badge>}
135+
/>
136+
))}
137+
</div>
138+
),
139+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as React from "react";
2+
3+
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
4+
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item";
5+
import { cn } from "@/lib/utils";
6+
7+
export interface RichListItemProps extends Omit<React.ComponentProps<typeof Item>, "asChild" | "children"> {
8+
/** Slot before the text content — typically an avatar or icon. */
9+
leading?: React.ReactNode;
10+
/** Primary line of text. Truncates to a single line. */
11+
primary: React.ReactNode;
12+
/** Secondary line of text shown beneath `primary`. Truncates to a single line. */
13+
secondary?: React.ReactNode;
14+
/** Right-aligned supplemental content (badges, timestamps) shown above `actions`. */
15+
trailing?: React.ReactNode;
16+
/** Right-aligned interactive controls (buttons, menus). */
17+
actions?: React.ReactNode;
18+
}
19+
20+
const CONTENT_COLUMNS_BOTH = "grid-cols-[minmax(0,1fr)_auto_auto]";
21+
const CONTENT_COLUMNS_ONE = "grid-cols-[minmax(0,1fr)_auto]";
22+
const CONTENT_COLUMNS_NONE = "grid-cols-[minmax(0,1fr)]";
23+
24+
function getContentColumns(hasTrailing: boolean, hasActions: boolean) {
25+
if (hasTrailing && hasActions) return CONTENT_COLUMNS_BOTH;
26+
if (hasTrailing || hasActions) return CONTENT_COLUMNS_ONE;
27+
return CONTENT_COLUMNS_NONE;
28+
}
29+
30+
function RichListItem({
31+
leading,
32+
primary,
33+
secondary,
34+
trailing,
35+
actions,
36+
className,
37+
variant = "default",
38+
size = "default",
39+
...props
40+
}: RichListItemProps) {
41+
const hasSecondary = Boolean(secondary);
42+
const contentColumns = getContentColumns(Boolean(trailing), Boolean(actions));
43+
const supplementalSpanClass = hasSecondary ? "row-span-2" : "row-span-1";
44+
45+
return (
46+
<Item
47+
data-slot="rich-list-item"
48+
variant={variant}
49+
size={size}
50+
className={cn(
51+
"flex-nowrap items-center gap-3 rounded-none border-transparent px-4 py-3 hover:bg-muted/50",
52+
className,
53+
)}
54+
{...props}
55+
>
56+
{leading && <ItemMedia className="self-center">{leading}</ItemMedia>}
57+
<ItemContent className="min-w-0">
58+
<div className={cn("grid min-w-0 items-center gap-x-3 gap-y-1", contentColumns)}>
59+
<ItemTitle className="min-w-0 max-w-full truncate">{primary}</ItemTitle>
60+
{trailing && (
61+
<div className={cn("flex flex-col items-end gap-1 whitespace-nowrap text-right", supplementalSpanClass)}>
62+
{trailing}
63+
</div>
64+
)}
65+
{actions && (
66+
<ItemActions className={cn("shrink-0 self-center", supplementalSpanClass)}>{actions}</ItemActions>
67+
)}
68+
{secondary && (
69+
<div
70+
data-slot="rich-list-item-secondary"
71+
className="min-w-0 truncate text-xs leading-tight text-muted-foreground"
72+
>
73+
{secondary}
74+
</div>
75+
)}
76+
</div>
77+
</ItemContent>
78+
</Item>
79+
);
80+
}
81+
82+
export interface RichListItemAvatarProps extends Omit<React.ComponentProps<typeof Avatar>, "children"> {
83+
initials: string;
84+
fallbackClassName?: string;
85+
}
86+
87+
function RichListItemAvatar({
88+
initials,
89+
className,
90+
fallbackClassName,
91+
size = "default",
92+
...props
93+
}: RichListItemAvatarProps) {
94+
return (
95+
<Avatar className={className} size={size} {...props}>
96+
<AvatarFallback className={cn("bg-primary/10 text-xs font-semibold text-primary", fallbackClassName)}>
97+
{initials}
98+
</AvatarFallback>
99+
</Avatar>
100+
);
101+
}
102+
103+
export { RichListItem, RichListItemAvatar };
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { RichListItem, RichListItemAvatar } from "./RichListItem";
2+
export type { RichListItemProps, RichListItemAvatarProps } from "./RichListItem";

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ export * from "@/components/composed/FormPatterns";
66
export * from "@/components/composed/StatCard";
77
export * from "@/components/composed/DataAppShell";
88
export * from "@/components/composed/Chat";
9+
export * from "@/components/composed/RichListItem";
910
export * from "@/components/composed/EmptyState";
1011

1112
// Tetra Data Platform (TDP) Specific
1213
export * from "@/components/composed/TdpSearch";
1314
export * from "@/components/composed/tdp-link";
1415
export * from "@/components/composed/tdp-url";
1516

16-
1717
// Charts
1818
export * from "@/components/charts/AreaGraph";
1919
export * from "@/components/charts/BarGraph";

0 commit comments

Comments
 (0)