diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d31e34d6..e371ee4b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,11 +5,13 @@ This is a dual-purpose repository containing both the **Mantine DataTable** comp ## Project Architecture ### Dual Repository Structure + - **Package code**: `package/` - The actual DataTable component exported to npm - **Documentation site**: `app/`, `components/` - Next.js app with examples and docs - **Build outputs**: Package builds to `dist/`, docs build for GitHub Pages deployment ### Package Development Flow + ```bash # Core development commands (use pnpm, not yarn despite legacy docs) pnpm dev # Start Next.js dev server for docs/examples @@ -20,6 +22,7 @@ pnpm lint # ESLint + TypeScript checks ``` ### Component Architecture Pattern + The DataTable follows a **composition-based architecture** with specialized sub-components: ```typescript @@ -39,21 +42,26 @@ Each sub-component has its own `.tsx`, `.css`, and sometimes `.module.css` files ## Development Conventions ### Import Alias Pattern + Examples use `import { DataTable } from '__PACKAGE__'` - this resolves to the local package during development. Never import from `mantine-datatable` in examples. ### TypeScript Patterns + - **Generic constraints**: `DataTable` where T extends record type - **Prop composition**: Props inherit from base Mantine components (TableProps, etc.) - **Accessor pattern**: Use `idAccessor` prop for custom ID fields, defaults to `'id'` ### CSS Architecture + - **Layered imports**: `styles.css` imports all component styles - **CSS layers**: `@layer mantine, mantine-datatable` for proper specificity - **Utility classes**: Defined in `utilityClasses.css` for common patterns - **CSS variables**: Dynamic values injected via `cssVariables.ts` ### Hook Patterns + Custom hooks follow the pattern `useDataTable*` and are located in `package/hooks/`: + - `useDataTableColumns` - Column management and persistence - `useRowExpansion` - Row expansion state - `useLastSelectionChangeIndex` - Selection behavior @@ -61,7 +69,9 @@ Custom hooks follow the pattern `useDataTable*` and are located in `package/hook ## Documentation Development ### Example Structure + Each example in `app/examples/` follows this pattern: + ``` feature-name/ ├── page.tsx # Next.js page with controls @@ -70,17 +80,21 @@ feature-name/ ``` ### Code Block Convention + Use the `CodeBlock` component for syntax highlighting. Example files should be minimal and focused on demonstrating a single feature clearly. ## Data Patterns ### Record Structure + Examples use consistent data shapes: + - `companies.json` - Basic company data with id, name, address -- `employees.json` - Employee data with departments/relationships +- `employees.json` - Employee data with departments/relationships - `async.ts` - Simulated API calls with delay/error simulation ### Selection Patterns + - **Gmail-style additive selection**: Shift+click for range selection - **Trigger modes**: `'checkbox'` | `'row'` | `'cell'` - **Custom selection logic**: Use `isRecordSelectable` for conditional selection @@ -88,12 +102,14 @@ Examples use consistent data shapes: ## Build System ### Package Build (tsup) + - **ESM**: `tsup.esm.ts` - Modern module format -- **CJS**: `tsup.cjs.ts` - CommonJS compatibility +- **CJS**: `tsup.cjs.ts` - CommonJS compatibility - **Types**: `tsup.dts.ts` - TypeScript declarations - **CSS**: PostCSS processes styles to `dist/` ### Documentation Deployment + - **GitHub Pages**: `output: 'export'` in `next.config.js` - **Base path**: `/mantine-datatable` when `GITHUB_PAGES=true` - **Environment injection**: Package version, NPM downloads via build-time fetch @@ -101,6 +117,7 @@ Examples use consistent data shapes: ## Common Patterns ### Adding New Features + 1. Create component in `package/` with `.tsx` and `.css` files 2. Add to main `DataTable.tsx` component composition 3. Export new types from `package/types/index.ts` @@ -108,12 +125,14 @@ Examples use consistent data shapes: 5. Update main navigation in `app/config.ts` ### Styling New Components + - Use CSS custom properties for theming - Follow existing naming: `.mantine-datatable-component-name` - Import CSS in `package/styles.css` - Add utility classes to `utilityClasses.css` if reusable ### TypeScript Integration + - Extend base Mantine props where possible - Use composition over inheritance for prop types - Export all public types from `package/types/index.ts` @@ -124,4 +143,4 @@ Examples use consistent data shapes: - **Virtualization**: Not implemented - DataTable handles reasonable record counts (< 1000s) - **Memoization**: Use `useMemo` for expensive column calculations - **CSS-in-JS**: Avoided in favor of CSS modules for better performance -- **Bundle size**: Keep dependencies minimal (only Mantine + React) \ No newline at end of file +- **Bundle size**: Keep dependencies minimal (only Mantine + React) diff --git a/app/config.ts b/app/config.ts index 7d60ccee..9de5ee4f 100644 --- a/app/config.ts +++ b/app/config.ts @@ -182,6 +182,11 @@ export const ROUTES: RouteInfo[] = [ title: 'Handling cell clicks', description: `Example: handling cell click events on ${PRODUCT_NAME}`, }, + { + href: '/examples/inline-cell-editing', + title: 'Inline cell editing', + description: `Example: inline cell editing with ${PRODUCT_NAME}`, + }, { href: '/examples/using-with-mantine-contextmenu', title: `Using with ${MANTINE_CONTEXTMENU_PRODUCT_NAME}`, diff --git a/app/examples/inline-cell-editing/InlineCellEditingExample.tsx b/app/examples/inline-cell-editing/InlineCellEditingExample.tsx new file mode 100644 index 00000000..aca14774 --- /dev/null +++ b/app/examples/inline-cell-editing/InlineCellEditingExample.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { DataTable } from '__PACKAGE__'; +import { useMemo, useState } from 'react'; +import companies from '~/data/companies.json'; + +type Company = { + id: string; + name: string; + streetAddress: string; + city: string; + state: string; + employees: number; + foundedDate: string; +}; + +export function InlineCellEditingExample() { + const initialRecords = useMemo( + () => + companies.slice(0, 5).map((company, index) => ({ + ...company, + employees: 100 + index * 50, + foundedDate: new Date(2015 + index, index % 12, 1).toISOString(), + })), + [] + ); + + const [data, setData] = useState(initialRecords); + + const handleEdit = (record: Company, index: number) => { + const newData = [...data]; + newData[index] = record; + setData(newData); + }; + + return ( + + ); +} diff --git a/app/examples/inline-cell-editing/page.tsx b/app/examples/inline-cell-editing/page.tsx new file mode 100644 index 00000000..e1d765e8 --- /dev/null +++ b/app/examples/inline-cell-editing/page.tsx @@ -0,0 +1,46 @@ +import type { Route } from 'next'; +import { PRODUCT_NAME } from '~/app/config'; +import { CodeBlock } from '~/components/CodeBlock'; +import { PageNavigation } from '~/components/PageNavigation'; +import { PageTitle } from '~/components/PageTitle'; +import { Txt } from '~/components/Txt'; +import { readCodeFile } from '~/lib/code'; +import { allPromiseProps, getRouteMetadata } from '~/lib/utils'; +import { InlineCellEditingExample } from './InlineCellEditingExample'; + +const PATH: Route = '/examples/inline-cell-editing'; + +export const metadata = getRouteMetadata(PATH); + +export default async function InlineEditingExamplePage() { + const code = await allPromiseProps({ + 'InlineCellEditingExample.tsx': readCodeFile(`${PATH}/InlineCellEditingExample.tsx`), + 'companies.json': readCodeFile('/../data/companies.json'), + }); + + return ( + <> + + + This example demonstrates how to implement inline cell editing in {PRODUCT_NAME}. This is achieved by setting + the editable property to true in the column definition. Additionally, the{' '} + onEdit callback is provided to handle updates to the record when the cell value is changed. + + + The editType property allows you to specify the type of input to use when editing cells. Supported + types are: text (default), number, date, and boolean. The + DataTable automatically renders the appropriate input component for each type - TextInput for text, NumberInput + for numbers, DatePickerInput for dates, and Checkbox for booleans. + + + This is baked in to the DataTable component for the column definitions, so no additional libraries + are required. However, this only supports the basic single cell editing scenario, for a more complex case of + editing the entire row or adding validation it is still recommended to implement the logic yourself by changing + the logic of the render function of the column to show input fields when in edit mode. + + + + + + ); +} diff --git a/package.json b/package.json index 82f72790..175b6309 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ }, "peerDependencies": { "@mantine/core": ">=8.3", + "@mantine/dates": ">=8.3", "@mantine/hooks": ">=8.3", "clsx": ">=2", "react": ">=19", diff --git a/package/DataTableRow.tsx b/package/DataTableRow.tsx index 596e454e..86817e91 100644 --- a/package/DataTableRow.tsx +++ b/package/DataTableRow.tsx @@ -115,6 +115,9 @@ export function DataTableRow({ cellsClassName, cellsStyle, customCellAttributes, + editable, + onEdit, + editType, } = { ...defaultColumnProps, ...columnProps }; return ( @@ -148,6 +151,9 @@ export function DataTableRow({ render={render} defaultRender={defaultColumnRender} customCellAttributes={customCellAttributes} + editable={editable} + onEdit={onEdit} + editType={editType} /> ); })} diff --git a/package/DataTableRowCell.tsx b/package/DataTableRowCell.tsx index d5616846..c6c4fb29 100644 --- a/package/DataTableRowCell.tsx +++ b/package/DataTableRowCell.tsx @@ -1,5 +1,7 @@ -import { TableTd, type MantineStyleProp } from '@mantine/core'; +import { type MantineStyleProp, NumberInput, TableTd, TextInput } from '@mantine/core'; +import { DatePickerInput } from '@mantine/dates'; import clsx from 'clsx'; +import { useState } from 'react'; import { useMediaQueryStringOrFunction } from './hooks'; import type { DataTableColumn } from './types'; import { @@ -26,7 +28,17 @@ type DataTableRowCellProps = { onContextMenu: React.MouseEventHandler | undefined; } & Pick< DataTableColumn, - 'accessor' | 'visibleMediaQuery' | 'textAlign' | 'width' | 'noWrap' | 'ellipsis' | 'render' | 'customCellAttributes' + | 'accessor' + | 'visibleMediaQuery' + | 'textAlign' + | 'width' + | 'noWrap' + | 'ellipsis' + | 'render' + | 'customCellAttributes' + | 'editable' + | 'onEdit' + | 'editType' >; export function DataTableRowCell({ @@ -46,15 +58,131 @@ export function DataTableRowCell({ render, defaultRender, customCellAttributes, + editable, + onEdit, + editType = 'text', }: DataTableRowCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [editedValue, setEditedValue] = useState(''); + if (!useMediaQueryStringOrFunction(visibleMediaQuery)) return null; + + const handleEdit = () => { + if (onEdit && editType !== 'date') { + const valueToSave = editedValue; + const newRecord = { + ...record, + [accessor as keyof T]: valueToSave as T[keyof T], + }; + onEdit(newRecord, index); + } + setIsEditing(false); + }; + + const handleClick = (e: React.MouseEvent) => { + if (editable) { + e.stopPropagation(); + setIsEditing(true); + const currentValue = getValueAtPath(record, accessor); + if (editType === 'date' && typeof currentValue === 'string') { + setEditedValue(new Date(currentValue)); + } else { + setEditedValue(currentValue as string | number | Date); + } + } + onClick?.(e); + }; + + let cellContent: React.ReactNode; + if (render) { + cellContent = render(record, index); + } else if (defaultRender) { + cellContent = defaultRender(record, index, accessor); + } else { + const value = getValueAtPath(record, accessor); + if (editType === 'date' && (value instanceof Date || typeof value === 'string')) { + const date = value instanceof Date ? value : new Date(value); + cellContent = date.toLocaleDateString(); + } else { + cellContent = value as React.ReactNode; + } + } + + const renderEditInput = () => { + switch (editType) { + case 'number': + return ( + setEditedValue(value ?? 0)} + onBlur={handleEdit} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleEdit(); + } + if (event.key === 'Escape') { + setIsEditing(false); + } + }} + autoFocus + /> + ); + case 'date': + return ( + { + if (value) { + const date = + typeof value === 'object' && value !== null && 'toISOString' in value + ? (value as Date) + : new Date(value as string); + setEditedValue(date); + if (onEdit) { + const dateValue = date.toISOString(); + const newRecord = { + ...record, + [accessor as keyof T]: dateValue as T[keyof T], + }; + onEdit(newRecord, index); + } + setIsEditing(false); + } + }} + autoFocus + popoverProps={{ withinPortal: true }} + /> + ); + default: + return ( + setEditedValue(event.currentTarget.value)} + onBlur={handleEdit} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleEdit(); + } + if (event.key === 'Escape') { + setIsEditing(false); + } + }} + autoFocus + /> + ); + } + }; + return ( ({ }, style, ]} - onClick={onClick} + onClick={handleClick} onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} {...customCellAttributes?.(record, index)} > - {render - ? render(record, index) - : defaultRender - ? defaultRender(record, index, accessor) - : (getValueAtPath(record, accessor) as React.ReactNode)} + {isEditing ? renderEditInput() : cellContent} ); } diff --git a/package/types/DataTableColumn.ts b/package/types/DataTableColumn.ts index ff3d32b0..a3d642e1 100644 --- a/package/types/DataTableColumn.ts +++ b/package/types/DataTableColumn.ts @@ -169,22 +169,48 @@ export type DataTableColumn> = { } & ( | { /** - * If true, cell content in this column will be truncated with ellipsis as needed and will not wrap - * to multiple lines (i.e. `overflow: hidden; text-overflow: ellipsis`; `white-space: nowrap`). - * On a column, you can either set this property or `noWrap`, but not both. + * If true, the cells in this column will be editable. */ - ellipsis?: boolean; - - noWrap?: never; + editable?: false; + onEdit?: never; + editType?: never; } | { - ellipsis?: never; - /** - * If true, cell content in this column will not wrap to multiple lines (i.e. `white-space: nowrap`). - * This is useful for columns containing long strings. - * On a column, you can either set this property or `ellipsis`, but not both. + * If true, the cells in this column will be editable. + */ + editable: true; + /** + * Callback fired when a cell in this column is edited. + * Receives the edited record and its index as arguments. + */ + onEdit: (record: T, index: number) => void; + /** + * Type of input to use when editing cells in this column. + * @default 'text' */ - noWrap?: boolean; + editType?: 'text' | 'number' | 'date'; } -); +) & + ( + | { + /** + * If true, cell content in this column will be truncated with ellipsis as needed and will not wrap + * to multiple lines (i.e. `overflow: hidden; text-overflow: ellipsis`; `white-space: nowrap`). + * On a column, you can either set this property or `noWrap`, but not both. + */ + ellipsis?: boolean; + + noWrap?: never; + } + | { + ellipsis?: never; + + /** + * If true, cell content in this column will not wrap to multiple lines (i.e. `white-space: nowrap`). + * This is useful for columns containing long strings. + * On a column, you can either set this property or `ellipsis`, but not both. + */ + noWrap?: boolean; + } + ); diff --git a/package/utils.ts b/package/utils.ts index ca7eb50a..45fb46cc 100644 --- a/package/utils.ts +++ b/package/utils.ts @@ -1,7 +1,6 @@ import type { DropResult } from '@hello-pangea/dnd'; import type { DataTableColumn, DataTableColumnGroup } from './types'; - /** * Utility function that returns a humanized version of a string, e.g. "camelCase" -> "Camel Case" */ @@ -138,7 +137,6 @@ export function calculateColSpan(group: DataTableColumnGroup, visibles?: ( return 0; } - /** * Gets all groups at a specific depth level */ @@ -161,7 +159,6 @@ export function getGroupsAtDepth( return result; } - /** * Checks if a group needs a right border based on its position and context */ @@ -173,4 +170,3 @@ export function needsRightBorder( if (!withColumnBorders) return false; return !isLastGroup || hasMoreColumnsAfter; } -