diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bcbfda..4d322d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - name: Lint run: npm run lint + - name: Unit tests + run: npm test + - name: Build library run: npm run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 036f6f4..d2af691 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,6 +27,9 @@ jobs: - name: Lint run: npm run lint + - name: Unit tests + run: npm test + - name: Build library run: npm run build diff --git a/.gitignore b/.gitignore index 7b5c7ba..2f7ea27 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,12 @@ docs-dist *.njsproj *.sln *.sw? + +# local snapshots, never committed +Archive.zip +*.tgz + +# local only: browser test scripts and working notes +scripts/e2e/ +docs/WALKTHROUGH-3.0.0.md +HANDOFF.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a780a4b..006b3e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,27 +11,55 @@ react-table-dnd is a React table component library with drag-and-drop reordering ``` src/ ├── Components/ -│ ├── TableContainer/ # Root provider — drag context, state machine, refs -│ │ ├── index.tsx # TableProvider with DragContext + useReducer -│ │ ├── useTable.tsx # TableContext & useTable hook -│ │ └── styles.tsx # Scoped styled-components (minimal resets) -│ ├── Draggable.tsx # Wraps any row/column cell — creates clone on pointerdown +│ ├── TableContainer/ # Root provider — store, reducer, refs, the drag clone element +│ │ ├── index.tsx # TableContainer: props, reducer, controlled/uncontrolled selection +│ │ ├── store.ts # Tiny external store (useSyncExternalStore), no-op dispatch guard +│ │ ├── useTable.tsx # useTable / useTableStore(selector) / useTableDispatch +│ │ └── styles.tsx # Root wrapper div +│ ├── Draggable.tsx # Outer/inner divs every row and column cell renders (data-rtdnd="draggable") │ ├── BodyRow.tsx # Row wrapper (Draggable type="row") │ ├── ColumnCell.tsx # Column header cell (Draggable type="column") -│ ├── RowCell.tsx # Cell within a row — hides when its column is dragged +│ ├── RowCell.tsx # Cell within a row (a column drag hides and shifts it through the DOM) │ ├── TableHeader.tsx # Header container — syncs horizontal scroll with body │ ├── TableBody.tsx # Body container — scrollable, hosts rows │ ├── DragHandle.tsx # Optional grip icon to restrict drag start area -│ ├── utils.ts # Binary search for drop targets, range validation +│ ├── SelectHandle.tsx # Optional checkbox area: only presses inside it change the selection +│ ├── style.css # Scoped library CSS (import 'react-table-dnd/styles') +│ ├── utils.ts # Range validation, arrayMoveMultiple / moveRowsById helpers │ └── index.ts # Public API exports ├── hooks/ -│ ├── types.ts # TypeScript interfaces (DraggedState, Options, etc.) -│ ├── useDragContextEvents.tsx # Main orchestrator — drag start/move/end/cancel +│ ├── types.ts # TypeScript interfaces (DraggedState, DragGroup, Options, etc.) +│ ├── useDragContextEvents.tsx # Orchestrator — sequences one drag (start/move/end/cancel) +│ ├── useSelectionGestures.ts # Press / release / tap gestures → selection commits +│ ├── useDeferredActivation.ts # The 5px wait between mousedown and a row drag +│ ├── useDragClone.ts # The element that follows the pointer (card or column strip) +│ ├── useHiddenElements.ts # Rows / cells hidden during a drag, restored afterwards +│ ├── useDragWindowEvents.ts # Window pointer/keyboard listeners while a drag runs +│ ├── useDropTarget.ts # Collapsed-coordinate drop resolution (rows, columns) +│ ├── useShiftTransforms.ts # Sibling shift transforms + placeholder +│ ├── useDropSettle.ts # FLIP "unfold" animation after a drop +│ ├── useIndexMaps.ts # O(1) index → element maps, staleness detection │ ├── useAutoScroll.ts # Edge-zone auto-scroll with acceleration -│ └── useLongPress.ts # Mobile long-press detection + JS scrolling fallback +│ ├── useLongPress.ts # Mobile long-press detection + JS scrolling fallback +│ └── drag/ # Pure decision logic (no React; `dom.ts` / `cloneBuilders.ts` touch the DOM only) +│ ├── constants.ts # Timings, distances, transition strings +│ ├── selectionRules.ts # pressRule / releaseRule / tapRule / selectHandlePressRule +│ ├── dragGroup.ts # resolveDragGroup, member height prefix sums +│ ├── dropMath.ts # midpoint counting, gap ↔ targetIndex ↔ insertIndex +│ ├── shiftMath.ts # shiftFor, placeholderSlot, affectedRange +│ ├── dom.ts # findDraggable, select-handle lookups, row queries +│ ├── cloneBuilders.ts # buildRowCard / buildColumnStrip / topUpColumnStrip +│ ├── inlineStyleStash.ts # saves the inline styles a drag overwrites, restores them after +│ └── __tests__/ # vitest unit tests for the pure modules (`npm test`) └── examples/ # 8+ demo files showing various configurations ``` +**One drag model.** Every drag is described by a `DragGroup` (`ids`, sorted `indices`, per-row +`heights`, `grabbed`, `cardHeight`, `count`). A plain single-row drag is a group of one; there is no +separate single-row code path. Decisions (which rows form the group, where the slot is, how far +each row slides, what a press does to the selection) are pure functions under `hooks/drag/` with +unit tests; the hooks own refs and DOM writes; the orchestrator only sequences them. + --- ## Component Hierarchy @@ -53,18 +81,18 @@ TableContainer (Context Provider) ### Phase 1: Drag Start -**Desktop:** `mousedown` on a row/column → `beginDrag()` fires immediately. +**Desktop:** `mousedown` on a row/column → `beginDrag()` fires immediately (tables with `selectable` on defer row drags until the pointer has moved 5px — see Multi-select). **Mobile:** Touch on a row → 300ms long-press timer starts. During the wait, `preventDefault()` is called on every `touchmove` to block native scrolling. If the finger moves >8px, the timer is cancelled and JS-based scrolling takes over. If the finger stays still for 300ms, `beginDrag()` fires. -`beginDrag()` does the following: -1. Walks up the DOM from the event target to find the `.draggable` element -2. Checks if a `DragHandle` is present — if so, drag only starts from the handle -3. Captures the dragged element's size, position, and index -4. Caches all row/column positions (via `computeRowItems()` / `computeColumnItems()`) -5. Caches the container rect (used by auto-scroll — calculated once, never again) -6. Dispatches `dragStart` to the reducer — React renders the clone element -7. Positions the clone at the element's current viewport location +`beginDrag()` checks that the press can become a drag (the row is still mounted, a `DragHandle` +row was pressed on its handle, a touch press was not resolved as a tap) and then `startDrag()`: +1. Flushes a previous drop that is still animating, then resets the per-drag state +2. Measures the grabbed element and the container once (`captureGeometry`) and builds the index maps +3. Resolves the `DragGroup` (`resolveDragGroup`): the selection if the grabbed row is in it, else the row alone +4. Caches all row/column positions in collapsed coordinates (`computeRowItems()` / `computeColumnItems()`) +5. Hides the grabbed row and every other member (`useHiddenElements`), builds the clone (`useDragClone`) +6. Dispatches `dragStart` to the reducer — React renders the clone element at the row's location ### Phase 2: Drag Move @@ -72,13 +100,13 @@ TableContainer (Context Provider) 1. **Clone follows finger/cursor** — sets `transform: translate(x, y)` on the clone element. This is a pure CSS write, no React re-render. -2. **Drop target detection** — uses `binarySearchDropIndex()` (O(log n)) to find which row/column the pointer is over. The search operates in absolute scroll-space coordinates so it works regardless of the current scroll position. +2. **Drop target detection** — `resolveDropIndex()` works in *collapsed* coordinates: the dragged row/column is removed from the cached item list and later items are pulled up/left by its size, so thresholds match what the user sees. The gap is the number of collapsed items whose midpoint lies before the dragged item's leading edge (O(log n) binary search), i.e. a swap needs half the neighbour's height/width of travel in either direction — the same rule for every column width. Rows are measured in absolute scroll-space so the cache survives scrolling. 3. **Visual feedback** — when the drop target changes, `applyShiftTransforms()` runs via `requestAnimationFrame`: - Iterates all rows/columns - Applies `translateY()` / `translateX()` to shift siblings out of the way - Positions the placeholder indicator at the drop gap - - Uses CSS transitions (`all 450ms cubic-bezier(0.2, 0, 0, 1)`) for smooth animation + - Uses CSS transitions (`transform 450ms cubic-bezier(0.2, 0, 0, 1)`) for smooth animation 4. **Auto-scroll** — if the pointer is within 30px of the container edge, `startAutoScroll()` is triggered (see [Auto-Scroll](#auto-scroll) below). @@ -92,9 +120,42 @@ TableContainer (Context Provider) 4. Fires `onDragEnd({ sourceIndex, targetIndex, dragType })` — the consumer reorders their data 5. Dispatches `dragEnd` to the reducer — React unmounts the clone 6. Restores scroll position (synchronously + in `requestAnimationFrame` to survive React's reflow) +7. Row drags: a FLIP pass (`useDropSettle`) measures every mounted row before and after the consumer's re-render and transitions each from its old visual spot to its new slot (~260ms). Rows that moved start on the card's landing slot, invisible, so a group unfolds out of the card instead of popping open. Skipped under `prefers-reduced-motion`; cancelled by a new drag. --- +### Multi-select & group drag (rows) + +Opt-in via `selectable` on `TableContainer`. Selection lives in the store (`state.selection = { ids: Set, anchorId, anchorIndex }` — the anchor row for Shift ranges is tracked by id, with the index as a fallback) and is synced from the `selectedIds` prop when controlled. `BodyRow` subscribes to `ids.has(id)`, so only rows whose flag flips re-render. + +**Gestures** are split across two moments, because a drag starts on `mousedown` and the press that starts dragging a group must not deselect it (Finder semantics): + +| Gesture | mousedown (`pressRule`) | mouseup before the drag activates, i.e. under 5px of movement (`releaseRule`) | +|---|---|---| +| plain, row not selected | selection = [row] | — | +| plain, row selected | — (group may be dragged) | selection = [row], or [] if it was the only one | +| plain, empty body space (`dragStart`) | selection = [] | — | +| Escape (not dragging) | selection = [] | — | +| Ctrl/Cmd, not selected | add row | — | +| Ctrl/Cmd, selected | — | remove row | +| Shift | range(anchor..row) | — | + +Ctrl, Cmd and Alt are all treated as the toggle modifier. The rules are pure functions in `hooks/drag/selectionRules.ts` (`pressRule`, `releaseRule`, `tapRule`, `selectHandlePressRule`); `useSelectionGestures` reads the store, runs the rule and commits the result. With `selectable` on, `dragStart` does not call `beginDrag` on mousedown: it runs the press rule, then `useDeferredActivation` waits on window pointer events until the pointer has moved `DRAG_ACTIVATION_DISTANCE` (5px) before activating the drag, so a click never flashes the clone or starts a drag. Non-selectable tables keep the immediate mousedown activation. + +**`` rows.** When a row contains `[data-select-handle]`, the press rule only runs for presses inside it: a press toggles the row (Shift: adds the range) and is resolved immediately, so it never becomes a drag; a press elsewhere on the row leaves the selection alone but may still drag (the group if the row is selected). Empty-space clearing is disabled for tables that use select handles. + +Touch: a tap (no long-press, no scroll) toggles the row via `useLongPress.onTap` (only inside the handle for `` rows). + +**Group drag.** `resolveDragGroup` (`hooks/drag/dragGroup.ts`) builds the `DragGroup` for every drag: when the grabbed row belongs to a multi-row selection it holds every selected, mounted, unlocked row (`ids` in table order, sorted `indices`, per-row `heights`, `count`), otherwise just the grabbed row. The clone is a compact card: the grabbed row plus a `[data-rtdnd="drag-count"]` badge and a stacked-card shadow. The members left in the table are hidden (`opacity: 0`, left in flow so nothing reflows) and removed from the drop-target items; at drag start `useHiddenElements.foldGroupRows` animates each one into the card (a `GRAB_FOLD_MS` = 360ms frame loop that chases the card's live position, since it is already following the pointer, and allows for body scroll; the row stays opaque for the first half and fades during the second, positioned and raised so it is drawn above the sliding rows), the mirror of the drop unfold, after which it is in the plain hidden state. `useShiftTransforms` then moves every non-member row by `shiftFor` (`hooks/drag/shiftMath.ts`): + +``` +shift(i) = (i >= gap ? +H : 0) - Σ heights of members with index < i (gap = insertIndex) +``` + +where `H` is `group.cardHeight` (the grabbed row's height), so the gap that opens is one row tall and the other members simply vanish from the list; with one member this is the classic single-row shift. The placeholder (`placeholderSlot`) is `H` tall and sits above the first non-member row at or after the gap. + +The drop target is resolved in the same **collapsed coordinates**: `useDropTarget.computeRowItems` drops every member and pulls each later row up by the member heights above it, so the thresholds match what the user sees rather than the stale layout. The gap is the number of collapsed rows whose midpoint lies above the clone's top edge (`resolveDrop` in `hooks/drag/dropMath.ts`). That rule needs half a neighbour's height of travel before the gap moves in either direction, so a small nudge never shifts anything. A single-row drag is the one-member case of the same rule. On drop, `DragEndResult` carries `selectedIds`, `sourceIndices` and `insertIndex` (the gap in the original array, `targetIndex + (source < target ? 1 : 0)`); `moveRowsById` / `arrayMoveMultiple` apply it. In virtual tables only mounted members can be resolved, so ids are the source of truth. + ## Auto-Scroll **File:** `src/hooks/useAutoScroll.ts` @@ -141,7 +202,7 @@ Mobile drag-and-drop required solving several Chrome Android-specific issues. Chrome Android evaluates `touch-action` at `pointerdown` time. Setting it dynamically (e.g., 300ms later after confirming a long press) is **ignored**. If `touch-action` is not `none` when the finger touches down, Chrome can fire `touchcancel` at any time (especially when programmatic scrolling via `scrollTop` occurs), killing all touch and pointer event delivery. -**Solution:** `touch-action: none` is set permanently on the body element (via `useEffect` on mount in `useDragContextEvents`). Since this disables native touch scrolling, `useLongPress` implements JS-based scrolling as a fallback when the long press is cancelled. +**Solution:** `touch-action: none` is in place before the finger lands: `style.css` sets it on draggable rows and drag handles, `Draggable` sets it on each row's outer element (not on locked rows, not on rows that have a `DragHandle`), and `useHiddenElements.grab` locks the whole table while a drag runs. Since this disables native touch scrolling on those rows, `useLongPress` scrolls in JavaScript when the long press is cancelled, and passes the rest of a swipe on to the page once the table body reaches its edge. ### Problem 2: No `scrollTop` Writes During Touch Handlers @@ -177,9 +238,9 @@ touchstart → start 300ms timer | Operation | Cost | When | |---|---|---| -| `computeRowItems()` | O(n) × `getBoundingClientRect()` | Every `dragMove` call (recomputes fresh positions) | +| `computeRowItems()` | O(n) × `getBoundingClientRect()` | Once at drag start; again only after auto-scroll or a virtual remount invalidates the cache | | `applyShiftTransforms()` | O(n) DOM writes + 2 `getBoundingClientRect()` | Only when drop target changes (via `requestAnimationFrame`) | -| `binarySearchDropIndex()` | O(log n) | Every `dragMove` call | +| `resolveDropIndex()` | O(log n) | Every `dragMove` call | | `autoScroll` tick | ~0ms (cached rect, pure `scrollTop` write) | Every animation frame during auto-scroll | | `dragMove` pointer/clone update | ~0ms (style write + ref update) | Every pointer/touch move | @@ -187,11 +248,11 @@ touchstart → start 300ms timer 1. **No React re-renders during drag** — all visual updates are direct DOM manipulation (transforms, inline styles). React only renders on `dragStart` (clone creation) and `dragEnd` (cleanup). -2. **Binary search for drop targets** — O(log n) instead of iterating all elements. Items are cached in absolute scroll-space so they survive scroll position changes. +2. **Binary search for drop targets** — O(log n) over the cached collapsed item list instead of iterating all elements. Row items are cached in absolute scroll-space so they survive scroll position changes. 3. **Event delegation** — single `mousedown`/`touchstart` listener on the table element, not one per row. -4. **CSS transitions for shifts** — `all 450ms cubic-bezier(0.2, 0, 0, 1)` on sibling transforms. The browser handles the animation on the compositor thread. +4. **CSS transitions for shifts** — `transform 450ms cubic-bezier(0.2, 0, 0, 1)` on sibling transforms. The browser handles the animation on the compositor thread. 5. **Deferred shift transforms** — `applyShiftTransforms` is batched via `requestAnimationFrame`. Multiple drop index changes per frame collapse into one DOM update. @@ -200,27 +261,31 @@ touchstart → start 300ms timer ## Data Flow ``` -User drags row 5 to position 3: +User drags row 5 (nothing selected) to position 3: -1. pointerdown/touchstart - └── beginDrag() → dispatch("dragStart") → React renders clone +1. mousedown (selectable: after 5px of movement) / 300ms long-press + └── beginDrag() → startDrag() + ├── resolveDragGroup → a group of one: { indices: [5], grabbed: 5 } + ├── computeRowItems() [once: collapsed positions] + ├── hide row 5, clone.build() → buildRowCard [direct DOM] + └── dispatch("dragStart") [React render #1] -2. pointermove/touchmove (60x/sec) +2. pointermove/touchmove (one call per animation frame) └── dragMove(x, y) - ├── clone.style.transform = translate(x, y) [direct DOM] - ├── dropIndex = binarySearch(y, cachedItems) [O(log n)] - ├── applyShiftTransforms(5, 3, "row") [rAF batched] - │ ├── row 3: translateY(+height) [shift down] - │ ├── row 4: translateY(+height) [shift down] - │ └── placeholder at row 3 position - └── startAutoScroll() if near edge + ├── clone.follow(x − grabX, y − grabY) [direct DOM] + ├── target = resolveDropIndex(...) [O(log n), no DOM reads] + └── if target changed → next frame: applyShiftTransforms(target) + ├── rows 3, 4: translateY(+height) [shiftFor, affected range only] + └── placeholder above row 3 [placeholderSlot] 3. pointerup/touchend - └── dragEnd() - ├── onDragEnd({ source: 5, target: 3, type: "row" }) - │ └── consumer calls arrayMove(data, 5, 3) + setState - ├── dispatch("dragEnd") → React unmounts clone - └── restore scroll position + └── dragEnd() → clone.snapTo(placeholder) → 200ms → finalizeDrop() + ├── settle.measure() → hidden.restore() + ├── onDragEnd({ sourceIndex: 5, targetIndex: 3, dragType: "row", + │ selectedIds: ["row-5"], sourceIndices: [5], insertIndex: 3 }) + │ └── consumer: moveRowsById(data, selectedIds, insertIndex) [React render #2] + ├── clearShiftTransforms(), restore scroll + └── settle.play() [FLIP to the new layout] ``` --- @@ -232,13 +297,20 @@ interface DragEndResult { sourceIndex: number; targetIndex: number; dragType: "row" | "column"; + // row drags only — see Multi-select + selectedIds?: string[]; // every row that moved, in table order + sourceIndices?: number[]; // their indices as rendered + insertIndex?: number; // gap in the original array to insert the group at } -interface Options { - rowDragRange: { start?: number; end?: number }; - columnDragRange: { start?: number; end?: number }; +// the `options` prop of TableContainer (both optional) +interface TableOptionsProp { + rowDragRange?: { start?: number; end?: number }; // end is inclusive + columnDragRange?: { start?: number; end?: number }; } +// internal Options in the store also carry defaultSizing, selectable, showDragCount + interface HookRefs { tableRef: MutableRefObject | null; bodyRef: MutableRefObject | null; @@ -256,9 +328,9 @@ interface HookRefs { import { TableContainer, TableHeader, TableBody, BodyRow, ColumnCell, RowCell, DragHandle } from "react-table-dnd"; { - if (dragType === "row") reorderRows(sourceIndex, targetIndex); - else reorderColumns(sourceIndex, targetIndex); + onDragEnd={(r) => { + if (r.dragType === "row") setRows((p) => moveRowsById(p, r.selectedIds!, r.insertIndex!)); + else if (r.sourceIndex !== r.targetIndex) setColumns((p) => arrayMove(p, r.sourceIndex, r.targetIndex)); }} options={{ rowDragRange: { start: 1 }, // freeze first row @@ -267,7 +339,7 @@ import { TableContainer, TableHeader, TableBody, BodyRow, ColumnCell, RowCell, D > {columns.map((col, i) => ( - + {col.title} @@ -289,8 +361,8 @@ import { TableContainer, TableHeader, TableBody, BodyRow, ColumnCell, RowCell, D ## Build & Distribution -- **Build:** `npm run build` → `tsc -b && vite build` -- **Output:** `dist/index.es.js` (ESM), `dist/index.cjs.js` (CJS), `dist/index.d.ts` (types) -- **Tree-shakeable:** `sideEffects: false` -- **Peer deps:** React >=17.0.0 -- **Runtime deps:** `classnames`, `styled-components` +- **Build:** `npm run build` → `tsc -b && vite build && node scripts/finish-build.mjs` +- **Output:** `dist/index.es.js` (ESM), `dist/index.cjs` (CommonJS), `dist/index.d.ts` (one rolled-up types file) and `dist/index.d.cts` (the same, for `require`), `dist/react-table-dnd.css` +- **Side effects:** `["**/*.css"]` (the JS is tree-shakeable; the stylesheet must not be dropped) +- **Peer deps:** `react` / `react-dom` >= 18.0.0 +- **Runtime deps:** none diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee379b..1670ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,79 @@ # Changelog +## 3.0.0 (2026-10-02) + +### Breaking / behaviour changes + +- Drop resolution changed for every drag: drops follow the placeholder more precisely. `targetIndex` keeps its 2.x meaning (the index `arrayMove(data, sourceIndex, targetIndex)` expects), but the same pointer position can give a different index than in 2.x +- Row drags always report `selectedIds`, `sourceIndices` and `insertIndex` in `DragEndResult`, even on tables without `selectable` (a test that compares the whole result object will see the extra fields) +- `DragEndResult` is now a union on `dragType` (`RowDragEndResult | ColumnDragEndResult`): after `if (r.dragType === 'row')` the three row fields are typed as always present, so no `!` is needed. Code that builds a row result object by hand (e.g. in tests) must include them +- `BodyRow` and `ColumnCell` now declare `id` and `index` as required props in their types (they were always required at runtime; omitting them used to compile and produce a row id of `"undefined"`) +- `BodyRow`, `ColumnCell` and `RowCell` no longer accept any prop name in their types (the `[propName: string]: any` signature is gone; unknown props were silently dropped at runtime). Real HTML attributes (`aria-*`, `data-*`, `title`, `onClick`, …) are now typed and passed to the row / header cell / cell element; `TableContainer`, `TableHeader`, `TableBody`, `DragHandle` and `SelectHandle` pass them to their element too +- `DraggableProps` is no longer exported (it described an internal component); every public component's props type is exported instead: `TableContainerProps`, `TableHeaderProps`, `ColumnCellProps`, `TableBodyProps`, `BodyRowProps`, `RowCellProps`, `DragHandleProps`, `SelectHandleProps` +- The drag card is `[data-rtdnd='clone']` instead of `id="portalroot"` (two tables on one page rendered the same id); update CSS that targets `#portalroot` +- Rows get `data-selected="true"` while selected whenever `selectedIds` or `defaultSelectedIds` is passed +- The CommonJS build is now `dist/index.cjs` (was `dist/index.cjs.js`); `require('react-table-dnd')` resolves to it through `exports`. Only deep imports of the old file path are affected +- With `selectable` on, a row drag starts after the pointer has moved 5px (a click never flashes the clone); non-selectable tables and columns still start on mousedown +- Escape during a drag now cancels the drag only: it no longer reaches your own Escape handlers (a surrounding dialog stays open) +- Presses in a text field, textarea, select or contenteditable inside a row no longer start a drag +- The v2 README, architecture notes and examples are archived under `docs/v2/` + +### Features + +- Multi-select rows: `selectable`, `selectedIds`, `defaultSelectedIds`, `onSelectionChange` on `TableContainer`; click, Ctrl/Cmd/Alt+click and Shift+click on desktop, tap on touch; pressing empty body space or Escape (when your last press was in the table) clears the selection +- Group drag: dragging a selected row moves the whole selection; you drag a compact card with a count badge, the other selected rows fold into the card as the drag starts (360ms), and on drop they unfold out of the landing slot into place (260ms); both animations are skipped under `prefers-reduced-motion` +- `DragEndResult` gains `selectedIds`, `sourceIndices` and `insertIndex`; `RowDragEndResult` / `ColumnDragEndResult` types; new `moveRowsById` and `arrayMoveMultiple` helpers; `SelectionState` type exported +- `disabled` on `BodyRow` and `ColumnCell` works: that item cannot be picked up (it was typed but ignored in 2.x) +- Drag lifecycle callbacks on `TableContainer`: `onDragStart`, `onDragOver` (each new drop slot, with what a drop there would give) and `onDragCancel` (Escape, window blur, a release without moving). New `DragStartInfo` and `DragCancelInfo` types. Every event, `DragEndResult` included, carries `id`: the id of the grabbed row or column +- Docs: a TanStack Table guide and a **TanStack Table** demo (checkboxes, a filter, drops that keep hidden rows in place; `@tanstack/react-table` 9 is a dev dependency of the docs only), and a Drag events guide with a live event log +- `showDragCount` prop on `TableContainer` (default `true`) to hide the count badge on the group drag card +- `SelectHandle` component: checkbox-style selection — only presses inside the handle change the selection for that row +- `BodyRow` gains `selectedClassName` / `selectedStyle` and sets `data-selected="true"` while selected +- Styling hooks: `[data-rtdnd='drag-count']` (badge), `[data-rtdnd='drag-stack']` (ghost cards), `--rtdnd-card-bg` (card colour), `[data-drop-target]` (the row/column the drop will land on) +- `options.rowDragRange` and `options.columnDragRange` are now optional in the type (either can be left out) +- Demos: new Multi-Select and Virtual + Multi-Select demos; checkbox (`SelectHandle`) selection in Showcase and Custom Heights; click selection in Tailwind, styled-components, className & style, Fixed Sizes, Drag Handle, Column Widths, Scrollable Cells and Drag Ranges + +### Changed + +- Drops are consistent in both directions and with mixed row heights and column widths. Previously moving an item forward reacted much sooner than moving it back, which felt uneven with mixed column widths +- Under `prefers-reduced-motion` the drop is applied immediately instead of after the 200ms snap + +### Bug Fixes + +- `require('react-table-dnd')` threw `exports is not defined in ES module scope` (the CommonJS build was a `.js` file in a `"type": "module"` package) +- Types did not resolve under `moduleResolution: node16` / `nodenext` (extension-less relative imports); the types are now one self-contained file, with a `.d.cts` copy for `require` +- Server-side rendering failed with "Missing getServerSnapshot", and React 18 printed a `useLayoutEffect` warning for every table rendered on the server +- The published types needed `esModuleInterop` / `allowSyntheticDefaultImports`; without them every component's props silently became `any` +- A dragged row lost its grab cursor for good after the drop (the engine cleared the inline cursor React had set) +- A drag erased inline styles you set yourself: `opacity`, `transform` and `transition` on `RowCell`s after a column drag, and `touch-action` on `TableContainer`; a column drop also left the engine's transition on half the body cells +- During auto-scroll several rows could carry `[data-drop-target]` at once +- A drag kept following the pointer after the mouse button was released outside the window or the window lost focus; it now cancels +- Buttons, links and other controls inside a row never got their clicks in a table without `selectable`: the press started a drag at once. A press on a control now waits for a 5px move before dragging +- With `selectable`, a row could not be dragged by pressing an image or a link in it: the browser's own drag of the image took the pointer +- A selected row removed from the data (not scrolled away in a virtual table) still counted in the group: the drag card showed the wrong count and `selectedIds` listed the missing id +- Shift+click could start a range from a row that was no longer on screen or selected (for example after a filter hid it and the selection was cleared); it now starts a new range +- Touch: lifting a second finger dropped the drag; a long-press that turned into a click (off a drag handle) could start an auto-scroll that never stopped; a swipe on a row could not scroll the page once the table body reached its edge +- Scrolling the page, a parent or the table body without moving the pointer (a wheel, a sideways scroll) or resizing the window during a drag left the drop slot where the rows used to be +- Virtual tables: rows that mounted during a column drag were neither hidden, shifted nor shown in the drag card +- Escape stopped clearing the selection when the table sat inside a focusable element (a dialog, a `tabIndex={-1}` wrapper): clicking plain cell text focuses that wrapper, which counted as leaving the table +- Touch scrolling did not work on rows or columns locked by `rowDragRange` / `columnDragRange` +- Passing a `ref` to `TableHeader` broke column drag and header/body scroll sync +- An element with its own `data-id` attribute inside a cell was mistaken for a draggable row +- The idle drag clone was not hidden, so browsers with always-visible scrollbars could paint empty scrollbars in the top-left corner +- Escape during a drag also closed surrounding dialogs, and Escape during the 5px wait cleared the selection +- Escape during the 200ms drop snap reported the drag a second time with no target +- A press during the 200ms drop snap was resolved against the pre-drop layout (wrong row, wrong Shift range) +- A timer from a previous tap could re-enable mouse handling during the next tap on touch devices +- `rowDragRange: { end: 0 }` was ignored when resolving drop slots +- Escape during a drag now restores the dragged row / column cells instead of leaving them invisible +- A press exactly on an SVG shape inside a `DragHandle` (e.g. one of the grip dots) no longer gets mistaken for a scrollbar click and ignored +- Starting a new drag while the previous drop is still snapping into place now finalizes that drop before measuring the pressed row +- `DragRange.end` was documented as exclusive; it is inclusive + +### Internals + +- The drag engine was split: one drag model (every drag is a `DragGroup`, a single row is a group of one), pure decision modules under `src/hooks/drag/` (selection rules, group resolution, drop maths, shift maths), and small hooks for gestures, deferred activation, the clone, hidden elements, the fold and the settle animation. `useDragContextEvents` only sequences them +- Unit tests with vitest (`npm test`) for the pure modules and the `arrayMoveMultiple` / `moveRowsById` helpers, run in CI before the build +- `styled-components` and `classnames` removed from the bundler externals (neither was used) ## 2.0.18 (2026-07-17) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b669f60..535179c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,129 +1,33 @@ -# Contributing to react-table-dnd +# Contributing -Thank you for your interest in contributing! This guide will help you get started. - -## Getting Started - -### Prerequisites - -- Node.js >= 18 -- npm >= 9 - -### Setup - -1. **Fork** the repository on GitHub -2. **Clone** your fork locally: - ```bash - git clone https://github.com//react-table-dnd.git - cd react-table-dnd - ``` -3. **Install** dependencies: - ```bash - npm install - ``` -4. **Start** the dev server: - ```bash - npm run dev - ``` - -## Development Workflow - -### Branch Naming - -Create a branch from `main` using one of these prefixes: - -| Prefix | Use for | -| ------------ | ------------------------------ | -| `feat/` | New features | -| `fix/` | Bug fixes | -| `refactor/` | Code refactoring | -| `docs/` | Documentation changes | -| `chore/` | Tooling, CI, dependency updates | - -Example: `feat/virtual-scroll-improvements` - -### Commit Messages - -We follow [Conventional Commits](https://www.conventionalcommits.org/): +Node 18 or newer. +```bash +git clone https://github.com//react-table-dnd.git +cd react-table-dnd +npm install +npm run dev # docs site with the live demos +npm test # unit tests +npm run lint && npm run format:check && npm run build ``` -(): -[optional body] +Prettier runs on commit. -[optional footer] -``` +## Branches and commits -**Types:** `feat`, `fix`, `refactor`, `docs`, `chore`, `test`, `perf`, `style` +Branch from `main` with one of `feat/`, `fix/`, `refactor/`, `docs/`, `chore/`, for example `feat/virtual-scroll`. -Examples: -- `feat(drag): add horizontal auto-scroll support` -- `fix(column): prevent drag outside container bounds` -- `docs: update README with virtual scroll example` +Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat(drag): add horizontal auto-scroll`, `fix(column): keep the drag inside the container`, `docs: virtual scroll example`. -### Code Style +## Pull requests -- Code is auto-formatted with **Prettier** on commit via Husky + lint-staged -- Run `npm run format` to format all files manually -- Run `npm run lint` to check for linting errors -- Run `npm run format:check` to verify formatting without writing +- One feature or fix per PR. +- Add user-facing changes to `CHANGELOG.md`. -### Making Changes +## Bugs and features -1. Create your feature branch: `git checkout -b feat/my-feature` -2. Make your changes -3. Ensure your code is formatted: `npm run format` -4. Ensure linting passes: `npm run lint` -5. Build the library to check for errors: `npm run build` -6. Commit your changes following the commit convention above -7. Push to your fork: `git push origin feat/my-feature` -8. Open a **Pull Request** against `main` - -## Pull Request Guidelines - -- Fill out the PR template completely -- Keep PRs focused — one feature or fix per PR -- Update `CHANGELOG.md` for user-facing changes -- Ensure CI checks pass -- Be responsive to review feedback - -## Reporting Bugs - -Use the [Bug Report](https://github.com/samiodeh1337/react-table-dnd/issues/new?template=bug_report.md) template. Include: - -- Steps to reproduce -- Expected vs actual behavior -- A minimal reproduction (CodeSandbox/StackBlitz link is ideal) - -## Requesting Features - -Use the [Feature Request](https://github.com/samiodeh1337/react-table-dnd/issues/new?template=feature_request.md) template. Describe: - -- The problem you're trying to solve -- Your proposed solution -- Any alternatives you've considered - -## Project Structure - -``` -src/ -├── Components/ # Core table components -│ ├── TableContainer/ # Main container + useTable hook -│ ├── TableHeader.tsx # Header row -│ ├── TableBody.tsx # Body rows -│ ├── BodyRow.tsx # Individual row -│ ├── ColumnCell.tsx # Header cell -│ ├── RowCell.tsx # Body cell -│ ├── Draggable.tsx # Drag wrapper -│ ├── DragHandle.tsx # Drag handle component -│ └── index.ts # Public exports -├── hooks/ # Custom hooks -│ ├── useAutoScroll.ts -│ ├── useDragContextEvents.tsx -│ └── useLongPress.ts -└── examples/ # Demo examples (docs site) -``` +Use the [bug report](https://github.com/samiodeh1337/react-table-dnd/issues/new?template=bug_report.md) or [feature request](https://github.com/samiodeh1337/react-table-dnd/issues/new?template=feature_request.md) template. For a bug, a minimal reproduction (CodeSandbox or StackBlitz) helps most. ## License -By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE). +Contributions are licensed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index 2ae28bc..7cf0f23 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,11 @@ # react-table-dnd

