diff --git a/README.md b/README.md index d954079..80d9723 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,57 @@ A simple table component for React. +## How to + +### Render columns + +It is advisable to define a columns variable with `useMemo()` so that it will +not be re-generated on each component render. +But take care what value you use to watch for changes! + +When working with async data with Tanstack query, it is best to use the `data` +attribute from useQuery. + +```tsx +const { data } = useQuery({ /* options */ }) +const columns = useMemo(() => [ + { + key: "name", + title: (state) => "User name", + /* etc */ + }, +], [data]); // <-- columns will only rerender when there's new data +``` + +### Sorting + +The data you provide to the table determines the content and order of the rows. +**Any of the `sort`-related props are only intended for displaying a sort state in the table header.** + +```tsx +const columns = useMemo(() => [ + { + title: (state) => ( + <> + {/* Only show an error when it is the sorted column and point it to the right direction */} + User Name {state.isSortedColumn + ? state.direction === "Asc" + ? "↓" + : "↑" + : ""} + + ), + /* etc */ + }, +], [data]); +``` + +The `onSorted` callback will be fired after you click a sortable header. +Use it to trigger a change of the data you pass to the table. + +Note that currently, when selecting a new column it will always default to `"Desc"`. +**This is rather limiting**: a PR will be made to fix this. + ## Development This component was developed [using Bun for running & package management](https://bun.sh), [BiomeJS for formatting and linting](https://biomejs.dev) [and `vite` for hosting](https://vite.dev). Run it locally using `bun run dev`. diff --git a/biome.json b/biome.json index 9541a13..0ca97eb 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -7,15 +7,13 @@ }, "files": { "ignoreUnknown": false, - "ignore": ["node_modules", "dist"] + "includes": ["**", "!**/node_modules", "!**/dist"] }, "formatter": { "enabled": true, "indentStyle": "tab" }, - "organizeImports": { - "enabled": true - }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "rules": { diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 0335c7d..e16e256 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dist/components/table/table-sort-button.js b/dist/components/table/table-sort-button.js index 6acd9b5..601ff14 100644 --- a/dist/components/table/table-sort-button.js +++ b/dist/components/table/table-sort-button.js @@ -5,10 +5,7 @@ import '../../assets/table-sort-button.css';const n = "_button_158ku_1", r = "_s sorted: r, desc: d, asc: _ -}, i = ({ - state: o, - children: s -}) => /* @__PURE__ */ c( +}, i = ({ state: o, children: s }) => /* @__PURE__ */ c( "div", { className: e([ diff --git a/dist/components/table/table.d.ts b/dist/components/table/table.d.ts index e168a4b..4a05628 100644 --- a/dist/components/table/table.d.ts +++ b/dist/components/table/table.d.ts @@ -42,5 +42,5 @@ type TableProps = Readonly<{ /** An element that renders its `data` in a grid-like fashion, with native * HTML table elements underneath to keep it accessible. */ -declare const Table: ({ data, columns, makeKey, className, empty }: TableProps) => import("react/jsx-runtime").JSX.Element; +declare const Table: ({ data, columns, makeKey, className, empty, }: TableProps) => import("react/jsx-runtime").JSX.Element; export default Table; diff --git a/dist/components/table/table.js b/dist/components/table/table.js index 4b54b3a..af0d035 100644 --- a/dist/components/table/table.js +++ b/dist/components/table/table.js @@ -1,52 +1,50 @@ -import { jsxs as p, jsx as r } from "react/jsx-runtime"; -import b from "classnames"; -import { useState as u } from "react"; -import '../../assets/table.css';const k = "_table_1hpbm_1", _ = "_sticky_1hpbm_39", x = "_header_1hpbm_45", a = { - table: k, - sticky: _, - header: x -}, C = ({ - data: i, +import { jsxs as b, jsx as n } from "react/jsx-runtime"; +import k from "classnames"; +import { useState as _ } from "react"; +import '../../assets/table.css';const x = "_table_1hpbm_1", v = "_sticky_1hpbm_39", D = "_header_1hpbm_45", a = { + table: x, + sticky: v, + header: D +}, j = ({ + data: r, columns: t, - makeKey: n, + makeKey: d, className: o, empty: f }) => { - const [d, h] = m(t); - return /* @__PURE__ */ p( + const [i, S] = N(t); + return /* @__PURE__ */ b( "table", { cellSpacing: "0", - className: b([a.table, o]), + className: k([a.table, o]), style: { - // @ts-ignore Typescript doesnt like custom css vars "--columns": t.length }, children: [ - /* @__PURE__ */ r("thead", { children: /* @__PURE__ */ r("tr", { children: t.map((e, c) => { - const s = c === (d == null ? void 0 : d.index), S = d == null ? void 0 : d.direction, y = e.onSorted ? "button" : "div"; - return /* @__PURE__ */ r( + /* @__PURE__ */ n("thead", { children: /* @__PURE__ */ n("tr", { children: t.map((e, c) => { + const s = c === (i == null ? void 0 : i.index), y = i == null ? void 0 : i.direction, p = e.onSorted ? "button" : "div"; + return /* @__PURE__ */ n( "th", { scope: "col", className: e.sticky ? a.sticky : void 0, - children: /* @__PURE__ */ r( - y, + children: /* @__PURE__ */ n( + p, { type: e.onSorted && "button", className: a.header, onClick: e.onSorted ? async () => { - const l = !s || (d == null ? void 0 : d.direction) !== "Desc" ? "Desc" : "Asc"; - await e.onSorted( - l - ), h({ + var h; + const l = !s || (i == null ? void 0 : i.direction) !== "Desc" ? "Desc" : "Asc"; + await ((h = e.onSorted) == null ? void 0 : h.call(e, l)), S({ index: c, direction: l }); } : void 0, children: typeof e.title == "function" ? e.title({ isSortedColumn: s, - direction: S + direction: y }) : e.title } ) @@ -54,31 +52,31 @@ import '../../assets/table.css';const k = "_table_1hpbm_1", _ = "_sticky_1hpbm_3 e.key ); }) }) }), - /* @__PURE__ */ r("tbody", { children: i != null && i.length ? i.map((e, c) => /* @__PURE__ */ r("tr", { children: t.map((s) => /* @__PURE__ */ r( + /* @__PURE__ */ n("tbody", { children: r != null && r.length ? r.map((e, c) => /* @__PURE__ */ n("tr", { children: t.map((s) => /* @__PURE__ */ n( "td", { className: s.sticky ? a.sticky : void 0, children: s.render(e) }, s.key - )) }, n(e, c))) : f }) + )) }, d(e, c))) : f }) ] } ); -}, m = (i) => u(() => { - const t = i.findIndex( +}, N = (r) => _(() => { + const t = r.findIndex( (o) => o.defaultSorted ); if (t === -1) return; - const n = i[t]; - return n.onSorted ? { - direction: n.defaultSorted === !0 ? "Desc" : n.defaultSorted || "Desc", + const d = r[t]; + return d.onSorted ? { + direction: d.defaultSorted === !0 ? "Desc" : d.defaultSorted || "Desc", index: t } : { index: t, - direction: typeof n.defaultSorted == "string" ? n.defaultSorted : void 0 + direction: typeof d.defaultSorted == "string" ? d.defaultSorted : void 0 }; }); export { - C as default + j as default }; diff --git a/dist/main.d.ts b/dist/main.d.ts index 17738ad..67d4f4a 100644 --- a/dist/main.d.ts +++ b/dist/main.d.ts @@ -1,2 +1,2 @@ -export { default as Table, type ColumnState, type OrderDirection, type TableColumn } from './components/table/table'; +export { type ColumnState, default as Table, type OrderDirection, type TableColumn, } from './components/table/table'; export { default as TableSortButton } from './components/table/table-sort-button'; diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..5e32d47 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,7 @@ +pre-commit: + parallel: true + jobs: + - run: bun run biome check --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} + - run: bun run prettier --check {staged_files} + glob: "**/*.{s,}css" + diff --git a/lib/components/table/table-sort-button.module.scss b/lib/components/table/table-sort-button.module.scss index 8715bdf..e482625 100644 --- a/lib/components/table/table-sort-button.module.scss +++ b/lib/components/table/table-sort-button.module.scss @@ -1,17 +1,17 @@ .button { - transition: all 0.1s ease; - opacity: 0.5; - display: inline-block; + transition: all 0.1s ease; + opacity: 0.5; + display: inline-block; } .sorted { - opacity: 1; + opacity: 1; } .desc { - transform: rotateX(0deg); + transform: rotateX(0deg); } .asc { - transform: rotateX(180deg); + transform: rotateX(180deg); } diff --git a/lib/components/table/table-sort-button.tsx b/lib/components/table/table-sort-button.tsx index 501c939..1a7fa76 100644 --- a/lib/components/table/table-sort-button.tsx +++ b/lib/components/table/table-sort-button.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; -import { ReactNode } from "react"; -import { ColumnState } from "./table"; +import type { ReactNode } from "react"; +import type { ColumnState } from "./table"; import style from "./table-sort-button.module.scss"; type TableSortButtonProps = Readonly<{ @@ -8,17 +8,14 @@ type TableSortButtonProps = Readonly<{ children?: ReactNode; }>; -const TableSortButton = ({ - state, - children -}: TableSortButtonProps) => ( +const TableSortButton = ({ state, children }: TableSortButtonProps) => (
+ state.direction && style[state.direction.toLowerCase()], + ])} + > {children}
); diff --git a/lib/components/table/table.module.scss b/lib/components/table/table.module.scss index 4332c19..0425adc 100644 --- a/lib/components/table/table.module.scss +++ b/lib/components/table/table.module.scss @@ -1,62 +1,59 @@ .table { - display: grid; - grid-template-columns: repeat( - var(--columns), - minmax(max-content, 1fr) - ); - place-items: center; - overflow-x: auto; - grid-auto-rows: min-content; - - // Prevent depth/z-plane fighting - isolation: isolate; - - thead, - tbody { - display: contents; - } - - tr { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / -1; - - &:hover td { - background-color: var(--color-primary-095); - } - } - - td { - padding: var(--space-x-small) var(--space-small); - display: flex; - flex-direction: row; - align-items: center; - } - - th { - padding: var(--space-small) 0; - font-weight: unset; - background-color: var(--color-primary-095); - border-bottom: 2px solid var(--color-neutral-080); - } - - td { - background: var(--color-white); - border-bottom: 2px solid var(--color-neutral-090); - font: var(--font-body-small-regular); - } + display: grid; + grid-template-columns: repeat(var(--columns), minmax(max-content, 1fr)); + place-items: center; + overflow-x: auto; + grid-auto-rows: min-content; + + // Prevent depth/z-plane fighting + isolation: isolate; + + thead, + tbody { + display: contents; + } + + tr { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + + &:hover td { + background-color: var(--color-primary-095); + } + } + + td { + padding: var(--space-x-small) var(--space-small); + display: flex; + flex-direction: row; + align-items: center; + } + + th { + padding: var(--space-small) 0; + font-weight: unset; + background-color: var(--color-primary-095); + border-bottom: 2px solid var(--color-neutral-080); + } + + td { + background: var(--color-white); + border-bottom: 2px solid var(--color-neutral-090); + font: var(--font-body-small-regular); + } } .sticky { - position: sticky; - inset-inline-start: 0; - z-index: 1; + position: sticky; + inset-inline-start: 0; + z-index: 1; } .header { - width: 100%; - border: none; - background: none; - text-align: start; - padding: 0; + width: 100%; + border: none; + background: none; + text-align: start; + padding: 0; } diff --git a/lib/components/table/table.tsx b/lib/components/table/table.tsx index 2adcd97..750534a 100644 --- a/lib/components/table/table.tsx +++ b/lib/components/table/table.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { ReactNode, useState } from "react"; +import { type ReactNode, useState } from "react"; import style from "./table.module.scss"; export type OrderDirection = "Asc" | "Desc"; @@ -17,9 +17,7 @@ export type TableColumn = Readonly<{ * * Can be any react node. */ - title: - | ((state: ColumnState) => ReactNode) - | ReactNode; + title: ((state: ColumnState) => ReactNode) | ReactNode; /** How to render the table cells. * @@ -28,9 +26,7 @@ export type TableColumn = Readonly<{ render: (cell: T) => ReactNode; /** The function to call when a column title is clicked to start sorting. */ - onSorted?: ( - direction: OrderDirection - ) => Promise | void; + onSorted?: (direction: OrderDirection) => Promise | void; /** Whether this column is sorted by default. * @@ -67,7 +63,7 @@ const Table = ({ columns, makeKey, className, - empty + empty, }: TableProps) => { const [sort, setSort] = useSort(columns); @@ -75,56 +71,49 @@ const Table = ({ + style={ + { + "--columns": columns.length, + } as React.CSSProperties + } + > {columns.map((column, columnIndex) => { - const isSortedColumn = - columnIndex === sort?.index; + const isSortedColumn = columnIndex === sort?.index; const direction = sort?.direction; - const Header = column.onSorted - ? "button" - : "div"; + const Header = column.onSorted ? "button" : "div"; return ( @@ -137,19 +126,16 @@ const Table = ({ ? empty : data.map((row, i) => ( - {columns.map(column => ( + {columns.map((column) => ( ))} - ))} + ))}
+ className={column.sticky ? style.sticky : undefined} + >
{ const newDirection = - !isSortedColumn || - sort?.direction !== "Desc" + !isSortedColumn || sort?.direction !== "Desc" ? "Desc" : "Asc"; - await column.onSorted!( - newDirection - ); + await column.onSorted?.(newDirection); setSort({ index: columnIndex, - direction: newDirection + direction: newDirection, }); - } + } : undefined - }> + } + > {typeof column.title === "function" ? column.title({ isSortedColumn, - direction - }) + direction, + }) : column.title}
+ className={column.sticky ? style.sticky : undefined} + > {column.render(row)}
); @@ -157,9 +143,7 @@ const Table = ({ export default Table; -const useSort = ( - columns: TableProps["columns"] -) => +const useSort = (columns: TableProps["columns"]) => useState< | Readonly<{ index?: number; @@ -168,30 +152,27 @@ const useSort = ( | undefined >(() => { const defaultSortedColumnIndex = columns.findIndex( - column => column.defaultSorted + (column) => column.defaultSorted, ); if (defaultSortedColumnIndex === -1) return; - const defaultSortedColumn = - columns[defaultSortedColumnIndex]!; + const defaultSortedColumn = columns[defaultSortedColumnIndex]; if (defaultSortedColumn.onSorted) { const direction = defaultSortedColumn.defaultSorted === true ? "Desc" - : defaultSortedColumn.defaultSorted || - "Desc"; + : defaultSortedColumn.defaultSorted || "Desc"; return { direction, - index: defaultSortedColumnIndex + index: defaultSortedColumnIndex, }; } return { index: defaultSortedColumnIndex, direction: - typeof defaultSortedColumn.defaultSorted === - "string" + typeof defaultSortedColumn.defaultSorted === "string" ? defaultSortedColumn.defaultSorted - : undefined + : undefined, }; }); diff --git a/lib/main.ts b/lib/main.ts index 03aa356..bf689fd 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,7 +1,7 @@ export { - default as Table, type ColumnState, + default as Table, type OrderDirection, - type TableColumn + type TableColumn, } from "./components/table/table"; export { default as TableSortButton } from "./components/table/table-sort-button"; diff --git a/package.json b/package.json index 8d07ca0..46dffee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-table", "private": false, - "version": "1.0.0", + "version": "1.0.2", "type": "module", "main": "dist/main.js", "types": "dist/main.d.ts", @@ -21,18 +21,20 @@ "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "sass": "^1.85.1" + "sass": "^1.92.1" }, "devDependencies": { - "@biomejs/biome": "1.9.4", - "@types/bun": "^1.2.2", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "glob": "^11.0.1", - "typescript": "~5.6.2", - "vite": "^6.0.5", - "vite-plugin-dts": "^4.5.0", - "vite-plugin-lib-inject-css": "^2.2.1" + "@biomejs/biome": "2.2.4", + "@types/bun": "^1.2.22", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "glob": "^11.0.3", + "lefthook": "^1.13.0", + "prettier": "^3.6.2", + "typescript": "~5.9.2", + "vite": "^6.3.6", + "vite-plugin-dts": "^4.5.4", + "vite-plugin-lib-inject-css": "^2.2.2" } } diff --git a/src/main.tsx b/src/main.tsx index e86d14b..ac9fad8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -51,6 +51,7 @@ const App = () => { Title v ), + defaultSorted: true, render: (post) => post.title, onSorted: (dir) => { setParams((params) => { diff --git a/tsconfig.json b/tsconfig.json index 2c8b8c2..2949031 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "esnext", "skipLibCheck": true, diff --git a/vite.config.ts b/vite.config.ts index aa72984..f787253 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ -import react from "@vitejs/plugin-react"; -import { glob } from "glob"; import * as Path from "node:path"; import * as NodeUrl from "node:url"; +import react from "@vitejs/plugin-react"; +import { glob } from "glob"; import { defineConfig } from "vite"; import dts from "vite-plugin-dts"; import { libInjectCss } from "vite-plugin-lib-inject-css"; @@ -9,49 +9,35 @@ import { libInjectCss } from "vite-plugin-lib-inject-css"; // https://dev.to/receter/how-to-create-a-react-component-library-using-vites-library-mode-4lma // https://vite.dev/config/ export default defineConfig({ - plugins: [ - react(), - dts({ include: ["lib"] }), - libInjectCss() - ], + plugins: [react(), dts({ include: ["lib"] }), libInjectCss()], server: { - open: true + open: true, }, build: { copyPublicDir: false, rollupOptions: { - external: [ - "classnames", - "react", - "react-dom", - "react/jsx-runtime" - ], + external: ["classnames", "react", "react-dom", "react/jsx-runtime"], input: Object.fromEntries( glob .sync("lib/**/*.{ts,tsx}", { - ignore: ["lib/**/*.d.ts"] + ignore: ["lib/**/*.d.ts"], }) .map((file: string) => [ Path.relative( "lib", - file.slice( - 0, - file.length - Path.extname(file).length - ) + file.slice(0, file.length - Path.extname(file).length), ), - NodeUrl.fileURLToPath( - new URL(file, import.meta.url) - ) - ]) + NodeUrl.fileURLToPath(new URL(file, import.meta.url)), + ]), ), output: { assetFileNames: "assets/[name][extname]", - entryFileNames: "[name].js" - } + entryFileNames: "[name].js", + }, }, lib: { entry: Path.resolve(__dirname, "lib/main.ts"), - formats: ["es"] - } - } + formats: ["es"], + }, + }, });