feat(ui): put the budgets CTA in the tab bar and scroll the rows, not the page

The create button now sits in the tab bar beside the tabs, the way Teams
lays it out, with one divider between them and the rule running the full
width underneath.

Adds a fillHeight mode to DataTable that treats the parent's height as a
ceiling rather than a target, so the table still sizes to its rows and a
short one keeps its footer under the last row, while a long one scrolls
its rows under a sticky header instead of scrolling the page. This replaces
the hardcoded viewport-height caps those tables would otherwise need. Two
details the mode has to fix: the Table primitive's own overflow container
would capture the sticky header, and rows would show through the
semi-transparent header tint.
This commit is contained in:
Yuneng Jiang 2026-07-31 12:55:16 -07:00
parent 2769fbe37b
commit 79b2a5e56e
No known key found for this signature in database
5 changed files with 96 additions and 29 deletions

View file

@ -226,6 +226,7 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
columns={columns}
getRowId={(budget, index) => budget.budget_id || String(index)}
defaultColumnVisibility={BUDGET_TABLE_HIDDEN_COLUMNS}
fillHeight
sortingMode="server"
sorting={list.sorting}
onSortingChange={list.onSortingChange}

View file

@ -7,6 +7,7 @@ import { Plus, Wallet } from "lucide-react";
import React, { useCallback, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { PageHeader } from "@/components/shared/PageHeader";
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
@ -78,31 +79,34 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
};
return (
<div className="flex flex-col gap-4 p-6 px-12">
<div className="flex h-full flex-col gap-4 p-6 px-12">
<PageHeader
icon={<Wallet className="size-5" />}
title="Budgets"
subtitle="Spend, TPM and RPM limits you can assign to customers."
/>
{canModify && (
<div>
<Button onClick={() => setIsCreateModelVisible(true)}>
<Plus className="size-4" />
Create Budget
</Button>
<Tabs defaultValue="budgets" className="min-h-0 flex-1 gap-0">
<div className="flex items-center gap-4 border-b border-border">
{canModify && (
<>
<Button onClick={() => setIsCreateModelVisible(true)}>
<Plus className="size-4" />
Create Budget
</Button>
<ToolbarSeparator className="h-6" />
</>
)}
<TabsList variant="line">
<TabsTrigger value="budgets" className="flex-none px-4">
Budgets
</TabsTrigger>
<TabsTrigger value="examples" className="flex-none px-4">
Examples
</TabsTrigger>
</TabsList>
</div>
)}
<Tabs defaultValue="budgets">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
Budgets
</TabsTrigger>
<TabsTrigger value="examples" className="flex-none rounded-none px-4 py-2">
Examples
</TabsTrigger>
</TabsList>
<TabsContent value="budgets">
<div className="mt-6">
<TabsContent value="budgets" className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col pt-6">
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
{selectedBudget && (
<EditBudgetModal
@ -134,8 +138,8 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
/>
</div>
</TabsContent>
<TabsContent value="examples">
<div className="mt-6">
<TabsContent value="examples" className="min-h-0 flex-1 overflow-y-auto">
<div className="pt-6">
<p className="text-base text-muted-foreground">How to use budget id</p>
<Tabs defaultValue="assign-budget">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">

View file

@ -625,6 +625,44 @@ describe("DataTable layout", () => {
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
expect(scroller.style.maxHeight).toBe("240px");
});
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
const frame = scroller.parentElement as HTMLElement;
const outer = frame.parentElement as HTMLElement;
// A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table.
expect(outer.className).toContain("max-h-full");
expect(outer.className).not.toContain("flex-1");
expect(frame.className).not.toContain("flex-1");
expect(scroller.className).not.toContain("flex-1");
expect(outer.className).toContain("flex-col");
expect(frame.className).toContain("flex-col");
expect(scroller.className).toContain("min-h-0");
expect(scroller.className).toContain("overflow-auto");
expect(scroller.style.maxHeight).toBe("");
// Without this the Table primitive's own overflow container captures the sticky header.
expect(scroller.className).toContain("[&_[data-slot=table-container]]:overflow-visible");
const thead = container.querySelector("thead") as HTMLElement;
expect(thead.className).toContain("sticky");
// Rows pass under the header, so the semi-transparent row tint alone would let them show through.
expect(thead.className).toContain("bg-background");
});
it("leaves the default layout untouched when neither height mode is set", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
expect(scroller.className).toContain("overflow-x-auto");
expect(scroller.className).not.toContain("min-h-0");
expect(scroller.style.maxHeight).toBe("");
expect((scroller.parentElement as HTMLElement).className).not.toContain("flex-col");
expect(container.querySelector("thead")?.className).not.toContain("sticky");
expect(container.querySelector("thead")?.className).not.toContain("bg-background");
});
});
describe("DataTable misconfiguration guards", () => {

View file

@ -48,6 +48,22 @@ const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox
const noop = () => {};
/**
* Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so
* a short table keeps its footer under the last row and a long one scrolls its rows instead of the
* page. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it
* captures the sticky header and the header scrolls away with the rows. And rows pass under that
* header, which the semi-transparent header row tint alone would not hide.
*/
const FILL_CLASSES = {
outer: "flex max-h-full min-h-0 flex-col",
frame: "flex min-h-0 flex-col",
body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible",
header: "bg-background",
} as const;
const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const;
export class DataTableConfigError extends Error {
constructor(messages: readonly string[]) {
super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`);
@ -538,6 +554,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
rowClassName,
renderSubComponent,
maxBodyHeight,
fillHeight = false,
size = "default",
toolbar,
paginationSlot,
@ -548,7 +565,8 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const rows = table.getRowModel().rows;
const visibleColumnCount = table.getVisibleLeafColumns().length;
const stickyHeader = maxBodyHeight !== undefined;
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
const renderPagination = (): React.ReactNode => {
@ -604,15 +622,15 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const paginationNode = renderPagination();
return (
<div className="w-full">
<div className="overflow-hidden rounded-lg border border-border">
{toolbar !== undefined && <div className="border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div className={cn("w-full", fill.outer)}>
<div className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div
className={stickyHeader ? "overflow-auto" : "overflow-x-auto"}
style={stickyHeader ? { maxHeight: maxBodyHeight } : undefined}
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader className={stickyHeader ? "sticky top-0 z-20" : ""}>
<TableHeader className={cn(stickyHeader ? "sticky top-0 z-20" : "", fill.header)}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => (
@ -631,7 +649,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
</TableRoot>
</div>
{paginationNode !== null && <div className="border-t border-border">{paginationNode}</div>}
{paginationNode !== null && <div className="shrink-0 border-t border-border">{paginationNode}</div>}
</div>
</div>
);

View file

@ -69,6 +69,12 @@ export interface DataTableProps<TData extends RowData, TValue> {
rowClassName?: (row: Row<TData>) => string;
maxBodyHeight?: number | string;
/**
* Scroll the rows inside whatever height the parent gives the table, rather than growing the page.
* The table becomes a flex column, so the parent must be a height-constrained flex container; without
* one it degrades to the normal auto-height layout. Use instead of `maxBodyHeight` to avoid a magic number.
*/
fillHeight?: boolean;
size?: DataTableSize;
toolbar?: (table: Table<TData>) => React.ReactNode;