Compare commits

...

21 commits

Author SHA1 Message Date
04d28f880f
Allow to set className of a Smartable.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-25 19:13:57 +02:00
6b96a85554
Add a way to disable sort and filter features with properties.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-25 18:51:26 +02:00
d13f56b24a
Add missing key of table rows.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-25 15:42:34 +02:00
79b616dd95
Fix row removal and add a demo table to test it.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-24 22:44:00 +02:00
649d608c89
Fix package externals for correct library build.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-23 17:02:09 +02:00
cefae670ab
Fix exported library classes.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-23 16:41:14 +02:00
2376d780cc
Add router dom to peer dependencies and fix library build configuration.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-23 10:57:13 +02:00
5e14ebf780
Set KernelUI Core as a peer dependency.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-09-23 00:17:15 +02:00
222242163d
Fix dev dependencies after changes in core package.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-09-23 00:08:07 +02:00
9ef04dbbe1
Version 1.0.0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-29 22:50:15 +02:00
0bb88b177f
Add paginated table content.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-29 11:46:03 +02:00
267afa68dd
Add column enum filter.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 15:41:10 +02:00
71b10a3c89
Reorganize columns sorting and filter systems.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 14:36:36 +02:00
519facc608
Add columns filters system and default StringFilter dans NumberFilter.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 14:18:17 +02:00
f25ca0cc2e
Add clickable cells and allow customizations on cells.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 12:49:24 +02:00
a82da6541d
Show column sort order.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 18:56:10 +02:00
e71d0aa446
Add a random table as demo table.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 14:34:49 +02:00
c8601aaa30
Improve loading rows and cells animations.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 14:13:37 +02:00
2753b6eb9f
Fix rows loading indefinitely when no rows are returned.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 14:05:42 +02:00
2513e711b1
Add multi-columns rows sorting with customizable comparison function for each column.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 13:45:25 +02:00
5ac1e4e7b8
Normalize async content handling with AsyncManager.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
+ Add AsyncManager which manages asynchronous table content for sorting and rendering.
2024-07-27 11:49:55 +02:00
25 changed files with 1790 additions and 508 deletions

View file

@ -1,10 +1,17 @@
import React from "react"; import React from "react";
import {Application} from "@kernelui/core"; import {Application} from "@kernelui/core";
import {DemoTable} from "./DemoTable";
import {RemoveDemoTable} from "./RemoveDemoTable";
export function DemoApp() export function DemoApp()
{ {
return ( return (
<Application> <Application>
<h1>Random table</h1>
<DemoTable />
<RemoveDemoTable />
</Application> </Application>
) )
} }

171
demo/DemoTable.tsx Normal file
View file

@ -0,0 +1,171 @@
import React, {useMemo} from "react";
import {createSmartable, SmartableColumns, SmartableData} from "../src/Smartable/Smartable";
import {createColumn, createColumns} from "../src/Smartable/Column";
import {RowDefinition} from "../src/Smartable/Row";
import {CellDefinition} from "../src/Smartable/Cell";
import {ClickableCell} from "../src/Smartable/Cells/ClickableCell";
import {Buttons, Modal, ModalType, useModals} from "@kernelui/core";
import {StringFilter} from "../src/Smartable/Filters/StringFilter";
import {NumberFilter} from "../src/Smartable/Filters/NumberFilter";
/**
* Some ants names.
*/
const names: string[] = [
"Formica rufa",
"Lasius niger",
"Camponotus pennsylvanicus",
"Solenopsis invicta",
"Atta cephalotes",
"Pogonomyrmex barbatus",
"Myrmica rubra",
"Dorymyrmex insanus",
"Pheidole megacephala",
"Crematogaster scutellaris",
"Tetramorium caespitum",
"Tapinoma sessile",
"Linepithema humile",
"Monomorium pharaonis",
"Odontomachus bauri",
"Paraponera clavata",
"Oecophylla smaragdina",
"Pseudomyrmex gracilis",
"Eciton burchellii",
"Anoplolepis gracilipes",
"Acromyrmex octospinosus",
"Acanthomyops claviger",
"Dorylus nigricans",
"Neivamyrmex nigrescens",
"Hypoponera punctatissima",
"Solenopsis geminata",
"Camponotus chromaiodes",
"Brachymyrmex depilis",
"Ectatomma ruidum",
"Proceratium silaceum",
"Cephalotes atratus",
"Neoponera villosa",
"Dinoponera gigantea",
"Prenolepis imparis",
"Lasius flavus",
"Formica fusca",
"Myrmecia gulosa",
"Solenopsis molesta",
"Camponotus herculeanus",
"Cataulacus granulatus",
"Daceton armigerum",
"Polyrhachis dives",
"Pheidole dentata",
"Tetramorium immigrans",
"Messor barbarus",
"Cardiocondyla obscurior",
"Nylanderia flavipes",
"Forelius pruinosus",
"Amblyopone pallipes"
];
// Create main table.
const Smartable = createSmartable({
columns: createColumns(
createColumn("name", {
title: "Name",
filter: StringFilter,
}),
createColumn("quantity", {
title: "Quantity",
filter: NumberFilter,
}),
createColumn("unit-price", {
title: "Unit price",
}),
createColumn("total-price", {
title: "Total",
}),
),
});
/**
* Generate a random quantity.
*/
export function randomQuantity(): number
{
return Math.floor(Math.random() * 8) + 1;
}
/**
* Generate a random unit price.
*/
export function randomPrice(): number
{
return Math.floor(Math.random() * 25) + 5;
}
/**
* Get a random name from `names` array.
*/
export function randomName(): string
{
return names[Math.floor(Math.random() * names.length)];
}
/**
* Generate a random computation time between 0 and 10s.
*/
export function randomComputationTime(): number
{
return Math.random() * 1000 * 10;
}
export function DemoTable()
{
const modals = useModals();
const demoDataPromise = useMemo<SmartableData<SmartableColumns<typeof Smartable>>>(() => (
new Promise((resolve) => {
// Resolving promise in 2s.
window.setTimeout(() => {
resolve(Array.from({ length: 43 }).map(() => {
// Compute random quantity and unit price.
const name = randomName();
const quantity = randomQuantity();
const price = randomPrice();
// Fake long computation of total price for each row.
const totalPricePromise = new Promise<CellDefinition<number>>((resolve) => {
window.setTimeout(() => {
return resolve({
data: quantity * price,
});
}, randomComputationTime());
});
return {
cells: {
name: {
data: name,
element: <ClickableCell onClick={() => {
const uuid = modals.open(<Modal type={ModalType.INFO} title={name}>
A great description about these ants.
<Buttons>
<button onClick={() => modals.close(uuid)}>OK</button>
</Buttons>
</Modal>);
}} />,
},
quantity: {
data: quantity,
},
"unit-price": {
data: price,
},
"total-price": totalPricePromise,
},
} as RowDefinition<SmartableColumns<typeof Smartable>>;
}));
}, 2000);
})
), []);
return (
<Smartable.Table data={demoDataPromise} paginate={{ pageSize: 6 }} />
);
}

