From db3397888ce30a998b905ca10e09f3168bb33c42 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 15 Jan 2026 00:17:43 +0900 Subject: [PATCH 1/4] Support remote mode --- docs/astro.config.mjs | 4 - .../content/docs/advanced/data-adapter.mdx | 261 ------------------ .../src/content/docs/guides/4-remote-data.mdx | 185 +++++++++++++ .../5-composable-ui.mdx} | 2 +- packages/core/src/plugin/Context.test.tsx | 1 + packages/core/src/table/SeizenTable.tsx | 55 +++- packages/core/src/table/index.ts | 1 + packages/core/src/table/styles.css.ts | 36 ++- .../core/src/table/useSeizenTable.test.ts | 214 ++++++++++++-- packages/core/src/table/useSeizenTable.ts | 80 +++++- 10 files changed, 543 insertions(+), 296 deletions(-) delete mode 100644 docs/src/content/docs/advanced/data-adapter.mdx create mode 100644 docs/src/content/docs/guides/4-remote-data.mdx rename docs/src/content/docs/{advanced/composable-api.mdx => guides/5-composable-ui.mdx} (99%) diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9fdd96f..774f8ed 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -100,10 +100,6 @@ export default defineConfig({ label: "Features", autogenerate: { directory: "features" }, }, - { - label: "Advanced", - autogenerate: { directory: "advanced" }, - }, { label: "Table API", items: [tableAPITypeDocSidebarGroup], diff --git a/docs/src/content/docs/advanced/data-adapter.mdx b/docs/src/content/docs/advanced/data-adapter.mdx deleted file mode 100644 index db01d11..0000000 --- a/docs/src/content/docs/advanced/data-adapter.mdx +++ /dev/null @@ -1,261 +0,0 @@ ---- -title: Data Adapter -description: Connect your SeizenTable to any backend with Data Adapters. ---- - -import { Aside } from '@astrojs/starlight/components'; - -## Why Data Adapters? - -Enterprise applications rarely work with static, in-memory data. Real-world scenarios involve: - -- **Remote data sources**: REST APIs, GraphQL endpoints, gRPC services -- **Server-side operations**: Pagination, sorting, and filtering handled by the backend -- **Complex state management**: Loading states, error handling, caching, optimistic updates -- **Protocol diversity**: Different backends require different query formats and response handling - -Without abstraction, you end up with: - -```tsx -// ❌ Tight coupling to specific API format -function UsersTable() { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - const [page, setPage] = useState(0); - - useEffect(() => { - setLoading(true); - fetch(`/api/users?page=${page}&limit=10`) - .then(res => res.json()) - .then(data => { - setData(data.items); - setLoading(false); - }); - }, [page]); - - // ... repeat for sorting, filtering, etc. -} -``` - -This approach has problems: -- API format is hardcoded throughout the component -- Switching backends requires rewriting data fetching logic -- Difficult to test and maintain -- No reusability across different tables - -## The Data Adapter Solution - -Data Adapters provide a **protocol-agnostic abstraction** for fetching and managing table data: - -```tsx -// ✅ Clean separation of concerns -const usersAdapter = GraphQLAdapter.configure({ - client: apolloClient, - query: GET_USERS_QUERY, - transform: (data) => ({ - data: data.users.nodes, - totalCount: data.users.totalCount, - }), -}); - -function UsersTable() { - const { state } = useAdapter(usersAdapter); - - return ( - - ); -} -``` - -Benefits: -- **Swap backends easily**: Change from REST to GraphQL without touching table code -- **Consistent API**: Same interface regardless of underlying protocol -- **Built-in state management**: Loading, error, pagination states handled automatically -- **Testable**: Mock adapters for testing without network calls - -## Pagination Modes - -Data Adapters support two pagination strategies: - -### Offset Pagination - -Traditional page-based pagination. Best for: -- Datasets where users need to jump to specific pages -- Backends that support `OFFSET` / `LIMIT` queries -- When total count is always available - -```typescript -interface OffsetPaginationState { - mode: "offset"; - pageIndex: number; // Current page (0-based) - pageSize: number; // Items per page -} -``` - -### Cursor Pagination - -Cursor-based pagination (common in GraphQL). Best for: -- Large or frequently-changing datasets -- Real-time data where offset would cause duplicates/gaps -- Backends that use cursor-based queries (Relay-style) - -```typescript -interface CursorPaginationState { - mode: "cursor"; - pageSize: number; - startCursor: string | null; - endCursor: string | null; - hasNextPage: boolean; - hasPreviousPage: boolean; -} -``` - -### Comparison - -| Feature | Offset Mode | Cursor Mode | -|---------|-------------|-------------| -| Jump to page | ✅ `goToPage(n)` | ❌ Not supported | -| Next/Previous | ✅ `nextPage()` / `previousPage()` | ✅ `nextPage()` / `previousPage()` | -| Total count | ✅ Always available | ⚠️ May be null | -| Performance | ⚠️ Slower on large datasets | ✅ Consistent performance | -| Real-time data | ⚠️ May skip/duplicate on changes | ✅ Stable cursor position | - -## Defining Custom Adapters - -Use `defineDataAdapter` to create custom adapters: - -```typescript -import { defineDataAdapter } from "@izumisy/seizen-table"; - -const MyCustomAdapter = defineDataAdapter({ - id: "my-custom-adapter", - - // Initialize adapter state - initialState: { - data: [], - totalCount: 0, - isLoading: false, - error: null, - pageIndex: 0, - pageSize: 10, - }, - - // Fetch implementation - fetch: async (state, options) => { - const response = await myApi.getItems({ - page: state.pageIndex, - limit: state.pageSize, - sort: state.sorting, - filter: state.filter, - }); - return { - data: response.items, - totalCount: response.total, - }; - }, -}); -``` - -## Built-in Adapters - -### GraphQL Adapter - -For Apollo Client or similar GraphQL clients: - -```typescript -import { GraphQLAdapter } from "@izumisy/seizen-datatable-adapter-graphql"; - -const usersAdapter = GraphQLAdapter.configure({ - client: apolloClient, - query: GET_USERS_QUERY, - variables: (state) => ({ - first: state.pageSize, - offset: state.pageIndex * state.pageSize, - filter: state.filter, - orderBy: state.sorting, - }), - transform: (data) => ({ - data: data.users.nodes, - totalCount: data.users.totalCount, - }), -}); -``` - -### REST/OpenAPI Adapter - -For REST APIs: - -```typescript -import { RESTAdapter } from "@izumisy/seizen-datatable-adapter-rest"; - -const usersAdapter = RESTAdapter.configure({ - baseUrl: "/api/users", - buildUrl: (state) => ({ - params: { - page: state.pageIndex, - limit: state.pageSize, - sort: state.sorting?.[0]?.id, - order: state.sorting?.[0]?.desc ? "desc" : "asc", - }, - }), - transform: (response) => ({ - data: response.items, - totalCount: response.total, - }), -}); -``` - -## Adapter API - -The adapter instance provides a framework-agnostic API: - -```typescript -// State (readonly) -adapter.state.data; // TData[] -adapter.state.totalCount; // number -adapter.state.isLoading; // boolean -adapter.state.error; // Error | null -adapter.state.pageIndex; // number -adapter.state.pageSize; // number - -// Actions -adapter.fetch(); // Fetch data -adapter.refetch(); // Refetch current page -adapter.setPageIndex(2); // Go to page -adapter.setPageSize(20); // Change page size -adapter.setFilter({ status: "active" }); // Set filter -adapter.setSorting([{ id: "name", desc: false }]); // Set sorting -``` - -## Usage with SeizenTable - -```tsx -import { useAdapter } from "@izumisy/seizen-table"; - -const usersAdapter = GraphQLAdapter.configure({ - client: apolloClient, - query: GET_USERS_QUERY, - // ... -}); - -function UsersTable() { - // useAdapter subscribes to state changes and triggers re-render - const { state, actions } = useAdapter(usersAdapter); - - return ( - - ); -} -``` - - diff --git a/docs/src/content/docs/guides/4-remote-data.mdx b/docs/src/content/docs/guides/4-remote-data.mdx new file mode 100644 index 0000000..c20448d --- /dev/null +++ b/docs/src/content/docs/guides/4-remote-data.mdx @@ -0,0 +1,185 @@ +--- +title: Remote Data +description: Learn how to integrate Seizen Table with external data sources like REST APIs, GraphQL, or TanStack DB. +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components'; + +Seizen Table supports a **Remote Mode** that enables seamless integration with external data sources. In this mode, filtering, sorting, and pagination are handled by your external data source (e.g., REST API, GraphQL, TanStack DB), while Seizen Table manages the UI state. + +## Enabling Remote Mode + +Enable Remote Mode by adding the `remote` option to `useSeizenTable`: + +```tsx +import { useSeizenTable, SeizenTable } from "@izumisy/seizen-table"; + +// Without pagination +const table = useSeizenTable({ + data: filteredUsers, // Pre-filtered data from external source + columns, + remote: true, +}); + +// With pagination (requires totalRowCount for page calculation) +const table = useSeizenTable({ + data: filteredUsers, + columns, + remote: { totalRowCount: 1000 }, +}); +``` + + + +## How It Works + +| Mode | Filtering/Sorting/Pagination | +|------|------------------------------| +| **Default** | TanStack Table processes data internally | +| **Remote** | External source processes data; UI state is synchronized | + +In Remote Mode: +1. User interacts with filter/sort/pagination controls +2. Internal state updates (for UI synchronization) +3. Events are emitted (`filter-change`, `sorting-change`, `pagination-change`) +4. Your app subscribes to events and updates the external data source +5. New `data` prop is passed to the table + +## Loading State + +Use the `loading` prop to show a loading overlay while fetching data: + +```tsx + + +// With custom loader +} +/> +``` + +## Complete Example + +Here's a complete example integrating with a REST API: + +```tsx +import { useState, useEffect } from "react"; +import { + useSeizenTable, + useSeizenTableEvent, + SeizenTable, + type ColumnFiltersState, + type SortingState, + type PaginationState, +} from "@izumisy/seizen-table"; +import { FilterPlugin } from "@izumisy/seizen-table-plugins"; + +function UsersTable() { + const [data, setData] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // State for external data fetching + const [filters, setFilters] = useState([]); + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20 + }); + + const table = useSeizenTable({ + data, + columns, + plugins: [FilterPlugin()], + remote: { totalRowCount: totalCount }, + }); + + // Subscribe to state change events + useSeizenTableEvent(table, "filter-change", setFilters); + useSeizenTableEvent(table, "sorting-change", setSorting); + useSeizenTableEvent(table, "pagination-change", setPagination); + + // Fetch data when state changes + useEffect(() => { + const controller = new AbortController(); + + const fetchData = async () => { + setIsLoading(true); + try { + const query = new URLSearchParams(); + + // Convert ColumnFiltersState to query params + filters.forEach((f) => { + const { operator, value } = f.value as { operator: string; value: string }; + query.append(`filter[${f.id}][${operator}]`, value); + }); + + // Add sorting + sorting.forEach((s) => { + query.append("sort", `${s.desc ? "-" : ""}${s.id}`); + }); + + // Add pagination + query.append("page", String(pagination.pageIndex + 1)); + query.append("limit", String(pagination.pageSize)); + + const res = await fetch(`/api/users?${query}`, { + signal: controller.signal + }); + const json = await res.json(); + + setData(json.items); + setTotalCount(json.total); + } catch (e) { + if ((e as Error).name !== "AbortError") { + console.error(e); + } + } finally { + setIsLoading(false); + } + }; + + fetchData(); + return () => controller.abort(); + }, [filters, sorting, pagination]); + + return ; +} +``` + +## Checking Remote Mode in Plugins + +Plugins can check if Remote Mode is enabled via `table.remote`: + +```tsx +function MyPluginComponent() { + const { table } = usePluginContext(); + + if (table.remote) { + // Adjust behavior for Remote Mode + // e.g., hide global search + return null; + } + + return ; +} +``` + +## CSS Variables + +Customize the loading overlay appearance: + +```css +:root { + --szui-loading-overlay-bg: rgba(255, 255, 255, 0.8); + --szui-spinner-size: 32px; + --szui-spinner-color: #3b82f6; +} +``` diff --git a/docs/src/content/docs/advanced/composable-api.mdx b/docs/src/content/docs/guides/5-composable-ui.mdx similarity index 99% rename from docs/src/content/docs/advanced/composable-api.mdx rename to docs/src/content/docs/guides/5-composable-ui.mdx index 7d95f42..64dced7 100644 --- a/docs/src/content/docs/advanced/composable-api.mdx +++ b/docs/src/content/docs/guides/5-composable-ui.mdx @@ -1,5 +1,5 @@ --- -title: Composable API +title: Composable UI description: Build custom SeizenTable layouts with composable components and plugin slots. --- diff --git a/packages/core/src/plugin/Context.test.tsx b/packages/core/src/plugin/Context.test.tsx index 2590cdd..549c47a 100644 --- a/packages/core/src/plugin/Context.test.tsx +++ b/packages/core/src/plugin/Context.test.tsx @@ -89,6 +89,7 @@ function createMockTableInstance( plugins: [], plugin: mockPlugin as any, eventBus: mockEventBus as any, + remote: false, _tanstackTable: {} as any, ...overrides, }; diff --git a/packages/core/src/table/SeizenTable.tsx b/packages/core/src/table/SeizenTable.tsx index fd8b75f..a0312a8 100644 --- a/packages/core/src/table/SeizenTable.tsx +++ b/packages/core/src/table/SeizenTable.tsx @@ -10,6 +10,7 @@ import { } from "./components"; import { Paginator } from "./components/Paginator"; import { SeizenTablePlugins } from "../plugin/SeizenTablePlugins"; +import * as styles from "./styles.css"; export interface PaginateOptions { /** @@ -40,6 +41,43 @@ export interface SeizenTableProps { * Pagination options */ paginate?: PaginateOptions; + + /** + * Show loading overlay on the table. + * Useful for Remote Mode when fetching data. + * + * @default false + */ + loading?: boolean; + + /** + * Custom loader component. Defaults to a built-in spinner. + * + * @example + * loaderComponent={} + */ + loaderComponent?: React.ReactNode; +} + +/** + * Default loading spinner component + */ +function DefaultLoader() { + return ( +
+ + + +
+ ); } /** @@ -86,6 +124,8 @@ export function SeizenTable({ table, className, paginate, + loading = false, + loaderComponent, }: SeizenTableProps) { const paginateEnabled = paginate?.enable ?? true; const paginateSizeOptions = paginate?.sizeOptions ?? [10, 20, 50, 100]; @@ -95,10 +135,17 @@ export function SeizenTable({ - - - - +
+ {loading && ( +
+ {loaderComponent ?? } +
+ )} + + + + +
{paginateEnabled && ( diff --git a/packages/core/src/table/index.ts b/packages/core/src/table/index.ts index 04a9789..034d214 100644 --- a/packages/core/src/table/index.ts +++ b/packages/core/src/table/index.ts @@ -21,6 +21,7 @@ export type { SeizenTableColumn, SeizenTableInstance, UseSeizenTableOptions, + RemoteOptions, } from "./useSeizenTable"; // Re-export PluginArgsRegistry for module augmentation diff --git a/packages/core/src/table/styles.css.ts b/packages/core/src/table/styles.css.ts index b9757d8..afa39b9 100644 --- a/packages/core/src/table/styles.css.ts +++ b/packages/core/src/table/styles.css.ts @@ -1,4 +1,4 @@ -import { style, globalStyle } from "@vanilla-extract/css"; +import { style, globalStyle, keyframes } from "@vanilla-extract/css"; /** * CSS Variables for theming @@ -54,6 +54,7 @@ export const mainContent = style({ // Wrapper for the main table export const tableWrapper = style({ + position: "relative", flex: 1, overflow: "auto", }); @@ -226,3 +227,36 @@ export const paginatorPageNumber = style({ whiteSpace: "nowrap", padding: "0 8px", }); +// ============================================================================= +// Loading Overlay Styles +// ============================================================================= + +export const loadingOverlay = style({ + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: `var(--szui-loading-overlay-bg, rgba(255, 255, 255, 0.8))`, + zIndex: 10, + backdropFilter: "blur(1px)", +}); + +const spinAnimation = keyframes({ + from: { transform: "rotate(0deg)" }, + to: { transform: "rotate(360deg)" }, +}); + +export const spinner = style({ + width: `var(--szui-spinner-size, 32px)`, + height: `var(--szui-spinner-size, 32px)`, + color: `var(--szui-spinner-color, #3b82f6)`, +}); + +// Global styles for spinner SVG animation +globalStyle(`${spinner} svg`, { + animation: `${spinAnimation} 1s linear infinite`, +}); diff --git a/packages/core/src/table/useSeizenTable.test.ts b/packages/core/src/table/useSeizenTable.test.ts index 3c17088..9f7c538 100644 --- a/packages/core/src/table/useSeizenTable.test.ts +++ b/packages/core/src/table/useSeizenTable.test.ts @@ -168,22 +168,6 @@ describe("useSeizenTable", () => { expect(selectedRows).toHaveLength(1); expect(selectedRows).toContainEqual(data[0]); }); - - it("should call onSelectionChange callback when selection changes", () => { - const data = createTestData(); - const columns = createTestColumns(); - const onSelectionChange = vi.fn(); - - const { result } = renderHook(() => - useSeizenTable({ data, columns, onSelectionChange }) - ); - - act(() => { - result.current.setSelectedRows([data[0]]); - }); - - expect(onSelectionChange).toHaveBeenCalled(); - }); }); // =========================================================================== @@ -699,4 +683,202 @@ describe("useSeizenTable", () => { expect(result.current.getData()).toEqual(newData); }); }); + + // =========================================================================== + // Remote Mode Tests + // =========================================================================== + + describe("Remote Mode", () => { + it("should set remote: false by default", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => useSeizenTable({ data, columns })); + + expect(result.current.remote).toBe(false); + }); + + it("should set remote: true when remote option is true", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: true }) + ); + + expect(result.current.remote).toBe(true); + }); + + it("should set remote: true when remote option has totalRowCount", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: { totalRowCount: 100 } }) + ); + + expect(result.current.remote).toBe(true); + }); + + it("should calculate pageCount from totalRowCount in Remote Mode", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: { totalRowCount: 55 } }) + ); + + // Default pageSize is 10, so 55 rows = 6 pages + const tanstackTable = result.current._tanstackTable; + expect(tanstackTable.getPageCount()).toBe(6); + }); + + it("should update pageCount when pageSize changes", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: { totalRowCount: 100 } }) + ); + + // Default pageSize is 10, so 100 rows = 10 pages + expect(result.current._tanstackTable.getPageCount()).toBe(10); + + // Change pageSize to 20 + act(() => { + result.current.setPageSize(20); + }); + + // Now 100 rows = 5 pages + expect(result.current._tanstackTable.getPageCount()).toBe(5); + }); + + it("should not filter data internally when remote is enabled", () => { + // Create data where only 2 rows would match a filter + const data: TestRow[] = [ + { id: 1, name: "Alice", age: 30, status: "active" }, + { id: 2, name: "Bob", age: 25, status: "inactive" }, + { id: 3, name: "Charlie", age: 35, status: "active" }, + ]; + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: true }) + ); + + // Apply a filter + act(() => { + result.current.setFilter([ + { id: "status", value: { operator: "equals", value: "active" } }, + ]); + }); + + // In Remote Mode, internal filtering is disabled (manualFiltering: true) + // So all rows should still be displayed + const tanstackTable = result.current._tanstackTable; + expect(tanstackTable.getRowModel().rows).toHaveLength(3); + }); + + it("should not sort data internally when remote is enabled", () => { + const data: TestRow[] = [ + { id: 3, name: "Charlie", age: 35, status: "active" }, + { id: 1, name: "Alice", age: 30, status: "active" }, + { id: 2, name: "Bob", age: 25, status: "inactive" }, + ]; + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: true }) + ); + + // Apply sorting + act(() => { + result.current.setSorting([{ id: "id", desc: false }]); + }); + + // In Remote Mode, internal sorting is disabled (manualSorting: true) + // So the order should remain as provided + const rows = result.current._tanstackTable.getRowModel().rows; + expect(rows[0].original.id).toBe(3); + expect(rows[1].original.id).toBe(1); + expect(rows[2].original.id).toBe(2); + }); + + it("should not paginate data internally when remote is enabled", () => { + // Create more than one page of data (default pageSize is 10) + const data: TestRow[] = Array.from({ length: 15 }, (_, i) => ({ + id: i + 1, + name: `User ${i + 1}`, + age: 20 + i, + status: "active", + })); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: { totalRowCount: 15 } }) + ); + + // In Remote Mode, internal pagination is disabled (manualPagination: true) + // So all 15 rows should be in the row model (data is pre-paginated externally) + const tanstackTable = result.current._tanstackTable; + expect(tanstackTable.getRowModel().rows).toHaveLength(15); + }); + + it("should still update internal filter state for UI sync", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: true }) + ); + + // Apply a filter + act(() => { + result.current.setFilter([ + { id: "status", value: { operator: "equals", value: "active" } }, + ]); + }); + + // Internal state should be updated for plugin UI sync + expect(result.current.getFilterState()).toEqual([ + { id: "status", value: { operator: "equals", value: "active" } }, + ]); + }); + + it("should still update internal sorting state for UI sync", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: true }) + ); + + // Apply sorting + act(() => { + result.current.setSorting([{ id: "name", desc: true }]); + }); + + // Internal state should be updated for plugin UI sync + expect(result.current.getSortingState()).toEqual([ + { id: "name", desc: true }, + ]); + }); + + it("should still update internal pagination state for UI sync", () => { + const data = createTestData(); + const columns = createTestColumns(); + + const { result } = renderHook(() => + useSeizenTable({ data, columns, remote: { totalRowCount: 100 } }) + ); + + // Change page + act(() => { + result.current.setPageIndex(3); + }); + + // Internal state should be updated for plugin UI sync + expect(result.current.getPaginationState().pageIndex).toBe(3); + }); + }); }); diff --git a/packages/core/src/table/useSeizenTable.ts b/packages/core/src/table/useSeizenTable.ts index fa922d0..de08cf5 100644 --- a/packages/core/src/table/useSeizenTable.ts +++ b/packages/core/src/table/useSeizenTable.ts @@ -32,6 +32,18 @@ export type SeizenTableColumn = ColumnDef; // useSeizenTable Types // ============================================================================= +/** + * Options for Remote Mode with pagination support. + * Use this when you need pagination in Remote Mode. + */ +export interface RemoteOptions { + /** + * Total row count for pagination calculation. + * Required when using pagination in Remote Mode. + */ + totalRowCount: number; +} + /** * Options for useSeizenTable hook */ @@ -42,7 +54,28 @@ export interface UseSeizenTableOptions { plugins?: SeizenTablePlugin[]; initialSelection?: RowSelectionState; enableMultiSelect?: boolean; - onSelectionChange?: (selection: TData[]) => void; + /** + * Enable Remote Mode. + * + * When enabled: + * - Internal filtering/sorting/pagination is disabled (TanStack Table manual* options) + * - `data` is expected to be pre-filtered/sorted/paginated by external source + * - `setFilter()`, `setSorting()`, `setPageIndex()` etc. emit events AND update internal state + * - Internal state is maintained for plugin UI synchronization + * - TanStack Table does NOT apply filtering/sorting/pagination to data + * + * Use with EventBus to integrate with external data sources: + * - `filter-change`: Emitted when filter state changes + * - `sorting-change`: Emitted when sorting changes + * - `pagination-change`: Emitted when pagination changes + * + * Usage: + * - `remote: true` - Enable without pagination support + * - `remote: { totalRowCount: 100 }` - Enable with pagination (totalRowCount required) + * + * @default false + */ + remote?: boolean | RemoteOptions; } /** @@ -227,6 +260,24 @@ export interface SeizenTableInstance { */ eventBus: EventBus; + // =========================================================================== + // Remote Mode + // =========================================================================== + + /** + * Whether table is in Remote Mode. + * Plugins can use this to adjust their behavior. + * + * @example + * ```tsx + * if (table.remote) { + * // Hide global search in Remote Mode + * return null; + * } + * ``` + */ + readonly remote: boolean; + // =========================================================================== // Advanced // =========================================================================== @@ -249,8 +300,13 @@ export function useSeizenTable({ plugins = [], initialSelection = {}, enableMultiSelect = true, - onSelectionChange, + remote = false, }: UseSeizenTableOptions): SeizenTableInstance { + // Remote Mode flags + const isRemote = remote === true || typeof remote === "object"; + const totalRowCount = + typeof remote === "object" ? remote.totalRowCount : undefined; + // Table state const [rowSelection, setRowSelection] = useState(initialSelection); @@ -319,6 +375,15 @@ export function useSeizenTable({ getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), + // Remote Mode: disable internal processing + manualFiltering: isRemote, + manualSorting: isRemote, + manualPagination: isRemote, + // For pagination in Remote Mode + pageCount: + isRemote && totalRowCount != null + ? Math.ceil(totalRowCount / pagination.pageSize) + : undefined, }); // Create SeizenTableInstance with helper methods @@ -405,6 +470,9 @@ export function useSeizenTable({ // Event bus eventBus, + // Remote Mode + remote: isRemote, + // TanStack Table instance for advanced usage _tanstackTable: tanstackTable, }; @@ -421,14 +489,8 @@ export function useSeizenTable({ columnOrder, plugin, eventBus, + isRemote, ]); - // Notify selection changes - useMemo(() => { - if (onSelectionChange) { - onSelectionChange(instance.getSelectedRows()); - } - }, [rowSelection, onSelectionChange, instance]); - return instance; } From a27bf19d2f36935573125546e898f2ab5bd1506a Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 15 Jan 2026 00:25:40 +0900 Subject: [PATCH 2/4] Add SeizenTable.Loader --- .../content/docs/guides/5-composable-ui.mdx | 32 ++++++++++- packages/core/src/table/SeizenTable.tsx | 57 +++++++++++++++---- packages/core/src/table/components/Table.tsx | 28 ++++++++- packages/core/src/table/components/index.ts | 2 +- packages/core/src/table/index.ts | 7 ++- 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/docs/src/content/docs/guides/5-composable-ui.mdx b/docs/src/content/docs/guides/5-composable-ui.mdx index 64dced7..18c8ec3 100644 --- a/docs/src/content/docs/guides/5-composable-ui.mdx +++ b/docs/src/content/docs/guides/5-composable-ui.mdx @@ -111,12 +111,13 @@ Core table structure components: |-----------|--------------|-------------| | `SeizenTable.Root` | `
` | Provider wrapper (required, provides context) | | `SeizenTable.Content` | `
` | Main content area (between side panels) | -| `SeizenTable.Table` | `` | Pure `
` element wrapper | +| `SeizenTable.Table` | `
` + `
` | Table wrapper with scrollable container | | `SeizenTable.Header` | `` | Column headers with sorting and context menu | | `SeizenTable.Body` | `` | Row rendering with render props support | | `SeizenTable.Row` | `` | Individual row with click handlers | | `SeizenTable.Cell` | `
` | Individual cell with context menu support | | `SeizenTable.Paginator` | `
` | Pagination controls | +| `SeizenTable.Loader` | `
` | Loading overlay for Remote Mode | ### SeizenTablePlugins @@ -138,6 +139,7 @@ SeizenTable.Root (providers + context) ├─ SeizenTable.Content (main content area) │ ├─ SeizenTablePlugins.Header (plugin-provided toolbars) │ ├─ SeizenTable.Table +│ │ ├─ SeizenTable.Loader (optional, via `before` prop) │ │ ├─ SeizenTable.Header │ │ └─ SeizenTable.Body │ │ ├─ SeizenTable.Row @@ -148,6 +150,34 @@ SeizenTable.Root (providers + context) └─ SeizenTablePlugins.SidePanel (position: "right") ``` +## Loading Overlay + +For [Remote Mode](/seizen-table/guides/4-remote-data/), use `SeizenTable.Loader` to show a loading state while fetching data: + +```tsx +const [isLoading, setIsLoading] = useState(false); + +}> + + + +``` + +Custom loader component: + +```tsx + + + + } +> + + + +``` + ## Customization ### Custom Row Rendering diff --git a/packages/core/src/table/SeizenTable.tsx b/packages/core/src/table/SeizenTable.tsx index a0312a8..9f91858 100644 --- a/packages/core/src/table/SeizenTable.tsx +++ b/packages/core/src/table/SeizenTable.tsx @@ -26,6 +26,19 @@ export interface PaginateOptions { sizeOptions?: number[]; } +export interface LoaderProps { + /** + * Whether to show the loading overlay. + * @default true + */ + loading?: boolean; + + /** + * Custom loader component. Defaults to a built-in spinner. + */ + children?: React.ReactNode; +} + export interface SeizenTableProps { /** * The table instance from useSeizenTable @@ -62,7 +75,7 @@ export interface SeizenTableProps { /** * Default loading spinner component */ -function DefaultLoader() { +function DefaultSpinner() { return (
@@ -80,6 +93,30 @@ function DefaultLoader() { ); } +/** + * Loading overlay component for Composable UI. + * Use with `SeizenTable.Table`'s `before` prop to show loading state. + * + * @example + * ```tsx + * }> + * + * + * + * ``` + */ +function Loader({ loading = true, children }: LoaderProps) { + if (!loading) { + return null; + } + + return ( +
+ {children ?? } +
+ ); +} + /** * SeizenTable component with default UI rendering (High-Level API) * Uses semantic HTML table elements with CSS Variables for theming @@ -135,17 +172,12 @@ export function SeizenTable({ -
- {loading && ( -
- {loaderComponent ?? } -
- )} - - - - -
+ {loaderComponent}} + > + + + {paginateEnabled && ( @@ -171,3 +203,4 @@ SeizenTable.Body = TableBody; SeizenTable.Row = TableRow; SeizenTable.Cell = TableCell; SeizenTable.Paginator = Paginator; +SeizenTable.Loader = Loader; diff --git a/packages/core/src/table/components/Table.tsx b/packages/core/src/table/components/Table.tsx index 19bfd4b..53c0662 100644 --- a/packages/core/src/table/components/Table.tsx +++ b/packages/core/src/table/components/Table.tsx @@ -1,14 +1,40 @@ import * as styles from "../styles.css"; +export interface TableTableProps { + children?: React.ReactNode; + /** + * Additional elements to render inside the table wrapper (e.g., Loader). + * These are rendered before the table element. + */ + before?: React.ReactNode; +} + /** * Table component that renders the HTML table. * * This component should be used inside SeizenTable.Content to handle * the layout with side panels. + * + * @example Basic usage + * ```tsx + * + * + * + * + * ``` + * + * @example With loader + * ```tsx + * }> + * + * + * + * ``` */ -export function TableTable({ children }: React.PropsWithChildren) { +export function TableTable({ children, before }: TableTableProps) { return (
+ {before} {children}
); diff --git a/packages/core/src/table/components/index.ts b/packages/core/src/table/components/index.ts index 4ae9d7b..a203f90 100644 --- a/packages/core/src/table/components/index.ts +++ b/packages/core/src/table/components/index.ts @@ -1,6 +1,6 @@ export { TableRoot, useSeizenTableContext, type TableRootProps } from "./Root"; export { TableContent } from "./Content"; -export { TableTable } from "./Table"; +export { TableTable, type TableTableProps } from "./Table"; export { TableHeader } from "./Header"; export { TableBody } from "./Body"; export { TableRow, type TableRowProps } from "./Row"; diff --git a/packages/core/src/table/index.ts b/packages/core/src/table/index.ts index 034d214..0d55070 100644 --- a/packages/core/src/table/index.ts +++ b/packages/core/src/table/index.ts @@ -3,7 +3,11 @@ export { Paginator } from "./components/Paginator"; export { useSeizenTable } from "./useSeizenTable"; export { useSeizenTableEvent } from "./useSeizenTableEvent"; export { useSeizenTableContext } from "./components/Root"; -export type { SeizenTableProps, PaginateOptions } from "./SeizenTable"; +export type { + SeizenTableProps, + PaginateOptions, + LoaderProps, +} from "./SeizenTable"; export type { PaginatorProps } from "./components/Paginator"; // Compound component types @@ -11,6 +15,7 @@ export type { TableRootProps, TableRowProps, TableCellProps, + TableTableProps, } from "./components"; // Hooks - re-export from plugin for convenience From 1788c9b424668ed75266ff96ce3ea8261f15f5be Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 15 Jan 2026 00:26:52 +0900 Subject: [PATCH 3/4] Tweak --- packages/core/src/table/components/Table.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/table/components/Table.tsx b/packages/core/src/table/components/Table.tsx index 53c0662..4176cfe 100644 --- a/packages/core/src/table/components/Table.tsx +++ b/packages/core/src/table/components/Table.tsx @@ -1,7 +1,6 @@ import * as styles from "../styles.css"; export interface TableTableProps { - children?: React.ReactNode; /** * Additional elements to render inside the table wrapper (e.g., Loader). * These are rendered before the table element. @@ -31,7 +30,10 @@ export interface TableTableProps { * * ``` */ -export function TableTable({ children, before }: TableTableProps) { +export function TableTable({ + children, + before, +}: React.PropsWithChildren) { return (
{before} From 98f11ada8ad8e8a33b9b8617cccedea7423b05bd Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 15 Jan 2026 00:32:53 +0900 Subject: [PATCH 4/4] Fix links --- docs/src/content/docs/index.mdx | 8 ++++---- docs/src/content/docs/installation.mdx | 2 +- docs/src/content/docs/overview.mdx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 96c38ea..a5f95e7 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -75,15 +75,15 @@ import { DemoPreview } from '@components/DemoPreview'; Three levels of API abstraction—from zero-config to fully custom layouts with composable components. - Learn more → + Learn more → Type-safe event bus for reactive table interactions. Subscribe to built-in and custom plugin events with full TypeScript support. Learn more → - - Connect to Supabase, Firestore, Hasura, and more with zero boilerplate using data adapters. - Learn more → + + Connect to REST APIs, GraphQL, TanStack DB and more with Remote Mode for external data sources. + Learn more → Built on TanStack Table—the industry standard for headless tables. Get sorting, filtering, grouping, pagination, and more out of the box. diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx index edf4309..fc41a89 100644 --- a/docs/src/content/docs/installation.mdx +++ b/docs/src/content/docs/installation.mdx @@ -91,4 +91,4 @@ Learn more about the [Plugin System](/seizen-table/guides/2-plugins/) to create ## Further Reading - Learn about the [Plugin System](/seizen-table/guides/2-plugins/) to add features like filtering, row details, and more -- Explore [Data Adapters](/seizen-table/advanced/data-adapter/) for connecting to various data sources +- Explore [Remote Data API](/seizen-table/guides/4-remote-data/) for connecting to external data sources diff --git a/docs/src/content/docs/overview.mdx b/docs/src/content/docs/overview.mdx index 9a9f17e..2ef673c 100644 --- a/docs/src/content/docs/overview.mdx +++ b/docs/src/content/docs/overview.mdx @@ -22,12 +22,12 @@ Seizen Table bridges this gap by providing: Every feature beyond core table rendering is a [plugin](/seizen-table/guides/2-plugins/). Keep your bundle small and include only what you need. - Customize the look with [CSS variables](/seizen-table/guides/1-theming/), or build entirely custom layouts using the [Composable API](/seizen-table/advanced/composable-api/). + Customize the look with [CSS variables](/seizen-table/guides/1-theming/), or build entirely custom layouts using the [Composable UI](/seizen-table/guides/5-composable-ui/). Full TypeScript support with comprehensive type definitions, including [type-safe custom events](/seizen-table/guides/3-events/#custom-events-with-module-augmentation). - Connect to various data sources like Supabase, Firestore, and Hasura with [minimal boilerplate](/seizen-table/advanced/data-adapter/). + Connect to various data sources like REST APIs, GraphQL, and TanStack DB with [Remote Mode](/seizen-table/guides/4-remote-data/).