Skip to content

Commit f3260f1

Browse files
committed
Add UI Blocks to Context Panel
1 parent d5e7eed commit f3260f1

File tree

6 files changed

+384
-1
lines changed

6 files changed

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

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)