diff --git a/package-lock.json b/package-lock.json
index 65b0aef..67620e4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.0.0",
"license": "MIT",
"dependencies": {
- "lucide-react": "^0.428.0"
+ "lucide-react": "^0.428.0",
+ "pretty-ms": "^9.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.6.1",
@@ -7690,6 +7691,17 @@
"node": ">=6"
}
},
+ "node_modules/parse-ms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
+ "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -7973,6 +7985,20 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/pretty-ms": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz",
+ "integrity": "sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==",
+ "dependencies": {
+ "parse-ms": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
diff --git a/package.json b/package.json
index 378e77e..dc8a412 100644
--- a/package.json
+++ b/package.json
@@ -32,11 +32,11 @@
"React"
],
"dependencies": {
- "lucide-react": "^0.428.0"
+ "lucide-react": "^0.428.0",
+ "pretty-ms": "^9.1.0"
},
"peerDependencies": {
"@codex/sdk-js": "@codex/sdk-js#master",
- "@tanstack/react-query": "^5.51.24",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/src/components/Backdrop/backdrop.css b/src/components/Backdrop/backdrop.css
index 1a36b7d..8bb1052 100644
--- a/src/components/Backdrop/backdrop.css
+++ b/src/components/Backdrop/backdrop.css
@@ -10,3 +10,7 @@
display: block;
z-index: 1;
}
+
+.document-noOverflow {
+ overflow: hidden;
+}
diff --git a/src/components/SimpleText/SimpleText.tsx b/src/components/SimpleText/SimpleText.tsx
index 21615f2..9d8cd11 100644
--- a/src/components/SimpleText/SimpleText.tsx
+++ b/src/components/SimpleText/SimpleText.tsx
@@ -32,6 +32,8 @@ type Props = {
size?: "normal" | "small";
center?: boolean;
+
+ bold?: boolean;
};
export function SimpleText({
@@ -41,8 +43,9 @@ export function SimpleText({
size = "normal",
style,
children,
+ bold,
}: Props) {
- const c = `text text--${variant} ${className} ${center ? "text--center" : ""}`;
+ const c = `text text--${variant} ${className} ${center ? "text--center" : ""} ${bold ? "text--bold" : ""}`;
if (size === "small") {
return (
diff --git a/src/components/SimpleText/simpleText.css b/src/components/SimpleText/simpleText.css
index 6712fef..fbb6201 100644
--- a/src/components/SimpleText/simpleText.css
+++ b/src/components/SimpleText/simpleText.css
@@ -21,3 +21,7 @@
.text--warning {
color: var(--codex-color-warning);
}
+
+.text--bold {
+ font-weight: bold;
+}
diff --git a/src/components/Table/ActionCellRender.tsx b/src/components/Table/ActionCellRender.tsx
new file mode 100644
index 0000000..f0c6c03
--- /dev/null
+++ b/src/components/Table/ActionCellRender.tsx
@@ -0,0 +1,13 @@
+import { SimpleText } from "../SimpleText/SimpleText";
+
+export const ActionCellRender =
+ (action: string, onClick: (row: string[]) => void) =>
+ (_: string, row: string[]) => {
+ return (
+ onClick(row)} className="cell--action">
+
+ {action}
+
+
+ );
+ };
diff --git a/src/components/Table/BreakCellRender.tsx b/src/components/Table/BreakCellRender.tsx
new file mode 100644
index 0000000..f0e2cd8
--- /dev/null
+++ b/src/components/Table/BreakCellRender.tsx
@@ -0,0 +1,3 @@
+export const BreakCellRender = (val: string) => (
+ {val || " "}
+);
diff --git a/src/components/Table/CellRender.css b/src/components/Table/CellRender.css
new file mode 100644
index 0000000..c490888
--- /dev/null
+++ b/src/components/Table/CellRender.css
@@ -0,0 +1,22 @@
+.cell--break {
+ word-break: break-all;
+}
+
+.cell--action {
+ text-decoration: none;
+ transition: text-shadow 0.35s;
+}
+
+.cell--action:hover {
+ text-shadow: var(--codex-color-primary) 0px 0 20px;
+}
+
+.cell-state {
+ border-radius: var(--codex-border-radius);
+ padding: 0.5rem 0.75rem;
+}
+
+.cell-state--error {
+ background-color: var(--codex-color-error);
+ mix-blend-mode: difference;
+}
diff --git a/src/components/Table/CellRender.tsx b/src/components/Table/CellRender.tsx
new file mode 100644
index 0000000..c8809d2
--- /dev/null
+++ b/src/components/Table/CellRender.tsx
@@ -0,0 +1,10 @@
+import { ReactNode } from "react";
+import "./CellRender.css";
+
+export type CellRender = (
+ value: string,
+ row: string[],
+ index: number
+) => ReactNode;
+
+export const DefaultCellRender = (val: string) => val;
diff --git a/src/components/Table/DefaultCellRender.tsx b/src/components/Table/DefaultCellRender.tsx
new file mode 100644
index 0000000..5ec8c84
--- /dev/null
+++ b/src/components/Table/DefaultCellRender.tsx
@@ -0,0 +1,3 @@
+import "./CellRender.css";
+
+export const DefaultCellRender = (val: string) => val;
diff --git a/src/components/Table/DurationCellRender.tsx b/src/components/Table/DurationCellRender.tsx
new file mode 100644
index 0000000..a010b27
--- /dev/null
+++ b/src/components/Table/DurationCellRender.tsx
@@ -0,0 +1,10 @@
+import prettyMilliseconds from "pretty-ms";
+
+export function DurationCellRender(value: string) {
+ const ms = parseInt(value, 10);
+ if (isNaN(ms)) {
+ return "Nan";
+ }
+
+ return prettyMilliseconds(ms, { compact: true });
+}
diff --git a/src/components/Table/StateCellRender.tsx b/src/components/Table/StateCellRender.tsx
new file mode 100644
index 0000000..653bbcf
--- /dev/null
+++ b/src/components/Table/StateCellRender.tsx
@@ -0,0 +1,11 @@
+type Mapping = { [k: string]: "success" | "warning" | "error" | "default" };
+
+export const StateCellRender = (mapping: Mapping) => (value: string) => {
+ return (
+
+
+ {value}
+
+
+ );
+};
diff --git a/src/components/Table/Table.css b/src/components/Table/Table.css
new file mode 100644
index 0000000..3bec4f4
--- /dev/null
+++ b/src/components/Table/Table.css
@@ -0,0 +1,34 @@
+.table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.table-container {
+ border: 1px solid var(--codex-border-color);
+ background-color: var(--codex-background-secondary);
+ padding: 2rem;
+ border-radius: var(--codex-border-radius);
+ overflow-x: auto;
+}
+
+.table-theadTr {
+ border-bottom: 1px solid var(--codex-border-color);
+}
+
+.table-theadTh {
+ color: var(--codex-color-light);
+ font-weight: normal;
+ text-transform: uppercase;
+ font-size: 0.9rem;
+ text-align: left;
+ padding: 1rem;
+}
+
+.table-tbodyTr {
+ border-bottom: 1px solid var(--codex-border-color);
+}
+
+.table-tbodyTd {
+ text-align: left;
+ padding: 1rem;
+}
diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx
new file mode 100644
index 0000000..bdaca55
--- /dev/null
+++ b/src/components/Table/Table.tsx
@@ -0,0 +1,56 @@
+import { ReactNode } from "react";
+import "./Table.css";
+import { CellRender } from "./CellRender";
+
+type Props = {
+ /**
+ * List of header names
+ */
+ headers: string[];
+
+ /**
+ * The data are represented by a 2 dimensions array.
+ * Each row contains a dataset whose data structure is a string array.
+ */
+ data: string[][];
+
+ /**
+ * The cell render is an array of function that returns the cell data.
+ */
+ cells: CellRender[];
+
+ className?: string;
+};
+
+export function Table({ data, headers, cells, className }: Props) {
+ return (
+
+
+
+
+ {headers.map((col) => (
+
+ {col}
+ |
+ ))}
+
+
+
+ {data.map((row, index) => (
+
+ {headers.map((header, idx) => {
+ const render = cells[idx];
+
+ return (
+
+ {render(row[idx], row, index)}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
deleted file mode 100644
index ed17dc9..0000000
--- a/src/components/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export { Button } from "../components/Button/Button";
-export { ButtonIcon } from "../components/ButtonIcon/ButtonIcon";
-export { Input } from "../components/Input/Input";
-export { InputGroup } from "../components/InputGroup/InputGroup";
-export { SimpleText } from "../components/SimpleText/SimpleText";
-export { Upload } from "../components/Upload/Upload";
-export { Card } from "../components/Card/Card";
-export { Select } from "../components/Select/Select";
-export { Toast } from "../components/Toast/Toast";
-export { SpaceAllocation } from "../components/SpaceAllocation/SpaceAllocation";
-export { EmptyPlaceholder } from "../components/EmptyPlaceholder/EmptyPlaceholder";
-export { Dropdown, type DropdownOption } from "../components/Dropdown/Dropdown";
-export { Failure } from "../components/Failure/Failure";
-export { Alert } from "../components/Alert/Alert";
-export { Spinner } from "../components/Spinner/Spinner";
-export { WebFileIcon } from "../components/WebFileIcon/WebFileIcon";
-export { Stepper } from "../components/Stepper/Stepper";
-export { Backdrop } from "../components/Backdrop/Backdrop";
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..8d7d6a5
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,24 @@
+export { Button } from "./components/Button/Button";
+export { ButtonIcon } from "./components/ButtonIcon/ButtonIcon";
+export { Input } from "./components/Input/Input";
+export { InputGroup } from "./components/InputGroup/InputGroup";
+export { SimpleText } from "./components/SimpleText/SimpleText";
+export { Upload } from "./components/Upload/Upload";
+export { Card } from "./components/Card/Card";
+export { Select } from "./components/Select/Select";
+export { Toast } from "./components/Toast/Toast";
+export { SpaceAllocation } from "./components/SpaceAllocation/SpaceAllocation";
+export { EmptyPlaceholder } from "./components/EmptyPlaceholder/EmptyPlaceholder";
+export { Dropdown, type DropdownOption } from "./components/Dropdown/Dropdown";
+export { Failure } from "./components/Failure/Failure";
+export { Alert } from "./components/Alert/Alert";
+export { Spinner } from "./components/Spinner/Spinner";
+export { WebFileIcon } from "./components/WebFileIcon/WebFileIcon";
+export { Stepper } from "./components/Stepper/Stepper";
+export { Backdrop } from "./components/Backdrop/Backdrop";
+export { ActionCellRender } from "./components/Table/ActionCellRender";
+export { BreakCellRender } from "./components/Table/BreakCellRender";
+export { DefaultCellRender } from "./components/Table/CellRender";
+export { DurationCellRender } from "./components/Table/DurationCellRender";
+export { StateCellRender } from "./components/Table/StateCellRender";
+export { Table } from "./components/Table/Table";
diff --git a/stories/Table.stories.css b/stories/Table.stories.css
new file mode 100644
index 0000000..5657725
--- /dev/null
+++ b/stories/Table.stories.css
@@ -0,0 +1,3 @@
+.tableSmall {
+ width: 350px;
+}
diff --git a/stories/Table.stories.tsx b/stories/Table.stories.tsx
new file mode 100644
index 0000000..9d8d0dd
--- /dev/null
+++ b/stories/Table.stories.tsx
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Table } from "../src/components/Table/Table";
+import { DefaultCellRender } from "../src/components/Table/CellRender";
+import { ActionCellRender } from "../src/components/Table/ActionCellRender";
+import { BreakCellRender } from "../src/components/Table/BreakCellRender";
+import "./Table.stories.css";
+import { StateCellRender } from "../src/components/Table/StateCellRender";
+import prettyMilliseconds from "pretty-ms";
+import { DurationCellRender } from "../src/components/Table/DurationCellRender";
+
+console.info(prettyMilliseconds(1337000000n, { compact: true }));
+
+const meta = {
+ title: "Components/Table",
+ component: Table,
+ parameters: {
+ layout: "centered",
+ },
+ tags: ["autodocs"],
+ argTypes: {},
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ cells: [
+ DefaultCellRender,
+ DefaultCellRender,
+ DefaultCellRender,
+ ActionCellRender("Action", (row) => console.info(row)),
+ ],
+ data: [["Ox45678FDGHJKL", "My file", "Some data"]],
+ headers: ["id", "title", "other", "actions"],
+ },
+};
+
+export const Scroll: Story = {
+ args: {
+ className: "tableSmall",
+ cells: [
+ DefaultCellRender,
+ DefaultCellRender,
+ DefaultCellRender,
+ ActionCellRender("Action", (row) => console.info(row)),
+ ],
+ data: [["Ox45678FDGHJKLBSA21", "My file", "Some data"]],
+ headers: ["id", "title", "other", "actions"],
+ },
+};
+
+export const BreakableCell: Story = {
+ args: {
+ cells: [
+ BreakCellRender,
+ DefaultCellRender,
+ DefaultCellRender,
+ ActionCellRender("Action", (row) => console.info(row)),
+ ],
+ data: [["veryverylongvaluetobreak", "My file", "Some data"]],
+ headers: ["break", "title", "other", "actions"],
+ className: "tableSmall",
+ },
+};
+
+export const StateCell: Story = {
+ args: {
+ cells: [
+ DefaultCellRender,
+ DefaultCellRender,
+ StateCellRender({ cancelled: "error" }),
+ ActionCellRender("Action", (row) => console.info(row)),
+ ],
+ data: [["Ox45678FDGHJKL", "My file", "cancelled", "Action"]],
+ headers: ["id", "title", "state", "actions"],
+ },
+};
+
+export const DurationCell: Story = {
+ args: {
+ cells: [
+ DefaultCellRender,
+ DefaultCellRender,
+ DurationCellRender,
+ ActionCellRender("Action", (row) => console.info(row)),
+ ],
+ data: [["Ox45678FDGHJKL", "My file", "3600000", "Action"]],
+ headers: ["id", "title", "duration", "actions"],
+ },
+};
diff --git a/stories/Upload.stories.tsx b/stories/Upload.stories.tsx
index 67d5faf..df74034 100644
--- a/stories/Upload.stories.tsx
+++ b/stories/Upload.stories.tsx
@@ -1,4 +1,4 @@
-import type { Meta, StoryObj } from "@storybook/react";
+import type { Meta } from "@storybook/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UploadResponse } from "@codex/sdk-js";
import { Upload } from "../src/components/Upload/Upload";