Improve analytics tables layout and data grid scrolling

- Make the analytics tables page fill the shell height and scroll internally so rows never paint under the sticky translucent header
- Add horizontalScrollbarPosition option to DataGrid (top puts the horizontal scrollbar under the column headers) with header/body scroll syncing and wheel forwarding, plus tests
- Add an opaque dark-mode underlay behind the floating header to fix the bright seam when content scrolls behind it
- Scope the CmdK trigger glow to the button itself instead of a document-level mousemove listener that recomposited the header on every cursor move
This commit is contained in:
Developing-Gamer 2026-07-13 16:00:24 -07:00
parent c1e2884e61
commit 6c672a3427
7 changed files with 205 additions and 90 deletions

View File

@ -27,8 +27,6 @@ type TableConfig = {
};
type TableId = string;
const ANALYTICS_TABLES_STICKY_TOP = "var(--analytics-tables-sticky-top)";
const ANALYTICS_TABLES_SIDEBAR_HEIGHT = "var(--analytics-tables-sidebar-height)";
const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
[
@ -200,7 +198,9 @@ function TableContent({ tableId }: { tableId: TableId }) {
);
return (
<div className="flex min-w-0 flex-col">
// fillHeight grid owns its own scrollport below the page header, so rows never
// paint under the sticky translucent header (the dark-mode seam bug).
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<QueryDataGrid
query={effectiveQuery}
mode={effectiveMode}
@ -210,8 +210,9 @@ function TableContent({ tableId }: { tableId: TableId }) {
searchBar={aiSearchBar}
toolbarExtra={renderToolbarExtra}
exportFilename={`${tableId}-export`}
fillHeight={false}
stickyTop={ANALYTICS_TABLES_STICKY_TOP}
fillHeight
stickyTop={0}
horizontalScrollbarPosition="top"
/>
<AiQueryDialog
@ -229,70 +230,83 @@ export default function PageClient() {
return (
<AppEnabledGuard appId="analytics">
<PageLayout fillWidth noPadding>
<AnalyticsEventLimitBanner />
<div className="flex w-full min-w-0 items-stretch lg:-ml-2 [--analytics-tables-sticky-top:3.5rem] [--analytics-tables-sidebar-height:calc(100vh-3.5rem)] dark:[--analytics-tables-sticky-top:5rem] dark:[--analytics-tables-sidebar-height:calc(100vh-6rem)]">
{/* Left sidebar — hidden on mobile */}
<div
className="hidden lg:flex w-48 min-h-0 flex-shrink-0 self-start flex-col overflow-hidden border-r border-border/50 pl-2 sticky"
style={{
top: ANALYTICS_TABLES_STICKY_TOP,
height: ANALYTICS_TABLES_SIDEBAR_HEIGHT,
}}
>
<div className="px-4 py-4">
<Typography className="px-3 mb-3 text-xs font-semibold uppercase tracking-wide text-foreground/70">
Tables
</Typography>
<div className="space-y-1">
{[...AVAILABLE_TABLES.entries()].map(([id, config]) => (
<button
key={id}
onClick={() => setSelectedTable(id)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:transition-none",
selectedTable === id
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{config.displayName}
</button>
))}
</div>
<Link
href="./queries"
className="mt-4 flex items-center gap-2 border-t border-border/50 px-3 pt-4 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
>
<CodeIcon className="h-4 w-4" />
Queries
</Link>
</div>
{/* containedHeight: page fills the shell under the header and scrolls internally,
so table rows cannot travel behind the floating dark-mode header. */}
<PageLayout fillWidth noPadding containedHeight>
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0">
<AnalyticsEventLimitBanner />
</div>
{/* Right content — flush to card edge; companion gap is on <main> in sidebar-layout */}
<div
className={cn(
"flex min-w-0 flex-1 flex-col",
"[&_[role=grid]]:rounded-r-none",
"[&_[role=grid]_.sticky]:rounded-t-none",
// Toolbar row only (first child of sticky chrome) — analytics layout
"[&_[role=grid]_.sticky>div:first-child>div]:pt-3",
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
"[&_[role=grid]_.sticky>div:first-child>div]:pr-0",
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
)}
>
{selectedTable ? (
<TableContent key={selectedTable} tableId={selectedTable} />
) : (
<div className="flex-1 flex items-center justify-center">
<Typography variant="secondary">
Select a table to view its contents
</Typography>
{/* Match the primary nav's dark:rounded-2xl so the gap junction mirrors
the same radius on both sides (nav top-right tables top-left). */}
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl rounded-tr-2xl lg:-ml-2">
{/* Use the same surface treatment as the primary sidebar so equal radii render
identically. Omit the right border to keep the sidebar/grid junction divider-free. */}
<div className="hidden w-48 min-h-0 flex-shrink-0 flex-col overflow-hidden rounded-l-2xl bg-black/[0.03] dark:border dark:border-r-0 dark:border-foreground/5 dark:bg-foreground/5 dark:backdrop-blur-2xl dark:shadow-sm lg:flex">
<div className="min-h-0 flex-1 overflow-y-auto pl-2">
<div className="min-h-full px-4 py-4">
<Typography className="px-3 mb-3 text-xs font-semibold uppercase tracking-wide text-foreground/70">
Tables
</Typography>
<div className="space-y-1">
{[...AVAILABLE_TABLES.entries()].map(([id, config]) => (
<button
key={id}
onClick={() => setSelectedTable(id)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:transition-none",
selectedTable === id
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{config.displayName}
</button>
))}
</div>
<Link
href="./queries"
className="mt-4 flex items-center gap-2 border-t border-border/50 px-3 pt-4 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
>
<CodeIcon className="h-4 w-4" />
Queries
</Link>
</div>
</div>
)}
</div>
{/* Right content — grid fills remaining height and scrolls internally */}
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
"[&_[role=grid]]:rounded-none",
"[&_[role=grid]_.sticky]:rounded-none",
// Toolbar row only (first child of sticky chrome) — analytics layout
"[&_[role=grid]_.sticky>div:first-child>div]:pt-3",
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
"[&_[role=grid]_.sticky>div:first-child>div]:pr-0",
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
)}
style={{
// Outer top-right corner of the block. Same clip-path trick as the
// sidebar: the grid's sticky chrome is its own composited layer and
// paints square over the parent's rounded overflow clip without this.
borderTopRightRadius: "1rem",
clipPath: "inset(0 round 0 1rem 0 0)",
}}
>
{selectedTable ? (
<TableContent key={selectedTable} tableId={selectedTable} />
) : (
<div className="flex flex-1 items-center justify-center">
<Typography variant="secondary">
Select a table to view its contents
</Typography>
</div>
)}
</div>
</div>
</div>
</PageLayout>

View File

@ -133,6 +133,8 @@ export type QueryDataGridProps = {
fillHeight?: boolean,
/** Sticky top offset forwarded to the underlying DataGrid. */
stickyTop?: number | string,
/** Where the horizontal scrollbar is shown when columns overflow. */
horizontalScrollbarPosition?: "top" | "bottom",
};
export type QueryDataGridHandle = {
@ -329,6 +331,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
footer = false,
fillHeight = true,
stickyTop,
horizontalScrollbarPosition,
},
ref,
) {
@ -654,6 +657,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
selectionMode="none"
fillHeight={fillHeight}
stickyTop={stickyTop}
horizontalScrollbarPosition={horizontalScrollbarPosition}
toolbar={resolvedToolbar}
toolbarExtra={resolvedToolbarExtra}
footer={footer ? undefined : false}

View File

@ -731,6 +731,13 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) {
WebkitMaskImage: 'linear-gradient(to bottom, black 50%, transparent 100%)',
}}
/>
{/* Opaque underlay for dark mode. Table/page content scrolls under this sticky
header; without a solid base, the translucent fill + backdrop-filter composite
a bright seam along the bottom edge whenever rows pass behind it. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 hidden rounded-2xl bg-background dark:block"
/>
<div className="relative flex h-14 items-center justify-between px-5 dark:bg-foreground/5 dark:px-4 dark:border dark:border-foreground/5 dark:backdrop-blur-2xl dark:shadow-sm dark:rounded-2xl">
{/* Left section: Logo + Menu + Project Switcher */}
<div className="flex grow-1 items-center gap-2">

View File

@ -1005,20 +1005,21 @@ export function CmdKSearch({
// Trigger button component that can be placed in the header
export function CmdKTrigger() {
const mouseCursorRef = useRef<HTMLDivElement>(null);
const mouseCursorParentRef = useRef<HTMLDivElement>(null);
const modifierKeyLabel = getShortcutModifierKeyLabel();
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (mouseCursorRef.current && mouseCursorParentRef.current) {
const rect = mouseCursorParentRef.current.getBoundingClientRect();
mouseCursorRef.current.style.left = `${e.clientX - rect.left}px`;
mouseCursorRef.current.style.top = `${e.clientY - rect.top}px`;
mouseCursorRef.current.style.display = "block";
}
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
const handleMouseMove = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const cursor = mouseCursorRef.current;
if (cursor == null) {
return;
}
// Keep this glow local to the trigger. A document-level mousemove listener used to
// reposition a blurred layer under the translucent header on every cursor move across
// the page, continuously recompositing the sticky header while table rows scrolled
// behind it and making the header's bottom edge flicker.
const rect = event.currentTarget.getBoundingClientRect();
cursor.style.left = `${event.clientX - rect.left}px`;
cursor.style.top = `${event.clientY - rect.top}px`;
}, []);
return (
@ -1026,6 +1027,7 @@ export function CmdKTrigger() {
<button
data-walkthrough-nav="cmdk-trigger"
onClick={() => window.dispatchEvent(new CustomEvent("spotlight-toggle"))}
onMouseMove={handleMouseMove}
className={cn(
"group relative flex items-center gap-3 h-9 px-4 min-w-[240px]",
"rounded-[12px]",
@ -1035,10 +1037,9 @@ export function CmdKTrigger() {
)}
>
<div
ref={mouseCursorParentRef}
className={cn(
"absolute inset-[2px] overflow-hidden rounded-[10px] -z-20",
"group-hover:opacity-100 transition-opacity duration-300 group-hover:transition-none",
"opacity-0 group-hover:opacity-100 transition-opacity duration-300 group-hover:transition-none",
)}
>
<div
@ -1049,7 +1050,6 @@ export function CmdKTrigger() {
"rounded-full",
"pointer-events-none",
"-translate-x-1/2 -translate-y-1/2",
"hidden",
)}
/>
</div>

View File

@ -204,7 +204,9 @@ function InteractiveDataGridHarness(props: {
);
}
function WideDataGridHarness() {
function WideDataGridHarness(props: {
horizontalScrollbarPosition?: "top" | "bottom",
} = {}) {
const [state, setState] = useState(() => createDefaultDataGridState(wideColumns));
return (
@ -215,6 +217,7 @@ function WideDataGridHarness() {
getRowId={(row) => row.id}
state={state}
onChange={setState}
horizontalScrollbarPosition={props.horizontalScrollbarPosition}
/>
</div>
);
@ -578,4 +581,33 @@ describe("DataGrid horizontal scrolling", () => {
expect((stickyChrome as HTMLElement).className).toContain("overflow-visible");
expect(container.textContent).toContain("Email");
});
it("puts the horizontal scrollbar under the column headers when position is top", () => {
const { container } = render(<WideDataGridHarness horizontalScrollbarPosition="top" />);
const stickyChrome = container.querySelector('[role="grid"]')?.firstElementChild;
expect(stickyChrome).toBeInstanceOf(HTMLElement);
const headerScroll = stickyChrome?.querySelector(".overflow-x-auto");
expect(headerScroll).toBeInstanceOf(HTMLElement);
const bodyScroll = container.querySelector('[role="grid"]')?.children.item(1);
expect(bodyScroll).toBeInstanceOf(HTMLElement);
expect((bodyScroll as HTMLElement).className).toContain("overflow-x-hidden");
expect((bodyScroll as HTMLElement).className).toContain("overflow-y-auto");
Object.defineProperty(headerScroll as HTMLElement, "scrollLeft", {
configurable: true,
writable: true,
value: 0,
});
Object.defineProperty(bodyScroll as HTMLElement, "scrollLeft", {
configurable: true,
writable: true,
value: 0,
});
(headerScroll as HTMLElement).scrollLeft = 120;
fireEvent.scroll(headerScroll as HTMLElement);
expect((bodyScroll as HTMLElement).scrollLeft).toBe(120);
});
});

View File

@ -640,6 +640,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
maxHeight,
fillHeight = true,
stickyTop,
horizontalScrollbarPosition = "bottom",
toolbar,
toolbarExtra,
emptyState,
@ -970,12 +971,55 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
};
}, []);
const handleBodyScroll = useCallback(() => {
// Keep header + body horizontal offsets locked. When the scrollbar lives under
// the column headers (`horizontalScrollbarPosition="top"`), the header is the
// scroll owner and the body mirrors it (and vice versa for the default bottom bar).
const isSyncingHorizontalScrollRef = useRef(false);
const syncHorizontalScroll = useCallback((source: "header" | "body") => {
const body = scrollContainerRef.current;
const header = headerScrollRef.current;
if (body && header) header.scrollLeft = body.scrollLeft;
if (body == null || header == null || isSyncingHorizontalScrollRef.current) return;
const from = source === "header" ? header : body;
const to = source === "header" ? body : header;
if (to.scrollLeft === from.scrollLeft) return;
isSyncingHorizontalScrollRef.current = true;
to.scrollLeft = from.scrollLeft;
isSyncingHorizontalScrollRef.current = false;
}, []);
const handleBodyScroll = useCallback(() => {
syncHorizontalScroll("body");
}, [syncHorizontalScroll]);
const handleHeaderScroll = useCallback(() => {
syncHorizontalScroll("header");
}, [syncHorizontalScroll]);
const horizontalScrollbarAtTop = horizontalScrollbarPosition === "top";
// Shared thin thumb styling. When the bar is on top, the header scrollport shows
// it; the body keeps overflow-x hidden so a second thumb doesn't appear at the bottom.
const headerScrollbarClassName = cn(
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
);
const bodyScrollbarClassName = cn(
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
);
// Trackpad / shift-wheel horizontal gestures land on the body; with the top
// scrollbar the body has overflow-x:hidden, so forward deltaX to the header.
const handleBodyWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!horizontalScrollbarAtTop || event.deltaX === 0) return;
const header = headerScrollRef.current;
if (header == null) return;
header.scrollLeft += event.deltaX;
}, [horizontalScrollbarAtTop]);
// ── Toolbar / Footer context ─────────────────────────────────
const toolbarCtx: DataGridToolbarContext<TRow> = useMemo(
() => ({
@ -1084,7 +1128,13 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
)}
<div
ref={headerScrollRef}
className="w-full min-w-0 shrink-0 overflow-hidden border-b border-foreground/[0.06]"
className={cn(
"w-full min-w-0 shrink-0 border-b border-foreground/[0.06]",
horizontalScrollbarAtTop
? cn("overflow-x-auto overflow-y-hidden", headerScrollbarClassName)
: "overflow-hidden",
)}
onScroll={horizontalScrollbarAtTop ? handleHeaderScroll : undefined}
>
<div
className="flex"
@ -1120,14 +1170,15 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
<div
ref={scrollContainerRef}
className={cn(
"relative z-0 w-full min-w-0 overflow-auto bg-transparent",
"relative z-0 w-full min-w-0 bg-transparent",
// Top scrollbar: body is y-only so the horizontal thumb isn't duplicated
// under the rows. scrollLeft is still set programmatically from the header.
horizontalScrollbarAtTop ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
isBounded ? "min-h-0 flex-1" : "flex-none",
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
bodyScrollbarClassName,
)}
onScroll={handleBodyScroll}
onWheel={horizontalScrollbarAtTop ? handleBodyWheel : undefined}
>
<div
ref={rowsClipRef}

View File

@ -345,6 +345,13 @@ export type DataGridProps<TRow> = {
fillHeight?: boolean;
/** Top offset for the sticky toolbar + header (px or CSS string). */
stickyTop?: number | string;
/**
* Where the horizontal scrollbar is shown when columns overflow.
* - `"bottom"` (default): on the row scrollport, under the last visible rows.
* - `"top"`: under the column headers (top of the table body), so wide
* tables stay reachable without scrolling to the bottom first.
*/
horizontalScrollbarPosition?: "top" | "bottom";
// ── Callbacks ──────────────────────────────────────────────────
} & DataGridCallbacks<TRow> & {