Skip to content

Commit 4814e7c

Browse files
committed
Add UI Blocks to Context Panel
1 parent c2959e5 commit 4814e7c

File tree

6 files changed

+371
-1
lines changed

6 files changed

+371
-1
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useState, type ReactNode } from "react";
2+
3+
import { Icon, type IconName } from "@/components/ui/icon";
4+
import { InlineStack } from "@/components/ui/layout";
5+
6+
import TooltipButton from "../../Buttons/TooltipButton";
7+
import { ConfirmationDialog } from "../../Dialogs";
8+
9+
export type Action = {
10+
label: string;
11+
destructive?: boolean;
12+
disabled?: boolean;
13+
hidden?: boolean;
14+
confirmation?: string;
15+
onClick: () => void;
16+
className?: string;
17+
} & (
18+
| { icon: IconName; content?: never }
19+
| { content: ReactNode; icon?: never }
20+
);
21+
22+
// Temporary: ReactNode included for backward compatibility with some existing buttons. In the long-term we should strive for only Action types.
23+
export type ActionOrReactNode = Action | ReactNode;
24+
25+
interface ActionBlockProps {
26+
actions: ActionOrReactNode[];
27+
className?: string;
28+
}
29+
30+
export const ActionBlock = ({ actions, className }: ActionBlockProps) => {
31+
const [isOpen, setIsOpen] = useState(false);
32+
const [dialogAction, setDialogAction] = useState<Action | null>(null);
33+
34+
const openConfirmationDialog = (action: Action) => {
35+
return () => {
36+
setDialogAction(action);
37+
setIsOpen(true);
38+
};
39+
};
40+
41+
const handleConfirm = () => {
42+
setIsOpen(false);
43+
dialogAction?.onClick();
44+
setDialogAction(null);
45+
};
46+
47+
const handleCancel = () => {
48+
setIsOpen(false);
49+
setDialogAction(null);
50+
};
51+
52+
return (
53+
<>
54+
<InlineStack gap="2" className={className}>
55+
{actions.map((action, index) => {
56+
if (!action || typeof action !== "object" || !("label" in action)) {
57+
return <div key={index}>{action}</div>;
58+
}
59+
60+
if (action.hidden) {
61+
return null;
62+
}
63+
64+
return (
65+
<TooltipButton
66+
key={action.label}
67+
variant={action.destructive ? "destructive" : "outline"}
68+
tooltip={action.label}
69+
onClick={
70+
!!action.confirmation
71+
? openConfirmationDialog(action)
72+
: action.onClick
73+
}
74+
disabled={action.disabled}
75+
className={action.className}
76+
>
77+
{action.content === undefined && action.icon ? (
78+
<Icon name={action.icon} />
79+
) : (
80+
action.content
81+
)}
82+
</TooltipButton>
83+
);
84+
})}
85+
</InlineStack>
86+
87+
<ConfirmationDialog
88+
isOpen={isOpen}
89+
title={dialogAction?.label}
90+
description={dialogAction?.confirmation}
91+
onConfirm={handleConfirm}
92+
onCancel={handleCancel}
93+
/>
94+
</>
95+
);
96+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { type ReactNode } from "react";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Collapsible,
6+
CollapsibleContent,
7+
CollapsibleTrigger,
8+
} from "@/components/ui/collapsible";
9+
import { Icon } from "@/components/ui/icon";
10+
import { BlockStack, InlineStack } from "@/components/ui/layout";
11+
import { Heading } from "@/components/ui/typography";
12+
13+
type ContentBlockProps =
14+
| {
15+
title: string;
16+
children?: ReactNode;
17+
collapsible?: false;
18+
defaultOpen?: never;
19+
className?: string;
20+
}
21+
| {
22+
title: string;
23+
children?: ReactNode;
24+
collapsible: true;
25+
defaultOpen?: boolean;
26+
className?: string;
27+
};
28+
29+
export const ContentBlock = ({
30+
title,
31+
children,
32+
collapsible,
33+
defaultOpen = false,
34+
className,
35+
}: ContentBlockProps) => {
36+
if (!children) {
37+
return null;
38+
}
39+
40+
return (
41+
<BlockStack className={className}>
42+
<Collapsible className="w-full" defaultOpen={defaultOpen}>
43+
<InlineStack blockAlign="center" gap="1">
44+
<Heading level={3}>{title}</Heading>
45+
{collapsible && (
46+
<CollapsibleTrigger asChild>
47+
<Button variant="ghost" size="sm">
48+
<Icon name="ChevronsUpDown" />
49+
<span className="sr-only">Toggle</span>
50+
</Button>
51+
</CollapsibleTrigger>
52+
)}
53+
</InlineStack>
54+
55+
{collapsible ? (
56+
<CollapsibleContent className="w-full mt-1">
57+
{children}
58+
</CollapsibleContent>
59+
) : (
60+
children
61+
)}
62+
</Collapsible>
63+
</BlockStack>
64+
);
65+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { InlineStack } from "@/components/ui/layout";
2+
import { Link } from "@/components/ui/link";
3+
import { Paragraph } from "@/components/ui/typography";
4+
import { cn } from "@/lib/utils";
5+
6+
import { CopyText } from "../../CopyText/CopyText";
7+
8+
export interface KeyValuePairProps {
9+
label?: string;
10+
value?: string | { href: string; text: string };
11+
critical?: boolean;
12+
copyable?: boolean;
13+
}
14+
15+
export const KeyValuePair = ({
16+
label,
17+
value,
18+
critical,
19+
copyable,
20+
}: KeyValuePairProps) => {
21+
if (!value) {
22+
return null;
23+
}
24+
25+
return (
26+
<InlineStack gap="2" blockAlign="center" wrap="nowrap">
27+
{label && (
28+
<Paragraph
29+
size="xs"
30+
tone={critical ? "critical" : "inherit"}
31+
className="shrink-0 whitespace-nowrap"
32+
>
33+
{label}:
34+
</Paragraph>
35+
)}
36+
37+
<div className="min-w-0 flex-1 overflow-hidden">
38+
{isLink(value) ? (
39+
<Link
40+
href={value.href}
41+
size="xs"
42+
variant="classic"
43+
external
44+
target="_blank"
45+
rel="noopener noreferrer"
46+
>
47+
{value.text}
48+
</Link>
49+
) : copyable ? (
50+
<CopyText
51+
className={cn(
52+
"text-xs truncate",
53+
critical ? "text-destructive" : "text-muted-foreground",
54+
)}
55+
>
56+
{value}
57+
</CopyText>
58+
) : (
59+
<Paragraph
60+
size="xs"
61+
tone={critical ? "critical" : "subdued"}
62+
className="truncate"
63+
>
64+
{value}
65+
</Paragraph>
66+
)}
67+
</div>
68+
</InlineStack>
69+
);
70+
};
71+
72+
const isLink = (
73+
val: string | { href: string; text: string },
74+
): val is { href: string; text: string } => {
75+
return typeof val === "object" && val !== null && "href" in val;
76+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { BlockStack } from "@/components/ui/layout";
2+
import { Heading } from "@/components/ui/typography";
3+
4+
import { KeyValuePair, type KeyValuePairProps } from "./KeyValuePair";
5+
6+
interface ListBlockProps {
7+
title?: string;
8+
items: KeyValuePairProps[];
9+
marker?: "bullet" | "number" | "none";
10+
className?: string;
11+
}
12+
13+
export const ListBlock = ({
14+
title,
15+
items,
16+
marker = "bullet",
17+
className,
18+
}: ListBlockProps) => {
19+
const listElement = marker === "number" ? "ol" : "ul";
20+
21+
const getListStyle = () => {
22+
switch (marker) {
23+
case "bullet":
24+
return "pl-5 list-disc";
25+
case "number":
26+
return "pl-5 list-decimal";
27+
case "none":
28+
return "list-none";
29+
default:
30+
return "pl-5 list-disc";
31+
}
32+
};
33+
34+
return (
35+
<BlockStack className={className}>
36+
{title && <Heading level={3}>{title}</Heading>}
37+
<BlockStack as={listElement} gap="1" className={getListStyle()}>
38+
{items.map((item, index) => {
39+
if (!item.value) {
40+
return null;
41+
}
42+
43+
return (
44+
<li key={index}>
45+
<KeyValuePair {...item} />
46+
</li>
47+
);
48+
})}
49+
</BlockStack>
50+
</BlockStack>
51+
);
52+
};
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Button } from "@/components/ui/button";
2+
import {
3+
Collapsible,
4+
CollapsibleContent,
5+
CollapsibleTrigger,
6+
} from "@/components/ui/collapsible";
7+
import { Icon } from "@/components/ui/icon";
8+
import { BlockStack, InlineStack } from "@/components/ui/layout";
9+
import { Heading, Paragraph } from "@/components/ui/typography";
10+
import { cn } from "@/lib/utils";
11+
12+
import { CopyText } from "../../CopyText/CopyText";
13+
14+
interface TextBlockProps {
15+
title: string;
16+
text?: string;
17+
copyable?: boolean;
18+
collapsible?: boolean;
19+
mono?: boolean;
20+
className?: string;
21+
}
22+
23+
export const TextBlock = ({
24+
title,
25+
text,
26+
copyable,
27+
collapsible,
28+
mono,
29+
className,
30+
}: TextBlockProps) => {
31+
if (!text) {
32+
return null;
33+
}
34+
35+
const content = copyable ? (
36+
<CopyText
37+
className={cn("text-xs text-muted-foreground truncate", {
38+
"font-mono": mono,
39+
})}
40+
>
41+
{text}
42+
</CopyText>
43+
) : (
44+
<Paragraph
45+
tone="subdued"
46+
font={mono ? "mono" : "default"}
47+
size="xs"
48+
className="truncate"
49+
>
50+
{text}
51+
</Paragraph>
52+
);
53+
54+
return (
55+
<BlockStack className={className}>
56+
<Collapsible className="w-full">
57+
<InlineStack blockAlign="center" gap="1">
58+
<Heading level={3}>{title}</Heading>
59+
{collapsible && (
60+
<CollapsibleTrigger asChild>
61+
<Button variant="ghost" size="sm">
62+
<Icon name="ChevronsUpDown" />
63+
<span className="sr-only">Toggle</span>
64+
</Button>
65+
</CollapsibleTrigger>
66+
)}
67+
</InlineStack>
68+
69+
{collapsible ? (
70+
<CollapsibleContent className="w-full mt-1">
71+
{content}
72+
</CollapsibleContent>
73+
) : (
74+
content
75+
)}
76+
</Collapsible>
77+
</BlockStack>
78+
);
79+
};

src/components/ui/icon.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ const iconVariants = cva("", {
1515
},
1616
});
1717

18+
export type IconName = keyof typeof icons;
19+
1820
interface IconProps extends VariantProps<typeof iconVariants> {
19-
name: keyof typeof icons;
21+
name: IconName;
2022
className?: string;
2123
}
2224

0 commit comments

Comments
 (0)