- react-table-dnd — drag rows and columns + react-table-dnd — drag rows and columns

Drag-and-drop row & column reordering for React tables.

-

60fps animations · Auto-scroll · Mobile long-press · Virtual scrolling · Zero UI deps

-

npm bundle size @@ -31,19 +29,11 @@ --- -## Why react-table-dnd? +> Upgrading from 2.x? The v2 README, architecture notes and examples are archived in [`docs/v2/`](https://github.com/samiodeh1337/react-table-dnd/blob/main/docs/v2/README.md). See the [changelog](https://github.com/samiodeh1337/react-table-dnd/blob/main/CHANGELOG.md) for what changed in 3.0. + +## Features -- **Rows & columns** — reorder both independently, automatic direction detection -- **60fps** — direct DOM transforms during drag, no React re-renders until drop -- **Mobile** — long-press to drag on touch devices, optimized for Chrome Android & Safari iOS -- **Auto-scroll** — accelerates when dragging near container edges -- **100k+ rows** — works with `@tanstack/react-virtual` -- **Drag handles** — restrict drag to a grip icon with `` -- **Constraints** — lock specific rows or columns via drag range options -- **Drop animation** — clone smoothly flies to the drop target -- **Fully styleable** — `className` + `style` on every component — Tailwind, styled-components, CSS modules -- **TypeScript** — full type definitions out of the box -- **Tiny** — only peer dependency is React +Reorder rows and columns, drag a multi-row selection as one group, virtual lists of 100,000+ rows, touch, drag handles, locked ranges. No dependencies besides React. ## Install @@ -62,11 +52,14 @@ import 'react-table-dnd/styles' ## Quick Start ```jsx +import { useState } from "react"; import { TableContainer, TableHeader, ColumnCell, - TableBody, BodyRow, RowCell, + TableBody, BodyRow, RowCell, moveRowsById, } from "react-table-dnd"; +import "react-table-dnd/styles"; +// columns move one at a time: take one item out and put it back at targetIndex function arrayMove(arr, from, to) { const next = [...arr]; const [item] = next.splice(from, 1); @@ -88,9 +81,10 @@ export default function App() { return ( { - if (dragType === "column") setCols(arrayMove(cols, sourceIndex, targetIndex)); - else setRows(arrayMove(rows, sourceIndex, targetIndex)); + onDragEnd={(r) => { + // one row or the whole selection + if (r.dragType === "row") setRows((prev) => moveRowsById(prev, r.selectedIds, r.insertIndex)); + else setCols((prev) => arrayMove(prev, r.sourceIndex, r.targetIndex)); }} > @@ -122,40 +116,70 @@ export default function App() { | Component | Props | Description | |---|---|---| -| **`TableContainer`** | `onDragEnd`, `options`, `renderPlaceholder`, `className`, `style` | Root wrapper — provides drag context | +| **`TableContainer`** | `onDragEnd`, `onDragStart`, `onDragOver`, `onDragCancel`, `options`, `renderPlaceholder`, `selectable`, `selectedIds`, `defaultSelectedIds`, `onSelectionChange`, `showDragCount`, `className`, `style` | Root; provides the drag context | | **`TableHeader`** | `className`, `style` | Header row container | -| **`ColumnCell`** | **`id`**, **`index`**, `className`, `style` | Draggable column header cell | -| **`TableBody`** | `className`, `style` | Scrollable body — pass `ref` for virtual scrolling | -| **`BodyRow`** | **`id`**, **`index`**, `className`, `style` | Draggable row | +| **`ColumnCell`** | **`id`**, **`index`**, `disabled`, `className`, `style` | Draggable column header cell | +| **`TableBody`** | `className`, `style` | Scrollable body. Its `ref` is the scroll element for a virtualizer | +| **`BodyRow`** | **`id`**, **`index`**, `disabled`, `className`, `style`, `styles`, `selectedClassName`, `selectedStyle` | Draggable row. `styles` styles the row's outer element (the one a virtual list positions) | | **`RowCell`** | **`index`**, `className`, `style` | Cell within a row | -| **`DragHandle`** | `className`, `style` | Wrap inside BodyRow/ColumnCell to restrict drag to this element | +| **`DragHandle`** | `className`, `style` | Only this element starts a drag | +| **`SelectHandle`** | `className`, `style` | Only this element changes the selection | + +Bold props are required. `disabled` means the row or column cannot be picked up (a row also cannot be selected); others can still be dropped around it (to pin items in place, use the drag ranges in `options`). Every component passes other HTML attributes (`role`, `aria-*`, `data-*`, `title`, …) to its element; `TableContainer` keeps `onMouseDown` / `onTouchStart` for itself. Every component's props type is exported: `BodyRowProps`, `TableContainerProps`, and so on. + +### Drag events -Bold props are required. +Besides `onDragEnd`, `TableContainer` takes `onDragStart`, `onDragOver` (the drop slot changed) and `onDragCancel` (Escape, the window lost focus, or released without moving). Every drag calls `onDragStart`, then either `onDragEnd` or `onDragCancel`. + +### TanStack Table + +These components can render a TanStack Table, which keeps the column order while `onDragEnd` updates it. See the [TanStack Table guide](https://samiodeh1337.github.io/react-table-dnd/#/docs/tanstack-table) for a full example. ### Column Width -Pass `width` inside the `style` prop on `ColumnCell`. Columns grow proportionally by default to fill available space. To fix a column at exactly its pixel size, also pass `flex`: +Pass `width` inside the `style` prop on `ColumnCell` (a number, or a pixel string like `"150px"`; the default is 50). The `RowCell`s in that column follow it. Columns grow proportionally by default to fill available space. To fix a column at exactly its pixel size, pass the same `flex` to the `ColumnCell` and to every `RowCell` in that column: ```jsx -{/* Flex — grows proportionally to fill container (default) */} +{/* default: grows to fill */} Name -{/* Fixed — stays exactly 150px regardless of container width */} +{/* fixed at 150px */} Name +… ``` ### Types ```typescript -interface DragEndResult { - sourceIndex: number; +// check dragType and TypeScript knows which fields are set +type DragEndResult = RowDragEndResult | ColumnDragEndResult; + +interface RowDragEndResult { + dragType: "row"; + id: string; // the grabbed row + sourceIndex: number; // index of the row you grabbed + targetIndex: number; // where the grabbed row lands (a one-item move, as in 2.x) + selectedIds: string[]; // every row that moved (rows scrolled out of view come last) + sourceIndices: number[]; // their indices as rendered + insertIndex: number; // the slot in the original array: pass it to moveRowsById +} + +interface ColumnDragEndResult { + dragType: "column"; + id: string; // the grabbed column + sourceIndex: number; // arrayMove(columns, sourceIndex, targetIndex) targetIndex: number; - dragType: "row" | "column"; } +// what onDragStart and onDragCancel receive +type DragStartInfo = + | { dragType: "row"; id: string; sourceIndex: number; selectedIds: string[]; sourceIndices: number[] } + | { dragType: "column"; id: string; sourceIndex: number }; +interface DragCancelInfo { dragType: "row" | "column"; id: string; sourceIndex: number } + interface DragRange { start?: number; // first draggable index - end?: number; // last draggable index (exclusive) + end?: number; // last draggable index (inclusive) } ``` @@ -165,7 +189,7 @@ interface DragRange { ``` @@ -183,6 +207,28 @@ import { DragHandle } from "react-table-dnd"; ``` +### Multi-select + +Turn on `selectable` and rows select with a click, `Ctrl`/`Cmd`+click (toggle) and `Shift`+click (range); a tap on touch. Dragging a selected row moves the whole selection. With `selectable` on, a row drag starts after the pointer moves a few pixels, so a click stays a click. + +```jsx +import { TableContainer, moveRowsById } from "react-table-dnd"; + +const [selected, setSelected] = useState([]); + + { + if (dragType === "column") setCols((prev) => arrayMove(prev, sourceIndex, targetIndex)); + else setRows((prev) => moveRowsById(prev, selectedIds, insertIndex)); // one row or the whole selection + }} +/> +``` + +Selected rows get `data-selected="true"`; `selectedClassName` / `selectedStyle` on `BodyRow` also work. Checkboxes, touch, virtual tables and locked rows: [Multi-select guide](https://samiodeh1337.github.io/react-table-dnd/#/docs/multi-select). + ### Custom Placeholder ```jsx @@ -239,14 +285,30 @@ const Col = styled(ColumnCell)` -## Browser Support +### Styling hooks + +| Selector / variable | What it targets | +|---|---| +| `[data-selected="true"]` | a selected row's `[data-rtdnd='tr']` element (or use `selectedClassName` / `selectedStyle`) | +| `[data-drop-target]` | the row or column the drop will land on, while dragging (style the `[data-rtdnd='tr']` or `[data-rtdnd='th']` inside it) | +| `[data-rtdnd='drag-count']` | the count badge on the group drag card | +| `[data-rtdnd='drag-stack']` | the two ghost cards under the group drag card | +| `[data-rtdnd='clone']` | the drag card itself; it carries `[data-group-size]` while several rows move | +| `--rtdnd-card-bg` | the drag card's colour when several rows move, copied from the row's first cell, the row or the table body, whichever has a solid background; to force one, set it on the card: `[data-group-size] { --rtdnd-card-bg: #1e293b !important; }` | + +### Advanced: the store + +`useTable()`, `useTableStore(selector)` and `useTableDispatch()` read and update the table's internal store from a component rendered inside `TableContainer`. They are exported for advanced integrations and marked experimental: the state shape and action types are internal and may change in a minor release. Prefer the props above. + +A `useTableStore` selector must return a primitive or an existing reference (`(s) => s.dragged.isDragging`, `(s) => s.selection`), never a new object: `(s) => ({ ... })` re-renders forever. + +## Server-side rendering + +The components render on the server; dragging starts on the client after hydration. In the Next.js App Router, put your table in a component that starts with `'use client'`. Both ESM (`import`) and CommonJS (`require`) entry points are published, with types for each. -| | Chrome | Firefox | Safari | Edge | -|---|---|---|---|---| -| **Desktop** | ✅ | ✅ | ✅ | ✅ | -| **Mobile** | ✅ | ✅ | ✅ | ✅ | +## Browser support -Mobile uses long-press to initiate drag. +Current Chrome, Firefox, Safari and Edge, on desktop and mobile. ## Contributing @@ -255,8 +317,10 @@ git clone https://github.com/samiodeh1337/react-table-dnd.git cd react-table-dnd npm install npm run dev # docs site at localhost:5173 +npm test # unit tests (vitest) for the pure drag logic under src/hooks/drag/ +npm run lint && npm run format:check ``` ## License -[MIT](LICENSE) © [Sami Odeh](https://github.com/samiodeh1337) +[MIT](https://github.com/samiodeh1337/react-table-dnd/blob/main/LICENSE) © [Sami Odeh](https://github.com/samiodeh1337) diff --git a/colhead-home-full-dark.png b/colhead-home-full-dark.png new file mode 100644 index 0000000..af92f9e Binary files /dev/null and b/colhead-home-full-dark.png differ diff --git a/colhead-home-full-light.png b/colhead-home-full-light.png new file mode 100644 index 0000000..761a0bc Binary files /dev/null and b/colhead-home-full-light.png differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..f35ddd3 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/docs/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/docs/components", + "utils": "@/docs/lib/utils", + "ui": "@/docs/components/ui", + "lib": "@/docs/lib", + "hooks": "@/docs/hooks" + } +} diff --git a/docs/desktop2.gif b/docs/desktop2.gif new file mode 100644 index 0000000..0c047e4 Binary files /dev/null and b/docs/desktop2.gif differ diff --git a/docs/v2/ARCHITECTURE.md b/docs/v2/ARCHITECTURE.md new file mode 100644 index 0000000..392f4ec --- /dev/null +++ b/docs/v2/ARCHITECTURE.md @@ -0,0 +1,298 @@ +> **Archived v2 documentation.** Architecture notes for react-table-dnd 2.x. The current version is in [ARCHITECTURE.md](../../ARCHITECTURE.md). + +# react-table-dnd — Architecture & Technical Deep Dive + +## Overview + +react-table-dnd is a React table component library with drag-and-drop reordering for both rows and columns. It achieves 60fps drag animations by manipulating the DOM directly (CSS transforms) instead of triggering React re-renders during drag. The library supports desktop (mouse/pen) and mobile (long-press + touch) with auto-scrolling near container edges. + +--- + +## Project Structure + +``` +src/ +├── Components/ +│ ├── TableContainer/ # Root provider — drag context, state machine, refs +│ │ ├── index.tsx # TableProvider with DragContext + useReducer +│ │ ├── useTable.tsx # TableContext & useTable hook +│ │ └── styles.tsx # Scoped styled-components (minimal resets) +│ ├── Draggable.tsx # Wraps any row/column cell — creates clone on pointerdown +│ ├── BodyRow.tsx # Row wrapper (Draggable type="row") +│ ├── ColumnCell.tsx # Column header cell (Draggable type="column") +│ ├── RowCell.tsx # Cell within a row — hides when its column is dragged +│ ├── TableHeader.tsx # Header container — syncs horizontal scroll with body +│ ├── TableBody.tsx # Body container — scrollable, hosts rows +│ ├── DragHandle.tsx # Optional grip icon to restrict drag start area +│ ├── utils.ts # Binary search for drop targets, range validation +│ └── index.ts # Public API exports +├── hooks/ +│ ├── types.ts # TypeScript interfaces (DraggedState, Options, etc.) +│ ├── useDragContextEvents.tsx # Main orchestrator — drag start/move/end/cancel +│ ├── useAutoScroll.ts # Edge-zone auto-scroll with acceleration +│ └── useLongPress.ts # Mobile long-press detection + JS scrolling fallback +└── examples/ # 8+ demo files showing various configurations +``` + +--- + +## Component Hierarchy + +``` +TableContainer (Context Provider) +├── TableHeader +│ └── ColumnCell × N → Draggable (type="column") +│ └── optional DragHandle +└── TableBody + └── BodyRow × N → Draggable (type="row") + ├── optional DragHandle + └── RowCell × N +``` + +--- + +## How Drag-and-Drop Works + +### Phase 1: Drag Start + +**Desktop:** `mousedown` on a row/column → `beginDrag()` fires immediately. + +**Mobile:** Touch on a row → 300ms long-press timer starts. During the wait, `preventDefault()` is called on every `touchmove` to block native scrolling. If the finger moves >8px, the timer is cancelled and JS-based scrolling takes over. If the finger stays still for 300ms, `beginDrag()` fires. + +`beginDrag()` does the following: +1. Walks up the DOM from the event target to find the `.draggable` element +2. Checks if a `DragHandle` is present — if so, drag only starts from the handle +3. Captures the dragged element's size, position, and index +4. Caches all row/column positions (via `computeRowItems()` / `computeColumnItems()`) +5. Caches the container rect (used by auto-scroll — calculated once, never again) +6. Dispatches `dragStart` to the reducer — React renders the clone element +7. Positions the clone at the element's current viewport location + +### Phase 2: Drag Move + +`dragMove(clientX, clientY)` is called on every pointer/touch move: + +1. **Clone follows finger/cursor** — sets `transform: translate(x, y)` on the clone element. This is a pure CSS write, no React re-render. + +2. **Drop target detection** — uses `binarySearchDropIndex()` (O(log n)) to find which row/column the pointer is over. The search operates in absolute scroll-space coordinates so it works regardless of the current scroll position. + +3. **Visual feedback** — when the drop target changes, `applyShiftTransforms()` runs via `requestAnimationFrame`: + - Iterates all rows/columns + - Applies `translateY()` / `translateX()` to shift siblings out of the way + - Positions the placeholder indicator at the drop gap + - Uses CSS transitions (`all 450ms cubic-bezier(0.2, 0, 0, 1)`) for smooth animation + +4. **Auto-scroll** — if the pointer is within 30px of the container edge, `startAutoScroll()` is triggered (see [Auto-Scroll](#auto-scroll) below). + +### Phase 3: Drag End + +`dragEnd()` runs when the finger lifts or mouse releases: + +1. Captures `targetIndex` and `sourceIndex` from refs +2. Saves the current scroll position (to restore after reflow) +3. Clears all shift transforms and hides the placeholder +4. Fires `onDragEnd({ sourceIndex, targetIndex, dragType })` — the consumer reorders their data +5. Dispatches `dragEnd` to the reducer — React unmounts the clone +6. Restores scroll position (synchronously + in `requestAnimationFrame` to survive React's reflow) + +--- + +## Auto-Scroll + +**File:** `src/hooks/useAutoScroll.ts` + +When the pointer enters the 30px edge zone of the scrollable container, auto-scrolling begins. + +### How It Works + +``` +startAutoScroll(speed, container, direction) +└── Sets flag, defers first tick via requestAnimationFrame + └── autoScroll(speed, ref, dir) [recursive rAF loop] + ├── Check pointer against cached container rect + │ └── If pointer left edge zone → stop + ├── ref.scrollTop += speed (or scrollLeft) + ├── Check boundary (scrollTop >= maxScroll or <= 0) → stop + └── Schedule next tick: rAF(autoScroll(speed + decay)) +``` + +### Key Design Decisions + +| Decision | Why | +|---|---| +| **Container rect cached once** (at drag start) | `getBoundingClientRect()` forces synchronous layout. Calling it 60x/sec starved touch events on mobile. The container doesn't move during drag, so one read is enough. | +| **First tick deferred** (not synchronous) | Writing `scrollTop` inside a touch event handler causes Chrome Android to reclaim the touch sequence (`touchcancel`), killing all future touch/pointer events. Deferring to `requestAnimationFrame` avoids this. | +| **Quadratic acceleration** | `decaySpeed += speed / 1000` each tick. Speed compounds: `speed + decaySpeed` feeds into the next tick. Starts slow, builds up naturally. No cap — the boundary check stops it. | +| **Pointer check every frame** | Uses cached rect + `pointerRef` (updated by drag handler). Pure in-memory comparison, no DOM reads. Stops auto-scroll when finger leaves edge zone. | + +### Scroll Position Preservation + +When the drag ends, `clearShiftTransforms()` removes CSS transforms from all rows. This causes a layout reflow that can shift `scrollTop`. The `onDragEnd` callback then triggers a React re-render (data reorder), causing another reflow. Scroll position is restored: +1. Synchronously after `clearShiftTransforms()` +2. In `requestAnimationFrame` after React re-renders + +--- + +## Mobile Support — The Hard Part + +**File:** `src/hooks/useLongPress.ts` + +Mobile drag-and-drop required solving several Chrome Android-specific issues. + +### Problem 1: `touch-action` Must Be Set Before Touch + +Chrome Android evaluates `touch-action` at `pointerdown` time. Setting it dynamically (e.g., 300ms later after confirming a long press) is **ignored**. If `touch-action` is not `none` when the finger touches down, Chrome can fire `touchcancel` at any time (especially when programmatic scrolling via `scrollTop` occurs), killing all touch and pointer event delivery. + +**Solution:** `touch-action: none` is set permanently on the body element (via `useEffect` on mount in `useDragContextEvents`). Since this disables native touch scrolling, `useLongPress` implements JS-based scrolling as a fallback when the long press is cancelled. + +### Problem 2: No `scrollTop` Writes During Touch Handlers + +Writing `ref.scrollTop` inside a `touchmove` handler causes Chrome Android to reclaim the touch for native scrolling (even with `touch-action: none` set after `pointerdown`). + +**Solution:** `startAutoScroll()` defers the first scroll tick to `requestAnimationFrame` instead of calling `autoScroll()` synchronously. The scroll write happens in a separate execution context, outside the touch handler. + +### Problem 3: Reliable Event Delivery + +Desktop uses `window.pointermove` for drag tracking — always reliable. Mobile originally used `tableEl.touchmove { passive: false }`, which blocks the compositor and can starve the main thread. + +**Solution:** With `touch-action: none` set permanently, Chrome delivers `pointermove` and `pointerup` reliably for touch input. The window-level pointer event listeners handle touch the same way as mouse — `dragMove()` and `dragEnd()` fire from `pointermove`/`pointerup` for all pointer types. The `touchmove` handler in `useLongPress` provides a fallback. + +### Problem 4: JS Scrolling Fallback + +With native scrolling disabled (`touch-action: none`), users need an alternative way to scroll the table when not dragging. + +**Solution:** `useLongPress` detects scroll intent (finger moves >8px during the 300ms wait) and switches to a JS scroll mode: + +``` +touchstart → start 300ms timer + ├── finger moves >8px → cancel timer, enter JS scroll mode + │ └── touchmove: body.scrollTop -= deltaY, body.scrollLeft -= deltaX + └── 300ms elapses → enter drag mode + └── touchmove: onDragMove(clientX, clientY) +``` + +--- + +## Performance Architecture + +### Where the Heavy Work Lives + +| Operation | Cost | When | +|---|---|---| +| `computeRowItems()` | O(n) × `getBoundingClientRect()` | Every `dragMove` call (recomputes fresh positions) | +| `applyShiftTransforms()` | O(n) DOM writes + 2 `getBoundingClientRect()` | Only when drop target changes (via `requestAnimationFrame`) | +| `binarySearchDropIndex()` | O(log n) | Every `dragMove` call | +| `autoScroll` tick | ~0ms (cached rect, pure `scrollTop` write) | Every animation frame during auto-scroll | +| `dragMove` pointer/clone update | ~0ms (style write + ref update) | Every pointer/touch move | + +### Why It's Fast + +1. **No React re-renders during drag** — all visual updates are direct DOM manipulation (transforms, inline styles). React only renders on `dragStart` (clone creation) and `dragEnd` (cleanup). + +2. **Binary search for drop targets** — O(log n) instead of iterating all elements. Items are cached in absolute scroll-space so they survive scroll position changes. + +3. **Event delegation** — single `mousedown`/`touchstart` listener on the table element, not one per row. + +4. **CSS transitions for shifts** — `all 450ms cubic-bezier(0.2, 0, 0, 1)` on sibling transforms. The browser handles the animation on the compositor thread. + +5. **Deferred shift transforms** — `applyShiftTransforms` is batched via `requestAnimationFrame`. Multiple drop index changes per frame collapse into one DOM update. + +--- + +## Data Flow + +``` +User drags row 5 to position 3: + +1. pointerdown/touchstart + └── beginDrag() → dispatch("dragStart") → React renders clone + +2. pointermove/touchmove (60x/sec) + └── dragMove(x, y) + ├── clone.style.transform = translate(x, y) [direct DOM] + ├── dropIndex = binarySearch(y, cachedItems) [O(log n)] + ├── applyShiftTransforms(5, 3, "row") [rAF batched] + │ ├── row 3: translateY(+height) [shift down] + │ ├── row 4: translateY(+height) [shift down] + │ └── placeholder at row 3 position + └── startAutoScroll() if near edge + +3. pointerup/touchend + └── dragEnd() + ├── onDragEnd({ source: 5, target: 3, type: "row" }) + │ └── consumer calls arrayMove(data, 5, 3) + setState + ├── dispatch("dragEnd") → React unmounts clone + └── restore scroll position +``` + +--- + +## Key Types + +```typescript +interface DragEndResult { + sourceIndex: number; + targetIndex: number; + dragType: "row" | "column"; +} + +interface Options { + rowDragRange: { start?: number; end?: number }; + columnDragRange: { start?: number; end?: number }; +} + +interface HookRefs { + tableRef: MutableRefObject | null; + bodyRef: MutableRefObject | null; + headerRef: MutableRefObject | null; + cloneRef: MutableRefObject | null; + placeholderRef: MutableRefObject | null; +} +``` + +--- + +## Consumer API + +```tsx +import { TableContainer, TableHeader, TableBody, BodyRow, ColumnCell, RowCell, DragHandle } from "react-table-dnd"; + + { + if (dragType === "row") reorderRows(sourceIndex, targetIndex); + else reorderColumns(sourceIndex, targetIndex); + }} + options={{ + rowDragRange: { start: 1 }, // freeze first row + columnDragRange: { start: 0, end: 5 }, // only first 5 columns draggable + }} +> + + {columns.map((col, i) => ( + + + {col.title} + + ))} + + + {rows.map((row, i) => ( + + {columns.map((col, ci) => ( + {row[col.id]} + ))} + + ))} + + +``` + +--- + +## Build & Distribution + +- **Build:** `npm run build` → `tsc -b && vite build` +- **Output:** `dist/index.es.js` (ESM), `dist/index.cjs.js` (CJS), `dist/index.d.ts` (types) +- **Tree-shakeable:** `sideEffects: false` +- **Peer deps:** React >=17.0.0 +- **Runtime deps:** `classnames`, `styled-components` diff --git a/docs/v2/CHANGELOG.md b/docs/v2/CHANGELOG.md new file mode 100644 index 0000000..07f6894 --- /dev/null +++ b/docs/v2/CHANGELOG.md @@ -0,0 +1,28 @@ +> **Archived v2 changelog.** Current changelog: [CHANGELOG.md](../../CHANGELOG.md). + +# Changelog +## 2.0.18 (2026-07-17) + +### Bug Fixes + +- Flush pending drop cleanup so a fast second drag (or a cancel) no longer races the ~200ms drop-snap animation timeout, which could leave the previous row hidden or corrupt drag state +- Skip the post-shift transform frame when the drag has already ended + +### Chores + +- Bump `styled-components`, `typescript-eslint`, and `vite` devDependencies + +## 1.0.0 (2025-03-14) + +### Features + +- Drag-and-drop reordering for both rows and columns +- Smooth 60fps animations using direct DOM transforms +- Auto-scroll when dragging near container edges +- Drag range constraints (`columnDragRange`, `rowDragRange`) +- Custom placeholder rendering via `renderPlaceholder` +- `DragHandle` component for restricting drag to a grip element +- Virtual scrolling support (compatible with `@tanstack/react-virtual`) +- Full `className` and `style` prop support on every component +- Event delegation — single listener regardless of row count +- TypeScript support with full type definitions diff --git a/docs/v2/README.md b/docs/v2/README.md new file mode 100644 index 0000000..4657ac7 --- /dev/null +++ b/docs/v2/README.md @@ -0,0 +1,264 @@ +> **Archived v2 documentation.** This is the README for react-table-dnd 2.x, kept for users on that major. The current docs live in the [repository README](../../README.md). + +

