Skip to content

Commit 8f2efda

Browse files
committed
feat(web): redesign usage insights
1 parent 27bb7f4 commit 8f2efda

11 files changed

Lines changed: 461 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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ChevronDownIcon } from "lucide-react";
2+
3+
import { Button } from "./ui/button";
4+
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
5+
6+
export interface HeaderFilterMenuOption<Value extends string> {
7+
value: Value;
8+
label: string;
9+
}
10+
11+
export function HeaderFilterMenu<Value extends string>({
12+
label,
13+
value,
14+
options,
15+
onChange,
16+
align = "start",
17+
popupClassName,
18+
}: {
19+
label: string;
20+
value: Value;
21+
options: ReadonlyArray<HeaderFilterMenuOption<Value>>;
22+
onChange: (value: Value) => void;
23+
align?: "start" | "center" | "end";
24+
popupClassName?: string;
25+
}) {
26+
const current = options.find((option) => option.value === value) ?? options[0]!;
27+
return (
28+
<Menu>
29+
<MenuTrigger
30+
render={
31+
<Button size="compact" variant="ghost-muted" aria-label={label} className="text-sm" />
32+
}
33+
>
34+
{current.label}
35+
<ChevronDownIcon aria-hidden className="size-3 text-muted-foreground/70" />
36+
</MenuTrigger>
37+
<MenuPopup align={align} side="bottom" className={popupClassName ?? "min-w-40"}>
38+
<MenuRadioGroup value={current.value} onValueChange={(next) => onChange(next as Value)}>
39+
{options.map((option) => (
40+
<MenuRadioItem key={option.value} value={option.value}>
41+
{option.label}
42+
</MenuRadioItem>
43+
))}
44+
</MenuRadioGroup>
45+
</MenuPopup>
46+
</Menu>
47+
);
48+
}

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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
className,
22+
...props
23+
}: {
24+
selected: boolean;
25+
} & Omit<ComponentProps<typeof Toggle>, "aria-pressed" | "pressed" | "size" | "type" | "variant">) {
26+
return (
27+
<Toggle
28+
type="button"
29+
pressed={selected}
30+
size="segmented"
31+
variant="segmented"
32+
className={className}
33+
{...props}
34+
/>
35+
);
36+
}
37+
38+
export { SegmentedTab, SegmentedTabList };

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ 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)]",
2123
sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
2224
xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
2325
},
@@ -27,6 +29,8 @@ const toggleVariants = cva(
2729
"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",
2830
outline:
2931
"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",
32+
segmented:
33+
"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",
3034
},
3135
},
3236
},

0 commit comments

Comments
 (0)