95
demo/RemoveDemoTable.tsx Normal file
View file

@ -0,0 +1,95 @@
import React, {useState} from "react";
import {createSmartable} from "../src/Smartable/Smartable";
import {createColumn, createColumns} from "../src/Smartable/Column";
import {StringFilter} from "../src/Smartable/Filters/StringFilter";
import { Cell } from "../src/Smartable/Cell";
// Create main table.
const Smartable = createSmartable({
columns: createColumns(
createColumn("name", {
title: "Name",
filter: StringFilter,
}),
createColumn("actions", {
title: "Actions",
}),
),
});
export function RemoveDemoTable()
{
const [rows, setRows] = useState<string[]>([
"Formica rufa",
"Lasius niger",
"Camponotus pennsylvanicus",
"Solenopsis invicta",
"Atta cephalotes",
"Pogonomyrmex barbatus",
"Myrmica rubra",
"Dorymyrmex insanus",
"Pheidole megacephala",
"Crematogaster scutellaris",
"Tetramorium caespitum",
"Tapinoma sessile",
"Linepithema humile",
"Monomorium pharaonis",
"Odontomachus bauri",
"Paraponera clavata",
"Oecophylla smaragdina",
"Pseudomyrmex gracilis",
"Eciton burchellii",
"Anoplolepis gracilipes",
"Acromyrmex octospinosus",
"Acanthomyops claviger",
"Dorylus nigricans",
"Neivamyrmex nigrescens",
"Hypoponera punctatissima",
"Solenopsis geminata",
"Camponotus chromaiodes",
"Brachymyrmex depilis",
"Ectatomma ruidum",
"Proceratium silaceum",
"Cephalotes atratus",
"Neoponera villosa",
"Dinoponera gigantea",
"Prenolepis imparis",
"Lasius flavus",
"Formica fusca",
"Myrmecia gulosa",
"Solenopsis molesta",
"Camponotus herculeanus",
"Cataulacus granulatus",
"Daceton armigerum",
"Polyrhachis dives",
"Pheidole dentata",
"Tetramorium immigrans",
"Messor barbarus",
"Cardiocondyla obscurior",
"Nylanderia flavipes",
"Forelius pruinosus",
"Amblyopone pallipes",
]);
return (
<Smartable.Table data={rows.map((row, rowIndex) => ({
cells: {
name: {
data: row,
},
actions: {
data: null,
element: (
<Cell>
<button className={"remove"}
onClick={() => setRows(rows.toSpliced(rowIndex, 1))}>
Remove
</button>
</Cell>
)
},
},
}))} disableFilter={true} disableSort={true} />
);
}

View file

@ -1,2 +1,18 @@
import "./src/styles/smartable.less"; import "./src/styles/smartable.less";
export * from "./src/Smartable/AsyncManager";
export * from "./src/Smartable/Cell";
export * from "./src/Smartable/Column";
export * from "./src/Smartable/Instance";
export * from "./src/Smartable/Row";
export * from "./src/Smartable/Smartable";
export * from "./src/Smartable/Sort";
export * from "./src/Smartable/Cells/ClickableCell";
export * from "./src/Smartable/Columns/ColumnFilter";
export * from "./src/Smartable/Columns/ColumnHeading";
export * from "./src/Smartable/Filters/EnumFilter";
export * from "./src/Smartable/Filters/NumberFilter";
export * from "./src/Smartable/Filters/StringFilter";

View file