+ +# react-table-dnd + +

+ react-table-dnd — drag rows and columns +

+ +

Drag-and-drop row & column reordering for React tables.

+ +

60fps animations · Auto-scroll · Mobile long-press · Virtual scrolling · Zero UI deps

+ +

+ npm + bundle size + downloads + license +

+ +

+ Live Demos & Docs +  ·  + Quick Start +  ·  + API +  ·  + GitHub +

+ +
+ +--- + +## Why react-table-dnd? + +- **Rows & columns** — reorder both independently, automatic direction detection +- **60fps** — direct DOM transforms during drag, no React re-renders until drop +- **Mobile** — long-press to drag on touch devices, optimized for Chrome Android & Safari iOS +- **Auto-scroll** — accelerates when dragging near container edges +- **100k+ rows** — works with `@tanstack/react-virtual` +- **Drag handles** — restrict drag to a grip icon with `` +- **Constraints** — lock specific rows or columns via drag range options +- **Drop animation** — clone smoothly flies to the drop target +- **Fully styleable** — `className` + `style` on every component — Tailwind, styled-components, CSS modules +- **TypeScript** — full type definitions out of the box +- **Tiny** — only peer dependency is React + +## Install + +```bash +npm install react-table-dnd +``` + +> Requires `react` and `react-dom` >= 18.0.0 + +Then import the styles once in your app entry (e.g. `main.tsx`): + +```js +import 'react-table-dnd/styles' +``` + +## Quick Start + +```jsx +import { + TableContainer, TableHeader, ColumnCell, + TableBody, BodyRow, RowCell, +} from "react-table-dnd"; + +function arrayMove(arr, from, to) { + const next = [...arr]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; +} + +export default function App() { + const [cols, setCols] = useState([ + { id: "name", label: "Name", width: 150 }, + { id: "age", label: "Age", width: 100 }, + { id: "city", label: "City", width: 160 }, + ]); + const [rows, setRows] = useState([ + { id: "1", name: "Alice", age: 28, city: "NYC" }, + { id: "2", name: "Bob", age: 34, city: "LA" }, + { id: "3", name: "Carol", age: 22, city: "SF" }, + ]); + + return ( + { + if (dragType === "column") setCols(arrayMove(cols, sourceIndex, targetIndex)); + else setRows(arrayMove(rows, sourceIndex, targetIndex)); + }} + > + + {cols.map((col, i) => ( + + {col.label} + + ))} + + + {rows.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + + ); +} +``` + +## API + +### Components + +| Component | Props | Description | +|---|---|---| +| **`TableContainer`** | `onDragEnd`, `options`, `renderPlaceholder`, `className`, `style` | Root wrapper — provides drag context | +| **`TableHeader`** | `className`, `style` | Header row container | +| **`ColumnCell`** | **`id`**, **`index`**, `className`, `style` | Draggable column header cell | +| **`TableBody`** | `className`, `style` | Scrollable body — pass `ref` for virtual scrolling | +| **`BodyRow`** | **`id`**, **`index`**, `className`, `style` | Draggable row | +| **`RowCell`** | **`index`**, `className`, `style` | Cell within a row | +| **`DragHandle`** | `className`, `style` | Wrap inside BodyRow/ColumnCell to restrict drag to this element | + +Bold props are required. + +### Column Width + +Pass `width` inside the `style` prop on `ColumnCell`. Columns grow proportionally by default to fill available space. To fix a column at exactly its pixel size, also pass `flex`: + +```jsx +{/* Flex — grows proportionally to fill container (default) */} +Name + +{/* Fixed — stays exactly 150px regardless of container width */} +Name +``` + +### Types + +```typescript +interface DragEndResult { + sourceIndex: number; + targetIndex: number; + dragType: "row" | "column"; +} + +interface DragRange { + start?: number; // first draggable index + end?: number; // last draggable index (exclusive) +} +``` + +### Options + +```jsx + +``` + +### Drag Handle + +```jsx +import { DragHandle } from "react-table-dnd"; + + + + + Content here + + +``` + +### Custom Placeholder + +```jsx + ( +
+ )} +/> +``` + +## Styling + +Every component accepts `className` and `style`. No opinionated styles on cells. + + + + + + + + + + + + +
InlineTailwindstyled-components
+ +```jsx + +``` + + + +```jsx + +``` + + + +```jsx +const Col = styled(ColumnCell)` + padding: 0 16px; + font-weight: 700; +`; +``` + +
+ +## Browser Support + +| | Chrome | Firefox | Safari | Edge | +|---|---|---|---|---| +| **Desktop** | ✅ | ✅ | ✅ | ✅ | +| **Mobile** | ✅ | ✅ | ✅ | ✅ | + +Mobile uses long-press to initiate drag. + +## Contributing + +```bash +git clone https://github.com/samiodeh1337/react-table-dnd.git +cd react-table-dnd +npm install +npm run dev # docs site at localhost:5173 +``` + +## License + +[MIT](LICENSE) © [Sami Odeh](https://github.com/samiodeh1337) diff --git a/docs/v2/examples/README.md b/docs/v2/examples/README.md new file mode 100644 index 0000000..feeb927 --- /dev/null +++ b/docs/v2/examples/README.md @@ -0,0 +1,5 @@ +# react-table-dnd v2 examples (archived) + +These are the demo sources that shipped with react-table-dnd 2.x, kept for users still on that major. Imports point at `react-table-dnd`; the TypeScript files also import `./example-data.ts` from this folder. + +Current examples live in [`src/examples`](../../../src/examples) and run on the [docs site](https://samiodeh1337.github.io/react-table-dnd/). diff --git a/docs/v2/examples/example-data.ts b/docs/v2/examples/example-data.ts new file mode 100644 index 0000000..11bf120 --- /dev/null +++ b/docs/v2/examples/example-data.ts @@ -0,0 +1,50 @@ +// Shared data generation for examples + +export interface Row { + id: string + name: string + role: string + status: string + email: string + department: string + location: string + salary: string + joined: string + score: number + [key: string]: string | number +} + +const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] +const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] +const DEPTS = ['Engineering', 'Design', 'Product', 'Marketing', 'Sales', 'HR', 'Finance', 'Support'] +const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] +const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] +const LAST = ['Johnson', 'Smith', 'White', 'Brown', 'Davis', 'Lee', 'Kim', 'Miller', 'Chen', 'Park'] + +export function generateRows(count: number): Row[] { + const rows: Row[] = new Array(count) + for (let i = 0; i < count; i++) { + const first = FIRST[i % FIRST.length] + const last = LAST[Math.floor(i / FIRST.length) % LAST.length] + rows[i] = { + id: `row-${i}`, + name: `${first} ${last}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${first.toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + } + } + return rows +} + +export function arrayMove(arr: T[], from: number, to: number): T[] { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} diff --git a/docs/v2/examples/example-fixed.tsx b/docs/v2/examples/example-fixed.tsx new file mode 100644 index 0000000..3b71c02 --- /dev/null +++ b/docs/v2/examples/example-fixed.tsx @@ -0,0 +1,105 @@ +/** + * Example: Fixed-size table — 100 rows, fixed column widths, no virtualization. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'status', title: 'Status', width: 100 }, + { id: 'email', title: 'Email', width: 200 }, + { id: 'department', title: 'Dept', width: 120 }, + { id: 'joined', title: 'Joined', width: 100 }, +] + +const th: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 13, + fontWeight: 700, + color: '#94a3b8', + background: '#1e1e24', + borderBottom: '2px solid #2e2e36', + textTransform: 'uppercase', + letterSpacing: '0.04em', +} + +const td: React.CSSProperties = { + height: 36, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#16161c', + borderBottom: '1px solid #232329', + display: 'flex', + alignItems: 'center', +} + +const FixedExample = () => { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+

