mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Revert "Improve analytics tables layout and data grid scrolling"
This reverts commit ada90e2139.
This commit is contained in:
parent
5b0584e355
commit
c1e2884e61
@ -27,6 +27,8 @@ 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>([
|
||||
[
|
||||
@ -198,9 +200,7 @@ function TableContent({ tableId }: { tableId: TableId }) {
|
||||
);
|
||||
|
||||
return (
|
||||
// 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">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<QueryDataGrid
|
||||
query={effectiveQuery}
|
||||
mode={effectiveMode}
|
||||
@ -210,9 +210,8 @@ function TableContent({ tableId }: { tableId: TableId }) {
|
||||
searchBar={aiSearchBar}
|
||||
toolbarExtra={renderToolbarExtra}
|
||||
exportFilename={`${tableId}-export`}
|
||||
fillHeight
|
||||
stickyTop={0}
|
||||
horizontalScrollbarPosition="top"
|
||||
fillHeight={false}
|
||||
stickyTop={ANALYTICS_TABLES_STICKY_TOP}
|
||||
/>
|
||||
|
||||
<AiQueryDialog
|
||||
@ -230,83 +229,70 @@ export default function PageClient() {
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="analytics">
|
||||
{/* 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 />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
</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>
|
||||
|
||||
@ -133,8 +133,6 @@ 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 = {
|
||||
@ -331,7 +329,6 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
footer = false,
|
||||
fillHeight = true,
|
||||
stickyTop,
|
||||
horizontalScrollbarPosition,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@ -657,7 +654,6 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
selectionMode="none"
|
||||
fillHeight={fillHeight}
|
||||
stickyTop={stickyTop}
|
||||
horizontalScrollbarPosition={horizontalScrollbarPosition}
|
||||
toolbar={resolvedToolbar}
|
||||
toolbarExtra={resolvedToolbarExtra}
|
||||
footer={footer ? undefined : false}
|
||||
|
||||
@ -731,13 +731,6 @@ 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">
|
||||
|
||||
@ -1005,21 +1005,20 @@ 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();
|
||||
|
||||
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`;
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@ -1027,7 +1026,6 @@ 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]",
|
||||
@ -1037,9 +1035,10 @@ export function CmdKTrigger() {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={mouseCursorParentRef}
|
||||
className={cn(
|
||||
"absolute inset-[2px] overflow-hidden rounded-[10px] -z-20",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity duration-300 group-hover:transition-none",
|
||||
"group-hover:opacity-100 transition-opacity duration-300 group-hover:transition-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -1050,6 +1049,7 @@ export function CmdKTrigger() {
|
||||
"rounded-full",
|
||||
"pointer-events-none",
|
||||
"-translate-x-1/2 -translate-y-1/2",
|
||||
"hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -204,9 +204,7 @@ function InteractiveDataGridHarness(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function WideDataGridHarness(props: {
|
||||
horizontalScrollbarPosition?: "top" | "bottom",
|
||||
} = {}) {
|
||||
function WideDataGridHarness() {
|
||||
const [state, setState] = useState(() => createDefaultDataGridState(wideColumns));
|
||||
|
||||
return (
|
||||
@ -217,7 +215,6 @@ function WideDataGridHarness(props: {
|
||||
getRowId={(row) => row.id}
|
||||
state={state}
|
||||
onChange={setState}
|
||||
horizontalScrollbarPosition={props.horizontalScrollbarPosition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -581,33 +578,4 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -640,7 +640,6 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
maxHeight,
|
||||
fillHeight = true,
|
||||
stickyTop,
|
||||
horizontalScrollbarPosition = "bottom",
|
||||
toolbar,
|
||||
toolbarExtra,
|
||||
emptyState,
|
||||
@ -971,55 +970,12 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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 handleBodyScroll = useCallback(() => {
|
||||
const body = scrollContainerRef.current;
|
||||
const header = headerScrollRef.current;
|
||||
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;
|
||||
if (body && header) header.scrollLeft = body.scrollLeft;
|
||||
}, []);
|
||||
|
||||
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(
|
||||
() => ({
|
||||
@ -1128,13 +1084,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
)}
|
||||
<div
|
||||
ref={headerScrollRef}
|
||||
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}
|
||||
className="w-full min-w-0 shrink-0 overflow-hidden border-b border-foreground/[0.06]"
|
||||
>
|
||||
<div
|
||||
className="flex"
|
||||
@ -1170,15 +1120,14 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"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",
|
||||
"relative z-0 w-full min-w-0 overflow-auto bg-transparent",
|
||||
isBounded ? "min-h-0 flex-1" : "flex-none",
|
||||
bodyScrollbarClassName,
|
||||
"[&::-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]",
|
||||
)}
|
||||
onScroll={handleBodyScroll}
|
||||
onWheel={horizontalScrollbarAtTop ? handleBodyWheel : undefined}
|
||||
>
|
||||
<div
|
||||
ref={rowsClipRef}
|
||||
|
||||
@ -345,13 +345,6 @@ 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> & {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user