diff --git a/chartlets.js/CHANGES.md b/chartlets.js/CHANGES.md
index a89a622..43bc6cc 100644
--- a/chartlets.js/CHANGES.md
+++ b/chartlets.js/CHANGES.md
@@ -6,6 +6,7 @@
* New (MUI) components
- `DataGrid`
- `Dialog`
+ - `Table`
## Version 0.1.3 (from 2025/01/28)
diff --git a/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx
new file mode 100644
index 0000000..e196d30
--- /dev/null
+++ b/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { Table } from "@/plugins/mui/Table";
+import { createChangeHandler } from "@/plugins/mui/common.test";
+
+describe("Table", () => {
+ const rows = [
+ ["John", "Doe"],
+ ["Johnie", "Undoe"],
+ ];
+ const columns = [
+ { id: "firstName", label: "First Name" },
+ { id: "lastName", label: "Last Name" },
+ ];
+
+ it("should render the Table component", () => {
+ render(
+
{}}
+ />,
+ );
+
+ const table = screen.getByRole("table");
+ expect(table).toBeDefined();
+ columns.forEach((column) => {
+ expect(screen.getByText(column.label)).toBeInTheDocument();
+ });
+ rows.forEach((row, index) => {
+ expect(screen.getByText(row[index])).toBeInTheDocument();
+ });
+ });
+
+ it("should not render the Table component when no columns provided", () => {
+ render( {}} />);
+
+ const table = screen.queryByRole("table");
+ expect(table).toBeNull();
+ });
+
+ it("should not render the Table component when no rows provided", () => {
+ render( {}} />);
+
+ const table = screen.queryByRole("table");
+ expect(table).toBeNull();
+ });
+
+ it("should call onChange on row click", () => {
+ const { recordedEvents, onChange } = createChangeHandler();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getAllByRole("row")[1]);
+ expect(recordedEvents.length).toEqual(1);
+ expect(recordedEvents[0]).toEqual({
+ componentType: "Table",
+ id: "table",
+ property: "value",
+ value: {
+ firstName: "John",
+ lastName: "Doe",
+ },
+ });
+ });
+});
diff --git a/chartlets.js/packages/lib/src/plugins/mui/Table.tsx b/chartlets.js/packages/lib/src/plugins/mui/Table.tsx
new file mode 100644
index 0000000..c5dbd78
--- /dev/null
+++ b/chartlets.js/packages/lib/src/plugins/mui/Table.tsx
@@ -0,0 +1,110 @@
+import {
+ Paper,
+ Table as MuiTable,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+} from "@mui/material";
+import type { ComponentProps, ComponentState } from "@/index";
+import type { SxProps } from "@mui/system";
+
+interface TableCellProps {
+ id: string | number;
+ size?: "medium" | "small";
+ align?: "inherit" | "left" | "center" | "right" | "justify";
+ sx?: SxProps;
+}
+
+interface TableColumn extends TableCellProps {
+ label: string;
+}
+
+interface TableState extends ComponentState {
+ rows?: (string | number | boolean | undefined)[][];
+ columns?: TableColumn[];
+ hover?: boolean;
+ stickyHeader?: boolean;
+}
+
+interface TableProps extends ComponentProps, TableState {}
+
+export const Table = ({
+ type,
+ id,
+ style,
+ rows,
+ columns,
+ hover,
+ stickyHeader,
+ onChange,
+}: TableProps) => {
+ if (!columns || columns.length === 0) {
+ return No columns provided.
;
+ }
+
+ if (!rows || rows.length === 0) {
+ return No rows provided.
;
+ }
+
+ const handleRowClick = (row: (string | number | boolean | undefined)[]) => {
+ const rowData = row.reduce(
+ (acc, cell, cellIndex) => {
+ const columnId = columns[cellIndex]?.id;
+ if (columnId) {
+ acc[columnId] = cell;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+ if (id) {
+ onChange({
+ componentType: type,
+ id: id,
+ property: "value",
+ value: rowData,
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ {columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+
+ {rows.map((row, row_index) => (
+ handleRowClick(row)}
+ >
+ {row?.map((item, item_index) => (
+
+ {item}
+
+ ))}
+
+ ))}
+
+
+
+ );
+};
diff --git a/chartlets.js/packages/lib/src/plugins/mui/index.ts b/chartlets.js/packages/lib/src/plugins/mui/index.ts
index 9b10ac9..52eec87 100644
--- a/chartlets.js/packages/lib/src/plugins/mui/index.ts
+++ b/chartlets.js/packages/lib/src/plugins/mui/index.ts
@@ -14,6 +14,7 @@ import { Typography } from "./Typography";
import { Slider } from "./Slider";
import { DataGrid } from "@/plugins/mui/DataGrid";
import { Dialog } from "@/plugins/mui/Dialog";
+import { Table } from "@/plugins/mui/Table";
export default function mui(): Plugin {
return {
@@ -31,6 +32,7 @@ export default function mui(): Plugin {
["Select", Select],
["Slider", Slider],
["Switch", Switch],
+ ["Table", Table],
["Tabs", Tabs],
["Typography", Typography],
],
diff --git a/chartlets.py/CHANGES.md b/chartlets.py/CHANGES.md
index 683b9a4..c770c13 100644
--- a/chartlets.py/CHANGES.md
+++ b/chartlets.py/CHANGES.md
@@ -3,6 +3,7 @@
* New (MUI) components
- `DataGrid`
- `Dialog`
+ - `Table`
## Version 0.1.3 (from 2025/01/28)
diff --git a/chartlets.py/chartlets/components/__init__.py b/chartlets.py/chartlets/components/__init__.py
index 6fdb1e3..80df8b1 100644
--- a/chartlets.py/chartlets/components/__init__.py
+++ b/chartlets.py/chartlets/components/__init__.py
@@ -15,6 +15,7 @@
from .slider import Slider
from .switch import Switch
from .datagrid import DataGrid
+from .table import Table
from .tabs import Tab
from .tabs import Tabs
from .typography import Typography
diff --git a/chartlets.py/chartlets/components/table.py b/chartlets.py/chartlets/components/table.py
new file mode 100644
index 0000000..2f0405a
--- /dev/null
+++ b/chartlets.py/chartlets/components/table.py
@@ -0,0 +1,43 @@
+from dataclasses import dataclass
+from typing import Literal, TypedDict, TypeAlias
+from chartlets import Component
+
+
+class TableCellProps(TypedDict, total=False):
+ """Represents common properties of a table cell."""
+
+ id: str | int | float
+ """The unique identifier for the cell."""
+
+ size: Literal['medium', 'small'] | str | None
+ """The size of the cell."""
+
+ align: Literal["inherit", "left", "center", "right", "justify"] | None
+ """The alignment of the cell content."""
+
+
+class TableColumn(TableCellProps):
+ """Defines a column in the table."""
+
+ label: str
+ """The display label for the column header."""
+
+
+TableRow: TypeAlias = list[list[str | int | float | bool | None]]
+
+
+@dataclass(frozen=True)
+class Table(Component):
+ """A basic Table with configurable rows and columns."""
+
+ columns: list[TableColumn] | None = None
+ """The columns to display in the table."""
+
+ rows: TableRow | None = None
+ """The rows of data to display in the table."""
+
+ hover: bool | None = None
+ """A boolean indicating whether to highlight a row when hovered over"""
+
+ stickyHeader: bool | None = None
+ """A boolean to set the header of the table sticky"""
diff --git a/chartlets.py/demo/my_extension/__init__.py b/chartlets.py/demo/my_extension/__init__.py
index e1d4538..265f76a 100644
--- a/chartlets.py/demo/my_extension/__init__.py
+++ b/chartlets.py/demo/my_extension/__init__.py
@@ -4,6 +4,7 @@
from .my_panel_3 import panel as my_panel_3
from .my_panel_4 import panel as my_panel_4
from .my_panel_5 import panel as my_panel_5
+from .my_panel_6 import panel as my_panel_6
ext = Extension(__name__)
ext.add(my_panel_1)
@@ -11,3 +12,4 @@
ext.add(my_panel_3)
ext.add(my_panel_4)
ext.add(my_panel_5)
+ext.add(my_panel_6)
diff --git a/chartlets.py/demo/my_extension/my_panel_6.py b/chartlets.py/demo/my_extension/my_panel_6.py
new file mode 100644
index 0000000..7622c67
--- /dev/null
+++ b/chartlets.py/demo/my_extension/my_panel_6.py
@@ -0,0 +1,58 @@
+from chartlets import Component, Input, Output
+from chartlets.components import Box, Typography, Table
+
+from server.context import Context
+from server.panel import Panel
+
+from chartlets.components.table import TableColumn, TableRow
+
+panel = Panel(__name__, title="Panel F")
+
+
+# noinspection PyUnusedLocal
+@panel.layout()
+def render_panel(
+ ctx: Context,
+) -> Component:
+ columns: list[TableColumn] = [
+ {"id": "id", "label": "ID", "sortDirection": "desc"},
+ {
+ "id": "firstName",
+ "label": "First Name",
+ "align": "left",
+ "sortDirection": "desc",
+ },
+ {"id": "lastName", "label": "Last Name", "align": "center"},
+ {"id": "age", "label": "Age"},
+ ]
+
+ rows: TableRow = [
+ ["1", "John", "Doe", 30],
+ ["2", "Jane", "Smith", 25],
+ ["3", "Peter", "Jones", 40],
+ ]
+
+ table = Table(id="table", rows=rows, columns=columns, hover=True)
+
+ title_text = Typography(id="title_text", children=["Basic Table"])
+ info_text = Typography(id="info_text", children=["Click on any row."])
+
+ return Box(
+ style={
+ "display": "flex",
+ "flexDirection": "column",
+ "width": "100%",
+ "height": "100%",
+ "gap": "6px",
+ },
+ children=[title_text, table, info_text],
+ )
+
+
+# noinspection PyUnusedLocal
+@panel.callback(Input("table"), Output("info_text", "children"))
+def update_info_text(
+ ctx: Context,
+ table_row: int,
+) -> list[str]:
+ return [f"The clicked row value is {table_row}."]
diff --git a/chartlets.py/tests/components/table_test.py b/chartlets.py/tests/components/table_test.py
new file mode 100644
index 0000000..0248c39
--- /dev/null
+++ b/chartlets.py/tests/components/table_test.py
@@ -0,0 +1,38 @@
+from chartlets.components.table import TableColumn, Table, TableRow
+from tests.component_test import make_base
+
+
+class TableTest(make_base(Table)):
+
+ def test_is_json_serializable_empty(self):
+ self.assert_is_json_serializable(
+ self.cls(),
+ {
+ "type": "Table",
+ },
+ )
+
+ columns: list[TableColumn] = [
+ {"id": "id", "label": "ID"},
+ {"id": "firstName", "label": "First Name"},
+ {"id": "lastName", "label": "Last Name"},
+ {"id": "age", "label": "Age"},
+ ]
+ rows: TableRow = [
+ ["John", "Doe", 30],
+ ["Jane", "Smith", 25],
+ ["Johnie", "Undoe", 40],
+ ]
+ hover: bool = True
+ style = {"background-color": "lightgray", "width": "100%"}
+
+ self.assert_is_json_serializable(
+ self.cls(columns=columns, rows=rows, style=style, hover=hover),
+ {
+ "type": "Table",
+ "columns": columns,
+ "rows": rows,
+ "style": style,
+ "hover": hover,
+ },
+ )