@ -1,5 +1,5 @@
{ {
"version": "1.0.0-rc1", "version": "1.1.1",
"name": "@kernelui/smartable", "name": "@kernelui/smartable",
"description": "Kernel UI Smartable.", "description": "Kernel UI Smartable.",
"scripts": { "scripts": {
@ -16,17 +16,23 @@
"publishConfig": { "publishConfig": {
"@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/" "@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/"
}, },
"dependencies": {
"@kernelui/core": "^1.1.0"
},
"devDependencies": { "devDependencies": {
"@kernelui/core": "^1.6.0",
"@phosphor-icons/react": "^2.1.7",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"less": "^4.2.0", "less": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-dts": "^3.9.1" "vite-plugin-dts": "^3.9.1"
}, },
"packageManager": "yarn@4.2.2" "peerDependencies": {
"@kernelui/core": "^1.1.2"
},
"packageManager": "yarn@4.5.0"
} }

View file

@ -0,0 +1,355 @@
import React, {useEffect, useRef, useState} from "react";
import {ColumnKey} from "./Column";
import {normalizeRowDefinition, RowData, RowDefinition} from "./Row";
import {CellDefinition} from "./Cell";
import {SmartableData} from "./Smartable";
import {Modify, Promisable} from "@kernelui/core";
/**
* Current Smartable data state.
*/
export interface CurrentTableData<CK extends ColumnKey>
{
/**
* Current rows state, undefined if they are still loading.
*/
rows?: CurrentRowData<CK>[];
}
/**
* Smartable current row cells data state.
*/
export type CurrentRowData<CK extends ColumnKey, T = any> = Modify<RowData<CK>, {
cells: Record<CK, CellDefinition<T>>;
}>;
/**
* Get current async table data manager.
*/
export function useAsyncManager<CK extends ColumnKey>(data: SmartableData<CK>): AsyncManager<CK>
{
// Get the main instance of async manager.
const asyncManager = useRef<AsyncManager<CK>>();
// Get the main async manager state.
const [currentDataState, setCurrentDataState] = useState<CurrentTableData<CK>>({
rows: undefined,
});
if (!asyncManager.current)
{
// Initialize a new async manager if there is none.
asyncManager.current = new AsyncManager<CK>();
}
// Update current data state and its dispatcher.
asyncManager.current.currentDataState = currentDataState;
asyncManager.current.setCurrentDataState = setCurrentDataState;
// When defined table data change, process async state again.
useEffect(() => {
// Process new data.
asyncManager.current.handle(data);
}, [asyncManager, data]);
// Return the main instance of async manager.
return asyncManager.current;
}
/**
* Smartable async data manager.
*/
class AsyncManager<CK extends ColumnKey>
{
currentDataState: CurrentTableData<CK>;
setCurrentDataState: React.Dispatch<React.SetStateAction<CurrentTableData<CK>>>;
/**
* Main promised data.
* @protected
*/
protected promisedData: Promised<SmartableData<CK>>;
/**
* Promised rows data.
* @protected
*/
protected promisedRows: Promised<RowDefinition<CK>>[] = [];
/**
* Promised rows cells data.
* @protected
*/
protected promisedRowsCells: Partial<Record<CK, Promised<CellDefinition>>>[] = [];
/**
* Tells if rows are loaded or not.
* @protected
*/
protected rowsLoaded: boolean = false;
/**
* Tells if rows need to be reinitialized or not.
* @protected
*/
protected reinitRows: boolean = false;
/**
* Rows data.
* @protected
*/
protected rowsData: RowData<CK>[] = [];
/**
* Rows cells definitions.
* @protected
*/
protected cellsDefinitions: Partial<Record<CK, CellDefinition<CK>>>[] = [];
constructor()
{
// Initialize promised data object.
this.promisedData = new Promised(this.handleNewRowsDefinitions.bind(this));
}
/**
* Handle new smartable data.
* @param data Smartable data.
*/
handle(data: SmartableData<CK>): void
{
this.rowsLoaded = false;
this.promisedData.refresh(data);
}
/**
* Handle new rows definitions.
* @param rowsDefinitions Rows definitions.
* @protected
*/
protected handleNewRowsDefinitions(rowsDefinitions: Promisable<RowDefinition<CK>>[]): void
{
// Ignore undefined value.
if (rowsDefinitions == undefined) return;
// Rows have been reinitialized.
this.reinitRows = true;
// Initialize rows data and cells definitions.
this.rowsData = [];
this.cellsDefinitions = [];
this.rowsLoaded = true;
for (const [rowId, rowDefinition] of rowsDefinitions.entries())
{ // Get row data of each row.
if (!this.promisedRows[rowId])
this.promisedRows[rowId] = new Promised(this.handleNewRow.bind(this, rowId), rowDefinition);
else
this.promisedRows[rowId].refresh(rowDefinition);
}
// Try to update the current data state.
this.tryToUpdate();
}
/**
* Handle a new row.
* @param rowId Row ID.
* @param row Row definition.
* @protected
*/
protected handleNewRow(rowId: number, row: RowDefinition<CK>): void
{
// Ignore undefined value.
if (row == undefined) return;
// Normalize row data.
const rowData = normalizeRowDefinition(row);
// Save row data.
this.rowsData[rowId] = rowData;
// Initialize cells definition.
this.cellsDefinitions[rowId] = {};
for (const [columnKey, cellDefinition] of Object.entries(rowData.cells) as [CK, CellDefinition][])
{ // Get cell definition of each row cell.
if (!this.promisedRowsCells[rowId]) this.promisedRowsCells[rowId] = {};
if (!this.promisedRowsCells[rowId][columnKey])
this.promisedRowsCells[rowId][columnKey] = new Promised(this.handleNewCell.bind(this, rowId, columnKey), cellDefinition);
else
this.promisedRowsCells[rowId][columnKey].refresh(cellDefinition);
}
// Try to update the current data state.
this.tryToUpdate();
}
/**
* Handle a new cell.
* @param rowId Cell row ID.
* @param columnKey Cell column key.
* @param cellDefinition Cell definition.
* @protected
*/
protected handleNewCell(rowId: number, columnKey: CK, cellDefinition: CellDefinition): void
{
// Ignore undefined value.
if (cellDefinition == undefined) return;
// Save cell definition.
this.cellsDefinitions[rowId][columnKey] = cellDefinition;
// Try to update the current data state.
this.tryToUpdate();
}
/**
* True if there is a pending update.
* @protected
*/
protected pendingUpdate: boolean = false;
/**
* Try to update the current data state, if there are no more changes in the next 25ms.
* The next update will be in max 25ms.
* @protected
*/
protected tryToUpdate(): void
{
if (!this.pendingUpdate)
// Try to update in the next 25ms.
setTimeout(() => {
this.pendingUpdate = false;
this.update();
}, 25);
}
/**
* Update the current data state with the loaded rows and cells data.
* @protected
*/
protected update(): void
{
if (!(
// Checking that there is at least one changed value.
this.reinitRows ||
this.rowsData.length > 0 || this.cellsDefinitions.some((rowCells) => (
Object.keys(rowCells).length > 0
))
|| ((this.rowsLoaded && !this.currentDataState.rows) || (!this.rowsLoaded && this.currentDataState.rows))
)) // Nothing has changed.
return;
// Initialize new data.
const newData = {
rows: !this.rowsLoaded ? undefined : this.reinitRows ? [] : [
...(this.currentDataState?.rows ?? [])
],
};
// Rows have been reinitialized.
this.reinitRows = false;
for (const [rowId, newRow] of this.rowsData?.entries())
{ // Update value of each new row.
newData.rows[rowId] = {
element: newRow.element,
cellElement: newRow.cellElement,
cells: newData.rows[rowId]?.cells,
};
}
for (const [rowId, rowCells] of this.cellsDefinitions?.entries())
{ // Update cells of each changed row.
newData.rows[rowId] = {
...newData.rows[rowId],
cells: {...(newData.rows[rowId]?.cells ?? {}), ...rowCells} as Record<CK, CellDefinition>,
}
}
// Update the current data state.
this.currentDataState = newData;
this.setCurrentDataState(
newData
);
}
}
/**
* Promised data class.
*/
export class Promised<T>
{
/**
* The main data promise.
*/
promise?: Promise<T>;
/**
* Data retrieved from promise or given in parameter.
*/
data?: T;
/**
* Called when data is changed.
*/
onData?: (data: T) => void;
constructor(onChanged?: (data: T) => void, data?: Promisable<T>)
{
this.onData = onChanged;
if (data)
this.refresh(data);
}
/**
* Refresh the promised data.
* @param data Promised data.
*/
refresh(data: Promisable<T>): this
{
if (data instanceof Promise)
{ // We have a promise of data.
if (data != this.promise)
{ // The promise is different from the saved one.
// Save the new promise and set data to undefined.
this.promise = data;
this.data = undefined;
this.onData?.(undefined);
// Wait for promise to resolve to get actual data.
this.promise.then((data) => {
// Data is retrieved, saving it.
this.data = data;
this.onData?.(data);
});
}
}
else if (data != this.data)
{ // We already have data, and it is different from the current state.
this.data = data;
this.onData?.(data);
}
return this;
}
/**
* Return true if some data (or its promise) have been provided.
*/
isInitialized(): boolean
{
return !!this.data || !!this.promise;
}
/**
* Return true if we are waiting for a promise result.
*/
isLoading(): boolean
{
return this.data === undefined && !!this.promise;
}
}

View file

@ -22,14 +22,14 @@ export interface CellDefinition<T = any>
/** /**
* Default cell component. * Default cell component.
*/ */
export function Cell({children}: React.PropsWithChildren<{}>) export function Cell({children, ...props}: React.PropsWithChildren<React.TdHTMLAttributes<HTMLTableCellElement>>)
{ {
// Get cell data. // Get cell data.
const {data} = useCell(); const {data} = useCell();
// Try to render cell data to string when no children given. // Try to render cell data to string when no children given.
return ( return (
<td>{children ?? String(data)}</td> <td {...props}>{children ?? String(data)}</td>
); );
} }
@ -67,3 +67,11 @@ export function CellInstance<T>({cell}: {cell: CellDefinition<T>})
</CellContext.Provider> </CellContext.Provider>
) )
} }
/**
* Animated cell loader.
*/
export function CellLoader()
{
return <td className={"generic loader"}></td>;
}

