Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Mantine DataTable - Copilot Instructions

This is a dual-purpose repository containing both the **Mantine DataTable** component package and its documentation website. Understanding this hybrid architecture is crucial for effective development.

## 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
pnpm build:package # Build package only (tsup + postcss)
pnpm build:docs # Build documentation site
pnpm build # Build both package and docs
pnpm lint # ESLint + TypeScript checks
```

### Component Architecture Pattern
The DataTable follows a **composition-based architecture** with specialized sub-components:

```typescript
// Main component in package/DataTable.tsx
DataTable -> {
DataTableHeader,
DataTableRow[],
DataTableFooter,
DataTablePagination,
DataTableLoader,
DataTableEmptyState
}
```

Each sub-component has its own `.tsx`, `.css`, and sometimes `.module.css` files. Always maintain this parallel structure when adding features.

## 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<T>` 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

## Documentation Development

### Example Structure
Each example in `app/examples/` follows this pattern:
```
feature-name/
├── page.tsx # Next.js page with controls
├── FeatureExample.tsx # Actual DataTable implementation
└── FeaturePageContent.tsx # Documentation content
```

### 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
- `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

## Build System

### Package Build (tsup)
- **ESM**: `tsup.esm.ts` - Modern module format
- **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

## 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`
4. Create example in `app/examples/new-feature/`
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`
- Maintain strict null checks and proper generics

## Performance Considerations

- **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)
16 changes: 12 additions & 4 deletions app/examples/column-resizing/ResizingExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ import { companies, type Company } from '~/data';
export default function ResizingExample() {
const key = 'resize-example';

const [resizable, setResizable] = useState<boolean>(true);
const [withTableBorder, setWithTableBorder] = useState<boolean>(true);
const [withColumnBorders, setWithColumnBorders] = useState<boolean>(true);

const { effectiveColumns, resetColumnsWidth } = useDataTableColumns<Company>({
key,
columns: [
{ accessor: 'name', width: 200, resizable: true },
{ accessor: 'streetAddress', resizable: true },
{ accessor: 'city', ellipsis: true, resizable: true },
{ accessor: 'state', textAlign: 'right' },
{ accessor: 'name', resizable },
{ accessor: 'streetAddress', resizable },
{ accessor: 'city', resizable },
{ accessor: 'state', resizable },
{ accessor: 'missionStatement', resizable },
],
});

Expand All @@ -32,6 +34,12 @@ export default function ResizingExample() {
/>
<Group grow justify="space-between">
<Group justify="flex-start">
<Switch
checked={resizable}
onChange={(event) => setResizable(event.currentTarget.checked)}
labelPosition="left"
label="Resizable"
/>
<Switch
checked={withTableBorder}
onChange={(event) => setWithTableBorder(event.currentTarget.checked)}
Expand Down
122 changes: 120 additions & 2 deletions package/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Box, Table, type MantineSize } from '@mantine/core';
import { useMergedRef } from '@mantine/hooks';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DataTableColumnsProvider } from './DataTableDragToggleProvider';
import { DataTableEmptyRow } from './DataTableEmptyRow';
import { DataTableEmptyState } from './DataTableEmptyState';
Expand Down Expand Up @@ -138,6 +138,11 @@ export function DataTable<T>({
return effectiveColumns.some((col) => col.resizable);
}, [effectiveColumns]);

// When columns are resizable, start with auto layout to let the browser
// compute natural widths, then capture them and switch to fixed layout.
const [fixedLayoutEnabled, setFixedLayoutEnabled] = useState(false);
const prevHasResizableRef = useRef<boolean | null>(null);

const dragToggle = useDataTableColumns({
key: storeColumnsKey,
columns: effectiveColumns,
Expand All @@ -159,6 +164,119 @@ export function DataTable<T>({

const rowExpansionInfo = useRowExpansion<T>({ rowExpansion, records, idAccessor });

// Initialize content-based widths when resizable columns are present.
useEffect(() => {
// If resizable just became disabled, revert to auto layout
if (!hasResizableColumns) {
prevHasResizableRef.current = false;
setFixedLayoutEnabled(false);
return;
}

// Only run when switching from non-resizable -> resizable
if (prevHasResizableRef.current === true) return;
prevHasResizableRef.current = true;

let raf = requestAnimationFrame(() => {
const thead = refs.header.current;
if (!thead) {
setFixedLayoutEnabled(true);
return;
}

const headerCells = Array.from(thead.querySelectorAll<HTMLTableCellElement>('th[data-accessor]'));

if (headerCells.length === 0) {
setFixedLayoutEnabled(true);
return;
}

let measured = headerCells
.map((cell) => {
const accessor = cell.getAttribute('data-accessor');
if (!accessor || accessor === '__selection__') return null;
const width = Math.round(cell.getBoundingClientRect().width);
return { accessor, width } as const;
})
.filter(Boolean) as Array<{ accessor: string; width: number }>;

const viewport = refs.scrollViewport.current;
const viewportWidth = viewport?.clientWidth ?? 0;
if (viewportWidth && measured.length) {
const total = measured.reduce((acc, u) => acc + u.width, 0);
const overflow = total - viewportWidth;
if (overflow > 0) {
const last = measured[measured.length - 1];
last.width = Math.max(50, last.width - overflow);
}
}

const updates = measured.map((m) => ({ accessor: m.accessor, width: `${m.width}px` }));

setTimeout(() => {
if (updates.length) dragToggle.setMultipleColumnWidths(updates);
setFixedLayoutEnabled(true);
}, 0);
});

return () => cancelAnimationFrame(raf);
}, [hasResizableColumns]);

// If user resets widths to 'initial', recompute widths and re-enable fixed layout.
const allResizableWidthsInitial = useMemo(() => {
if (!hasResizableColumns) return false;
return effectiveColumns
.filter((c) => c.resizable && !c.hidden && c.accessor !== '__selection__')
.every((c) => c.width === undefined || c.width === '' || c.width === 'initial');
}, [effectiveColumns, hasResizableColumns]);

useEffect(() => {
if (!hasResizableColumns) return;
if (!allResizableWidthsInitial) return;

// Temporarily disable fixed layout so natural widths can be measured
setFixedLayoutEnabled(false);

let raf = requestAnimationFrame(() => {
const thead = refs.header.current;
if (!thead) {
setFixedLayoutEnabled(true);
return;
}

const headerCells = Array.from(thead.querySelectorAll<HTMLTableCellElement>('th[data-accessor]'));

let measured = headerCells
.map((cell) => {
const accessor = cell.getAttribute('data-accessor');
if (!accessor || accessor === '__selection__') return null;
const width = Math.round(cell.getBoundingClientRect().width);
return { accessor, width } as const;
})
.filter(Boolean) as Array<{ accessor: string; width: number }>;

const viewport = refs.scrollViewport.current;
const viewportWidth = viewport?.clientWidth ?? 0;
if (viewportWidth && measured.length) {
const total = measured.reduce((acc, u) => acc + u.width, 0);
const overflow = total - viewportWidth;
if (overflow > 0) {
const last = measured[measured.length - 1];
last.width = Math.max(50, last.width - overflow);
}
}

const updates = measured.map((m) => ({ accessor: m.accessor, width: `${m.width}px` }));

setTimeout(() => {
if (updates.length) dragToggle.setMultipleColumnWidths(updates);
setFixedLayoutEnabled(true);
}, 0);
});

return () => cancelAnimationFrame(raf);
}, [hasResizableColumns, allResizableWidthsInitial, refs.header, dragToggle]);

const handlePageChange = useCallback(
(page: number) => {
refs.scrollViewport.current?.scrollTo({ top: 0, left: 0 });
Expand Down Expand Up @@ -267,7 +385,7 @@ export function DataTable<T>({
'mantine-datatable-pin-last-column': pinLastColumn,
'mantine-datatable-selection-column-visible': selectionColumnVisible,
'mantine-datatable-pin-first-column': pinFirstColumn,
'mantine-datatable-resizable-columns': hasResizableColumns,
'mantine-datatable-resizable-columns': fixedLayoutEnabled,
},
classNames?.table
)}
Expand Down