Skip to content

Commit 1ae0c57

Browse files
committed
feat: first take on table component
1 parent 2549075 commit 1ae0c57

File tree

6 files changed

+285
-0
lines changed

6 files changed

+285
-0
lines changed

packages/ui-react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"dependencies": {
3333
"@base-ui-components/react": "1.0.0-beta.0",
3434
"@tailwindcss/vite": "^4.1.7",
35+
"@tanstack/react-table": "^8.21.3",
3536
"class-variance-authority": "^0.7.1",
3637
"clsx": "^2.1.1",
3738
"lucide-react": "^0.511.0",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Meta, Preview } from "@storybook/react";
2+
import { Card } from "./card.js";
3+
4+
export default {
5+
title: "Components/Card",
6+
component: Card,
7+
} satisfies Meta;
8+
9+
export const Simple = {
10+
args: {
11+
children: "Card Content",
12+
},
13+
} satisfies Preview;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { mergeProps, useRender } from "@base-ui-components/react";
2+
import { cva, type VariantProps } from "class-variance-authority";
3+
import { cn } from "../../lib/utils.js";
4+
5+
const cardVariants = cva(
6+
`
7+
p-4
8+
border
9+
rounded-lg
10+
text-gray-dark
11+
`,
12+
{
13+
variants: {
14+
variant: {
15+
light: "bg-gray-100 border-gray-300",
16+
medium: "bg-gray-200 border-gray-300",
17+
},
18+
},
19+
defaultVariants: {
20+
variant: "light",
21+
},
22+
},
23+
);
24+
25+
export type Props = useRender.ComponentProps<"div"> &
26+
VariantProps<typeof cardVariants>;
27+
28+
function Card(props: Props) {
29+
const { render = <div />, className, variant, ...otherProps } = props;
30+
31+
const defaultProps: useRender.ElementProps<"div"> = {
32+
className: cn(cardVariants({ variant }), className),
33+
};
34+
35+
return useRender({
36+
render,
37+
props: mergeProps(defaultProps, otherProps),
38+
});
39+
}
40+
41+
export { Card };
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Meta, Preview } from "@storybook/react";
2+
import { TableTest } from "./table.js";
3+
4+
export default {
5+
title: "Components/Table",
6+
component: TableTest,
7+
} satisfies Meta;
8+
9+
export const Simple = {} satisfies Preview;
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import {
2+
createColumnHelper,
3+
flexRender,
4+
getCoreRowModel,
5+
type Table as TanstackTable,
6+
useReactTable,
7+
} from "@tanstack/react-table";
8+
import { type ComponentProps, memo, useMemo, useState } from "react";
9+
import { cn } from "../../lib/utils.js";
10+
import { Card } from "../card/card.js";
11+
12+
type Attendee = {
13+
firstName: string;
14+
lastName: string;
15+
role: "scout" | "leader" | "volunteer";
16+
};
17+
18+
const defaultData: Attendee[] = [
19+
{ firstName: "Alice", lastName: "Smith", role: "scout" },
20+
{ firstName: "Bob", lastName: "Johnson", role: "leader" },
21+
{ firstName: "Charlie", lastName: "Brown", role: "volunteer" },
22+
];
23+
24+
const columnHelper = createColumnHelper<Attendee>();
25+
26+
const columns = [
27+
columnHelper.accessor("firstName", {
28+
header: () => "Förnamn",
29+
}),
30+
columnHelper.accessor("lastName", {
31+
header: () => "Efternamn",
32+
}),
33+
columnHelper.accessor("role", {
34+
header: () => "Roll",
35+
cell: (info) => {
36+
const role = info.getValue();
37+
return role.charAt(0).toUpperCase() + role.slice(1);
38+
},
39+
}),
40+
];
41+
42+
function TableTest() {
43+
const [data, _setData] = useState(() => [...defaultData]);
44+
45+
const table = useReactTable({
46+
data,
47+
columns,
48+
columnResizeMode: "onChange",
49+
getCoreRowModel: getCoreRowModel(),
50+
});
51+
52+
return <Table table={table} className="w-full" />;
53+
}
54+
55+
export type Props<TData> = ComponentProps<"table"> & {
56+
table: TanstackTable<TData>;
57+
};
58+
59+
function Table<TData>(props: Props<TData>) {
60+
const { table, className, ...otherProps } = props;
61+
62+
/**
63+
* Instead of calling `column.getSize()` on every render for every header
64+
* and especially every data cell (very expensive),
65+
* we will calculate all column sizes at once at the root table level in a useMemo
66+
* and pass the column sizes down as CSS variables to the <table> element.
67+
*/
68+
// biome-ignore lint/correctness/useExhaustiveDependencies: This is carefully optimized to avoid unnecessary recalculations
69+
const columnSizeVars = useMemo(() => {
70+
const headers = table.getFlatHeaders();
71+
const colSizes: { [key: string]: number } = {};
72+
for (const header of headers) {
73+
colSizes[`--header-${header.id}-size`] = header.getSize();
74+
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
75+
}
76+
return colSizes;
77+
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
78+
79+
return (
80+
<>
81+
<pre style={{ minHeight: "10rem" }}>
82+
{JSON.stringify(
83+
{
84+
columnSizing: table.getState().columnSizing,
85+
},
86+
null,
87+
2,
88+
)}
89+
</pre>
90+
91+
<Card
92+
className={cn("p-0 inline-block", className)}
93+
{...otherProps}
94+
variant="light"
95+
>
96+
<table
97+
style={{
98+
...columnSizeVars,
99+
width: table.getTotalSize(),
100+
}}
101+
>
102+
<thead className="border-b border-gray-300">
103+
{table.getHeaderGroups().map((headerGroup) => (
104+
<tr key={headerGroup.id}>
105+
{headerGroup.headers.map((header) => (
106+
<th
107+
key={header.id}
108+
className="relative p-2 text-left"
109+
style={{
110+
width: `calc(var(--header-${header?.id}-size) * 1px)`,
111+
}}
112+
>
113+
{header.isPlaceholder
114+
? null
115+
: flexRender(
116+
header.column.columnDef.header,
117+
header.getContext(),
118+
)}
119+
120+
{header.column.getCanResize() && (
121+
<div
122+
// FIXME: Hiding this from screen readers is not ideal.
123+
// Resizing columns might not be important for fully sight
124+
// impaired users, but it could be for low vision users.
125+
// https://github.com/Scouterna/ui/issues/15
126+
aria-hidden
127+
onDoubleClick={() => header.column.resetSize()}
128+
onMouseDown={header.getResizeHandler()}
129+
onTouchStart={header.getResizeHandler()}
130+
className={cn(
131+
"absolute flex justify-center items-center py-2 top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none",
132+
"after:w-0.5 after:h-full after:bg-gray-300",
133+
header.column.getIsResizing() &&
134+
"bg-blue-100 after:invisible",
135+
)}
136+
style={{
137+
transform:
138+
table.options.columnResizeMode === "onEnd" &&
139+
header.column.getIsResizing()
140+
? `translateX(${table.getState().columnSizingInfo.deltaOffset}px)`
141+
: "",
142+
}}
143+
></div>
144+
)}
145+
</th>
146+
))}
147+
</tr>
148+
))}
149+
</thead>
150+
{table.getState().columnSizingInfo.isResizingColumn ? (
151+
<MemoizedTableBody table={table} />
152+
) : (
153+
<TableBody table={table} />
154+
)}
155+
<tfoot>
156+
{table.getFooterGroups().map((footerGroup) => (
157+
<tr key={footerGroup.id}>
158+
{footerGroup.headers.map((header) => (
159+
<th key={header.id}>
160+
{header.isPlaceholder
161+
? null
162+
: flexRender(
163+
header.column.columnDef.footer,
164+
header.getContext(),
165+
)}
166+
</th>
167+
))}
168+
</tr>
169+
))}
170+
</tfoot>
171+
</table>
172+
</Card>
173+
</>
174+
);
175+
}
176+
177+
function TableBody<TData>({ table }: { table: TanstackTable<TData> }) {
178+
return (
179+
<tbody>
180+
{table.getRowModel().rows.map((row) => (
181+
<tr key={row.id} className="not-last:border-b border-gray-300">
182+
{row.getVisibleCells().map((cell) => (
183+
<td key={cell.id} className="text-left px-2 py-1">
184+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
185+
</td>
186+
))}
187+
</tr>
188+
))}
189+
</tbody>
190+
);
191+
}
192+
193+
//special memoized wrapper for our table body that we will use during column resizing
194+
export const MemoizedTableBody = memo(
195+
TableBody,
196+
(prev, next) => prev.table.options.data === next.table.options.data,
197+
) as typeof TableBody;
198+
199+
export { TableTest, Table };

pnpm-lock.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)