View file

@ -0,0 +1,17 @@
import React from "react";
import {Cell} from "../Cell";
import {Modify} from "@kernelui/core";
/**
* Clickable cell component.
*/
export function ClickableCell({role, children, ...props}: React.PropsWithChildren<Modify<React.TdHTMLAttributes<HTMLTableCellElement>, {
role?: never;
}>>)
{
return (
<Cell role={"button"} {...props}>
{children}
</Cell>
)
}

View file

@ -1,5 +1,7 @@
import React, {useCallback, useContext} from "react"; import React, {useCallback, useContext} from "react";
import {Smartable, useTable} from "./Smartable"; import {Smartable, useTable} from "./Smartable";
import {ColumnFilter} from "./Columns/ColumnFilter";
import {SortState} from "./Sort";
/** /**
* Basic column key type. * Basic column key type.
@ -25,7 +27,7 @@ export function createColumns<K extends ColumnKey>(...columns: [K, Column][]): C
/** /**
* Smartable column definition. * Smartable column definition.
*/ */
export interface Column export interface Column<T = any>
{ {
/** /**
* Column title element. * Column title element.
@ -36,6 +38,18 @@ export interface Column
* Column cell default element. * Column cell default element.
*/ */
cellElement?: React.ReactElement; cellElement?: React.ReactElement;
/**
* Cells data comparison in the column.
* @param a First data to compare.
* @param b Second data to compare.
*/
compare?: (a: T, b: T) => number;
/**
* Column filter definition.
*/
filter?: ColumnFilter;
} }
/** /**
@ -48,31 +62,6 @@ export function createColumn<K extends ColumnKey>(key: K, column: Column): [K, C
return [key, column]; return [key, column];
} }
/**
* Column sort type.
*/
export enum SortType
{
ASC = "asc",
DESC = "desc",
}
/**
* Column sort state.
*/
export interface SortState
{
/**
* Sort type (ascending or descending).
*/
type: SortType;
/**
* Sort order.
*/
order: number;
}
/** /**
* Table column context data. * Table column context data.
*/ */
@ -92,6 +81,17 @@ export interface ColumnContextData<CK extends ColumnKey>
* Column sort state. * Column sort state.
*/ */
sortState?: SortState; sortState?: SortState;
/**
* Column filter state.
*/
filterState: any;
/**
* Set current column filter state.
* @param filterState New filter state.
*/
setFilterState: <T = any>(filterState: T) => void;
} }
export const ColumnContext = React.createContext<ColumnContextData<ColumnKey>>(undefined); export const ColumnContext = React.createContext<ColumnContextData<ColumnKey>>(undefined);
@ -104,45 +104,6 @@ export function useColumn<CK extends ColumnKey>(smartable?: Smartable<CK>): Colu
return useContext(ColumnContext); return useContext(ColumnContext);
} }
/**
* Default column heading component.
*/
export function ColumnHeading()
{
// Get current column data.
const {key, column, sortState} = useColumn();
// Get column sort state setter.
const {setColumnSortState} = useTable();
// Initialize handle click function.
const handleClick = useCallback((event: React.MouseEvent) => {
if (event.button == 0)
{ // Normal click (usually left click).
// Toggle sort type.
setColumnSortState(key, sortState?.type == SortType.ASC ? SortType.DESC : SortType.ASC);
}
else if (event.button == 2 || event.button == 1)
{ // Alt click (usually right or middle click).
// Reset sort type.
setColumnSortState(key, null);
}
}, [key, sortState, setColumnSortState]);
// Disable context menu function.
const disableContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault();
return false;
}, []);
return (
<th className={sortState?.type ?? undefined} data-sort-order={sortState?.order ?? undefined}
onMouseDown={handleClick} onContextMenu={disableContextMenu}>
{column.title}
</th>
);
}
/** /**
* Auto column context provider from table context. * Auto column context provider from table context.
*/ */
@ -151,6 +112,12 @@ export function AutoColumnContextProvider({columnKey, children}: React.PropsWith
// Get table data. // Get table data.
const table = useTable(); const table = useTable();
// Initialize filterState dispatcher for the current column.
const setFilterState = useCallback((filterState: any) => (
// Set the filter state for the current column key.
table.setColumnFilterState(columnKey, filterState)
), [columnKey, table.setColumnFilterState]);
return ( return (
<ColumnContext.Provider value={{ <ColumnContext.Provider value={{
key: columnKey, key: columnKey,
@ -158,6 +125,10 @@ export function AutoColumnContextProvider({columnKey, children}: React.PropsWith
column: table.columns[columnKey], column: table.columns[columnKey],
// Get current column sort state from table data. // Get current column sort state from table data.
sortState: table.columnsSortState?.[columnKey], sortState: table.columnsSortState?.[columnKey],
// Get current column filter state from table data.
filterState: table.columnsFilterStates?.[columnKey],
// Current column filter state dispatcher.
setFilterState: setFilterState,
}}> }}>
{children} {children}
</ColumnContext.Provider> </ColumnContext.Provider>

View file

@ -0,0 +1,32 @@
import React from "react";
import {useColumn} from "../Column";
/**
* Column filter definition.
*/
export interface ColumnFilter<T = any, FilterState = any>
{
/**
* Visual element for the filter.
*/
element: React.ReactElement;
/**
* Data filter function.
* @param data Data to filter
* @param filterState Current filter state.
* @returns True when data should be kept, false otherwise.
*/
filter: (data: T, filterState: FilterState) => boolean;
}
/**
* Hook to get current column filter state.
*/
export function useFilterState<T = any>(initialValue: T): [T, (newFilterState: T) => void]
{
// Get current column data.
const column = useColumn();
// Return filter state array from current column data.
return [column.filterState ?? initialValue, column.setFilterState];
}

View file

@ -0,0 +1,45 @@
import React, {useCallback} from "react";
import {useColumn} from "../Column";
import {useTable} from "../Smartable";
import {SortType} from "../Sort";
/**
* Default column heading component.
*/
export function ColumnHeading()
{
// Get current column data.
const {key, column, sortState} = useColumn();
// Get column sort state setter.
const {setColumnSortState} = useTable();
// Initialize handle click function.
const handleClick = useCallback((event: React.MouseEvent) => {
if (event.button == 0)
{ // Normal click (usually left click).
// Toggle sort type.
setColumnSortState(key, sortState?.type == SortType.ASC ? SortType.DESC : SortType.ASC);
}
else if (event.button == 2 || event.button == 1)
{ // Alt click (usually right or middle click).
// Reset sort type.
setColumnSortState(key, null);
}
}, [key, sortState, setColumnSortState]);
// Disable context menu function.
const disableContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault();
return false;
}, []);
return (
<th className={sortState?.type ?? undefined} data-sort-order={sortState?.order ?? undefined}
onMouseDown={handleClick} onContextMenu={disableContextMenu}>
{column.title}
{sortState?.order && <span className={"order"}>{sortState.order}</span>}
</th>
);
}

View file

@ -0,0 +1,69 @@
import React, {useCallback} from "react";
import {ColumnFilter, useFilterState} from "../Columns/ColumnFilter";
import {Select} from "@kernelui/core";
/**
* String column filter.
*/
export function EnumFilter<OptionKey extends keyof any, Option>(options: Record<OptionKey, Option>): ColumnFilter<OptionKey[], EnumFilterState<OptionKey>>
{
return (
{
filter: (data: OptionKey|OptionKey[], filterState: EnumFilterState<OptionKey>) => {
// Normalized data array.
const dataArray = Array.isArray(data) ? data : [data];
// Nothing in filter value, allow everything.
if (!filterState?.value?.length) return true;
// Get current filter result.
return filterState?.value?.some(
// Keep the row if any element in data match the current filter value.
(optionKey) => dataArray.includes(optionKey)
);
},
element: <EnumFilterComponent options={options} />
}
);
}
/**
* Enumeration filter state.
*/
export interface EnumFilterState<OptionKey extends keyof any>
{
/**
* Filter value.
*/
value: OptionKey[];
}
/**
* Enum filter component.
*/
export function EnumFilterComponent<OptionKey extends keyof any, Option>({options}: {
/**
* Enum options.
*/
options: Record<OptionKey, Option>;
})
{
// Initialize enum filter state.
const [enumFilterState, setEnumFilterState] =
useFilterState<EnumFilterState<OptionKey>>({ value: [] });
// Handle filter input change.
const handleChange = useCallback((filter: OptionKey[]) => {
// Save the current filter value.
setEnumFilterState({
value: filter,
})
}, [setEnumFilterState]);
return (
<Select<OptionKey, Option> options={options} multiple={true} size={1}
className={!enumFilterState.value?.length ? "empty" : undefined}
value={enumFilterState.value} onChange={handleChange} />
)
}

View file

@ -0,0 +1,71 @@
import React, {useCallback} from "react";
import {ColumnFilter, useFilterState} from "../Columns/ColumnFilter";
/**
* Filter value regex.
*/
const filterRegex = /^([=!><])?([0-9]+)$/;
/**
* Number column filter.
*/
export const NumberFilter: ColumnFilter<number, NumberFilterState> = {
filter: (data: number, filterState: NumberFilterState) => {
// Read filter value.
const filterValue = filterRegex.exec(filterState.value);
// No valid filter value, allow everything.
if (!filterValue?.length) return true;
// Get current filter modifier and number.
const filterModifier = filterValue[1];
const filterNumber = Number.parseFloat(filterValue[2]);
// Return result based on modifier.
switch (filterModifier)
{
case ">":
return data > filterNumber;
case "<":
return data < filterNumber;
case "!":
return data != filterNumber;
case "=":
default:
return data == filterNumber;
}
},
element: <NumberFilterComponent />
};
/**
* Number filter state.
*/
export interface NumberFilterState
{
/**
* Filter value.
*/
value: string;
}
/**
* Number filter component.
*/
export function NumberFilterComponent()
{
// Initialize number filter state.
const [numberFilterState, setNumberFilterState] =
useFilterState<NumberFilterState>({ value: "" });
// Handle filter input change.
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
// Save the current filter value.
setNumberFilterState({
value: event.currentTarget.value,
})
}, [setNumberFilterState]);
return (
<input type={"text"} pattern={filterRegex.source} size={1}
value={numberFilterState.value} onChange={handleChange} />
);
}

