Skip to content

Commit 2ec34dc

Browse files
committed
feat(web): redesign usage insights
1 parent f181757 commit 2ec34dc

11 files changed

Lines changed: 469 additions & 321 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it } from "vite-plus/test";
3+
4+
import { HeaderFilterMenu } from "./HeaderFilterMenu";
5+
6+
describe("HeaderFilterMenu", () => {
7+
it("uses the shared button contract for its trigger", () => {
8+
const markup = renderToStaticMarkup(
9+
<HeaderFilterMenu
10+
label="Usage metric"
11+
value="cost"
12+
options={[
13+
{ value: "cost", label: "Cost" },
14+
{ value: "tokens", label: "Tokens" },
15+
]}
16+
onChange={() => {}}
17+
/>,
18+
);
19+
20+
expect(markup).toContain('data-slot="menu-trigger"');
21+
expect(markup).toContain("focus-visible:ring-2");
22+
expect(markup).toContain('aria-label="Usage metric"');
23+
expect(markup).toContain("Cost");
24+
});
25+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ChevronDownIcon } from "lucide-react";
2+
import type { ReactNode } from "react";
3+
4+
import { Button } from "./ui/button";
5+
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
6+
7+
export interface HeaderFilterMenuOption<Value extends string> {
8+
value: Value;
9+
label: string;
10+
icon?: ReactNode;
11+
disabledReason?: string;
12+
}
13+
14+
export function HeaderFilterMenu<Value extends string>({
15+
label,
16+
value,
17+
options,
18+
onChange,
19+
align = "start",
20+
popupClassName,
21+
}: {
22+
label: string;
23+
value: Value;
24+
options: ReadonlyArray<HeaderFilterMenuOption<Value>>;
25+
onChange: (value: Value) => void;
26+
align?: "start" | "center" | "end";
27+
popupClassName?: string;
28+
}) {
29+
const current = options.find((option) => option.value === value) ?? options[0]!;
30+
return (
31+
<Menu>
32+
<MenuTrigger
33+
render={
34+
<Button size="compact" variant="ghost-muted" aria-label={label} className="text-sm" />
35+
}
36+
>
37+
{current.label}
38+
<ChevronDownIcon aria-hidden className="size-3 text-muted-foreground/70" />
39+
</MenuTrigger>
40+
<MenuPopup align={align} side="bottom" className={popupClassName ?? "min-w-40"}>
41+
<MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
42+
{options.map((option) => (
43+
<MenuRadioItem
44+
key={option.value}
45+
value={option.value}
46+
disabled={option.disabledReason !== undefined}
47+
title={option.disabledReason}
48+
>
49+
<span className="flex min-w-0 items-center gap-2">
50+
{option.icon}
51+
{option.label}
52+
</span>
53+
</MenuRadioItem>
54+
))}
55+
</MenuRadioGroup>
56+
</MenuPopup>
57+
</Menu>
58+
);
59+
}

apps/web/src/components/WorkspaceBreadcrumb.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ export function WorkspaceBreadcrumbItem({
4545
);
4646
}
4747

48-
export function WorkspaceBreadcrumbSeparator() {
48+
export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) {
4949
return (
50-
<li aria-hidden="true" className="flex shrink-0 items-center text-icon-muted">
50+
<li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}>
5151
/
5252
</li>
5353
);

apps/web/src/components/WorkspacePageContainer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,11 @@ export function WorkspacePageHeader({
5252
/>
5353
);
5454
}
55+
56+
/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */
57+
export function WorkspacePageHeaderEdgeControl({
58+
className,
59+
...props
60+
}: ComponentPropsWithoutRef<"div">) {
61+
return <div className={cn("-me-[7px] flex shrink-0", className)} {...props} />;
62+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ComponentProps, HTMLAttributes } from "react";
2+
3+
import { cn } from "~/lib/utils";
4+
import { Toggle } from "~/components/ui/toggle";
5+
6+
function SegmentedTabList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
7+
return (
8+
<div
9+
className={cn(
10+
"inline-flex w-fit items-center gap-0.5 rounded-lg bg-muted/45 p-0.5",
11+
className,
12+
)}
13+
role="group"
14+
{...props}
15+
/>
16+
);
17+
}
18+
19+
function SegmentedTab({
20+
selected,
21+
density = "default",
22+
className,
23+
...props
24+
}: {
25+
selected: boolean;
26+
density?: "default" | "compact";
27+
} & Omit<ComponentProps<typeof Toggle>, "aria-pressed" | "pressed" | "size" | "type" | "variant">) {
28+
return (
29+
<Toggle
30+
type="button"
31+
pressed={selected}
32+
size={density === "compact" ? "segmented-compact" : "segmented"}
33+
variant="segmented"
34+
className={className}
35+
{...props}
36+
/>
37+
);
38+
}
39+
40+
export { SegmentedTab, SegmentedTabList };

apps/web/src/components/ui/toggle.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const toggleVariants = cva(
1818
"h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5",
1919
default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
2020
lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",
21+
segmented:
22+
"h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]",
23+
"segmented-compact":
24+
"h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]",
2125
sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
2226
xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
2327
},
@@ -27,6 +31,8 @@ const toggleVariants = cva(
2731
"border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
2832
outline:
2933
"border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
34+
segmented:
35+
"border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5",
3036
},
3137
},
3238
},

0 commit comments

Comments
 (0)