+ Fixed Sizes — {data.length} rows +

+ ( +
+ )} + style={{ height: 420, border: '1px solid #2e2e36', borderRadius: 8 }} + > + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + +
+ ) +} + +export default FixedExample diff --git a/docs/v2/examples/example-flex.tsx b/docs/v2/examples/example-flex.tsx new file mode 100644 index 0000000..a084560 --- /dev/null +++ b/docs/v2/examples/example-flex.tsx @@ -0,0 +1,160 @@ +/** + * Example: Custom row heights — 100 rows with varying heights, visible cell borders. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 170 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'department', title: 'Department', width: 140 }, + { id: 'email', title: 'Email', width: 210 }, + { id: 'location', title: 'Location', width: 130 }, +] + +const ROW_HEIGHTS = [40, 56, 72, 44, 88, 48, 64, 40, 96, 52] + +const th: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + height: 44, + padding: '0 14px', + fontSize: 11, + fontWeight: 800, + color: '#6ee7b7', + background: '#0d1f17', + borderBottom: '2px solid #134e33', + borderRight: '1px solid #1a3a28', + textTransform: 'uppercase', + letterSpacing: '0.06em', +} + +const makeTd = (h: number, isEven: boolean, isLast: boolean): React.CSSProperties => ({ + height: h, + padding: '8px 14px', + fontSize: 13, + color: '#cbd5e1', + background: isEven ? '#111a14' : '#0d1510', + borderBottom: '1px solid #1a2e20', + borderRight: isLast ? 'none' : '1px solid #1a2420', + display: 'flex', + alignItems: 'flex-start', + whiteSpace: 'pre-wrap', + lineHeight: '1.5', + overflow: 'hidden', +}) + +const heightLabel: React.CSSProperties = { + display: 'inline-block', + fontSize: 10, + fontWeight: 600, + color: '#6ee7b7', + background: '#064e3b', + borderRadius: 3, + padding: '1px 5px', + marginLeft: 6, + verticalAlign: 'middle', +} + +const CustomRowHeightsExample = () => { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + // Stable heights keyed by row id — survives reorder + const [heightMap] = useState>(() => { + const m = new Map() + generateRows(100).forEach((row, i) => m.set(row.id, ROW_HEIGHTS[i % ROW_HEIGHTS.length])) + return m + }) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+

+ Custom Row Heights — {data.length} rows (each row different size) +

+ ( +
+ Drop here +
+ )} + style={{ + height: 420, + border: '2px solid #134e33', + borderRadius: 10, + overflow: 'hidden', + background: '#0d1510', + }} + > + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => { + const h = heightMap.get(row.id) ?? 40 + const isEven = ri % 2 === 0 + return ( + + {cols.map((col, ci) => ( + + {ci === 0 ? ( + + {row[col.id]} + {h}px + + ) : ( + row[col.id] + )} + + ))} + + ) + })} + +
+
+ ) +} + +export default CustomRowHeightsExample diff --git a/docs/v2/examples/example-handle.tsx b/docs/v2/examples/example-handle.tsx new file mode 100644 index 0000000..5ba1e36 --- /dev/null +++ b/docs/v2/examples/example-handle.tsx @@ -0,0 +1,141 @@ +/** + * Example: Drag Handle — only drag from the grip icon, not the whole row/column. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, + DragHandle, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'email', title: 'Email', width: 200 }, + { id: 'department', title: 'Dept', width: 120 }, +] + +const GripIcon = () => ( + + + + + + + + +) + +const th: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + height: 40, + padding: '0 12px', + fontSize: 13, + fontWeight: 700, + color: '#94a3b8', + background: '#1e1e24', + borderBottom: '2px solid #2e2e36', + textTransform: 'uppercase', + letterSpacing: '0.04em', +} + +const td: React.CSSProperties = { + height: 36, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#16161c', + borderBottom: '1px solid #232329', + display: 'flex', + alignItems: 'center', +} + +const tdFirst: React.CSSProperties = { + ...td, + gap: 8, +} + +const handleStyle: React.CSSProperties = { + padding: '4px 2px', + borderRadius: 4, +} + +const DragHandleExample = () => { + const [data, setData] = useState(() => generateRows(50)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+
+

+ Drag Handle — {data.length} rows +

+

+ Only the ⡇ grip icon starts a drag. + Clicking elsewhere in the row/column does nothing. +

+
+ ( +
+ )} + style={{ height: 420, border: '1px solid #2e2e36', borderRadius: 8 }} + > + + {cols.map((col, i) => ( + + + + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {ci === 0 && ( + + + + )} + {row[col.id]} + + ))} + + ))} + + +
+ ) +} + +export default DragHandleExample diff --git a/docs/v2/examples/example-options.tsx b/docs/v2/examples/example-options.tsx new file mode 100644 index 0000000..2272b5b --- /dev/null +++ b/docs/v2/examples/example-options.tsx @@ -0,0 +1,148 @@ +/** + * Example: Table options — drag ranges, locked first/last rows & columns. + * Demonstrates columnDragRange and rowDragRange options. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 120 }, + { id: 'location', title: 'Location', width: 120 }, + { id: 'salary', title: 'Salary', width: 90 }, + { id: 'score', title: 'Score', width: 80 }, +] + +const LOCKED_COLS = 1 +const LOCKED_ROWS_START = 2 +const LOCKED_ROWS_END = 5 + +const thBase: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 12, + fontWeight: 700, + borderBottom: '2px solid #3b2d6e', + textTransform: 'uppercase', + letterSpacing: '0.04em', +} +const thLocked: React.CSSProperties = { ...thBase, background: '#1a1230', color: '#a78bfa' } +const thNormal: React.CSSProperties = { ...thBase, background: '#141020', color: '#c4b5fd' } + +const tdBase: React.CSSProperties = { + height: 36, + padding: '0 12px', + fontSize: 13, + borderBottom: '1px solid #1e1836', + display: 'flex', + alignItems: 'center', +} +const tdLocked: React.CSSProperties = { + ...tdBase, + background: '#140f26', + color: '#a78bfa', + fontStyle: 'italic', +} +const tdNormal: React.CSSProperties = { ...tdBase, background: '#110e1c', color: '#d4d0e8' } + +const OptionsExample = () => { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + + const options = useMemo( + () => ({ + columnDragRange: { start: LOCKED_COLS }, + rowDragRange: { start: LOCKED_ROWS_START, end: data.length - LOCKED_ROWS_END }, + }), + [data.length], + ) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + const isRowLocked = (i: number) => i < LOCKED_ROWS_START || i >= data.length - LOCKED_ROWS_END + const isColLocked = (i: number) => i < LOCKED_COLS + + return ( +
+

Options — Drag Ranges

+

+ First {LOCKED_COLS} col locked | First {LOCKED_ROWS_START} rows & last {LOCKED_ROWS_END}{' '} + rows locked (italic/purple = locked) +

+ ( +
+ Drop here +
+ )} + style={{ height: 400, border: '1px solid #3b2d6e', borderRadius: 8 }} + > + + {cols.map((col, i) => ( + + {isColLocked(i) ? `🔒 ${col.title}` : col.title} + + ))} + + + {data.map((row, ri) => { + const locked = isRowLocked(ri) + return ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ) + })} + +
+
+ ) +} + +export default OptionsExample diff --git a/docs/v2/examples/example-scrollcell.tsx b/docs/v2/examples/example-scrollcell.tsx new file mode 100644 index 0000000..da51624 --- /dev/null +++ b/docs/v2/examples/example-scrollcell.tsx @@ -0,0 +1,358 @@ +/** + * Example: Scrollable cell content — rows with horizontally scrollable cells. + * Demonstrates that touch scroll inside a cell scrolls the cell, not the table body. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const COLS = [ + { id: 'name', title: 'Name', width: 140 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'notes', title: 'Notes (h-scroll)', width: 220 }, + { id: 'tags', title: 'Tags (h-scroll)', width: 200 }, + { id: 'description', title: 'Description (v-scroll)', width: 220 }, + { id: 'status', title: 'Status', width: 100 }, +] + +const ROWS = [ + { + id: '1', + name: 'Alice Johnson', + role: 'Engineer', + notes: + 'Working on the new authentication module. Needs review from security team before merging. ETA: end of sprint.', + tags: 'react · typescript · auth · security · backend · api', + description: + 'Alice leads the platform security initiative.\nResponsible for OAuth2 integration.\nCoordinates with the infra team weekly.\nCurrently unblocked.', + status: '🟢 Active', + }, + { + id: '2', + name: 'Bob Martinez', + role: 'Designer', + notes: + 'Redesigning the onboarding flow. User research complete. Wireframes approved. Moving to high-fidelity mockups.', + tags: 'figma · ux · onboarding · mobile · accessibility', + description: + 'Bob owns the design system.\nCurrently iterating on mobile breakpoints.\nCollaborating with frontend on component specs.\nDelivery: next Thursday.', + status: '🟡 Review', + }, + { + id: '3', + name: 'Carol Smith', + role: 'PM', + notes: + 'Q2 roadmap finalized. Stakeholder sign-off received. Coordinating with engineering leads for sprint planning.', + tags: 'roadmap · q2 · planning · stakeholders · strategy', + description: + 'Carol manages cross-team dependencies.\nRoadmap approved by exec team.\nSprint planning scheduled for Monday.\nRisk: resource contention with data team.', + status: '🟢 Active', + }, + { + id: '4', + name: 'David Lee', + role: 'DevOps', + notes: + 'Migrating CI/CD pipeline to GitHub Actions. Docker images optimized. Kubernetes cluster upgrade scheduled.', + tags: 'devops · ci-cd · docker · kubernetes · github-actions', + description: + 'David is upgrading the K8s cluster to v1.29.\nDocker base images reduced by 40%.\nGitHub Actions migration 80% complete.\nDowntime window: Saturday 2am.', + status: '🔵 In Progress', + }, + { + id: '5', + name: 'Eva Chen', + role: 'QA', + notes: + 'Automated test coverage at 87%. Writing E2E tests for checkout flow. Regression suite runs in under 4 minutes.', + tags: 'testing · playwright · e2e · automation · coverage', + description: + 'Eva maintains the test automation framework.\nCoverage target: 90% by end of Q2.\nPlaywright suite added for checkout.\nFlaky test count reduced to 3.', + status: '🟢 Active', + }, + { + id: '6', + name: 'Frank Wilson', + role: 'Engineer', + notes: + 'Performance optimization sprint. Reduced bundle size by 34%. Lazy loading implemented for all route components.', + tags: 'performance · webpack · lazy-loading · optimization · react', + description: + 'Frank is leading the perf sprint.\nBundle size: 1.2MB → 790KB.\nLCP improved from 3.4s to 1.8s.\nNext: image optimization pipeline.', + status: '🟡 Review', + }, + { + id: '7', + name: 'Grace Kim', + role: 'Data', + notes: + 'Building real-time analytics dashboard. Integrating with Kafka streams. Grafana dashboards configured.', + tags: 'analytics · kafka · grafana · data · streaming · sql', + description: + 'Grace owns the data pipeline.\nKafka consumer lag < 200ms.\nGrafana dashboards live in staging.\nSQL query optimization ongoing.', + status: '🔵 In Progress', + }, + { + id: '8', + name: 'Henry Park', + role: 'Engineer', + notes: + 'Refactoring legacy payment service. Stripe integration updated to v3 API. PCI compliance review pending.', + tags: 'payments · stripe · refactor · pci · backend · node', + description: + 'Henry is blocked on PCI compliance sign-off.\nStripe v3 migration complete.\nLegacy code removed: 4,200 lines.\nAwaiting security audit results.', + status: '🔴 Blocked', + }, + { + id: '9', + name: 'Isla Torres', + role: 'Engineer', + notes: + 'Implementing real-time notifications via WebSockets. Backend complete. Frontend integration in progress.', + tags: 'websockets · notifications · real-time · node · react · redis', + description: + 'Isla is building the notification service.\nWebSocket server deployed to staging.\nFrontend toast component ready.\nLoad testing scheduled for Friday.', + status: '🔵 In Progress', + }, + { + id: '10', + name: 'James Nguyen', + role: 'DevOps', + notes: + 'Setting up observability stack. Prometheus and Grafana deployed. Alert rules configured for all critical services.', + tags: 'prometheus · grafana · observability · alerting · sre · ops', + description: + 'James owns the monitoring infrastructure.\nPrometheus scrape interval: 15s.\n12 alert rules active in production.\nOn-call rotation updated.', + status: '🟢 Active', + }, + { + id: '11', + name: 'Karen Liu', + role: 'Designer', + notes: + 'Creating the new icon library. 200+ icons designed in Figma. Export pipeline to SVG and React components automated.', + tags: 'icons · figma · svg · design-system · react · automation', + description: + 'Karen is leading the icon system project.\n200 icons complete, 40 in review.\nAutomated export via Figma API.\nDark mode variants in progress.', + status: '🟡 Review', + }, + { + id: '12', + name: 'Leo Patel', + role: 'Data', + notes: + 'Building ML pipeline for churn prediction. Feature engineering complete. Model training on AWS SageMaker.', + tags: 'ml · python · sagemaker · churn · feature-engineering · aws', + description: + 'Leo is training the churn prediction model.\nAUC-ROC: 0.87 on validation set.\nFeature store integrated with Redshift.\nDeployment target: end of month.', + status: '🔵 In Progress', + }, + { + id: '13', + name: 'Mia Robinson', + role: 'PM', + notes: + 'Coordinating the mobile app launch. App Store submission pending. Marketing campaign assets delivered.', + tags: 'mobile · launch · app-store · marketing · coordination · ios', + description: + 'Mia is managing the mobile launch.\nApp Store review submitted Monday.\nMarketing assets approved by brand team.\nLaunch date: April 15th.', + status: '🟢 Active', + }, + { + id: '14', + name: 'Noah Kim', + role: 'QA', + notes: + 'Auditing accessibility compliance. WCAG 2.1 AA gaps identified. Remediation tickets created for engineering.', + tags: 'accessibility · wcag · a11y · audit · screen-reader · aria', + description: + 'Noah is leading the a11y audit.\n23 WCAG violations found and ticketed.\nScreen reader testing on iOS and Android.\nTarget: full AA compliance by Q3.', + status: '🟡 Review', + }, + { + id: '15', + name: 'Olivia Scott', + role: 'Engineer', + notes: + 'Migrating database from PostgreSQL 13 to 16. Zero-downtime migration plan approved. Dry run completed successfully.', + tags: 'postgresql · migration · database · zero-downtime · backend · sql', + description: + 'Olivia is executing the DB migration.\nDry run completed in 4 minutes 12 seconds.\nRollback plan tested and documented.\nProduction migration: next maintenance window.', + status: '🟢 Active', + }, +] + +const th: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 12, + fontWeight: 700, + color: '#94a3b8', + background: '#1a1a22', + borderBottom: '2px solid #2e2e3e', + textTransform: 'uppercase', + letterSpacing: '0.05em', + whiteSpace: 'nowrap', +} + +const td: React.CSSProperties = { + height: 44, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#14141c', + borderBottom: '1px solid #1e1e2a', + display: 'flex', + alignItems: 'center', +} + +const scrollCellStyle: React.CSSProperties = { + overflowX: 'auto', + overflowY: 'hidden', + whiteSpace: 'nowrap', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + scrollbarWidth: 'thin', + scrollbarColor: '#3e3e52 transparent', + gap: 0, +} + +const ScrollableCell = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) + +const Tag = ({ label }: { label: string }) => ( + + {label} + +) + +const ScrollCellExample = () => { + const [rows, setRows] = useState(ROWS) + const [cols, setCols] = useState(COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setRows((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+

+ Scrollable Cells — drag rows/columns, scroll inside cells +

+

+ The Notes and{' '} + Tags columns scroll horizontally.{' '} + Description scrolls vertically. On mobile, + swiping inside a cell scrolls the cell first — then overflows to the table. +

+ ( +
+ )} + style={{ height: 420, border: '1px solid #2e2e3e', borderRadius: 8 }} + > + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {rows.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {col.id === 'notes' ? ( + + {row.notes} + + ) : col.id === 'tags' ? ( + + {row.tags.split(' · ').map((tag) => ( + + ))} + + ) : col.id === 'description' ? ( +
+ {row.description.split('\n').map((line, i) => ( +
+ {line} +
+ ))} +
+ ) : ( + row[col.id as keyof typeof row] + )} +
+ ))} +
+ ))} +
+ +
+ ) +} + +export default ScrollCellExample diff --git a/docs/v2/examples/example-styled.tsx b/docs/v2/examples/example-styled.tsx new file mode 100644 index 0000000..3e87c42 --- /dev/null +++ b/docs/v2/examples/example-styled.tsx @@ -0,0 +1,760 @@ +/** + * Example: Showcase — polished dark theme with status badges, score bars, + * avatar initials, role chips, gradient header, hover highlights, and + * a custom "Drop here" placeholder. + * Columns can be dynamically pinned left/right via the pin icon menu. + * Rows drag via grip handle only. + */ +import React, { useCallback, useState, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, + DragHandle, +} from 'react-table-dnd' +import { generateRows, arrayMove, type Row } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const INIT_COLS = [ + { id: '_handle', title: '', width: 40 }, + { id: 'name', title: 'Name', width: 200 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 120 }, + { id: 'department', title: 'Department', width: 140 }, + { id: 'email', title: 'Email', width: 200 }, + { id: 'location', title: 'Location', width: 130 }, + { id: 'salary', title: 'Salary', width: 100 }, + { id: 'joined', title: 'Joined', width: 110 }, + { id: 'score', title: 'Score', width: 120 }, +] + +// only _handle is always pinned and cannot be unpinned +const ALWAYS_PINNED = new Set(['_handle']) + +const STATUS: Record = { + Active: { bg: '#052e1f', color: '#34d399', dot: '#10b981' }, + Inactive: { bg: '#2a1215', color: '#f87171', dot: '#ef4444' }, + 'On Leave': { bg: '#2a1f05', color: '#fbbf24', dot: '#f59e0b' }, + Pending: { bg: '#0c1a2e', color: '#60a5fa', dot: '#3b82f6' }, + Terminated: { bg: '#1a0c2e', color: '#a78bfa', dot: '#8b5cf6' }, +} + +const ROLE_COLORS: Record = { + Engineer: '#818cf8', + Designer: '#f472b6', + PM: '#34d399', + QA: '#fbbf24', + DevOps: '#60a5fa', + Analyst: '#fb923c', + Lead: '#a78bfa', + Manager: '#2dd4bf', +} + +const AVATAR_COLORS = [ + '#6366f1', + '#ec4899', + '#14b8a6', + '#f59e0b', + '#8b5cf6', + '#06b6d4', + '#f43f5e', + '#22c55e', +] + +const stableHash = (s: string) => { + let h = 0 + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0 + return Math.abs(h) +} + +const GripIcon = ({ style }: { style?: React.CSSProperties }) => ( + + + + + + + + +) + +const PinIconSvg = ({ filled = false, size = 12 }: { filled?: boolean; size?: number }) => ( + + + + +) + +const thStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + height: 42, + padding: '0 12px 0 16px', + fontSize: 12, + fontWeight: 600, + color: '#94a3b8', + background: '#0f172a', + borderBottom: '2px solid #1e293b', + letterSpacing: '0.04em', +} + +const thPinnedStyle: React.CSSProperties = { + ...thStyle, + background: '#0a1120', + color: '#64748b', +} + +const tdStyle: React.CSSProperties = { + height: 48, + padding: '0 16px', + fontSize: 13, + color: '#e2e8f0', + display: 'flex', + alignItems: 'center', + borderBottom: '1px solid #1e293b', +} + +const tdPinnedStyle: React.CSSProperties = { + ...tdStyle, + background: '#0a1120', +} + +const MenuItem = ({ + children, + onClick, + danger, +}: { + children: React.ReactNode + onClick: () => void + danger?: boolean +}) => { + const [hovered, setHovered] = useState(false) + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + padding: '7px 10px', + fontSize: 12, + fontWeight: 500, + color: danger ? '#f87171' : '#cbd5e1', + cursor: 'pointer', + borderRadius: 6, + display: 'flex', + alignItems: 'center', + gap: 8, + whiteSpace: 'nowrap', + background: hovered ? (danger ? '#2a1215' : '#1e293b') : 'transparent', + transition: 'background 0.1s', + }} + > + {children} +
+ ) +} + +const Avatar = ({ name }: { name: string }) => { + const initials = name + .split(' ') + .map((n) => n[0]) + .join('') + const bg = AVATAR_COLORS[stableHash(name) % AVATAR_COLORS.length] + return ( +
+ {initials} +
+ ) +} + +const StatusBadge = ({ status }: { status: string }) => { + const s = STATUS[status] ?? { bg: '#1e293b', color: '#94a3b8', dot: '#64748b' } + return ( + + + {status} + + ) +} + +const RoleChip = ({ role }: { role: string }) => { + const color = ROLE_COLORS[role] ?? '#94a3b8' + return ( + + {role} + + ) +} + +const ScoreBar = ({ score }: { score: number }) => { + const color = score > 70 ? '#10b981' : score > 40 ? '#f59e0b' : '#ef4444' + return ( +
+
+
+
+ + {score} + +
+ ) +} + +const Placeholder = () => ( +
+ + + + Drop here +
+) + +const HintPill = ({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) => ( + + {icon} + {children} + +) + +const handleStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + padding: '0 2px', +} + +const ShowcaseExample = () => { + const [data, setData] = useState(() => generateRows(80)) + const [cols, setCols] = useState(INIT_COLS) + const [pinnedLeft, setPinnedLeft] = useState>(new Set(['_handle'])) + const [pinnedRight, setPinnedRight] = useState>(new Set()) + const [activeMenu, setActiveMenu] = useState(null) + const [menuRect, setMenuRect] = useState(null) + + // Close menu on outside click + useEffect(() => { + if (!activeMenu) return + const handler = () => setActiveMenu(null) + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [activeMenu]) + + const options = useMemo( + () => ({ + columnDragRange: { start: pinnedLeft.size, end: cols.length - 1 - pinnedRight.size }, + rowDragRange: {}, + }), + [cols.length, pinnedLeft.size, pinnedRight.size], + ) + + const getStickyLeft = useCallback( + (index: number) => { + let offset = 0 + for (let i = 0; i < index; i++) { + if (pinnedLeft.has(cols[i].id)) offset += cols[i].width ?? 100 + } + return offset + }, + [cols, pinnedLeft], + ) + + const getStickyRight = useCallback( + (index: number) => { + let offset = 0 + for (let i = cols.length - 1; i > index; i--) { + if (pinnedRight.has(cols[i].id)) offset += cols[i].width ?? 100 + } + return offset + }, + [cols, pinnedRight], + ) + + const openMenu = useCallback((colId: string, e: React.MouseEvent) => { + e.stopPropagation() + setActiveMenu((prev) => (prev === colId ? null : colId)) + setMenuRect(e.currentTarget.getBoundingClientRect()) + }, []) + + const pinLeft = useCallback( + (colId: string) => { + const currentIndex = cols.findIndex((c) => c.id === colId) + const targetIndex = pinnedLeft.size + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedLeft((p) => new Set([...p, colId])) + setPinnedRight((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + setActiveMenu(null) + }, + [cols, pinnedLeft.size], + ) + + const pinRight = useCallback( + (colId: string) => { + const currentIndex = cols.findIndex((c) => c.id === colId) + const targetIndex = cols.length - 1 - pinnedRight.size + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedRight((p) => new Set([...p, colId])) + setPinnedLeft((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + setActiveMenu(null) + }, + [cols, pinnedRight.size], + ) + + const unpin = useCallback( + (colId: string) => { + const currentIndex = cols.findIndex((c) => c.id === colId) + if (pinnedLeft.has(colId)) { + const targetIndex = pinnedLeft.size - 1 + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedLeft((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + } else { + const targetIndex = cols.length - pinnedRight.size + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedRight((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + } + setActiveMenu(null) + }, + [cols, pinnedLeft, pinnedRight], + ) + + const stripeSet = useMemo(() => { + const s = new Set() + data.forEach((r, i) => { + if (i % 2 === 1) s.add(r.id) + }) + return s + }, [data]) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + const renderCell = (row: Row, colId: string) => { + if (colId === 'name') + return ( + <> + + {row.name} + + ) + if (colId === 'role') return + if (colId === 'status') return + if (colId === 'score') return + if (colId === 'salary') + return {row.salary} + if (colId === 'joined') + return ( + {row.joined} + ) + return {row[colId]} + } + + const activeMenuColId = activeMenu + const activeMenuIsPinned = + !!activeMenuColId && (pinnedLeft.has(activeMenuColId) || pinnedRight.has(activeMenuColId)) + + return ( +
+
+ + + + } + > + Grip to drag rows + + + + + } + > + Grip to drag columns + + + + + + } + > + Mobile: long-press to start + + }>Pin columns via header icon +
+ + } + style={{ + height: 460, + borderRadius: 12, + overflow: 'hidden', + border: '1px solid #1e293b', + background: '#0f172a', + boxShadow: '0 4px 32px rgba(0,0,0,0.5)', + }} + > + + {cols.map((col, i) => { + const leftPinned = pinnedLeft.has(col.id) + const rightPinned = pinnedRight.has(col.id) + const colIsPinned = leftPinned || rightPinned + const alwaysPinned = ALWAYS_PINNED.has(col.id) + + const stickyStyle: React.CSSProperties | undefined = leftPinned + ? { + position: 'sticky', + left: getStickyLeft(i), + zIndex: 2, + flex: `0 0 ${col.width}px`, + } + : rightPinned + ? { + position: 'sticky', + right: getStickyRight(i), + zIndex: 2, + flex: `0 0 ${col.width}px`, + } + : undefined + + const cell = ( + + {col.id === '_handle' ? ( + + + + ) : ( + <> + {!colIsPinned && ( + + + + )} + + {col.title} + + {!alwaysPinned && ( + + )} + + )} + + ) + + return stickyStyle ? ( +
+ {cell} +
+ ) : ( + cell + ) + })} +
+ + + {data.map((row) => { + const isStripe = stripeSet.has(row.id) + const bg = isStripe ? '#131c2e' : '#0f172a' + return ( + + {cols.map((col, ci) => { + const leftPinned = pinnedLeft.has(col.id) + const rightPinned = pinnedRight.has(col.id) + const colIsPinned = leftPinned || rightPinned + return ( + + {col.id === '_handle' ? ( + + + + ) : ( + renderCell(row, col.id) + )} + + ) + })} + + ) + })} + +
+ + {/* Pin menu portal — rendered on document.body to escape overflow clipping */} + {activeMenu && + menuRect && + createPortal( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: menuRect.bottom + 4, + left: Math.max(4, menuRect.right - 148), + zIndex: 9999, + background: '#0f172a', + border: '1px solid #1e293b', + borderRadius: 10, + padding: '4px', + boxShadow: '0 16px 40px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)', + minWidth: 148, + }} + > + {!pinnedLeft.has(activeMenu) && ( + pinLeft(activeMenu)}> + + + + Pin Left + + )} + {!pinnedRight.has(activeMenu) && ( + pinRight(activeMenu)}> + + + + Pin Right + + )} + {activeMenuIsPinned && ( + <> +
+ unpin(activeMenu)} danger> + + + + + Unpin + + + )} +
, + document.body, + )} +
+ ) +} + +export default ShowcaseExample diff --git a/docs/v2/examples/example-styledcomp.tsx b/docs/v2/examples/example-styledcomp.tsx new file mode 100644 index 0000000..75bf960 --- /dev/null +++ b/docs/v2/examples/example-styledcomp.tsx @@ -0,0 +1,111 @@ +/** + * Example: Styled-components — override styles using styled() on each component. + * The className from styled() is forwarded to the inner element, so it + * survives cloning during drag (the clone inherits the styles). + * + * npm install styled-components + */ +import React, { useCallback, useState, useMemo } from 'react' +import styled from 'styled-components' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const StyledTable = styled(TableContainer)` + height: 420px; + border: 1px solid #1e3a5f; + border-radius: 10px; + background: #0c1929; +` + +const StyledCol = styled(ColumnCell)` + display: flex; + align-items: center; + height: 42px; + padding: 0 16px; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #60a5fa; + border-bottom: 2px solid #1e3a5f; + background: #0f2440; +` + +const StyledRow = styled(BodyRow)`` + +const StyledCell = styled(RowCell)` + display: flex; + align-items: center; + height: 38px; + padding: 0 16px; + font-size: 13px; + color: #93c5fd; + background: #0c1929; + border-bottom: 1px solid #1a2d45; +` + +const StyledCellAlt = styled(StyledCell)` + background: #0a1525; +` + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 130 }, + { id: 'location', title: 'City', width: 120 }, + { id: 'score', title: 'Score', width: 90 }, +] + +const StyledCompExample = () => { + const [data, setData] = useState(() => generateRows(60)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+

+ styled-components +

+ + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => { + const Cell = ri % 2 !== 0 ? StyledCellAlt : StyledCell + return ( + + {row[col.id]} + + ) + })} + + ))} + + +
+ ) +} + +export default StyledCompExample diff --git a/docs/v2/examples/example-styling.tsx b/docs/v2/examples/example-styling.tsx new file mode 100644 index 0000000..5bb4d5b --- /dev/null +++ b/docs/v2/examples/example-styling.tsx @@ -0,0 +1,142 @@ +/** + * Example: Style overrides — inline styles and CSS className on every component. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 130 }, + { id: 'location', title: 'City', width: 120 }, + { id: 'score', title: 'Score', width: 90 }, +] + +const StylingExample = () => { + const [data, setData] = useState(() => generateRows(60)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+ {/* Inline + +
+

+ Styling with{' '} + + className + +

+

+ Every component accepts{' '} + + className + {' '} + and{' '} + + style + {' '} + — full visual control, no overrides needed. +

+
+ + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + +
+ ) +} + +export default StylingExample diff --git a/docs/v2/examples/example-tailwind.tsx b/docs/v2/examples/example-tailwind.tsx new file mode 100644 index 0000000..2acf2e3 --- /dev/null +++ b/docs/v2/examples/example-tailwind.tsx @@ -0,0 +1,104 @@ +/** + * Example: Tailwind CSS — style with className using utility classes. + * npm install tailwindcss @tailwindcss/vite + * + * Note: This demo uses inline styles that mirror Tailwind classes since + * Tailwind isn't installed in the docs app. The "View code" shows real + * Tailwind className usage. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +// Inline styles mirroring the Tailwind classes shown in "View code" +const thStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + height: 42, + padding: '0 16px', + fontSize: 12, + fontWeight: 600, + color: '#9ca3af', + background: '#111827', + borderBottom: '1px solid #374151', + textTransform: 'uppercase', + letterSpacing: '0.05em', +} +const tdStyle = (ri: number): React.CSSProperties => ({ + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 16px', + fontSize: 13, + color: '#d1d5db', + background: ri % 2 === 0 ? '#111827' : '#1f2937', + borderBottom: '1px solid #1f2937', +}) + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 130 }, + { id: 'email', title: 'Email', width: 200 }, +] + +const TailwindExample = () => { + const [data, setData] = useState(() => generateRows(60)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + return ( +
+

+ Tailwind CSS — use className on every component +

+ + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + +
+ ) +} + +export default TailwindExample diff --git a/docs/v2/examples/example-virtual.tsx b/docs/v2/examples/example-virtual.tsx new file mode 100644 index 0000000..cb7d75d --- /dev/null +++ b/docs/v2/examples/example-virtual.tsx @@ -0,0 +1,230 @@ +/** + * Example: Virtual table — 10k rows, 20 columns, fixed column widths + * Uses @tanstack/react-virtual for row virtualization. + */ + +import React, { useCallback, useRef, useState, useMemo } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +// ── Data ────────────────────────────────────────────── + +const COLUMN_DEFS = [ + { key: 'firstName', title: 'First Name' }, + { key: 'lastName', title: 'Last Name' }, + { key: 'email', title: 'Email' }, + { key: 'phone', title: 'Phone' }, + { key: 'company', title: 'Company' }, + { key: 'jobTitle', title: 'Job Title' }, + { key: 'department', title: 'Department' }, + { key: 'city', title: 'City' }, + { key: 'state', title: 'State' }, + { key: 'country', title: 'Country' }, + { key: 'zipCode', title: 'Zip Code' }, + { key: 'address', title: 'Address' }, + { key: 'age', title: 'Age' }, + { key: 'salary', title: 'Salary' }, + { key: 'startDate', title: 'Start Date' }, + { key: 'status', title: 'Status' }, + { key: 'score', title: 'Score' }, + { key: 'rating', title: 'Rating' }, + { key: 'category', title: 'Category' }, + { key: 'notes', title: 'Notes' }, +] + +const STATUSES = ['Active', 'Inactive', 'Pending', 'On Leave', 'Terminated'] +const DEPARTMENTS = [ + 'Engineering', + 'Sales', + 'Marketing', + 'HR', + 'Finance', + 'Legal', + 'Support', + 'Product', +] + +function generateData(count: number) { + const rows = new Array(count) + for (let i = 0; i < count; i++) { + rows[i] = { + id: `row-${i}`, + firstName: `First${i}`, + lastName: `Last${i}`, + email: `user${i}@company.com`, + phone: `+1-${String(i).padStart(10, '0')}`, + company: `Company ${i % 500}`, + jobTitle: `Title ${i % 200}`, + department: DEPARTMENTS[i % DEPARTMENTS.length], + city: `City ${i % 80}`, + state: `State ${i % 50}`, + country: `Country ${i % 30}`, + zipCode: String(10000 + (i % 90000)), + address: `${i} Main St`, + age: 18 + (i % 62), + salary: `$${(30 + (i % 170)) * 1000}`, + startDate: `2020-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`, + status: STATUSES[i % STATUSES.length], + score: i % 101, + rating: ((i % 50) / 10 + 0.5).toFixed(1), + category: DEPARTMENTS[i % DEPARTMENTS.length], + notes: `Note for row ${i}`, + } + } + return rows +} + +function arrayMove(arr: T[], from: number, to: number): T[] { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +// ── Styles ──────────────────────────────────────────── + +const thStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 13, + fontWeight: 600, + color: '#94a3b8', + background: '#1e1e24', + borderBottom: '2px solid #2e2e36', + borderRight: '1px solid #2a2a32', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +} + +const tdStyle: React.CSSProperties = { + height: 40, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#16161c', + borderBottom: '1px solid #232329', + borderRight: '1px solid #232329', + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +} + +const placeholderStyle: React.CSSProperties = { + width: '100%', + height: '100%', + background: '#1a1a2e', + border: '2px dashed #6366f1', + borderRadius: 4, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 12, + color: '#818cf8', + fontWeight: 500, +} + +// ── Component ───────────────────────────────────────── + +const COL_WIDTH = 140 + +const VirtualExample = () => { + const [data, setData] = useState(() => generateData(10_000)) + const [cols, setCols] = useState(() => + COLUMN_DEFS.map((def, i) => ({ ...def, id: `col-${i}`, width: COL_WIDTH })), + ) + const bodyRef = useRef(null) + + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => bodyRef.current, + estimateSize: () => 40, + overscan: 5, + }) + + const options = useMemo( + () => ({ + columnDragRange: { start: 0 }, + rowDragRange: { start: 0 }, + }), + [], + ) + + const handleDragEnd = useCallback( + (result: { sourceIndex: number; targetIndex: number; dragType: 'row' | 'column' }) => { + if (result.sourceIndex === result.targetIndex) return + if (result.dragType === 'row') { + setData((prev) => arrayMove(prev, result.sourceIndex, result.targetIndex)) + } else { + setCols((prev) => arrayMove(prev, result.sourceIndex, result.targetIndex)) + } + }, + [], + ) + + const virtualItems = virtualizer.getVirtualItems() + + return ( +
+