View file

@ -0,0 +1,60 @@
import React, {useCallback} from "react";
import {ColumnFilter, useFilterState} from "../Columns/ColumnFilter";
import {normalizeString} from "@kernelui/core";
/**
* Filter value regex.
*/
const filterRegex = /^([=!])?([^=!].+)$/;
/**
* String column filter.
*/
export const StringFilter: ColumnFilter<string, StringFilterState> = {
filter: (data: string, filterState: StringFilterState) => {
// Read filter value.
const filterValue = filterRegex.exec(filterState.value);
// No valid filter value, allow everything.
if (!filterValue?.length) return true;
// Get current filter result.
const filterResult = normalizeString(data).includes(normalizeString(filterValue[2]));
// Invert filter result based on filter modifier.
return filterValue[1] == "!" ? !filterResult : filterResult;
},
element: <StringFilterComponent />
};
/**
* String filter state.
*/
export interface StringFilterState
{
/**
* Filter value.
*/
value: string;
}
/**
* String filter component.
*/
export function StringFilterComponent()
{
// Initialize string filter state.
const [stringFilterState, setStringFilterState] =
useFilterState<StringFilterState>({ value: "" });
// Handle filter input change.
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
// Save the current filter value.
setStringFilterState({
value: event.currentTarget.value,
})
}, [setStringFilterState]);
return (
<input type={"text"} pattern={filterRegex.source} size={1}
value={stringFilterState.value} onChange={handleChange} />
);
}

