diff --git a/package-lock.json b/package-lock.json index 349bbdd..1cf3f31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ }, "peerDependencies": { "@canonical/react-components": "0.47.4", + "@tanstack/react-table": "^8.15.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "classnames": "^2.3.2", @@ -8583,6 +8584,39 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@tanstack/react-table": { + "version": "8.15.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz", + "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==", + "peer": true, + "dependencies": { + "@tanstack/table-core": "8.15.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.15.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz", + "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", diff --git a/package.json b/package.json index 7fbee4c..ea56c3b 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "react-dom": "18.2.0", "react-dropzone": "14.2.3", "react-router-dom": "^6.0.0", - "vanilla-framework": "^4.6.0" + "vanilla-framework": "^4.6.0", + "@tanstack/react-table": "^8.15.3" } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..f3561da --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,11 @@ +// Vanilla breakpoint values https://vanillaframework.io/docs/settings/breakpoint-settings +export const BREAKPOINTS = { + // Mobile (portrait) + xSmall: 460, + // Mobile or tablet + small: 620, + // Desktop + large: 1036, + // Large desktop + xLarge: 1681, +}; diff --git a/src/lib/components/DynamicTable/DynamicTable.scss b/src/lib/components/DynamicTable/DynamicTable.scss new file mode 100644 index 0000000..f825e66 --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.scss @@ -0,0 +1,53 @@ +@import "vanilla-framework"; + +.p-table-dynamic { + margin-bottom: 0; + + thead, + tbody { + display: block; + overflow: hidden auto; + } + + thead { + scrollbar-gutter: stable; + } + + thead tr, + tbody tr { + display: table; + table-layout: fixed; + width: 100%; + } + + tbody { + height: auto; + min-height: auto; + } + + &:not([aria-busy="true"]) tbody:not([aria-busy="true"]) { + tr:hover, + tr:focus-within { + @include vf-transition($property: #{background}, $duration: fast); + + background-color: $colors--light-theme--background-hover; + } + } + + thead th:last-child, + tbody td:last-child { + text-align: right; + } + + thead th button { + padding-top: 0; + margin-bottom: 0; + } +} + +.p-table-dynamic.p-table-dynamic--with-select { + thead th:first-child, + tbody td:first-child { + width: 3rem; + } +} diff --git a/src/lib/components/DynamicTable/DynamicTable.stories.tsx b/src/lib/components/DynamicTable/DynamicTable.stories.tsx new file mode 100644 index 0000000..cc05e0e --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.stories.tsx @@ -0,0 +1,56 @@ +import { Button, Icon } from "@canonical/react-components"; +import { Meta, StoryObj } from "@storybook/react"; + +import { DynamicTable } from "@/lib/components/DynamicTable/DynamicTable"; + +const data = Array.from({ length: 25 }, (_, index) => ({ + fqdn: `machine-${index}`, + ipAddress: `192.168.1.${index}`, + zone: `zone-${index}`, + owner: `owner-${index}`, +})); + +const meta: Meta = { + title: "components/DynamicTable", + component: DynamicTable, + tags: ["autodocs"], +}; + +export default meta; + +export const Example: StoryObj = { + args: { + className: "machines-table", + children: ( + <> + + + FQDN + IP address + Zone + Owner + Actions + + + + {data.map((item) => ( + + {item.fqdn} + {item.fqdn} + {item.fqdn} + {item.fqdn} + + + + + ))} + + + ), + }, +}; diff --git a/src/lib/components/DynamicTable/DynamicTable.test.tsx b/src/lib/components/DynamicTable/DynamicTable.test.tsx new file mode 100644 index 0000000..26f86cb --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.test.tsx @@ -0,0 +1,73 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { vi } from "vitest"; + +import { DynamicTable } from "./DynamicTable"; + +import { BREAKPOINTS } from "@/constants"; + +const offset = 100; + +beforeAll(() => { + // simulate top offset as JSDOM doesn't support getBoundingClientRect + // - equivalent of another element of height 100px being displayed above the table + vi.spyOn( + window.HTMLElement.prototype, + "getBoundingClientRect", + ).mockReturnValue({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: offset, + width: 0, + } as DOMRect); +}); + +it("sets a fixed table body height based on top offset on large screens", async () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(BREAKPOINTS.xSmall); + + await act(async () => { + fireEvent(window, new Event("resize")); + }); + + const { container } = render( + + + + Test content + + + , + ); + + const tbody = container.querySelector("tbody"); + + await act(async () => { + fireEvent(window, new Event("resize")); + }); + + // does not alter the height on small screens + expect(tbody).toHaveStyle("height: undefined"); + + vi.spyOn(window, "innerWidth", "get").mockReturnValue(BREAKPOINTS.large); + + await act(async () => { + fireEvent(window, new Event("resize")); + }); + + await vi.waitFor(() => + expect(tbody).toHaveStyle(`height: calc(100vh - ${offset + 1}px)`), + ); +}); + +it("displays loading state", () => { + const { container } = render( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(container.querySelector("tbody")).toHaveAttribute("aria-busy", "true"); + expect(screen.getAllByRole("row", { hidden: true })).toHaveLength(10); +}); diff --git a/src/lib/components/DynamicTable/DynamicTable.tsx b/src/lib/components/DynamicTable/DynamicTable.tsx new file mode 100644 index 0000000..0754b59 --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.tsx @@ -0,0 +1,129 @@ +import type { AriaAttributes, PropsWithChildren, RefObject } from "react"; +import { + useState, + useEffect, + useLayoutEffect, + useRef, + useCallback, +} from "react"; + +import type { RowData, Table } from "@tanstack/react-table"; +import classNames from "classnames"; + +import { BREAKPOINTS } from "@/constants"; +import { Placeholder } from "@/lib/elements"; +import "./DynamicTable.scss"; + +export type DynamicTableProps = PropsWithChildren<{ className?: string }>; + +/** + * A table based on tanstack/react-table with a fixed header, where the table body can be scrolled vertically independent of the page itself. + * + * @param className A class name to apply to the element + * @param children The markup of the table itself, composed of and DynamicTable.Body + * @returns + */ +export const DynamicTable = ({ + className, + children, + ...props +}: DynamicTableProps) => { + return ( +
+ {children} +
+ ); +}; + +const SkeletonRows = ({ columns }: { columns: Array<{ id: string }> }) => ( + <> + {Array.from({ length: 10 }, (_, index) => { + return ( + + {columns.map((column, columnIndex) => { + return ( + + + + ); + })} + + ); + })} + +); + +const DynamicTableLoading = ({ + className, + table, +}: { + className?: string; + table?: Table; + placeholderLengths?: { [key: string]: string }; +}) => { + const columns = table + ? table.getAllColumns() + : (Array.from({ length: 10 }).fill({ id: "" }) as Array<{ id: string }>); + + return ( + <> + Loading... + + + + + ); +}; +/** + * sets a fixed height for the table body + * allowing it to be scrolled independently of the page + */ +const DynamicTableBody = ({ + className, + children, + ...props +}: PropsWithChildren<{ className?: string } & AriaAttributes>) => { + const tableBodyRef: RefObject = useRef(null); + const [offset, setOffset] = useState(null); + + const handleResize = useCallback(() => { + if (window.innerWidth > BREAKPOINTS.small) { + const top = tableBodyRef.current?.getBoundingClientRect?.().top; + if (top) setOffset(top + 1); + } else { + setOffset(null); + } + }, []); + + useLayoutEffect(() => { + handleResize(); + }, [handleResize]); + + useEffect(() => { + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [handleResize]); + + return ( + + {children} + + ); +}; +DynamicTable.Body = DynamicTableBody; +DynamicTable.Loading = DynamicTableLoading; diff --git a/src/lib/components/DynamicTable/index.ts b/src/lib/components/DynamicTable/index.ts new file mode 100644 index 0000000..aab07d3 --- /dev/null +++ b/src/lib/components/DynamicTable/index.ts @@ -0,0 +1 @@ +export * from "./DynamicTable"; diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 047c010..cfa1bd9 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -3,3 +3,5 @@ export * from "./Pagination"; export * from "./Stepper"; export * from "./FileUpload"; export * from "./MultiSelect"; + +export * from "./DynamicTable"; \ No newline at end of file