+ Virtual — {data.length.toLocaleString()} rows x {cols.length} cols +

+
Drop here
} + style={{ height: 420, border: '1px solid #2e2e36', borderRadius: 8 }} + > + + {cols.map((col, i) => ( + + {col.title} + + ))} + + +
+ {virtualItems.map((vRow) => { + const row = data[vRow.index] + return ( + + {cols.map((col, ci) => ( + + {row[col.key]} + + ))} + + ) + })} +
+
+
+
+ ) +} + +export default VirtualExample diff --git a/docs/v2/examples/example-virtual2d.tsx b/docs/v2/examples/example-virtual2d.tsx new file mode 100644 index 0000000..ccf8e4b --- /dev/null +++ b/docs/v2/examples/example-virtual2d.tsx @@ -0,0 +1,246 @@ +/** + * Example: Virtual 2D — 10k rows × 100 columns. + * Uses @tanstack/react-virtual for both row AND column virtualization. + * + * Row virtualizer → vertical scroll, renders only visible rows + * Column virtualizer → horizontal scroll, renders only visible columns (header + body) + * Column drag works for visible columns; off-screen columns are replaced by spacers. + */ + +import React, { useCallback, useRef, useState, useMemo } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +// ── Config ──────────────────────────────────────────── + +const ROW_HEIGHT = 36 +const COL_WIDTH = 120 +const ROW_COUNT = 10_000 +const COL_COUNT = 100 + +// ── Data ────────────────────────────────────────────── + +type DataRow = { id: string } & Record + +const INIT_COLS = Array.from({ length: COL_COUNT }, (_, i) => ({ + id: `col-${i}`, + key: `c${i}`, + title: `Col ${i + 1}`, +})) + +function generateData(count: number): DataRow[] { + const rows = new Array(count) + for (let r = 0; r < count; r++) { + const row: DataRow = { id: `row-${r}` } + for (let c = 0; c < COL_COUNT; c++) row[`c${c}`] = `${r}·${c}` + rows[r] = row + } + return rows +} + +function arrayMove(arr: T[], from: number, to: number): T[] { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +// ── Styles ──────────────────────────────────────────── + +const thStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + height: 38, + padding: '0 10px', + fontSize: 11, + fontWeight: 700, + color: '#64748b', + background: '#0f172a', + borderBottom: '2px solid #1e293b', + borderRight: '1px solid #1e293b', + whiteSpace: 'nowrap', + letterSpacing: '0.04em', + textTransform: 'uppercase', +} + +const tdStyle: React.CSSProperties = { + height: ROW_HEIGHT, + padding: '0 10px', + fontSize: 12, + color: '#475569', + background: '#0c1222', + borderBottom: '1px solid #1a2335', + borderRight: '1px solid #1a2335', + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + // flex: none — prevents cells from stretching in the virtual row flex container + flex: 'none', +} + +// ── Component ───────────────────────────────────────── + +const Virtual2DExample = () => { + const [data, setData] = useState(() => generateData(ROW_COUNT)) + const [cols, setCols] = useState(INIT_COLS) + const bodyRef = useRef(null) + + const options = useMemo(() => ({ columnDragRange: { start: 0 }, rowDragRange: { start: 0 } }), []) + + const handleDragEnd = useCallback( + (result: { sourceIndex: number; targetIndex: number; dragType: 'row' | 'column' }) => { + if (result.sourceIndex === result.targetIndex) return + if (result.dragType === 'row') { + setData((prev) => arrayMove(prev, result.sourceIndex, result.targetIndex)) + } else if (result.dragType === 'column') { + setCols((prev) => arrayMove(prev, result.sourceIndex, result.targetIndex)) + } + }, + [], + ) + + // Row virtualizer — vertical + // eslint-disable-next-line react-hooks/incompatible-library + const rowVirtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => bodyRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 1, + }) + + // Column virtualizer — horizontal, same scroll element + + const colVirtualizer = useVirtualizer({ + count: cols.length, + getScrollElement: () => bodyRef.current, + estimateSize: () => COL_WIDTH, + horizontal: true, + overscan: 1, + }) + + const rowVirtualItems = rowVirtualizer.getVirtualItems() + const colVirtualItems = colVirtualizer.getVirtualItems() + const totalRowHeight = rowVirtualizer.getTotalSize() + const totalColWidth = colVirtualizer.getTotalSize() + + // Spacer widths — fill space for off-screen columns + const leftSpacer = colVirtualItems[0]?.start ?? 0 + const rightSpacer = + colVirtualItems.length > 0 + ? Math.max(0, totalColWidth - colVirtualItems[colVirtualItems.length - 1].end) + : totalColWidth + + return ( +
+