View file

@ -1,8 +1,11 @@
import React from "react"; import React, {useMemo} from "react";
import {AutoColumnContextProvider, Column, ColumnContext, ColumnHeading, ColumnKey, Columns} from "./Column"; import {AutoColumnContextProvider, Column, ColumnKey, Columns} from "./Column";
import {SmartableProperties, useTable} from "./Smartable"; import {SmartableProperties, useTable} from "./Smartable";
import {Async, Promisable} from "@kernelui/core"; import {RowInstance, RowLoader} from "./Row";
import {RowDefinition, RowInstance} from "./Row"; import {useAsyncManager} from "./AsyncManager";
import {ColumnHeading} from "./Columns/ColumnHeading";
import {sortRows} from "./Sort";
import {AutoPaginate, classes} from "@kernelui/core";
/** /**
* Smartable instance component properties. * Smartable instance component properties.
@ -18,15 +21,60 @@ export interface InstanceProperties<CK extends ColumnKey> extends SmartablePrope
/** /**
* Main component for a Smartable table. * Main component for a Smartable table.
*/ */
export function Instance<CK extends ColumnKey>({columns}: InstanceProperties<CK>) export function Instance<CK extends ColumnKey>(props: InstanceProperties<CK>)
{
if (props.paginate)
{ // If pagination is enabled.
return <PaginatedInstance {...props} />
}
else
{ // No pagination, simple body render.
return (
<Table {...props}>
<TableBody />
</Table>
);
}
}
/**
* Paginated Smartable instance component.
*/
export function PaginatedInstance<CK extends ColumnKey>(props: InstanceProperties<CK>)
{
// Get data from table.
const {data} = useTable<CK>();
// Get current data state from the async table value.
const {currentDataState} = useAsyncManager<CK>(data);
// Compute page count for the current rows.
const pageCount = Math.ceil((currentDataState?.rows?.length ?? 0) / props.paginate.pageSize);
return (
<AutoPaginate count={pageCount}>
{(page) => (
// Render table with table body of the current page.
<Table {...props}>
<TableBody pagination={{page: page, pageSize: props.paginate.pageSize}} />
</Table>
)}
</AutoPaginate>
);
}
/**
* Base component for a Smartable table.
*/
export function Table<CK extends ColumnKey>({className, columns, children}: React.PropsWithChildren<InstanceProperties<CK>>)
{ {
return ( return (
<table> <table className={classes("smartable", className)}>
<thead> <thead>
<ColumnsHeadings columns={columns} /> <ColumnsHeadings columns={columns} />
</thead> </thead>
<tbody> <tbody>
<TableBody /> {children}
</tbody> </tbody>
</table> </table>
); );
@ -37,41 +85,86 @@ export function Instance<CK extends ColumnKey>({columns}: InstanceProperties<CK>
*/ */
export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Columns<CK>}) export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Columns<CK>})
{ {
// Get feature disable options.
const {disableSort, disableFilter} = useTable<CK>();
return ( return (
<tr className={"headings"}> <>
<tr className={classes("headings", disableSort === true ? "disable-sort" : undefined)}>
{ // Showing title of each column. { // Showing title of each column.
Object.keys(columns).map((key) => ( Object.keys(columns).map((key) => (
<AutoColumnContextProvider key={key} columnKey={key}> <AutoColumnContextProvider key={key} columnKey={key}>
<ColumnHeading /> <ColumnHeading/>
</AutoColumnContextProvider> </AutoColumnContextProvider>
)) ))
} }
</tr> </tr>
); { // Add filters if filter feature is not disabled.
} disableFilter !== true && (
<tr className={"filters"}>
export function TableBody<CK extends ColumnKey>() { // Add columns filters, if there are some.
{ (Object.entries(columns) as [CK, Column][]).map(([columnKey, column]) => (
// Get data from table. column.filter && (
const {data} = useTable(); <AutoColumnContextProvider key={columnKey as string} columnKey={columnKey}>
<td>{column.filter.element}</td>
return ( </AutoColumnContextProvider>
<Async<(Promisable<RowDefinition<CK>>)[]> promise={data}> )
{(rowsData) => (
// Rendering defined rows.
<>
{ // Rendering each row.
rowsData.map((rowData, index) => (
// Rendering current row from its definition.
<Async<RowDefinition<CK>> key={index} promise={rowData}>
{(rowDefinition) => (
<RowInstance row={rowDefinition} />
)}
</Async>
)) ))
} }
</tr>
)
}
</> </>
)} );
</Async> }
/**
* Smartable table body.
*/
export function TableBody<CK extends ColumnKey>({pagination}: {
/**
* Current pagination state.
*/
pagination?: { page: number; pageSize: number; };
})
{
// Get data from table.
const {data, columns, columnsSortState, columnsFilterStates} = useTable<CK>();
// Get current data state from the async table value.
const {currentDataState} = useAsyncManager<CK>(data);
// Memorize filtered rows.
const filteredRows = useMemo(() => (
currentDataState.rows?.filter((row) => (
// Checking each row to keep only those which match the filters.
(Object.entries(columnsFilterStates) as [CK, any][]).every(([columnKey, filterState]) => (
// For each filter, keep the row if data match the current filter.
columns[columnKey].filter.filter(row.cells[columnKey].data, filterState)
))
))
), [currentDataState.rows, columnsFilterStates]);
// Memorize sorted rows.
let sortedRows = useMemo(() => (
// Sort rows with the current columns sort state.
sortRows<CK>(filteredRows, columns, columnsSortState)
), [filteredRows, columns, columnsSortState]);
if (pagination)
{ // If pagination is enabled, showing only content of the current page.
const startIndex = (pagination.page - 1) * pagination.pageSize;
sortedRows = sortedRows?.slice(startIndex, startIndex + pagination.pageSize);
}
return (
sortedRows ? (
sortedRows.map((rowData, index) => (
// Rendering each row from its definition.
<RowInstance key={index} row={rowData} />
))
) : (
<RowLoader />
)
); );
} }

View file

