From 81f8fe39adae766c2b856e80b5ae49d90d475313 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 5 Jan 2026 23:58:58 +0900 Subject: [PATCH 1/3] Add plugin slots --- examples/vite-app/src/App.tsx | 9 +- .../vite-app/src/plugins/AllSlotsPlugin.tsx | 309 ++++++++++++++++++ packages/datatable-react/docs/plugin.md | 169 ++++++++-- .../datatable-react/src/plugin/Renderer.tsx | 225 +++++++++++-- .../src/plugin/definePlugin.ts | 249 +++++++++++++- packages/datatable-react/src/plugin/index.ts | 31 +- .../datatable-react/src/plugin/styles.css.ts | 29 ++ .../datatable-react/src/table/DataTable.tsx | 70 ++-- 8 files changed, 1021 insertions(+), 70 deletions(-) create mode 100644 examples/vite-app/src/plugins/AllSlotsPlugin.tsx diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index c46c775..1d65440 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -12,6 +12,7 @@ import { TsvExporter, } from "./plugins/FileExportPlugin"; import { ColumnControlPlugin } from "./plugins/ColumnControlPlugin"; +import { AllSlotsDemo } from "./plugins/AllSlotsPlugin"; import { data, type Person } from "./data"; import { StatusBadge, @@ -104,11 +105,17 @@ function App() { includeHeaders: true, exporters: [CsvExporter, JsonlExporter, TsvExporter], }), + AllSlotsDemo.configure({ + sidepanelTitle: "All Slots Demo", + enableCellHighlight: true, + primaryColor: "#8b5cf6", + }), ], }); useDataTableEvent(table, "row-click", (row) => { - table.plugin.open("row-detail", { row }); + // Open inline row when clicking a row + table.plugin.open("all-slots-demo", { id: row.id }); }); return ( diff --git a/examples/vite-app/src/plugins/AllSlotsPlugin.tsx b/examples/vite-app/src/plugins/AllSlotsPlugin.tsx new file mode 100644 index 0000000..0681979 --- /dev/null +++ b/examples/vite-app/src/plugins/AllSlotsPlugin.tsx @@ -0,0 +1,309 @@ +import { useState } from "react"; +import { z } from "zod"; +import { + definePlugin, + usePluginContext, + type PluginContext, +} from "@izumisy/seizen-datatable-react/plugin"; +import type { Cell, Row } from "@izumisy/seizen-datatable-react"; + +// ============================================================================= +// Module Augmentation for Type-Safe Plugin Args +// ============================================================================= + +declare module "@izumisy/seizen-datatable-react/plugin" { + interface PluginArgsRegistry { + "all-slots-demo": { id: number }; + } +} + +/** + * Schema for AllSlotsDemo plugin configuration + */ +const AllSlotsDemoSchema = z.object({ + /** Label shown in sidepanel */ + sidepanelTitle: z.string().default("All Slots Demo"), + /** Whether to enable cell highlighting */ + enableCellHighlight: z.boolean().default(true), + /** Primary color for styling */ + primaryColor: z.string().default("#3b82f6"), +}); + +type AllSlotsDemoConfig = z.infer; + +// ============================================================================= +// Slot Renderers +// ============================================================================= + +/** + * Sidepanel slot - Shows plugin info and controls + */ +function createSidepanelRenderer(context: PluginContext) { + const { args } = context; + + return function SidepanelContent() { + const { data, selectedRows, useEvent } = + usePluginContext<"all-slots-demo">(); + const [clickCount, setClickCount] = useState(0); + + useEvent("row-click", () => { + setClickCount((c) => c + 1); + }); + + return ( +
+

+ {args.sidepanelTitle} +

+ +
+ Stats: +
    +
  • Total rows: {(data as unknown[]).length}
  • +
  • Selected rows: {selectedRows.length}
  • +
  • Row clicks: {clickCount}
  • +
+
+ +
+ About this plugin: +

+ This plugin demonstrates all 5 slot types: sidepanel, header, + footer, cell, and inlineRow. +

+
+
+ ); + }; +} + +/** + * Header slot - Renders between table header and body + */ +function createHeaderRenderer(context: PluginContext) { + const { args } = context; + + return function HeaderContent() { + const { data, selectedRows } = usePluginContext(); + + return ( +
+ + 📊 Showing {(data as unknown[]).length} records + + {selectedRows.length > 0 && ( + + ✓ {selectedRows.length} selected + + )} +
+ ); + }; +} + +/** + * Footer slot - Renders below the table + */ +function createFooterRenderer(context: PluginContext) { + const { args } = context; + + return function FooterContent() { + const { columns } = usePluginContext(); + + return ( +
+ Columns: {columns.map((c) => c.header).join(", ")} + All Slots Demo Plugin +
+ ); + }; +} + +/** + * Cell slot - Custom cell renderer for all columns + */ +function createCellRenderer(context: PluginContext) { + const { args } = context; + + return function CellContent(cell: Cell<{ id: number }, unknown>) { + const value = cell.getValue(); + const isNumeric = typeof value === "number"; + + // Simple formatting based on value type + if (isNumeric && args.enableCellHighlight) { + return ( + + {value.toLocaleString()} + + ); + } + + // Default rendering + return <>{String(value ?? "")}; + }; +} + +/** + * Inline Row slot - Renders below a specific row when opened + */ +function createInlineRowRenderer(context: PluginContext) { + const { args } = context; + + return function InlineRowContent(row: Row<{ id: number }>) { + const { table } = usePluginContext(); + const data = row.original; + + return ( +
+
+
+

+ Row Details (ID: {String(data.id)}) +

+
+ {Object.entries(data).map(([key, value]) => ( +
+ + {key}: + {" "} + + {typeof value === "object" + ? JSON.stringify(value) + : String(value)} + +
+ ))} +
+
+ +
+
+ ); + }; +} + +// ============================================================================= +// Plugin Definition +// ============================================================================= + +/** + * AllSlotsDemo Plugin + * + * A demonstration plugin that uses all 5 slot types: + * - sidepanel: Shows plugin info and stats + * - header: Shows record count and selection info + * - footer: Shows column info + * - cell: Custom numeric highlighting + * - inlineRow: Expandable row details + * + * @example + * ```tsx + * import { AllSlotsDemo } from "./plugins/AllSlotsPlugin"; + * + * const table = useDataTable({ + * data, + * columns, + * plugins: [ + * AllSlotsDemo.configure({ + * sidepanelTitle: "Demo Panel", + * enableCellHighlight: true, + * primaryColor: "#8b5cf6", + * }), + * ], + * }); + * + * // Open inline row for a specific row + * table.plugin.open("all-slots-demo", { id: "row-1" }); + * ``` + */ +export const AllSlotsDemo = definePlugin< + { id: number }, + typeof AllSlotsDemoSchema +>({ + id: "all-slots-demo", + name: "All Slots", + args: AllSlotsDemoSchema, + slots: { + sidepanel: { + position: "right-sider", + header: "All Slots Demo", + render: createSidepanelRenderer, + }, + header: { + render: createHeaderRenderer, + }, + footer: { + render: createFooterRenderer, + }, + cell: { + render: createCellRenderer, + }, + inlineRow: { + render: createInlineRowRenderer, + }, + }, +}); diff --git a/packages/datatable-react/docs/plugin.md b/packages/datatable-react/docs/plugin.md index 52097b0..660489d 100644 --- a/packages/datatable-react/docs/plugin.md +++ b/packages/datatable-react/docs/plugin.md @@ -10,38 +10,47 @@ Different use cases require different UI enhancements: The plugin system allows these features to be added modularly. -## Layout +## Layout & Slots -DataTable provides built-in sidepanels with IDE-style vertical tabs on both sides. Each plugin specifies which side to render on. +DataTable provides 5 slots where plugins can render UI components: ``` ┌───────┬─────────────────────────────────────────────┬───────┐ -│ [N] │ │ [D] │ -│ [a] │ │ [e] │ -│ [v] │ │ [t] │ -│ [i] │ Table Body │ [a] │ -│ [g] │ │ [i] │ -│ [a] │ │ [l] │ -│ [t] ├─────────────────────────────────────────────┤ [s] │ -│ [e] │ ├───────┤ -│ │ │ [F] │ -│ ↑ │ │ [i] │ -│ left- │ │ [l] │ -│ sider │ │ [t] │ -│ │ │ [e] │ -│ │ │ [r] │ +│ [N] │ ┌─────────────────────────────────────┐ │ [D] │ +│ [a] │ │ Header Slot │ │ [e] │ +│ [v] │ └─────────────────────────────────────┘ │ [t] │ +│ [i] │ ┌─────────────────────────────────────┐ │ [a] │ +│ [g] │ │ Table Row 1 │ │ [i] │ +│ [a] │ ├─────────────────────────────────────┤ │ [l] │ +│ [t] │ │ Inline Row Slot (when opened) │ │ [s] │ +│ [e] │ ├─────────────────────────────────────┤ ├───────┤ +│ │ │ Table Row 2 │ │ [F] │ +│ ↑ │ │ ... │ │ [i] │ +│ left- │ └─────────────────────────────────────┘ │ [l] │ +│ sider │ ┌─────────────────────────────────────┐ │ [t] │ +│ │ │ Footer Slot │ │ [e] │ +│ │ └─────────────────────────────────────┘ │ [r] │ │ │ │ ↑ │ │ │ │ right-│ │ │ │ sider │ └───────┴─────────────────────────────────────────────┴───────┘ ``` -| Position | Description | -|----------|-------------| -| `left-sider` | IDE-style vertical tab on the left side. Ideal for navigation, tree views. | -| `right-sider` | IDE-style vertical tab on the right side. Ideal for details, inspectors. | +### Available Slots -Plugins can render modals/dialogs internally when needed. +| Slot | Description | Rendering Strategy | +|------|-------------|-------------------| +| `sidepanel` | IDE-style vertical tab panel (left or right) | Tab-based toggle | +| `header` | Between table header row and body rows | Sequential (all plugins) | +| `footer` | Below the table | Sequential (all plugins) | +| `cell` | Custom cell renderer for all columns | First match wins | +| `inlineRow` | Expandable sub-row below a specific row | First match wins | + +### Slot Rendering Strategies + +- **Tab-based toggle**: Only one sidepanel can be active at a time per position +- **Sequential**: All plugins with this slot render in registration order +- **First match wins**: Only the first plugin with this slot renders ## Plugin Usage @@ -150,6 +159,124 @@ export const MyPlugin = definePlugin({ /> ``` +## Creating a Slot-Based Plugin + +Use `definePlugin` with the `slots` option to create a plugin that uses multiple slots. + +### Basic Structure + +```tsx +import { z } from "zod"; +import { + definePlugin, + usePluginContext, + type PluginContext, +} from "@izumisy/seizen-datatable-react/plugin"; + +const MultiSlotSchema = z.object({ + primaryColor: z.string().default("#3b82f6"), +}); + +type MultiSlotConfig = z.infer; + +// Sidepanel renderer +function createSidepanelRenderer(context: PluginContext) { + return function SidepanelContent() { + const { data } = usePluginContext(); + return
Total: {data.length} rows
; + }; +} + +// Header renderer +function createHeaderRenderer(context: PluginContext) { + const { args } = context; + return function HeaderContent() { + return ( +
+ Header content +
+ ); + }; +} + +// Footer renderer +function createFooterRenderer(context: PluginContext) { + return function FooterContent() { + return
Footer content
; + }; +} + +// Cell renderer (receives cell, column, row) +function createCellRenderer(context: PluginContext) { + const { args } = context; + return function CellContent( + cell: { getValue: () => unknown }, + column: unknown, + row: unknown + ) { + const value = cell.getValue(); + if (typeof value === "number") { + return {value.toLocaleString()}; + } + return <>{String(value ?? "")}; + }; +} + +// Inline row renderer (receives row) +function createInlineRowRenderer(context: PluginContext) { + const { args } = context; + return function InlineRowContent(row: { original: Record }) { + const { table } = usePluginContext(); + return ( +
+