+ Virtual 2D — {data.length.toLocaleString()} rows + {' × '} + {cols.length} cols + {' · '} + + rendering {rowVirtualItems.length} × {colVirtualItems.length} cells + +

+ ( +
+ Drop here +
+ )} + style={{ + height: 420, + border: '1px solid #1e293b', + borderRadius: 8, + background: '#0c1222', + }} + > + + {leftSpacer > 0 &&
} + {colVirtualItems.map((vCol) => ( + + {cols[vCol.index].title} + + ))} + {rightSpacer > 0 &&
} + + + + {/* + Container sized to total virtual dimensions. + Rows are absolutely positioned via translateY. + Width = totalColWidth so horizontal scroll range matches header. + */} +
+ {rowVirtualItems.map((vRow) => { + const row = data[vRow.index] + return ( + + {/* Left spacer fills space for off-screen columns on the left */} + {leftSpacer > 0 && ( +
+ )} + + {colVirtualItems.map((vCol) => ( + + {row[cols[vCol.index].key]} + + ))} + + {/* Right spacer fills space for off-screen columns on the right */} + {rightSpacer > 0 && ( +
+ )} + + ) + })} +
+ + +
+ ) +} + +export default Virtual2DExample diff --git a/docs/v2/examples/example-widths.tsx b/docs/v2/examples/example-widths.tsx new file mode 100644 index 0000000..48ea7d9 --- /dev/null +++ b/docs/v2/examples/example-widths.tsx @@ -0,0 +1,207 @@ +/** + * Example: Column width modes — demonstrates flex (proportional grow), fixed (strict px), + * and mixed (some fixed, some flex) with a slider to resize the table container live. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' +import { generateRows, arrayMove } from './example-data' +import type { DragEndResult } from 'react-table-dnd' + +type WidthMode = 'flex' | 'fixed' | 'mixed' + +// fixed: true = this column stays strict px in mixed mode +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160, fixed: true }, + { id: 'role', title: 'Role', width: 120, fixed: false }, + { id: 'status', title: 'Status', width: 100, fixed: true }, + { id: 'email', title: 'Email', width: 200, fixed: false }, + { id: 'department', title: 'Dept', width: 120, fixed: false }, + { id: 'joined', title: 'Joined', width: 100, fixed: true }, +] + +const TOTAL_COL_WIDTH = INIT_COLS.reduce((s, c) => s + c.width, 0) + +const MODE_LABELS: Record = { + flex: 'Flex — all grow proportionally', + fixed: 'Fixed — all stay strict px', + mixed: 'Mixed — Name, Status, Joined fixed · Role, Email, Dept grow', +} + +const th: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 11, + fontWeight: 700, + color: '#94a3b8', + background: '#1e1e24', + borderBottom: '2px solid #2e2e36', + borderRight: '1px solid #2e2e36', + textTransform: 'uppercase', + letterSpacing: '0.04em', + whiteSpace: 'nowrap', +} + +const thFixed: React.CSSProperties = { + ...th, + color: '#6366f1', + background: '#1a1a2e', +} + +const td: React.CSSProperties = { + height: 36, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#16161c', + borderBottom: '1px solid #232329', + borderRight: '1px solid #232329', + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', +} + +const WidthsExample = () => { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + const [containerWidth, setContainerWidth] = useState(700) + const [mode, setMode] = useState('flex') + + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback((r: DragEndResult) => { + if (r.sourceIndex === r.targetIndex) return + if (r.dragType === 'row') setData((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + else setCols((p) => arrayMove(p, r.sourceIndex, r.targetIndex)) + }, []) + + const isColFixed = (col: (typeof INIT_COLS)[number]) => + mode === 'fixed' || (mode === 'mixed' && col.fixed) + + const isOverflow = containerWidth < TOTAL_COL_WIDTH + + return ( +
+ {/* Controls */} +
+ {/* Mode toggle */} +
+ Width mode +
+ {(['flex', 'fixed', 'mixed'] as WidthMode[]).map((m) => ( + + ))} +
+ {MODE_LABELS[mode]} +
+ + {/* Width slider */} +
+ Table width + setContainerWidth(Number(e.target.value))} + style={{ flex: 1, maxWidth: 400, accentColor: '#6366f1' }} + /> + + {containerWidth}px + + + {isOverflow + ? `scrolls (total = ${TOTAL_COL_WIDTH}px)` + : `fills (extra = +${containerWidth - TOTAL_COL_WIDTH}px)`} + +
+
+ + {/* Table wrapper — constrained to slider width */} +
+ + + {cols.map((col, i) => { + const fixed = isColFixed(col) + return ( + + {col.title} + + ) + })} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => { + const fixed = isColFixed(col) + return ( + + {row[col.id]} + + ) + })} + + ))} + + +
+
+ ) +} + +export default WidthsExample diff --git a/docs/v2/examples/jsx/example-fixed.jsx b/docs/v2/examples/jsx/example-fixed.jsx new file mode 100644 index 0000000..4380a93 --- /dev/null +++ b/docs/v2/examples/jsx/example-fixed.jsx @@ -0,0 +1,129 @@ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'status', title: 'Status', width: 100 }, + { id: 'email', title: 'Email', width: 200 }, + { id: 'department', title: 'Dept', width: 120 }, + { id: 'joined', title: 'Joined', width: 100 }, +] + +const th = { + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 13, + fontWeight: 700, + color: '#94a3b8', + background: '#1e1e24', + borderBottom: '2px solid #2e2e36', + textTransform: 'uppercase', +} + +const td = { + height: 36, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#16161c', + borderBottom: '1px solid #232329', + display: 'flex', + alignItems: 'center', +} + +export default function FixedExample() { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + + ) +} diff --git a/docs/v2/examples/jsx/example-flex.jsx b/docs/v2/examples/jsx/example-flex.jsx new file mode 100644 index 0000000..8e474f0 --- /dev/null +++ b/docs/v2/examples/jsx/example-flex.jsx @@ -0,0 +1,133 @@ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 170 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'department', title: 'Department', width: 140 }, + { id: 'email', title: 'Email', width: 210 }, + { id: 'location', title: 'Location', width: 130 }, +] + +const ROW_HEIGHTS = [40, 56, 72, 44, 88, 48, 64, 40, 96, 52] + +const th = { + display: 'flex', + alignItems: 'center', + height: 44, + padding: '0 14px', + fontSize: 11, + fontWeight: 800, + color: '#6ee7b7', + background: '#0d1f17', + borderBottom: '2px solid #134e33', + textTransform: 'uppercase', +} + +const makeTd = (h, isEven) => ({ + height: h, + padding: '8px 14px', + fontSize: 13, + color: '#cbd5e1', + background: isEven ? '#111a14' : '#0d1510', + borderBottom: '1px solid #1a2e20', + display: 'flex', + alignItems: 'flex-start', +}) + +export default function CustomRowHeightsExample() { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => { + const h = ROW_HEIGHTS[ri % ROW_HEIGHTS.length] + return ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ) + })} + + + ) +} diff --git a/docs/v2/examples/jsx/example-handle.jsx b/docs/v2/examples/jsx/example-handle.jsx new file mode 100644 index 0000000..bdbb585 --- /dev/null +++ b/docs/v2/examples/jsx/example-handle.jsx @@ -0,0 +1,154 @@ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, + DragHandle, +} from 'react-table-dnd' + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const GripIcon = () => ( + + + + + + + + +) + +const COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'email', title: 'Email', width: 200 }, + { id: 'department', title: 'Dept', width: 120 }, +] + +export default function DragHandleExample() { + const [data, setData] = useState(() => generateRows(50)) + const [cols, setCols] = useState(COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + + + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {ci === 0 && ( + + + + )} + {row[col.id]} + + ))} + + ))} + + + ) +} diff --git a/docs/v2/examples/jsx/example-options.jsx b/docs/v2/examples/jsx/example-options.jsx new file mode 100644 index 0000000..f0b603b --- /dev/null +++ b/docs/v2/examples/jsx/example-options.jsx @@ -0,0 +1,146 @@ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const LOCKED_COLS = 1 +const LOCKED_ROWS_START = 2 +const LOCKED_ROWS_END = 5 + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 120 }, + { id: 'location', title: 'Location', width: 120 }, + { id: 'salary', title: 'Salary', width: 90 }, + { id: 'score', title: 'Score', width: 80 }, +] + +export default function OptionsExample() { + const [data, setData] = useState(() => generateRows(100)) + const [cols, setCols] = useState(INIT_COLS) + + const options = useMemo( + () => ({ + columnDragRange: { start: LOCKED_COLS }, + rowDragRange: { start: LOCKED_ROWS_START, end: data.length - LOCKED_ROWS_END }, + }), + [data.length], + ) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + const isLocked = (ri) => ri < LOCKED_ROWS_START || ri >= data.length - LOCKED_ROWS_END + + return ( + + + {cols.map((col, i) => ( + + {i < LOCKED_COLS ? `🔒 ${col.title}` : col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + + ) +} diff --git a/docs/v2/examples/jsx/example-scrollcell.jsx b/docs/v2/examples/jsx/example-scrollcell.jsx new file mode 100644 index 0000000..a950100 --- /dev/null +++ b/docs/v2/examples/jsx/example-scrollcell.jsx @@ -0,0 +1,351 @@ +/** + * Example: Scrollable cell content — rows with horizontally and vertically scrollable cells. + * Demonstrates that touch scroll inside a cell scrolls the cell first, then overflows to the table. + */ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const COLS = [ + { id: 'name', title: 'Name', width: 140 }, + { id: 'role', title: 'Role', width: 120 }, + { id: 'notes', title: 'Notes (h-scroll)', width: 220 }, + { id: 'tags', title: 'Tags (h-scroll)', width: 200 }, + { id: 'description', title: 'Description (v-scroll)', width: 220 }, + { id: 'status', title: 'Status', width: 100 }, +] + +const ROWS = [ + { + id: '1', + name: 'Alice Johnson', + role: 'Engineer', + notes: + 'Working on the new authentication module. Needs review from security team before merging. ETA: end of sprint.', + tags: 'react · typescript · auth · security · backend · api', + description: + 'Alice leads the platform security initiative.\nResponsible for OAuth2 integration.\nCoordinates with the infra team weekly.\nCurrently unblocked.', + status: '🟢 Active', + }, + { + id: '2', + name: 'Bob Martinez', + role: 'Designer', + notes: + 'Redesigning the onboarding flow. User research complete. Wireframes approved. Moving to high-fidelity mockups.', + tags: 'figma · ux · onboarding · mobile · accessibility', + description: + 'Bob owns the design system.\nCurrently iterating on mobile breakpoints.\nCollaborating with frontend on component specs.\nDelivery: next Thursday.', + status: '🟡 Review', + }, + { + id: '3', + name: 'Carol Smith', + role: 'PM', + notes: + 'Q2 roadmap finalized. Stakeholder sign-off received. Coordinating with engineering leads for sprint planning.', + tags: 'roadmap · q2 · planning · stakeholders · strategy', + description: + 'Carol manages cross-team dependencies.\nRoadmap approved by exec team.\nSprint planning scheduled for Monday.\nRisk: resource contention with data team.', + status: '🟢 Active', + }, + { + id: '4', + name: 'David Lee', + role: 'DevOps', + notes: + 'Migrating CI/CD pipeline to GitHub Actions. Docker images optimized. Kubernetes cluster upgrade scheduled.', + tags: 'devops · ci-cd · docker · kubernetes · github-actions', + description: + 'David is upgrading the K8s cluster to v1.29.\nDocker base images reduced by 40%.\nGitHub Actions migration 80% complete.\nDowntime window: Saturday 2am.', + status: '🔵 In Progress', + }, + { + id: '5', + name: 'Eva Chen', + role: 'QA', + notes: + 'Automated test coverage at 87%. Writing E2E tests for checkout flow. Regression suite runs in under 4 minutes.', + tags: 'testing · playwright · e2e · automation · coverage', + description: + 'Eva maintains the test automation framework.\nCoverage target: 90% by end of Q2.\nPlaywright suite added for checkout.\nFlaky test count reduced to 3.', + status: '🟢 Active', + }, + { + id: '6', + name: 'Frank Wilson', + role: 'Engineer', + notes: + 'Performance optimization sprint. Reduced bundle size by 34%. Lazy loading implemented for all route components.', + tags: 'performance · webpack · lazy-loading · optimization · react', + description: + 'Frank is leading the perf sprint.\nBundle size: 1.2MB → 790KB.\nLCP improved from 3.4s to 1.8s.\nNext: image optimization pipeline.', + status: '🟡 Review', + }, + { + id: '7', + name: 'Grace Kim', + role: 'Data', + notes: + 'Building real-time analytics dashboard. Integrating with Kafka streams. Grafana dashboards configured.', + tags: 'analytics · kafka · grafana · data · streaming · sql', + description: + 'Grace owns the data pipeline.\nKafka consumer lag < 200ms.\nGrafana dashboards live in staging.\nSQL query optimization ongoing.', + status: '🔵 In Progress', + }, + { + id: '8', + name: 'Henry Park', + role: 'Engineer', + notes: + 'Refactoring legacy payment service. Stripe integration updated to v3 API. PCI compliance review pending.', + tags: 'payments · stripe · refactor · pci · backend · node', + description: + 'Henry is blocked on PCI compliance sign-off.\nStripe v3 migration complete.\nLegacy code removed: 4,200 lines.\nAwaiting security audit results.', + status: '🔴 Blocked', + }, + { + id: '9', + name: 'Isla Torres', + role: 'Engineer', + notes: + 'Implementing real-time notifications via WebSockets. Backend complete. Frontend integration in progress.', + tags: 'websockets · notifications · real-time · node · react · redis', + description: + 'Isla is building the notification service.\nWebSocket server deployed to staging.\nFrontend toast component ready.\nLoad testing scheduled for Friday.', + status: '🔵 In Progress', + }, + { + id: '10', + name: 'James Nguyen', + role: 'DevOps', + notes: + 'Setting up observability stack. Prometheus and Grafana deployed. Alert rules configured for all critical services.', + tags: 'prometheus · grafana · observability · alerting · sre · ops', + description: + 'James owns the monitoring infrastructure.\nPrometheus scrape interval: 15s.\n12 alert rules active in production.\nOn-call rotation updated.', + status: '🟢 Active', + }, + { + id: '11', + name: 'Karen Liu', + role: 'Designer', + notes: + 'Creating the new icon library. 200+ icons designed in Figma. Export pipeline to SVG and React components automated.', + tags: 'icons · figma · svg · design-system · react · automation', + description: + 'Karen is leading the icon system project.\n200 icons complete, 40 in review.\nAutomated export via Figma API.\nDark mode variants in progress.', + status: '🟡 Review', + }, + { + id: '12', + name: 'Leo Patel', + role: 'Data', + notes: + 'Building ML pipeline for churn prediction. Feature engineering complete. Model training on AWS SageMaker.', + tags: 'ml · python · sagemaker · churn · feature-engineering · aws', + description: + 'Leo is training the churn prediction model.\nAUC-ROC: 0.87 on validation set.\nFeature store integrated with Redshift.\nDeployment target: end of month.', + status: '🔵 In Progress', + }, + { + id: '13', + name: 'Mia Robinson', + role: 'PM', + notes: + 'Coordinating the mobile app launch. App Store submission pending. Marketing campaign assets delivered.', + tags: 'mobile · launch · app-store · marketing · coordination · ios', + description: + 'Mia is managing the mobile launch.\nApp Store review submitted Monday.\nMarketing assets approved by brand team.\nLaunch date: April 15th.', + status: '🟢 Active', + }, + { + id: '14', + name: 'Noah Kim', + role: 'QA', + notes: + 'Auditing accessibility compliance. WCAG 2.1 AA gaps identified. Remediation tickets created for engineering.', + tags: 'accessibility · wcag · a11y · audit · screen-reader · aria', + description: + 'Noah is leading the a11y audit.\n23 WCAG violations found and ticketed.\nScreen reader testing on iOS and Android.\nTarget: full AA compliance by Q3.', + status: '🟡 Review', + }, + { + id: '15', + name: 'Olivia Scott', + role: 'Engineer', + notes: + 'Migrating database from PostgreSQL 13 to 16. Zero-downtime migration plan approved. Dry run completed successfully.', + tags: 'postgresql · migration · database · zero-downtime · backend · sql', + description: + 'Olivia is executing the DB migration.\nDry run completed in 4 minutes 12 seconds.\nRollback plan tested and documented.\nProduction migration: next maintenance window.', + status: '🟢 Active', + }, +] + +const th = { + display: 'flex', + alignItems: 'center', + height: 40, + padding: '0 12px', + fontSize: 12, + fontWeight: 700, + color: '#94a3b8', + background: '#1a1a22', + borderBottom: '2px solid #2e2e3e', + textTransform: 'uppercase', + letterSpacing: '0.05em', + whiteSpace: 'nowrap', +} + +const td = { + height: 72, + padding: '0 12px', + fontSize: 13, + color: '#cbd5e1', + background: '#14141c', + borderBottom: '1px solid #1e1e2a', + display: 'flex', + alignItems: 'center', +} + +const scrollCellStyle = { + overflowX: 'auto', + overflowY: 'hidden', + whiteSpace: 'nowrap', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + scrollbarWidth: 'thin', + scrollbarColor: '#3e3e52 transparent', +} + +function ScrollableCell({ children }) { + return
{children}
+} + +function Tag({ label }) { + return ( + + {label} + + ) +} + +export default function ScrollCellExample() { + const [rows, setRows] = useState(ROWS) + const [cols, setCols] = useState(COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setRows((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + ( +
+ )} + style={{ height: 420, border: '1px solid #2e2e3e', borderRadius: 8 }} + > + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {rows.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {col.id === 'notes' ? ( + + {row.notes} + + ) : col.id === 'tags' ? ( + + {row.tags.split(' · ').map((tag) => ( + + ))} + + ) : col.id === 'description' ? ( +
+ {row.description.split('\n').map((line, i) => ( +
+ {line} +
+ ))} +
+ ) : ( + row[col.id] + )} +
+ ))} +
+ ))} +
+ + ) +} diff --git a/docs/v2/examples/jsx/example-styled.jsx b/docs/v2/examples/jsx/example-styled.jsx new file mode 100644 index 0000000..c892c8c --- /dev/null +++ b/docs/v2/examples/jsx/example-styled.jsx @@ -0,0 +1,756 @@ +import React, { useCallback, useState, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, + DragHandle, +} from 'react-table-dnd' + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] +const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] +const DEPTS = ['Engineering', 'Design', 'Product', 'Marketing', 'Sales', 'HR', 'Finance', 'Support'] +const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] +const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] +const LAST = ['Johnson', 'Smith', 'White', 'Brown', 'Davis', 'Lee', 'Kim', 'Miller', 'Chen', 'Park'] + +function generateRows(count) { + const rows = new Array(count) + for (let i = 0; i < count; i++) { + const first = FIRST[i % FIRST.length] + const last = LAST[Math.floor(i / FIRST.length) % LAST.length] + rows[i] = { + id: `row-${i}`, + name: `${first} ${last}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${first.toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + } + } + return rows +} + +const INIT_COLS = [ + { id: '_handle', title: '', width: 40 }, + { id: 'name', title: 'Name', width: 200 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 120 }, + { id: 'department', title: 'Department', width: 140 }, + { id: 'email', title: 'Email', width: 200 }, + { id: 'location', title: 'Location', width: 130 }, + { id: 'salary', title: 'Salary', width: 100 }, + { id: 'joined', title: 'Joined', width: 110 }, + { id: 'score', title: 'Score', width: 120 }, +] + +const ALWAYS_PINNED = new Set(['_handle']) + +const STATUS_MAP = { + Active: { bg: '#052e1f', color: '#34d399', dot: '#10b981' }, + Inactive: { bg: '#2a1215', color: '#f87171', dot: '#ef4444' }, + 'On Leave': { bg: '#2a1f05', color: '#fbbf24', dot: '#f59e0b' }, + Pending: { bg: '#0c1a2e', color: '#60a5fa', dot: '#3b82f6' }, + Terminated: { bg: '#1a0c2e', color: '#a78bfa', dot: '#8b5cf6' }, +} + +const ROLE_COLORS = { + Engineer: '#818cf8', + Designer: '#f472b6', + PM: '#34d399', + QA: '#fbbf24', + DevOps: '#60a5fa', + Analyst: '#fb923c', + Lead: '#a78bfa', + Manager: '#2dd4bf', +} + +const AVATAR_COLORS = [ + '#6366f1', + '#ec4899', + '#14b8a6', + '#f59e0b', + '#8b5cf6', + '#06b6d4', + '#f43f5e', + '#22c55e', +] + +function stableHash(s) { + let h = 0 + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0 + return Math.abs(h) +} + +const GripIcon = ({ style }) => ( + + + + + + + + +) + +const PinIconSvg = ({ filled = false, size = 12 }) => ( + + + + +) + +const thStyle = { + display: 'flex', + alignItems: 'center', + gap: 8, + height: 42, + padding: '0 12px 0 16px', + fontSize: 12, + fontWeight: 600, + color: '#94a3b8', + background: '#0f172a', + borderBottom: '2px solid #1e293b', + letterSpacing: '0.04em', +} +const thPinnedStyle = { ...thStyle, background: '#0a1120', color: '#64748b' } +const tdStyle = { + height: 48, + padding: '0 16px', + fontSize: 13, + color: '#e2e8f0', + display: 'flex', + alignItems: 'center', + borderBottom: '1px solid #1e293b', +} +const tdPinnedStyle = { ...tdStyle, background: '#0a1120' } +const handleStyle = { display: 'flex', alignItems: 'center', padding: '0 2px' } + +const MenuItem = ({ children, onClick, danger }) => { + const [hovered, setHovered] = useState(false) + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + padding: '7px 10px', + fontSize: 12, + fontWeight: 500, + color: danger ? '#f87171' : '#cbd5e1', + cursor: 'pointer', + borderRadius: 6, + display: 'flex', + alignItems: 'center', + gap: 8, + whiteSpace: 'nowrap', + background: hovered ? (danger ? '#2a1215' : '#1e293b') : 'transparent', + transition: 'background 0.1s', + }} + > + {children} +
+ ) +} + +const Avatar = ({ name }) => { + const initials = name + .split(' ') + .map((n) => n[0]) + .join('') + const bg = AVATAR_COLORS[stableHash(name) % AVATAR_COLORS.length] + return ( +
+ {initials} +
+ ) +} + +const StatusBadge = ({ status }) => { + const s = STATUS_MAP[status] || { bg: '#1e293b', color: '#94a3b8', dot: '#64748b' } + return ( + + + {status} + + ) +} + +const RoleChip = ({ role }) => { + const color = ROLE_COLORS[role] || '#94a3b8' + return ( + + {role} + + ) +} + +const ScoreBar = ({ score }) => { + const color = score > 70 ? '#10b981' : score > 40 ? '#f59e0b' : '#ef4444' + return ( +
+
+
+
+ + {score} + +
+ ) +} + +const Placeholder = () => ( +
+ + + + Drop here +
+) + +const HintPill = ({ icon, children }) => ( + + {icon} + {children} + +) + +const renderCell = (row, colId) => { + if (colId === 'name') + return ( + <> + + {row.name} + + ) + if (colId === 'role') return + if (colId === 'status') return + if (colId === 'score') return + if (colId === 'salary') + return {row.salary} + if (colId === 'joined') + return ( + {row.joined} + ) + return {row[colId]} +} + +export default function ShowcaseExample() { + const [data, setData] = useState(() => generateRows(80)) + const [cols, setCols] = useState(INIT_COLS) + const [pinnedLeft, setPinnedLeft] = useState(new Set(['_handle'])) + const [pinnedRight, setPinnedRight] = useState(new Set()) + const [activeMenu, setActiveMenu] = useState(null) + const [menuRect, setMenuRect] = useState(null) + + useEffect(() => { + if (!activeMenu) return + const handler = () => setActiveMenu(null) + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [activeMenu]) + + const options = useMemo( + () => ({ + columnDragRange: { start: pinnedLeft.size, end: cols.length - 1 - pinnedRight.size }, + rowDragRange: {}, + }), + [cols.length, pinnedLeft.size, pinnedRight.size], + ) + + const getStickyLeft = useCallback( + (index) => { + let offset = 0 + for (let i = 0; i < index; i++) { + if (pinnedLeft.has(cols[i].id)) offset += cols[i].width || 100 + } + return offset + }, + [cols, pinnedLeft], + ) + + const getStickyRight = useCallback( + (index) => { + let offset = 0 + for (let i = cols.length - 1; i > index; i--) { + if (pinnedRight.has(cols[i].id)) offset += cols[i].width || 100 + } + return offset + }, + [cols, pinnedRight], + ) + + const openMenu = useCallback((colId, e) => { + e.stopPropagation() + setActiveMenu((prev) => (prev === colId ? null : colId)) + setMenuRect(e.currentTarget.getBoundingClientRect()) + }, []) + + const pinLeft = useCallback( + (colId) => { + const currentIndex = cols.findIndex((c) => c.id === colId) + const targetIndex = pinnedLeft.size + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedLeft((p) => new Set([...p, colId])) + setPinnedRight((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + setActiveMenu(null) + }, + [cols, pinnedLeft.size], + ) + + const pinRight = useCallback( + (colId) => { + const currentIndex = cols.findIndex((c) => c.id === colId) + const targetIndex = cols.length - 1 - pinnedRight.size + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedRight((p) => new Set([...p, colId])) + setPinnedLeft((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + setActiveMenu(null) + }, + [cols, pinnedRight.size], + ) + + const unpin = useCallback( + (colId) => { + const currentIndex = cols.findIndex((c) => c.id === colId) + if (pinnedLeft.has(colId)) { + const targetIndex = pinnedLeft.size - 1 + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedLeft((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + } else { + const targetIndex = cols.length - pinnedRight.size + if (currentIndex !== targetIndex) setCols((p) => arrayMove(p, currentIndex, targetIndex)) + setPinnedRight((p) => { + const s = new Set(p) + s.delete(colId) + return s + }) + } + setActiveMenu(null) + }, + [cols, pinnedLeft, pinnedRight], + ) + + const stripeSet = useMemo(() => { + const s = new Set() + data.forEach((r, i) => { + if (i % 2 === 1) s.add(r.id) + }) + return s + }, [data]) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + const activeMenuIsPinned = + !!activeMenu && (pinnedLeft.has(activeMenu) || pinnedRight.has(activeMenu)) + + return ( +
+
+ + + + } + > + Grip to drag rows + + + + + } + > + Grip to drag columns + + + + + + } + > + Mobile: long-press to start + + }>Pin columns via header icon +
+ + } + style={{ + height: 460, + borderRadius: 12, + overflow: 'hidden', + border: '1px solid #1e293b', + background: '#0f172a', + boxShadow: '0 4px 32px rgba(0,0,0,0.5)', + }} + > + + {cols.map((col, i) => { + const leftPinned = pinnedLeft.has(col.id) + const rightPinned = pinnedRight.has(col.id) + const colIsPinned = leftPinned || rightPinned + const alwaysPinned = ALWAYS_PINNED.has(col.id) + + const stickyStyle = leftPinned + ? { + position: 'sticky', + left: getStickyLeft(i), + zIndex: 2, + flex: `0 0 ${col.width}px`, + } + : rightPinned + ? { + position: 'sticky', + right: getStickyRight(i), + zIndex: 2, + flex: `0 0 ${col.width}px`, + } + : undefined + + const cell = ( + + {col.id === '_handle' ? ( + + + + ) : ( + <> + {!colIsPinned && ( + + + + )} + + {col.title} + + {!alwaysPinned && ( + + )} + + )} + + ) + + return stickyStyle ? ( +
+ {cell} +
+ ) : ( + cell + ) + })} +
+ + + {data.map((row) => { + const bg = stripeSet.has(row.id) ? '#131c2e' : '#0f172a' + return ( + + {cols.map((col, ci) => { + const leftPinned = pinnedLeft.has(col.id) + const rightPinned = pinnedRight.has(col.id) + const colIsPinned = leftPinned || rightPinned + return ( + + {col.id === '_handle' ? ( + + + + ) : ( + renderCell(row, col.id) + )} + + ) + })} + + ) + })} + +
+ + {activeMenu && + menuRect && + createPortal( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: menuRect.bottom + 4, + left: Math.max(4, menuRect.right - 148), + zIndex: 9999, + background: '#0f172a', + border: '1px solid #1e293b', + borderRadius: 10, + padding: '4px', + boxShadow: '0 16px 40px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)', + minWidth: 148, + }} + > + {!pinnedLeft.has(activeMenu) && ( + pinLeft(activeMenu)}> + + + + Pin Left + + )} + {!pinnedRight.has(activeMenu) && ( + pinRight(activeMenu)}> + + + + Pin Right + + )} + {activeMenuIsPinned && ( + <> +
+ unpin(activeMenu)} danger> + + + + + Unpin + + + )} +
, + document.body, + )} +
+ ) +} diff --git a/docs/v2/examples/jsx/example-styledcomp.jsx b/docs/v2/examples/jsx/example-styledcomp.jsx new file mode 100644 index 0000000..da8de61 --- /dev/null +++ b/docs/v2/examples/jsx/example-styledcomp.jsx @@ -0,0 +1,147 @@ +// npm install styled-components +import React, { useCallback, useState, useMemo } from 'react' +import styled from 'styled-components' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +// styled() on each component — className goes to the inner element, +// so the clone during drag inherits the styles correctly. + +const StyledTable = styled(TableContainer)` + height: 420px; + border: 1px solid #1e3a5f; + border-radius: 10px; + background: #0c1929; +` + +const StyledCol = styled(ColumnCell)` + display: flex; + align-items: center; + height: 42px; + padding: 0 16px; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #60a5fa; + border-bottom: 2px solid #1e3a5f; + background: #0f2440; +` + +const StyledRow = styled(BodyRow)`` + +const StyledCell = styled(RowCell)` + display: flex; + align-items: center; + height: 38px; + padding: 0 16px; + font-size: 13px; + color: #93c5fd; + background: #0c1929; + border-bottom: 1px solid #1a2d45; +` + +const StyledCellAlt = styled(StyledCell)` + background: #0a1525; +` + +const COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 130 }, + { id: 'location', title: 'City', width: 120 }, + { id: 'score', title: 'Score', width: 90 }, +] + +export default function StyledCompExample() { + const [data, setData] = useState(() => generateRows(60)) + const [cols, setCols] = useState(COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => { + const Cell = ri % 2 !== 0 ? StyledCellAlt : StyledCell + return ( + + {row[col.id]} + + ) + })} + + ))} + + + ) +} diff --git a/docs/v2/examples/jsx/example-styling.jsx b/docs/v2/examples/jsx/example-styling.jsx new file mode 100644 index 0000000..b50c71a --- /dev/null +++ b/docs/v2/examples/jsx/example-styling.jsx @@ -0,0 +1,133 @@ +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +// ── CSS (styling.css) ────────────────────────────────── +// +// .my-table { border-radius: 10px; overflow: hidden; +// border: 2px solid #f59e0b44; } +// .my-col { display: flex; align-items: center; height: 42px; +// padding: 0 14px; font-size: 11px; font-weight: 800; +// color: #fbbf24; border-bottom: 2px solid #f59e0b44; +// text-transform: uppercase; cursor: grab; } +// .my-col:hover { background: #221f0e; } +// .my-row:hover +// .my-cell { background: #1a1810 !important; } +// .my-cell { display: flex; align-items: center; height: 38px; +// padding: 0 14px; font-size: 13px; color: #d6c896; +// border-bottom: 1px solid #2a2510; cursor: grab; } +// .my-cell-alt { background: #111008 !important; } + +const INIT_COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 130 }, + { id: 'location', title: 'City', width: 120 }, + { id: 'score', title: 'Score', width: 90 }, +] + +export default function StylingExample() { + const [data, setData] = useState(() => generateRows(60)) + const [cols, setCols] = useState(INIT_COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + + ) +} diff --git a/docs/v2/examples/jsx/example-tailwind.jsx b/docs/v2/examples/jsx/example-tailwind.jsx new file mode 100644 index 0000000..1f2c54b --- /dev/null +++ b/docs/v2/examples/jsx/example-tailwind.jsx @@ -0,0 +1,120 @@ +// npm install tailwindcss @tailwindcss/vite +// Add to your CSS: @import "tailwindcss"; +import React, { useCallback, useState, useMemo } from 'react' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +function generateRows(count) { + const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] + const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Design', + 'Product', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Support', + ] + const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] + const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] + const LAST = [ + 'Johnson', + 'Smith', + 'White', + 'Brown', + 'Davis', + 'Lee', + 'Kim', + 'Miller', + 'Chen', + 'Park', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +const COLS = [ + { id: 'name', title: 'Name', width: 160 }, + { id: 'role', title: 'Role', width: 130 }, + { id: 'status', title: 'Status', width: 110 }, + { id: 'department', title: 'Dept', width: 130 }, + { id: 'email', title: 'Email', width: 200 }, +] + +export default function TailwindExample() { + const [data, setData] = useState(() => generateRows(60)) + const [cols, setCols] = useState(COLS) + const options = useMemo(() => ({ columnDragRange: {}, rowDragRange: {} }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + + {data.map((row, ri) => ( + + {cols.map((col, ci) => ( + + {row[col.id]} + + ))} + + ))} + + + ) +} diff --git a/docs/v2/examples/jsx/example-virtual.jsx b/docs/v2/examples/jsx/example-virtual.jsx new file mode 100644 index 0000000..a6a8468 --- /dev/null +++ b/docs/v2/examples/jsx/example-virtual.jsx @@ -0,0 +1,169 @@ +import React, { useCallback, useRef, useState, useMemo } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +function generateData(count) { + const STATUSES = ['Active', 'Inactive', 'Pending', 'On Leave', 'Terminated'] + const DEPTS = [ + 'Engineering', + 'Sales', + 'Marketing', + 'HR', + 'Finance', + 'Legal', + 'Support', + 'Product', + ] + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + firstName: `First${i}`, + lastName: `Last${i}`, + email: `user${i}@company.com`, + phone: `+1-${String(i).padStart(10, '0')}`, + company: `Company ${i % 500}`, + jobTitle: `Title ${i % 200}`, + department: DEPTS[i % DEPTS.length], + city: `City ${i % 80}`, + state: `State ${i % 50}`, + country: `Country ${i % 30}`, + zipCode: String(10000 + (i % 90000)), + address: `${i} Main St`, + age: 18 + (i % 62), + salary: `$${(30 + (i % 170)) * 1000}`, + startDate: `2020-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`, + status: STATUSES[i % STATUSES.length], + score: i % 101, + rating: ((i % 50) / 10 + 0.5).toFixed(1), + category: DEPTS[i % DEPTS.length], + notes: `Note for row ${i}`, + })) +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const COL_DEFS = [ + { key: 'firstName', title: 'First Name' }, + { key: 'lastName', title: 'Last Name' }, + { key: 'email', title: 'Email' }, + { key: 'company', title: 'Company' }, + { key: 'jobTitle', title: 'Job Title' }, + { key: 'department', title: 'Department' }, + { key: 'city', title: 'City' }, + { key: 'salary', title: 'Salary' }, + { key: 'status', title: 'Status' }, + { key: 'score', title: 'Score' }, +] + +export default function VirtualExample() { + const [data, setData] = useState(() => generateData(100_000)) + const [cols, setCols] = useState(() => + COL_DEFS.map((d, i) => ({ ...d, id: `col-${i}`, width: 140 })), + ) + const bodyRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => bodyRef.current, + estimateSize: () => 40, + overscan: 5, + }) + + const options = useMemo( + () => ({ + columnDragRange: { start: 0 }, + rowDragRange: { start: 0 }, + }), + [], + ) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + return ( + + + {cols.map((col, i) => ( + + {col.title} + + ))} + + +
+ {virtualizer.getVirtualItems().map((vRow) => { + const row = data[vRow.index] + return ( + + {cols.map((col, ci) => ( + + {row[col.key]} + + ))} + + ) + })} +
+
+
+ ) +} diff --git a/docs/v2/examples/jsx/example-virtual2d.jsx b/docs/v2/examples/jsx/example-virtual2d.jsx new file mode 100644 index 0000000..df7185a --- /dev/null +++ b/docs/v2/examples/jsx/example-virtual2d.jsx @@ -0,0 +1,175 @@ +import React, { useCallback, useRef, useState, useMemo } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { + TableContainer, + TableHeader, + ColumnCell, + TableBody, + BodyRow, + RowCell, +} from 'react-table-dnd' + +const ROW_HEIGHT = 36 +const COL_WIDTH = 120 +const ROW_COUNT = 10_000 +const COL_COUNT = 100 + +const INIT_COLS = Array.from({ length: COL_COUNT }, (_, i) => ({ + id: `col-${i}`, + key: `c${i}`, + title: `Col ${i + 1}`, +})) + +function generateData(count) { + const rows = new Array(count) + for (let r = 0; r < count; r++) { + const row = { id: `row-${r}` } + for (let c = 0; c < COL_COUNT; c++) row[`c${c}`] = `${r}·${c}` + rows[r] = row + } + return rows +} + +function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const thStyle = { + display: 'inline-flex', + alignItems: 'center', + height: 38, + padding: '0 10px', + fontSize: 11, + fontWeight: 700, + color: '#64748b', + background: '#0f172a', + borderBottom: '2px solid #1e293b', + borderRight: '1px solid #1e293b', + whiteSpace: 'nowrap', + letterSpacing: '0.04em', + textTransform: 'uppercase', +} + +const tdStyle = { + height: ROW_HEIGHT, + padding: '0 10px', + fontSize: 12, + color: '#475569', + background: '#0c1222', + borderBottom: '1px solid #1a2335', + borderRight: '1px solid #1a2335', + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + flex: 'none', +} + +export default function Virtual2DExample() { + const [data, setData] = useState(() => generateData(ROW_COUNT)) + const [cols, setCols] = useState(INIT_COLS) + const bodyRef = useRef(null) + + const options = useMemo(() => ({ columnDragRange: { start: 0 }, rowDragRange: { start: 0 } }), []) + + const handleDragEnd = useCallback(({ sourceIndex, targetIndex, dragType }) => { + if (sourceIndex === targetIndex) return + if (dragType === 'row') setData((p) => arrayMove(p, sourceIndex, targetIndex)) + else setCols((p) => arrayMove(p, sourceIndex, targetIndex)) + }, []) + + const rowVirtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => bodyRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 5, + }) + + const colVirtualizer = useVirtualizer({ + count: cols.length, + getScrollElement: () => bodyRef.current, + estimateSize: () => COL_WIDTH, + horizontal: true, + overscan: 1, + }) + + const rowVirtualItems = rowVirtualizer.getVirtualItems() + const colVirtualItems = colVirtualizer.getVirtualItems() + const totalRowHeight = rowVirtualizer.getTotalSize() + const totalColWidth = colVirtualizer.getTotalSize() + + const leftSpacer = colVirtualItems[0]?.start ?? 0 + const rightSpacer = + colVirtualItems.length > 0 + ? Math.max(0, totalColWidth - colVirtualItems[colVirtualItems.length - 1].end) + : totalColWidth + + return ( + + + {leftSpacer > 0 &&
} + {colVirtualItems.map((vCol) => ( + + {cols[vCol.index].title} + + ))} + {rightSpacer > 0 &&
} + + + +
+ {rowVirtualItems.map((vRow) => { + const row = data[vRow.index] + return ( + + {leftSpacer > 0 && ( +
+ )} + {colVirtualItems.map((vCol) => ( + + {row[cols[vCol.index].key]} + + ))} + {rightSpacer > 0 && ( +
+ )} + + ) + })} +
+ + + ) +} diff --git a/docs/v2/examples/jsx/helpers.js b/docs/v2/examples/jsx/helpers.js new file mode 100644 index 0000000..7fe33e5 --- /dev/null +++ b/docs/v2/examples/jsx/helpers.js @@ -0,0 +1,66 @@ +export function arrayMove(arr, from, to) { + const next = arr.slice() + const [item] = next.splice(from, 1) + next.splice(to, 0, item) + return next +} + +const ROLES = ['Engineer', 'Designer', 'PM', 'QA', 'DevOps', 'Analyst', 'Lead', 'Manager'] +const STATUSES = ['Active', 'Inactive', 'On Leave', 'Pending', 'Terminated'] +const DEPTS = ['Engineering', 'Design', 'Product', 'Marketing', 'Sales', 'HR', 'Finance', 'Support'] +const CITIES = ['New York', 'London', 'Berlin', 'Tokyo', 'Sydney', 'Toronto', 'Mumbai', 'Paris'] +const FIRST = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Ivy', 'Jack'] +const LAST = ['Johnson', 'Smith', 'White', 'Brown', 'Davis', 'Lee', 'Kim', 'Miller', 'Chen', 'Park'] + +export function generateRows(count) { + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + name: `${FIRST[i % FIRST.length]} ${LAST[Math.floor(i / FIRST.length) % LAST.length]}`, + role: ROLES[i % ROLES.length], + status: STATUSES[i % STATUSES.length], + email: `${FIRST[i % FIRST.length].toLowerCase()}${i}@company.com`, + department: DEPTS[i % DEPTS.length], + location: CITIES[i % CITIES.length], + salary: `$${40 + (i % 160)}k`, + joined: `${2019 + (i % 6)}-${String((i % 12) + 1).padStart(2, '0')}`, + score: i % 101, + })) +} + +const V_STATUSES = ['Active', 'Inactive', 'Pending', 'On Leave', 'Terminated'] +const V_DEPTS = [ + 'Engineering', + 'Sales', + 'Marketing', + 'HR', + 'Finance', + 'Legal', + 'Support', + 'Product', +] + +export function generateData(count) { + return Array.from({ length: count }, (_, i) => ({ + id: `row-${i}`, + firstName: `First${i}`, + lastName: `Last${i}`, + email: `user${i}@company.com`, + phone: `+1-${String(i).padStart(10, '0')}`, + company: `Company ${i % 500}`, + jobTitle: `Title ${i % 200}`, + department: V_DEPTS[i % V_DEPTS.length], + city: `City ${i % 80}`, + state: `State ${i % 50}`, + country: `Country ${i % 30}`, + zipCode: String(10000 + (i % 90000)), + address: `${i} Main St`, + age: 18 + (i % 62), + salary: `$${(30 + (i % 170)) * 1000}`, + startDate: `2020-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`, + status: V_STATUSES[i % V_STATUSES.length], + score: i % 101, + rating: ((i % 50) / 10 + 0.5).toFixed(1), + category: V_DEPTS[i % V_DEPTS.length], + notes: `Note for row ${i}`, + })) +} diff --git a/eslint.config.js b/eslint.config.js index 4430e5d..d90a57c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ import eslintConfigPrettier from 'eslint-config-prettier' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'docs-dist', 'docs/v2']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -21,5 +21,11 @@ export default defineConfig([ globals: globals.browser, }, }, + { + // docs site: shadcn/ui components and content modules export data next to components; + // the rule only affects hot reload + files: ['src/docs/**/*.{ts,tsx}'], + rules: { 'react-refresh/only-export-components': 'off' }, + }, eslintConfigPrettier, ]) diff --git a/index.html b/index.html index 2e11c09..c8ff0d8 100644 --- a/index.html +++ b/index.html @@ -3,27 +3,27 @@ - react-table-dnd - Drag-and-Drop Sortable Table for React + + react-table-dnd: drag-and-drop rows and columns for React tables - - + - + - + - + - + + - - - - - - - - + - - - - -