@ -1,8 +1,9 @@
import React, {useContext, useMemo} from "react"; import React, {useContext} from "react";
import {ColumnContext, ColumnKey} from "./Column"; import {AutoColumnContextProvider, ColumnContext, ColumnKey} from "./Column";
import {CellDefinition, CellInstance} from "./Cell"; import {CellDefinition, CellInstance, CellLoader} from "./Cell";
import {Smartable, useTable} from "./Smartable"; import {Smartable, useTable} from "./Smartable";
import {Async, Promisable} from "@kernelui/core"; import { Promisable} from "@kernelui/core";
import {CurrentRowData} from "./AsyncManager";
/** /**
* Smartable row cells. * Smartable row cells.
@ -35,10 +36,27 @@ export interface RowData<CK extends ColumnKey, T = any>
*/ */
export type RowDefinition<CK extends ColumnKey> = RowCells<CK>|RowData<CK>; export type RowDefinition<CK extends ColumnKey> = RowCells<CK>|RowData<CK>;
/**
* Normalize row definition to row data.
*/
export function normalizeRowDefinition<CK extends ColumnKey>(rowDefinition: RowDefinition<CK>): RowData<CK>
{
if (!("cells" in rowDefinition) || !Object.values(rowDefinition?.cells).some((cellData: Promisable<CellDefinition>) => (
cellData instanceof Promise || ("data" in cellData)
))) { // If the row definition doesn't form a RowData object (= it is a RowCell object), converting it.
rowDefinition = {
cells: rowDefinition as RowCells<CK>,
};
}
// Return changed row definition, or just keep the default one if it matched RowData.
return rowDefinition;
}
/** /**
* Table row context data. * Table row context data.
*/ */
export interface RowContextData<CK extends ColumnKey, T = any> extends RowData<CK, T> export interface RowContextData<CK extends ColumnKey, T = any> extends CurrentRowData<CK, T>
{ {
} }
@ -73,14 +91,14 @@ export function RowCells()
const {columns} = useTable(); const {columns} = useTable();
return ( return (
Object.entries(columns).map(([columnKey, column]) => ( Object.keys(columns).map((columnKey) => (
<ColumnContext.Provider key={columnKey} value={{ key: columnKey, column: column }}> <AutoColumnContextProvider key={columnKey} columnKey={columnKey}>
<Async<CellDefinition> promise={row.cells?.[columnKey] ?? { data: undefined }}> { // Show current cell.
{(cellDefinition) => ( row.cells?.[columnKey]
<CellInstance cell={cellDefinition} /> ? <CellInstance cell={row.cells?.[columnKey]} />
)} : <CellLoader />
</Async> }
</ColumnContext.Provider> </AutoColumnContextProvider>
)) ))
); );
} }
@ -88,21 +106,29 @@ export function RowCells()
/** /**
* Row instance component. * Row instance component.
*/ */
export function RowInstance<CK extends ColumnKey>({row}: { row: RowDefinition<CK> }) export function RowInstance<CK extends ColumnKey>({row}: { row: CurrentRowData<CK> })
{ {
// Get row context value from given row definition. // Get table row element.
const rowContextValue = useMemo(() => (
// If a simple RowCells<CK> object is given, converting it to a RowData<CK>
!("cells" in row) ? { cells: row } : row
), [row]);
const {rowElement} = useTable(); const {rowElement} = useTable();
return ( return (
<RowContext.Provider value={rowContextValue}> <RowContext.Provider value={row}>
{ // Trying to render row-specific element, then table-specific element, then default element. { // Trying to render row-specific element, then table-specific element, then default element.
rowContextValue.element ?? rowElement ?? <Row /> row.element ?? rowElement ?? <Row />
} }
</RowContext.Provider> </RowContext.Provider>
); );
} }
/**
* Animated row loader.
*/
export function RowLoader()
{
// Get table columns to get their count.
const {columns} = useTable();
return (
<tr><td colSpan={Object.keys(columns).length} className={"generic loader"}></td></tr>
)
}

View file