Row Details

+
{JSON.stringify(row.original, null, 2)}
+ +
+ ); + }; +} + +export const MultiSlotPlugin = definePlugin({ + id: "multi-slot", + name: "Multi Slot", + args: MultiSlotSchema, + slots: { + sidepanel: { + position: "right-sider", + header: "Multi Slot Plugin", + render: createSidepanelRenderer, + }, + header: { + render: createHeaderRenderer, + }, + footer: { + render: createFooterRenderer, + }, + cell: { + render: createCellRenderer, + }, + inlineRow: { + render: createInlineRowRenderer, + }, + }, +}); +``` + +### Opening Inline Row + +The `inlineRow` slot renders below a specific row when opened via `plugin.open`: + +```tsx +// Open inline row for a specific row by ID +table.plugin.open("multi-slot", { id: row.id }); + +// Close the inline row +table.plugin.close(); +``` + +The `id` in `openArgs` is matched against each row's `id` field to determine which row to expand. + ## Creating a Context Menu Only Plugin Omit `position` and `render` to create a plugin that only adds context menu items. diff --git a/packages/datatable-react/src/plugin/Renderer.tsx b/packages/datatable-react/src/plugin/Renderer.tsx index 4203d24..64762f0 100644 --- a/packages/datatable-react/src/plugin/Renderer.tsx +++ b/packages/datatable-react/src/plugin/Renderer.tsx @@ -1,9 +1,11 @@ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; +import type { Cell, Column, Row } from "@tanstack/react-table"; import type { DataTablePlugin, PluginPosition, - SidepanelPlugin, + SidepanelSlot, } from "./definePlugin"; +import { isSlotPlugin, getSidepanelSlot } from "./definePlugin"; import { usePluginContext } from "./Context"; import * as styles from "./styles.css"; @@ -15,34 +17,46 @@ interface PluginRendererProps { } /** - * Check if a plugin is a sidepanel plugin + * Get sidepanel plugins with their slot configuration for a specific position */ -function isSidepanelPlugin( - plugin: DataTablePlugin -): plugin is SidepanelPlugin { - return "position" in plugin && "render" in plugin; +function getSidepanelPluginsForPosition( + plugins: DataTablePlugin[], + position: PluginPosition +): Array<{ plugin: DataTablePlugin; slot: SidepanelSlot }> { + return plugins + .map((plugin) => { + const slot = getSidepanelSlot(plugin); + if (slot && slot.position === position) { + return { plugin, slot }; + } + return null; + }) + .filter( + (item): item is { plugin: DataTablePlugin; slot: SidepanelSlot } => + item !== null + ); } /** - * Renders plugins for a specific position + * Renders sidepanel plugins for a specific position */ -export function PluginRenderer({ position }: PluginRendererProps) { +export function SidepanelSlotRenderer({ position }: PluginRendererProps) { const { table } = usePluginContext(); const plugins = table.plugins; const activePluginId = table.plugin.getActiveId(); const setActive = table.plugin.setActive; - // Filter plugins by position - const sidepanelPlugins = plugins.filter( - (p) => isSidepanelPlugin(p) && p.position === position - ) as SidepanelPlugin[]; + + // Get sidepanel plugins for this position + const sidepanelPlugins = getSidepanelPluginsForPosition(plugins, position); // Memoize plugin components to maintain stable references const pluginComponents = useMemo(() => { - return sidepanelPlugins.map((plugin) => ({ + return sidepanelPlugins.map(({ plugin, slot }) => ({ id: plugin.id, + name: plugin.name, // Use header if provided, otherwise fallback to name - header: plugin.header ?? plugin.name, - Component: plugin.render, + header: slot.header ?? plugin.name, + Component: slot.render, })); }, [sidepanelPlugins]); @@ -52,22 +66,22 @@ export function PluginRenderer({ position }: PluginRendererProps) { const dataPosition = position === "left-sider" ? "left" : "right"; // Check if the active plugin belongs to this position - const isActiveHere = sidepanelPlugins.some((p) => p.id === activePluginId); + const isActiveHere = sidepanelPlugins.some( + ({ plugin }) => plugin.id === activePluginId + ); return (
{/* Vertical tabs */}
- {sidepanelPlugins.map((plugin) => ( + {pluginComponents.map(({ id, name }) => ( ))}
@@ -105,3 +119,166 @@ export function PluginRenderer({ position }: PluginRendererProps) {
); } + +/** + * @deprecated Use SidepanelSlotRenderer instead + */ +export const PluginRenderer = SidepanelSlotRenderer; + +// ============================================================================= +// Header Slot Renderer +// ============================================================================= + +/** + * Renders all header slots from plugins in registration order + */ +export function HeaderSlotRenderer() { + const { table } = usePluginContext(); + const plugins = table.plugins; + + // Collect all header slots + const headerSlots = plugins + .filter(isSlotPlugin) + .filter((p) => p.slots.header !== undefined) + .map((p) => ({ id: p.id, render: p.slots.header!.render })); + + if (headerSlots.length === 0) { + return null; + } + + return ( + <> + {headerSlots.map(({ id, render: Render }) => ( +
+ +
+ ))} + + ); +} + +// ============================================================================= +// Footer Slot Renderer +// ============================================================================= + +/** + * Renders all footer slots from plugins in registration order + */ +export function FooterSlotRenderer() { + const { table } = usePluginContext(); + const plugins = table.plugins; + + // Collect all footer slots + const footerSlots = plugins + .filter(isSlotPlugin) + .filter((p) => p.slots.footer !== undefined) + .map((p) => ({ id: p.id, render: p.slots.footer!.render })); + + if (footerSlots.length === 0) { + return null; + } + + return ( + <> + {footerSlots.map(({ id, render: Render }) => ( +
+ +
+ ))} + + ); +} + +// ============================================================================= +// Cell Slot Renderer +// ============================================================================= + +interface CellSlotRendererProps { + cell: Cell; + column: Column; + row: Row; + /** Default content to render if no plugin handles the cell */ + children: ReactNode; +} + +/** + * Renders cell content using the first matching plugin cell slot, + * or falls back to default content + */ +export function CellSlotRenderer({ + cell, + column, + row, + children, +}: CellSlotRendererProps) { + const { table } = usePluginContext(); + const plugins = table.plugins; + + // Find first plugin with a cell slot (first match wins) + const cellPlugin = plugins.find( + (p) => isSlotPlugin(p) && p.slots.cell !== undefined + ); + + if (cellPlugin && isSlotPlugin(cellPlugin) && cellPlugin.slots.cell) { + return ( + <> + {cellPlugin.slots.cell.render(cell as any, column as any, row as any)} + + ); + } + + return <>{children}; +} + +// ============================================================================= +// Inline Row Slot Renderer +// ============================================================================= + +interface InlineRowSlotRendererProps { + row: Row; + /** Number of columns for colspan */ + colSpan: number; +} + +/** + * Renders inline row content for a specific row. + * Only renders when: + * 1. A plugin with inlineRow slot is open (via plugin.open) + * 2. The openArgs.id matches the row's original.id (supports string or number) + * First matching plugin wins. + */ +export function InlineRowSlotRenderer({ + row, + colSpan, +}: InlineRowSlotRendererProps) { + const { table, openArgs } = usePluginContext(); + const plugins = table.plugins; + const activePluginId = table.plugin.getActiveId(); + + // Get row id from original data (supports various id field types) + const rowData = row.original as Record; + const rowId = rowData.id; + + // Check if any plugin with inlineRow slot is open and matches this row + const activeInlineRowPlugin = plugins.find((p) => { + if (!isSlotPlugin(p) || !p.slots.inlineRow) return false; + if (p.id !== activePluginId) return false; + // Check if openArgs.id matches this row's id (convert to string for comparison) + const args = openArgs as { id?: string | number } | undefined; + return rowId !== undefined && String(args?.id) === String(rowId); + }); + + if (!activeInlineRowPlugin || !isSlotPlugin(activeInlineRowPlugin)) { + return null; + } + + const renderInlineRow = activeInlineRowPlugin.slots.inlineRow!.render; + + return ( + + + {renderInlineRow(row as any)} + + + ); +} diff --git a/packages/datatable-react/src/plugin/definePlugin.ts b/packages/datatable-react/src/plugin/definePlugin.ts index 4caa013..8cb5d4d 100644 --- a/packages/datatable-react/src/plugin/definePlugin.ts +++ b/packages/datatable-react/src/plugin/definePlugin.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { z } from "zod"; +import type { Cell, Column, Row } from "@tanstack/react-table"; import type { ContextMenuItemFactory } from "./contextMenuItem"; // ============================================================================= @@ -14,6 +15,83 @@ import type { ContextMenuItemFactory } from "./contextMenuItem"; */ export type PluginPosition = "left-sider" | "right-sider"; +/** + * Slot types available for plugins to render components. + * + * - `sidepanel`: IDE-style vertical tab sidepanel (left or right) + * - `header`: Renders between table header and body rows + * - `footer`: Renders below the table + * - `cell`: Custom cell renderer applied to all columns (first match wins) + * - `inlineRow`: Renders below a specific row when opened (first match wins) + */ +export type SlotType = "sidepanel" | "header" | "footer" | "cell" | "inlineRow"; + +// ============================================================================= +// Slot Definitions +// ============================================================================= + +/** + * Sidepanel slot configuration + */ +export interface SidepanelSlot { + position: PluginPosition; + /** Header content displayed at the top of the panel */ + header?: string | ReactNode; + /** Render function for sidepanel content */ + render: () => ReactNode; +} + +/** + * Header slot configuration - renders between thead and tbody + */ +export interface HeaderSlot { + /** Render function for header content */ + render: () => ReactNode; +} + +/** + * Footer slot configuration - renders below the table + */ +export interface FooterSlot { + /** Render function for footer content */ + render: () => ReactNode; +} + +/** + * Cell slot configuration - custom cell renderer for all columns + */ +export interface CellSlot { + /** Render function receiving cell, column, and row from TanStack Table */ + render: ( + cell: Cell, + column: Column, + row: Row + ) => ReactNode; +} + +/** + * Inline row slot configuration - renders below a specific row + */ +export interface InlineRowSlot { + /** Render function receiving the row */ + render: (row: Row) => ReactNode; +} + +/** + * Plugin slots configuration + */ +export interface PluginSlots { + sidepanel?: SidepanelSlot; + header?: HeaderSlot; + footer?: FooterSlot; + cell?: CellSlot; + inlineRow?: InlineRowSlot; +} + +// ============================================================================= +// Plugin Types +// ============================================================================= + /** * Base plugin interface - all plugins have id and name */ @@ -23,6 +101,17 @@ interface BasePlugin { } /** + * Plugin with slots - can have any combination of slots + */ +export interface SlotPlugin extends BasePlugin { + slots: PluginSlots; + contextMenu?: { + items: ContextMenuItemFactory[]; + }; +} + +/** + * @deprecated Use SlotPlugin instead. Kept for backward compatibility. * Sidepanel plugin - renders UI in sidepanel and optionally adds context menu items */ export interface SidepanelPlugin extends BasePlugin { @@ -45,13 +134,62 @@ export interface ContextMenuOnlyPlugin extends BasePlugin { } /** - * Plugin can be either a sidepanel plugin or a context menu only plugin. + * Plugin can be a slot plugin, sidepanel plugin (legacy), or context menu only plugin. * Use DataTablePlugin for plugins that don't use TData in context menu. */ export type DataTablePlugin = + | SlotPlugin | SidepanelPlugin | ContextMenuOnlyPlugin; +/** + * Type guard to check if a plugin is a slot plugin + */ +export function isSlotPlugin( + plugin: DataTablePlugin +): plugin is SlotPlugin { + return "slots" in plugin; +} + +/** + * Type guard to check if a plugin is a legacy sidepanel plugin + */ +export function isSidepanelPlugin( + plugin: DataTablePlugin +): plugin is SidepanelPlugin { + return "position" in plugin && "render" in plugin && !("slots" in plugin); +} + +/** + * Type guard to check if a plugin has a sidepanel (either slot or legacy) + */ +export function hasSidepanelSlot( + plugin: DataTablePlugin +): plugin is SlotPlugin | SidepanelPlugin { + return isSlotPlugin(plugin) + ? plugin.slots.sidepanel !== undefined + : isSidepanelPlugin(plugin); +} + +/** + * Get sidepanel configuration from a plugin (normalizes legacy and slot plugins) + */ +export function getSidepanelSlot( + plugin: DataTablePlugin +): SidepanelSlot | undefined { + if (isSlotPlugin(plugin)) { + return plugin.slots.sidepanel; + } + if (isSidepanelPlugin(plugin)) { + return { + position: plugin.position, + header: plugin.header, + render: plugin.render, + }; + } + return undefined; +} + // ============================================================================= // Plugin Definition Types // ============================================================================= @@ -105,9 +243,59 @@ export interface DefineContextMenuPluginOptions< }; } +/** + * Slot render function definitions for defineSlotPlugin + */ +export interface DefinePluginSlots { + /** Sidepanel slot - renders in left or right sidepanel */ + sidepanel?: { + position: PluginPosition; + header?: string | ((context: PluginContext>) => ReactNode); + render: (context: PluginContext>) => () => ReactNode; + }; + /** Header slot - renders between table header and body */ + header?: { + render: (context: PluginContext>) => () => ReactNode; + }; + /** Footer slot - renders below the table */ + footer?: { + render: (context: PluginContext>) => () => ReactNode; + }; + /** Cell slot - custom cell renderer for all columns (first match wins) */ + cell?: { + render: ( + context: PluginContext> + ) => ( + cell: Cell, + column: Column, + row: Row + ) => ReactNode; + }; + /** Inline row slot - renders below a specific row when opened (first match wins) */ + inlineRow?: { + render: ( + context: PluginContext> + ) => (row: Row) => ReactNode; + }; +} + +/** + * Options for defining a slot-based plugin + */ +export interface DefineSlotPluginOptions + extends BasePluginOptions { + /** Slot configurations */ + slots: DefinePluginSlots; + /** Optional context menu configuration */ + contextMenu?: { + items: ContextMenuItemFactory>[]; + }; +} + export type DefinePluginOptions = | DefineSidepanelPluginOptions - | DefineContextMenuPluginOptions; + | DefineContextMenuPluginOptions + | DefineSlotPluginOptions; /** * Define a plugin with type-safe configuration. @@ -194,7 +382,62 @@ export function definePlugin( const validatedArgs = options.args.parse(config); const context: PluginContext> = { args: validatedArgs }; - // Check if it's a sidepanel plugin + // Check if it's a slot-based plugin + if ("slots" in options) { + const slotOptions = options as DefineSlotPluginOptions; + const slots: PluginSlots = {}; + + // Build sidepanel slot + if (slotOptions.slots.sidepanel) { + const sidepanelDef = slotOptions.slots.sidepanel; + const header = + typeof sidepanelDef.header === "function" + ? sidepanelDef.header(context) + : sidepanelDef.header; + slots.sidepanel = { + position: sidepanelDef.position, + header, + render: sidepanelDef.render(context), + }; + } + + // Build header slot + if (slotOptions.slots.header) { + slots.header = { + render: slotOptions.slots.header.render(context), + }; + } + + // Build footer slot + if (slotOptions.slots.footer) { + slots.footer = { + render: slotOptions.slots.footer.render(context), + }; + } + + // Build cell slot + if (slotOptions.slots.cell) { + slots.cell = { + render: slotOptions.slots.cell.render(context), + }; + } + + // Build inlineRow slot + if (slotOptions.slots.inlineRow) { + slots.inlineRow = { + render: slotOptions.slots.inlineRow.render(context), + }; + } + + return { + id: options.id, + name: options.name, + slots, + contextMenu: slotOptions.contextMenu, + } as SlotPlugin; + } + + // Check if it's a legacy sidepanel plugin if ("position" in options && "render" in options) { const sidepanelOptions = options as DefineSidepanelPluginOptions< TData, diff --git a/packages/datatable-react/src/plugin/index.ts b/packages/datatable-react/src/plugin/index.ts index 12024e1..7f0d7f9 100644 --- a/packages/datatable-react/src/plugin/index.ts +++ b/packages/datatable-react/src/plugin/index.ts @@ -7,13 +7,32 @@ */ // definePlugin -export { definePlugin } from "./definePlugin"; +export { + definePlugin, + isSlotPlugin, + isSidepanelPlugin, + hasSidepanelSlot, + getSidepanelSlot, +} from "./definePlugin"; export type { + // Slot types + SlotType, + SidepanelSlot, + HeaderSlot, + FooterSlot, + CellSlot, + InlineRowSlot, + PluginSlots, + // Plugin types PluginPosition, + SlotPlugin, SidepanelPlugin, ContextMenuOnlyPlugin, DataTablePlugin, PluginContext, + // Define options + DefinePluginSlots, + DefineSlotPluginOptions, DefineSidepanelPluginOptions, DefineContextMenuPluginOptions, DefinePluginOptions, @@ -31,6 +50,16 @@ export type { export { usePluginContext, PluginContextProvider } from "./Context"; export type { PluginContextValue, PluginColumnInfo } from "./Context"; +// Slot Renderers +export { + SidepanelSlotRenderer, + HeaderSlotRenderer, + FooterSlotRenderer, + CellSlotRenderer, + InlineRowSlotRenderer, + PluginRenderer, // deprecated alias +} from "./Renderer"; + // PluginControl (for type-safe plugin.open()) export type { PluginArgsRegistry } from "./usePluginControl"; diff --git a/packages/datatable-react/src/plugin/styles.css.ts b/packages/datatable-react/src/plugin/styles.css.ts index b605638..cbebfbd 100644 --- a/packages/datatable-react/src/plugin/styles.css.ts +++ b/packages/datatable-react/src/plugin/styles.css.ts @@ -139,3 +139,32 @@ export const sidepanelTabLabel = style({ letterSpacing: "0.05em", whiteSpace: "nowrap", }); + +// ============================================================================= +// Header Slot Styles +// ============================================================================= + +export const headerSlot = style({ + borderBottom: `var(--szui-border-width, ${fallback.borderWidth}) solid var(--szui-border-color, ${fallback.borderColor})`, +}); + +// ============================================================================= +// Footer Slot Styles +// ============================================================================= + +export const footerSlot = style({ + borderTop: `var(--szui-border-width, ${fallback.borderWidth}) solid var(--szui-border-color, ${fallback.borderColor})`, +}); + +// ============================================================================= +// Inline Row Slot Styles +// ============================================================================= + +export const inlineRow = style({ + backgroundColor: `var(--szui-sidepanel-bg, ${fallback.sidepanelBg})`, +}); + +export const inlineRowCell = style({ + padding: `var(--szui-cell-padding-y, ${fallback.cellPaddingY}) var(--szui-cell-padding-x, ${fallback.cellPaddingX})`, + borderBottom: `var(--szui-border-width, ${fallback.borderWidth}) solid var(--szui-border-color, ${fallback.borderColor})`, +}); diff --git a/packages/datatable-react/src/table/DataTable.tsx b/packages/datatable-react/src/table/DataTable.tsx index 5e1c457..f1ebfd8 100644 --- a/packages/datatable-react/src/table/DataTable.tsx +++ b/packages/datatable-react/src/table/DataTable.tsx @@ -1,6 +1,13 @@ +import { Fragment } from "react"; import { flexRender } from "@tanstack/react-table"; import type { DataTableInstance } from "./useDataTable"; -import { PluginRenderer } from "../plugin/Renderer"; +import { + SidepanelSlotRenderer, + HeaderSlotRenderer, + FooterSlotRenderer, + CellSlotRenderer, + InlineRowSlotRenderer, +} from "../plugin/Renderer"; import { PluginContextProvider } from "../plugin/Context"; import * as styles from "./styles.css"; @@ -31,7 +38,7 @@ export function DataTable({ table, className }: DataTableProps) {
{/* Left Sidepanel */} - + {/* Main Table */}
@@ -51,33 +58,56 @@ export function DataTable({ table, className }: DataTableProps) { ))} ))} + {/* Header Slot - between thead rows and tbody */} + + + + + {tanstack.getRowModel().rows.map((row) => ( - { - table.eventBus.emit("row-click", row.original); - }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - + + { + table.eventBus.emit("row-click", row.original); + }} + > + {row.getVisibleCells().map((cell) => ( + + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + + ))} + + {/* Inline Row Slot - renders below matching row */} + + ))} + {/* Footer Slot - below the table */} +
{/* Right Sidepanel */} - +
); From 24aa2887fd3bf336bb226a636c6586cfe9fe3810 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 6 Jan 2026 00:12:08 +0900 Subject: [PATCH 2/3] Remove legacy API --- .../src/plugins/ColumnControlPlugin.tsx | 10 ++- .../vite-app/src/plugins/FileExportPlugin.tsx | 10 ++- .../vite-app/src/plugins/RowDetailPlugin.tsx | 10 ++- .../src/plugin/definePlugin.ts | 63 ++++--------------- packages/datatable-react/src/plugin/index.ts | 1 - 5 files changed, 33 insertions(+), 61 deletions(-) diff --git a/examples/vite-app/src/plugins/ColumnControlPlugin.tsx b/examples/vite-app/src/plugins/ColumnControlPlugin.tsx index 646b56e..88d9a2f 100644 --- a/examples/vite-app/src/plugins/ColumnControlPlugin.tsx +++ b/examples/vite-app/src/plugins/ColumnControlPlugin.tsx @@ -224,8 +224,12 @@ function ColumnControlRenderer(context: PluginContext) { export const ColumnControlPlugin = definePlugin({ id: "column-control", name: "Columns", - position: "right-sider" as const, args: ColumnControlSchema, - header: "Column Visibility", - render: ColumnControlRenderer, + slots: { + sidepanel: { + position: "right-sider", + header: "Column Visibility", + render: ColumnControlRenderer, + }, + }, }); diff --git a/examples/vite-app/src/plugins/FileExportPlugin.tsx b/examples/vite-app/src/plugins/FileExportPlugin.tsx index e69f270..5336f5d 100644 --- a/examples/vite-app/src/plugins/FileExportPlugin.tsx +++ b/examples/vite-app/src/plugins/FileExportPlugin.tsx @@ -352,8 +352,12 @@ function FileExportRenderer(context: PluginContext) { export const FileExportPlugin = definePlugin({ id: "file-export", name: "Export", - position: "right-sider" as const, args: FileExportSchema, - header: "File Export", - render: FileExportRenderer, + slots: { + sidepanel: { + position: "right-sider", + header: "File Export", + render: FileExportRenderer, + }, + }, }); diff --git a/examples/vite-app/src/plugins/RowDetailPlugin.tsx b/examples/vite-app/src/plugins/RowDetailPlugin.tsx index b14172b..95a6997 100644 --- a/examples/vite-app/src/plugins/RowDetailPlugin.tsx +++ b/examples/vite-app/src/plugins/RowDetailPlugin.tsx @@ -142,8 +142,12 @@ function formatValue(value: unknown): string { export const RowDetailPlugin = definePlugin({ id: "row-detail", name: "Details", - position: "right-sider" as const, args: RowDetailSchema, - header: "Row Details", - render: RowDetailRenderer, + slots: { + sidepanel: { + position: "right-sider", + header: "Row Details", + render: RowDetailRenderer, + }, + }, }); diff --git a/packages/datatable-react/src/plugin/definePlugin.ts b/packages/datatable-react/src/plugin/definePlugin.ts index 8cb5d4d..1008617 100644 --- a/packages/datatable-react/src/plugin/definePlugin.ts +++ b/packages/datatable-react/src/plugin/definePlugin.ts @@ -214,23 +214,6 @@ interface BasePluginOptions { args: TSchema; } -/** - * Options for defining a sidepanel plugin - */ -export interface DefineSidepanelPluginOptions - extends BasePluginOptions { - /** Plugin position in the DataTable layout */ - position: PluginPosition; - /** Header content displayed at the top of the panel. Can be a string or ReactNode. If not provided, name is used. */ - header?: string | ((context: PluginContext>) => ReactNode); - /** Render function that receives context with validated args and returns a React component */ - render: (context: PluginContext>) => () => ReactNode; - /** Optional context menu configuration */ - contextMenu?: { - items: ContextMenuItemFactory>[]; - }; -} - /** * Options for defining a context menu only plugin */ @@ -293,9 +276,8 @@ export interface DefineSlotPluginOptions } export type DefinePluginOptions = - | DefineSidepanelPluginOptions - | DefineContextMenuPluginOptions - | DefineSlotPluginOptions; + | DefineSlotPluginOptions + | DefineContextMenuPluginOptions; /** * Define a plugin with type-safe configuration. @@ -303,7 +285,7 @@ export type DefinePluginOptions = * This function creates a plugin factory with a `configure` method that validates * configuration at runtime using the provided Zod schema. * - * @example Sidepanel Plugin + * @example Slot Plugin with Sidepanel * ```tsx * import { z } from "zod"; * import { definePlugin, contextMenuItem, usePluginContext } from "@izumisy/seizen-datatable-react/plugin"; @@ -313,13 +295,10 @@ export type DefinePluginOptions = * enableExport: z.boolean().default(true), * }); * - * function BulkActionsRenderer(context: PluginContext>) { + * function createSidepanelRenderer(context: PluginContext>) { * const { args } = context; - * return function Render() { - * const { selectedRows, useOnChange } = usePluginContext(); - * useOnChange("selection", (selection) => { - * console.log("Selection changed:", selection); - * }); + * return function SidepanelContent() { + * const { selectedRows } = usePluginContext(); * if (selectedRows.length === 0) return null; * return ( *
@@ -334,9 +313,13 @@ export type DefinePluginOptions = * const BulkActions = definePlugin({ * id: "bulk-actions", * name: "Bulk Actions", - * position: "right-sider", * args: BulkActionsSchema, - * render: BulkActionsRenderer, + * slots: { + * sidepanel: { + * position: "right-sider", + * render: createSidepanelRenderer, + * }, + * }, * contextMenu: { * items: [ * contextMenuItem("delete", (ctx) => ({ @@ -360,7 +343,6 @@ export type DefinePluginOptions = * args: z.object({ * enableCopyId: z.boolean().default(true), * }), - * // No position or render - context menu only * contextMenu: { * items: [ * contextMenuItem("copy-id", (ctx) => ({ @@ -437,27 +419,6 @@ export function definePlugin( } as SlotPlugin; } - // Check if it's a legacy sidepanel plugin - if ("position" in options && "render" in options) { - const sidepanelOptions = options as DefineSidepanelPluginOptions< - TData, - TSchema - >; - // Resolve header: string stays as string, function gets called - const header = - typeof sidepanelOptions.header === "function" - ? sidepanelOptions.header(context) - : sidepanelOptions.header; - return { - id: options.id, - name: options.name, - position: sidepanelOptions.position, - header, - render: sidepanelOptions.render(context), - contextMenu: sidepanelOptions.contextMenu, - } as SidepanelPlugin; - } - // Context menu only plugin const contextMenuOptions = options as DefineContextMenuPluginOptions< TData, diff --git a/packages/datatable-react/src/plugin/index.ts b/packages/datatable-react/src/plugin/index.ts index 7f0d7f9..4624b1c 100644 --- a/packages/datatable-react/src/plugin/index.ts +++ b/packages/datatable-react/src/plugin/index.ts @@ -33,7 +33,6 @@ export type { // Define options DefinePluginSlots, DefineSlotPluginOptions, - DefineSidepanelPluginOptions, DefineContextMenuPluginOptions, DefinePluginOptions, } from "./definePlugin"; From b869e231229bc318ed58dc49263934f348a4ca75 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 6 Jan 2026 00:35:31 +0900 Subject: [PATCH 3/3] Plugin toggle for example --- examples/vite-app/src/App.tsx | 94 +++++++++++++----- .../vite-app/src/plugins/PluginToggle.tsx | 95 +++++++++++++++++++ .../vite-app/src/plugins/usePluginToggle.ts | 64 +++++++++++++ 3 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 examples/vite-app/src/plugins/PluginToggle.tsx create mode 100644 examples/vite-app/src/plugins/usePluginToggle.ts diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 1d65440..107bb33 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -4,6 +4,12 @@ import { DataTable, type ColumnDef, } from "@izumisy/seizen-datatable-react"; +import { data, type Person } from "./data"; +import { + usePluginToggle, + PluginToggleList, + type PluginRegistry, +} from "./plugins/PluginToggle"; import { RowDetailPlugin } from "./plugins/RowDetailPlugin"; import { FileExportPlugin, @@ -13,7 +19,6 @@ import { } from "./plugins/FileExportPlugin"; import { ColumnControlPlugin } from "./plugins/ColumnControlPlugin"; import { AllSlotsDemo } from "./plugins/AllSlotsPlugin"; -import { data, type Person } from "./data"; import { StatusBadge, DepartmentBadge, @@ -24,6 +29,44 @@ import { LocationCell, } from "./components/cells"; +// ============================================================================= +// Plugin Definitions +// ============================================================================= + +const AVAILABLE_PLUGINS = { + "row-detail": { + name: "Row Detail", + description: "Show row details in sidepanel", + plugin: RowDetailPlugin.configure({ width: 450 }), + }, + "column-control": { + name: "Column Control", + description: "Toggle column visibility", + plugin: ColumnControlPlugin.configure({ width: 400 }), + }, + "file-export": { + name: "File Export", + description: "Export data to CSV/JSON/TSV", + plugin: FileExportPlugin.configure({ + width: 450, + filename: "users", + includeHeaders: true, + exporters: [CsvExporter, JsonlExporter, TsvExporter], + }), + }, + "all-slots-demo": { + name: "All Slots Demo", + description: "Demo plugin using all 5 slots", + plugin: AllSlotsDemo.configure({ + sidepanelTitle: "All Slots Demo", + enableCellHighlight: true, + primaryColor: "#8b5cf6", + }), + }, +} as const satisfies PluginRegistry; + +type PluginId = keyof typeof AVAILABLE_PLUGINS; + const columns: ColumnDef[] = [ { accessorKey: "id", @@ -88,38 +131,43 @@ const columns: ColumnDef[] = [ }, ]; +// ============================================================================= +// App Component +// ============================================================================= + function App() { + const { plugins, pluginRegistry, enabledPlugins, togglePlugin, isEnabled } = + usePluginToggle({ + plugins: AVAILABLE_PLUGINS, + initialEnabled: ["row-detail", "column-control", "file-export"], + }); + const table = useDataTable({ data, columns, - plugins: [ - RowDetailPlugin.configure({ - width: 450, - }), - ColumnControlPlugin.configure({ - width: 400, - }), - FileExportPlugin.configure({ - width: 450, - filename: "users", - includeHeaders: true, - exporters: [CsvExporter, JsonlExporter, TsvExporter], - }), - AllSlotsDemo.configure({ - sidepanelTitle: "All Slots Demo", - enableCellHighlight: true, - primaryColor: "#8b5cf6", - }), - ], + plugins, }); useDataTableEvent(table, "row-click", (row) => { - // Open inline row when clicking a row - table.plugin.open("all-slots-demo", { id: row.id }); + if (isEnabled("all-slots-demo")) { + // Open inline row when AllSlotsDemo is enabled + table.plugin.open("all-slots-demo", { id: (row as Person).id }); + } else if (isEnabled("row-detail")) { + // Fallback to row detail + table.plugin.open("row-detail", { row }); + } }); return ( -
+
+ {/* Plugin Toggles */} + + + {/* DataTable */}
); diff --git a/examples/vite-app/src/plugins/PluginToggle.tsx b/examples/vite-app/src/plugins/PluginToggle.tsx new file mode 100644 index 0000000..68bd0f6 --- /dev/null +++ b/examples/vite-app/src/plugins/PluginToggle.tsx @@ -0,0 +1,95 @@ +import type { PluginRegistry } from "./usePluginToggle"; + +export { + usePluginToggle, + type PluginDefinition, + type PluginRegistry, + type UsePluginToggleOptions, +} from "./usePluginToggle"; + +// ============================================================================= +// PluginToggleItem Component +// ============================================================================= + +function PluginToggleItem({ + name, + description, + enabled, + onToggle, +}: { + name: string; + description: string; + enabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +// ============================================================================= +// PluginToggleList Component +// ============================================================================= + +export function PluginToggleList({ + pluginRegistry, + enabledPlugins, + onToggle, +}: { + pluginRegistry: PluginRegistry; + enabledPlugins: Set; + onToggle: (id: TId) => void; +}) { + return ( +
+

+ Plugins +

+
+ {( + Object.entries(pluginRegistry) as [TId, PluginRegistry[TId]][] + ).map(([id, { name, description }]) => ( + onToggle(id)} + /> + ))} +
+
+ ); +} diff --git a/examples/vite-app/src/plugins/usePluginToggle.ts b/examples/vite-app/src/plugins/usePluginToggle.ts new file mode 100644 index 0000000..1eef956 --- /dev/null +++ b/examples/vite-app/src/plugins/usePluginToggle.ts @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import type { DataTablePlugin } from "@izumisy/seizen-datatable-react/plugin"; + +// ============================================================================= +// Types +// ============================================================================= + +export type PluginDefinition = { + name: string; + description: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plugin: DataTablePlugin; +}; + +export type PluginRegistry = Record< + TId, + PluginDefinition +>; + +export type UsePluginToggleOptions = { + plugins: PluginRegistry; + initialEnabled?: TId[]; +}; + +// ============================================================================= +// usePluginToggle Hook +// ============================================================================= + +export function usePluginToggle({ + plugins: pluginRegistry, + initialEnabled = [], +}: UsePluginToggleOptions) { + const [enabledPlugins, setEnabledPlugins] = useState>( + new Set(initialEnabled) + ); + + const togglePlugin = (id: TId) => { + setEnabledPlugins((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const plugins = useMemo(() => { + return (Object.keys(pluginRegistry) as TId[]) + .filter((id) => enabledPlugins.has(id)) + .map((id) => pluginRegistry[id].plugin) as DataTablePlugin[]; + }, [enabledPlugins, pluginRegistry]); + + const isEnabled = (id: TId) => enabledPlugins.has(id); + + return { + plugins, + pluginRegistry, + enabledPlugins, + togglePlugin, + isEnabled, + }; +}