@ -1,8 +1,9 @@
import React, {useCallback, useContext, useMemo, useState} from "react"; import React, {useCallback, useContext, useMemo, useState} from "react";
import {Instance} from "./Instance"; import {Instance} from "./Instance";
import {ColumnKey, Columns, SortState, SortType} from "./Column"; import {ColumnKey, Columns} from "./Column";
import {RowDefinition} from "./Row"; import {RowDefinition} from "./Row";
import {Promisable} from "@kernelui/core"; import {Promisable} from "@kernelui/core";
import {SortState, SortType} from "./Sort";
/** /**
* Smartable data type. * Smartable data type.
@ -14,6 +15,11 @@ export type SmartableData<CK extends ColumnKey> = Promisable<(Promisable<RowDefi
*/ */
export interface SmartableProperties<CK extends ColumnKey> export interface SmartableProperties<CK extends ColumnKey>
{ {
/**
* Table custom class name.
*/
className?: string;
/** /**
* Table data. * Table data.
*/ */
@ -33,6 +39,26 @@ export interface SmartableProperties<CK extends ColumnKey>
* Default column heading element. * Default column heading element.
*/ */
columnHeadingElement?: React.ReactElement; columnHeadingElement?: React.ReactElement;
/**
* Table rows pagination.
*/
paginate?: {
/**
* Number of rows per page.
*/
pageSize: number;
};
/**
* Disable sort feature.
*/
disableSort?: boolean;
/**
* Disable filter feature.
*/
disableFilter?: boolean;
} }
/** /**
@ -45,6 +71,11 @@ export type Smartable<CK extends ColumnKey> = {
Table: React.FunctionComponent<SmartableProperties<CK>>; Table: React.FunctionComponent<SmartableProperties<CK>>;
}; };
/**
* Smartable columns infered type.
*/
export type SmartableColumns<CK> = CK extends Smartable<infer CK> ? CK : never;
/** /**
* Define a new Smartable. * Define a new Smartable.
*/ */
@ -81,10 +112,7 @@ export function createSmartable<CK extends ColumnKey>({columns}: {
return { return {
Table: (props: SmartableProperties<CK>) => { Table: (props: SmartableProperties<CK>) => {
// Initialize sort state. // Initialize sort state.
const [sortState, setSortState] = useState({} as Record<CK, SortState>); const [sortState, setSortState] = useState({} as Partial<Record<CK, SortState>>);
// Filter columns from the given property.
const filteredColumns = props.shownColumns ? filterColumns(columns, props.shownColumns) : columns;
// Set sort state of a specific column. // Set sort state of a specific column.
const setColumnSortState = useCallback((key: CK, sortType: SortType|null): void => { const setColumnSortState = useCallback((key: CK, sortType: SortType|null): void => {
@ -118,13 +146,33 @@ export function createSmartable<CK extends ColumnKey>({columns}: {
setSortState(newSortState); setSortState(newSortState);
}, [sortState, setSortState]); }, [sortState, setSortState]);
// Initialize filter states.
const [filterStates, setFilterStates] = useState<Partial<Record<CK, any>>>({});
// Set filter state of a specific column.
const setColumnFilterState = useCallback((key: CK, filterState: any) => {
setFilterStates(
{
// Copy the other filters states.
...filterStates,
// Set the filter state for the given column.
[key]: filterState,
}
);
}, [filterStates, setFilterStates]);
// Filter columns from the given property.
const filteredColumns = props.shownColumns ? filterColumns(columns, props.shownColumns) : columns;
// Initialize table context value. // Initialize table context value.
const contextValue = useMemo<TableContextData<CK>>(() => ({ const contextValue = useMemo<TableContextData<CK>>(() => ({
columns: filteredColumns, columns: filteredColumns,
columnsSortState: sortState, columnsSortState: sortState,
setColumnSortState: setColumnSortState, setColumnSortState: setColumnSortState,
columnsFilterStates: filterStates,
setColumnFilterState: setColumnFilterState,
...props, ...props,
}), [filteredColumns, sortState, setSortState, props]); }), [sortState, setSortState, filterStates, setColumnFilterState, filteredColumns, props]);
return ( return (
<TableContext.Provider value={contextValue}> <TableContext.Provider value={contextValue}>
@ -149,14 +197,26 @@ export interface TableContextData<CK extends ColumnKey> extends SmartablePropert
/** /**
* Current table columns sort state. * Current table columns sort state.
*/ */
columnsSortState: Record<CK, SortState>; columnsSortState: Partial<Record<CK, SortState>>;
/** /**
* Set current table columns sort state. * Set given table column sort state.
* @param key The column key for which to set the sort type. * @param key The column key for which to set the sort type.
* @param sortType The sort type to set for the given column. NULL to reset sort state. * @param sortType The sort type to set for the given column. NULL to reset sort state.
*/ */
setColumnSortState: (key: CK, sortType: SortType|null) => void; setColumnSortState: (key: CK, sortType: SortType|null) => void;
/**
* Current table columsn filter states.
*/
columnsFilterStates: Partial<Record<CK, any>>;
/**
* Set given table column filter state.
* @param key The column key for which to set the filter state.
* @param filterState The filter state to set for the given column.
*/
setColumnFilterState: (key: CK, filterState: any) => void;
} }
/** /**

79
src/Smartable/Sort.tsx Normal file
View file

@ -0,0 +1,79 @@
import {ColumnKey, Columns} from "./Column";
import {CurrentRowData} from "./AsyncManager";
/**
* Column sort type.
*/
export enum SortType
{
ASC = "asc",
DESC = "desc",
}
/**
* Column sort state.
*/
export interface SortState
{
/**
* Sort type (ascending or descending).
*/
type: SortType;
/**
* Sort order.
*/
order: number;
}
/**
* Sort the given rows from the columns sort state.
* @param rows Rows to sort.
* @param columns Columns definition.
* @param columnsSortState Columns current sort state.
*/
export function sortRows<CK extends ColumnKey>(rows: CurrentRowData<CK>[], columns: Columns<CK>, columnsSortState: Partial<Record<CK, SortState>>): CurrentRowData<CK>[]
{
// Normalize value to undefined when rows are not loaded.
if (!Array.isArray(rows)) return undefined;
// Get the sort column states following their priority order.
const orderedSortColumns = (Object.entries(columnsSortState) as [CK, SortState][]).sort((a, b) => (
a[1].order - b[1].order
));
// Sort the given rows.
return rows.toSorted((a, b) => {
for (const [columnKey, columnSort] of orderedSortColumns)
{
// Get the current comparison result for the given cells' data.
const comparison =
(columnSort.type == SortType.DESC ? 1 : -1) *
(columns[columnKey]?.compare ?? genericCompare)(a.cells[columnKey]?.data, b.cells[columnKey]?.data);
if (comparison != 0)
// If an order can be determined from the current comparison, returning it.
return comparison;
}
// The comparisons always returned 0.
return 0;
});
}
/**
* Generic comparison function for table data.
* @param a First data to compare.
* @param b Second data to compare.
*/
export function genericCompare(a: any, b: any): number
{
if (typeof a == "number" && typeof a == typeof b)
{ // Compare numbers.
return b - a;
}
else
{ // Compare data as strings.
return String(b).localeCompare(String(a));
}
}

11
src/styles/_cells.less Normal file
View file

@ -0,0 +1,11 @@
td[role="button"]
{
transition: background 0.2s ease;
&:hover
{
background: rgba(0, 0, 0, 0.1);
}
cursor: pointer;
}

30
src/styles/_filters.less Normal file
View file

@ -0,0 +1,30 @@
tr.filters
{ // Filters row style.
td
{
padding: 0.25em 0.125em;
&:first-child:not(:last-child)
{
padding-left: 0.25em;
}
input, .select
{
margin: 0;
width: 100%;
}
.select
{
ul.selected
{
margin: 0 0 0.25em 0;
}
&.empty ul.selected
{
margin: 0;
}
}
}
}

View file

@ -1,5 +1,12 @@
tr.headings tr.headings
{ {
&.disable-sort
{
th { pointer-events: none; }
}
&:not(.disable-sort)
{
th th
{ {
position: relative; position: relative;
@ -60,5 +67,23 @@ tr.headings
{ {
bottom: 0.5em; bottom: 0.5em;
} }
.order
{ // Sort order indicator.
position: relative;
top: 0;
bottom: 0;
display: block;
margin: auto 0;
padding: 0.3em 0.3em 0;
align-items: center;
justify-content: center;
color: var(--foreground-lightest);
font-size: 0.7em;
float: right;
}
}
} }
} }

4
src/styles/_loaders.less Normal file
View file

@ -0,0 +1,4 @@
tr > td.generic.loader:first-child:last-child
{
height: 3em;
}

View file

@ -1 +1,9 @@
@import "_headings"; table.smartable
{
margin: 0.5em auto;
@import "_cells";
@import "_filters";
@import "_headings";
@import "_loaders";
}

View file

@ -16,7 +16,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
fileName: "index", fileName: "index",
}, },
rollupOptions: { rollupOptions: {
external: ["react"], external: ["react", "react-dom", "react-router-dom", "@phosphor-icons/react", "@kernelui/core"],
}, },
}, },

667
yarn.lock

File diff suppressed because it is too large Load diff