gridcn
blockdata-grid-fillsource: ok · refreshed
Data Grid Fill
Fill-handle add-on for the gridcn data grid: drag-to-tile with series inference (arithmetic runs, zero-padded numbers, prefix+number sequences), mod+D/mod+R keyboard fill, via the overlay-plugin seam, with zero core re-renders.
npx shadcn add "https://gridcn.vercel.app/r/data-grid-fill.json"free
Where it goes
kanban
Interaction
drag
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightzustand
Registry dependencies
DammersCode/gridcn/data-grid
Files11 · source cached 10/8/2026, 4:07:30 AM
registry/default/blocks/data-grid-fill/compute-fill-target.ts
import type { CellCoord, GridRect } from "@/registry/default/components/data-grid/data-grid";
/** Which axes a fill-handle drag is permitted to snap to (see glide-behavior-spec.md §3). */
export type AllowedFillDirections = "orthogonal" | "horizontal" | "vertical" | "any";
/** Options for {@link computeFillTarget}. */
export type ComputeFillTargetOptions = {
allowedDirections: AllowedFillDirections;
rowCount: number;
colCount: number;
};
function isInside(rect: GridRect, x: number, y: number): boolean {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
function clampRect(rect: GridRect, colCount: number, rowCount: number): GridRect | null {
const x0 = Math.max(0, rect.x);
const y0 = Math.max(0, rect.y);
const x1 = Math.min(colCount, rect.x + rect.width);
const y1 = Math.min(rowCount, rect.y + rect.height);
if (x1 <= x0 || y1 <= y0) return null;
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
/**
* Computes the fill-handle drag highlight: the strip extending `source` toward
* `cursor` on a single axis (glide's `getClosestRect`, see glide-behavior-spec.md
* §3). For `allowedDirections: "any"` this is instead the combined rect
* (including `source`), matching glide's diagonal behavior. Returns `null`
* when the cursor is inside `source` (no fill) or when the resulting rect
* clamps to nothing at the grid bounds.
*/
export function computeFillTarget(
source: GridRect,
cursor: CellCoord,
opts: ComputeFillTargetOptions
): GridRect | null {
let px = cursor.col;
let py = cursor.row;
const { allowedDirections } = opts;
if (allowedDirections === "any") {
if (isInside(source, px, py)) return null;
const x = Math.min(source.x, px);
const y = Math.min(source.y, py);
const width = Math.max(source.x + source.width, px + 1) - x;
const height = Math.max(source.y + source.height, py + 1) - y;
return clampRect({ x, y, width, height }, opts.colCount, opts.rowCount);
}
if (allowedDirections === "vertical") px = source.x;
if (allowedDirections === "horizontal") py = source.y;
if (isInside(source, px, py)) return null;
const distanceToLeft = px - source.x;
const distanceToRight = source.x + source.width - px;
const distanceToTop = py - source.y + 1;
const distanceToBottom = source.y + source.height - py;
const minDistance = Math.min(
allowedDirections === "vertical" ? Number.MAX_SAFE_INTEGER : distanceToLeft,
allowedDirections === "vertical" ? Number.MAX_SAFE_INTEGER : distanceToRight,
allowedDirections === "horizontal" ? Number.MAX_SAFE_INTEGER : distanceToTop,
allowedDirections === "horizontal" ? Number.MAX_SAFE_INTEGER : distanceToBottom
);
let strip: GridRect;
if (minDistance === distanceToBottom) {
strip = { x: source.x, y: source.y + source.height, width: source.width, height: py - source.y - source.height + 1 };
} else if (minDistance === distanceToTop) {
strip = { x: source.x, y: py, width: source.width, height: source.y - py };
} else if (minDistance === distanceToRight) {
strip = { x: source.x + source.width, y: source.y, width: px - source.x - source.width + 1, height: source.height };
} else {
strip = { x: px, y: source.y, width: source.x - px, height: source.height };
}
return clampRect(strip, opts.colCount, opts.rowCount);
}
registry/default/blocks/data-grid-fill/data-grid-fill.ts
/** Public entry point — the only module consumers/docs should import from. */
export { useDataGridFill, type UseDataGridFillOptions, type UseDataGridFillResult, type FillArgs } from "./use-data-grid-fill";
export { computeFillTarget, type AllowedFillDirections, type ComputeFillTargetOptions } from "./fill";
export { detectSeries, type SeriesDescriptor } from "./fill";
export { generateFill, type FillDirection, type GenerateFillOptions } from "./fill";
export { fillDirection, rectRelativeTo } from "./fill";
/** Offers the `fillDown`/`fillRight` global-shortcut flags — this add-on is what makes them do anything. */
// Relative on purpose: the shadcn CLI rewrites import paths, never augmentation module names.
declare module "../data-grid/keyboard/global-shortcuts" {
interface DataGridGlobalShortcutActions {
fillDown: true;
fillRight: true;
}
}
registry/default/blocks/data-grid-fill/detect-series.ts
/** Extrapolates a detected series to any index; `null` for a non-series (falls back to tiling). */
export type SeriesDescriptor = {
extrapolate(index: number): string;
};
const NUMERIC_RE = /^-?\d+(\.\d+)?$/;
const PADDED_INT_RE = /^\d+$/;
const PREFIX_NUMBER_RE = /^(.*?)(\d+)(\D*)$/;
const EPSILON = 1e-9;
function decimalPlaces(value: string): number {
const dotIndex = value.indexOf(".");
return dotIndex === -1 ? 0 : value.length - dotIndex - 1;
}
function detectArithmetic(values: readonly string[]): SeriesDescriptor | null {
// callers only reach here via detectSeries, which requires values.length >= 2
if (values.length < 2) return null;
const trimmed = values.map((v) => v.trim());
const nums = trimmed.map((v) => (NUMERIC_RE.test(v) ? Number(v) : null));
if (nums.some((n) => n === null)) return null;
const numbers = nums as number[];
const first = numbers[0]!;
const delta = numbers[1]! - first;
for (let i = 2; i < numbers.length; i++) {
if (Math.abs(numbers[i]! - numbers[i - 1]! - delta) > EPSILON) return null;
}
const precision = Math.max(...trimmed.map(decimalPlaces));
return {
extrapolate: (index: number) => (first + delta * index).toFixed(precision),
};
}
function detectPaddedNumeric(values: readonly string[]): SeriesDescriptor | null {
if (values.length < 2) return null;
const trimmed = values.map((v) => v.trim());
if (!trimmed.every((v) => PADDED_INT_RE.test(v))) return null;
if (!trimmed.some((v) => v.length > 1 && v.startsWith("0"))) return null;
const width = trimmed[0]!.length;
if (!trimmed.every((v) => v.length === width)) return null;
const numbers = trimmed.map((v) => Number(v));
const first = numbers[0]!;
const delta = numbers[1]! - first;
for (let i = 2; i < numbers.length; i++) {
if (numbers[i]! - numbers[i - 1]! !== delta) return null;
}
return {
extrapolate: (index: number) => {
const n = first + delta * index;
const sign = n < 0 ? "-" : "";
const digits = String(Math.abs(n)).padStart(width, "0");
return sign + digits;
},
};
}
function detectPrefixNumber(values: readonly string[]): SeriesDescriptor | null {
if (values.length < 2) return null;
const matches = values.map((v) => v.trim().match(PREFIX_NUMBER_RE));
if (matches.some((m) => m === null)) return null;
const parsed = matches as RegExpMatchArray[];
const prefix = parsed[0]![1];
const suffix = parsed[0]![3];
if (!parsed.every((m) => m[1] === prefix && m[3] === suffix)) return null;
const numStrings = parsed.map((m) => m[2]!);
const numbers = numStrings.map((n) => Number(n));
const width = numStrings[0]!.length;
const uniformWidth = numStrings.every((n) => n.length === width) && numStrings.some((n) => n.startsWith("0"));
const first = numbers[0]!;
const delta = numbers[1]! - first;
for (let i = 2; i < numbers.length; i++) {
if (numbers[i]! - numbers[i - 1]! !== delta) return null;
}
return {
extrapolate: (index: number) => {
const n = first + delta * index;
const digits = uniformWidth && n >= 0 ? String(n).padStart(width, "0") : String(n);
return prefix + digits + suffix;
},
};
}
/**
* Detects a fillable series in `values` (source cells along the fill axis, in
* source order): arithmetic progressions, zero-padded numeric strings (pad
* width preserved), or shared-prefix/suffix + arithmetic numeric part
* ("Item 1", "Item 2"). Requires at least 2 values with a consistent delta;
* returns `null` when no pattern applies (caller falls back to tiling).
*/
export function detectSeries(values: readonly string[]): SeriesDescriptor | null {
if (values.length < 2) return null;
// padded numerics ("001") also match plain arithmetic, so check padding first or the leading zero is lost
return detectPaddedNumeric(values) ?? detectArithmetic(values) ?? detectPrefixNumber(values);
}
registry/default/blocks/data-grid-fill/fill-direction.ts
import type { GridRect } from "@/registry/default/components/data-grid/data-grid";
import type { FillDirection } from "./generate-fill";
/**
* Resolves the {@link FillDirection} implied by `strip`'s position relative to `source` — the
* strip returned by `computeFillTarget` (or the collapsed top-row/left-column for fillDown/fillRight)
* always extends `source` on exactly one axis, one side.
*/
export function fillDirection(source: GridRect, strip: GridRect): FillDirection {
if (strip.y + strip.height <= source.y) return "up";
if (strip.y >= source.y + source.height) return "down";
if (strip.x + strip.width <= source.x) return "left";
return "right";
}
registry/default/blocks/data-grid-fill/fill-overlay.tsx
"use client";
import type { CSSProperties, ReactNode } from "react";
import { GRID_LAYER, type GridRect, type OverlayPluginCtx } from "@/registry/default/components/data-grid/data-grid";
import type { FillHandleHandlers } from "./use-fill-handle";
/** The dashed fill-drag preview rect, window-relative, grid-line placed like core's own RangeOverlay; pinned segments add their zone's counter-offset + z-index 2 (above pinned cells' z1). */
function FillPreviewOverlay({ rect, windowStart, colOffset, pinStyle }: { rect: GridRect; windowStart: number; colOffset: number; pinStyle?: CSSProperties }) {
return (
<div
data-grid-fill-preview=""
data-pinned={pinStyle ? "" : undefined}
aria-hidden="true"
className="pointer-events-none border border-dashed border-primary bg-primary/5"
style={{
// positioned: cells are position:relative, so a static overlay would paint below them
position: "relative",
gridColumnStart: rect.x + colOffset,
gridColumnEnd: rect.x + rect.width + colOffset,
gridRowStart: rect.y - windowStart + 1,
gridRowEnd: rect.y + rect.height - windowStart + 1,
...pinStyle,
...(pinStyle ? { zIndex: GRID_LAYER.pinnedOverlaySegment } : {}),
}}
/>
);
}
/** The small draggable square at a range's bottom-right corner that starts a fill drag; adds the corner column's pin offset when it falls in a pinned zone. */
function FillHandle({
rect,
windowStart,
colOffset,
pinStyle,
onPointerDown,
}: {
rect: GridRect;
windowStart: number;
colOffset: number;
pinStyle?: CSSProperties;
onPointerDown: FillHandleHandlers["onPointerDown"];
}) {
return (
<div
data-grid-fill-handle=""
// mouse-only drag affordance (Ctrl+D/Ctrl+R cover the keyboard-equivalent fill actions) — not
// focusable/operable via keyboard or AT, so it's hidden from the accessibility tree entirely.
aria-hidden="true"
// grid-placed on the cell holding the range's bottom-right corner, self-aligned into that
// cell's own corner so it reads as sitting "on" the selection edge rather than centered in it.
className="pointer-events-auto z-20 size-2 place-self-end cursor-crosshair border border-background bg-primary"
style={{
gridColumnStart: rect.x + rect.width + colOffset - 1,
gridColumnEnd: rect.x + rect.width + colOffset,
gridRowStart: rect.y + rect.height - windowStart,
gridRowEnd: rect.y + rect.height - windowStart + 1,
...pinStyle,
}}
onPointerDown={onPointerDown}
/>
);
}
/**
* Everything {@link renderFillOverlay} needs beyond the plugin `ctx` — the live selection/editing
* state and drag preview the built-in overlay layer used to read directly from core's own store,
* plus `onPointerDown` itself (sourced from the add-on's `fillStore`, since the real handler is
* produced by `FillHandleTracker`, which renders in a different part of the tree than this plugin
* — see fill-store.ts's doc comment on `onPointerDown`).
*/
export type FillOverlayState = {
fillPreview: GridRect | null;
/** Primary selection range (view-space), or `null` when there's no current range (row/column-channel-only selection, or a clear). */
primaryRange: GridRect | null;
/** True while a cell is being edited — hides the handle. */
editing: boolean;
/** Fill is disabled (grid readOnly or the add-on's `disabled: true` option), so the handle would be dead UI — hidden. */
disabled: boolean;
/** `null` before `FillHandleTracker` mounts (or if it's never rendered) — hides the handle. */
onPointerDown: FillHandleHandlers["onPointerDown"] | null;
};
/**
* Renders the fill-preview strip and the fill handle into the overlay layer using the SAME
* window-clamp + pin-zone segmentation pipeline core's own built-in overlays use (via `ctx`).
* This is the function registered as `OverlayPlugin`: preview before the plugin slot, handle
* after the active-cell ring (the ring is core's own concern — see the ctx's doc comment on
* plugin placement), so DOM order matches the built-in layers.
*/
export function renderFillOverlay(state: FillOverlayState, ctx: OverlayPluginCtx): ReactNode {
const { windowStart, clampRowStart, clampRowEnd, colCount, colOffset, pinTrack, splitRectByPinZones, clampRectToWindow } = ctx;
const pinned = pinTrack && pinTrack.pins.some((p) => p === "left" || p === "right") ? pinTrack : undefined;
const segment = (rect: GridRect) => (pinned ? splitRectByPinZones(rect, pinned) : [{ rect, pinStyle: undefined }]);
const clampedFillPreview = state.fillPreview ? clampRectToWindow(state.fillPreview, clampRowStart, clampRowEnd, colCount) : null;
// hidden while editing, when disabled (the fill would be dead UI), when selection is
// rows/columns-channel only (no `current` range), or before FillHandleTracker has registered a
// handler — the same guard the built-in layer uses.
const onPointerDown = state.onPointerDown;
const showFillHandle = onPointerDown && !state.editing && state.primaryRange && !state.disabled;
const clampedPrimary = state.primaryRange ? clampRectToWindow(state.primaryRange, clampRowStart, clampRowEnd, colCount) : null;
const cornerCol = state.primaryRange ? state.primaryRange.x + state.primaryRange.width - 1 : null;
const fillHandlePinStyle =
cornerCol !== null && pinned && pinned.pins[cornerCol] ? segment({ x: cornerCol, y: 0, width: 1, height: 1 })[0]?.pinStyle : undefined;
return (
<>
{clampedFillPreview &&
segment(clampedFillPreview).map((seg, j) => (
<FillPreviewOverlay key={`fill-preview-${j}`} rect={seg.rect} windowStart={windowStart} colOffset={colOffset} pinStyle={seg.pinStyle} />
))}
{showFillHandle && clampedPrimary && state.primaryRange &&
clampedPrimary.y + clampedPrimary.height === state.primaryRange.y + state.primaryRange.height &&
clampedPrimary.x + clampedPrimary.width === state.primaryRange.x + state.primaryRange.width && (
<FillHandle
rect={state.primaryRange}
windowStart={windowStart}
colOffset={colOffset}
pinStyle={fillHandlePinStyle}
onPointerDown={onPointerDown}
/>
)}
</>
);
}
registry/default/blocks/data-grid-fill/fill-store.ts
"use client";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
import type { GridRect } from "@/registry/default/components/data-grid/data-grid";
import type { FillHandleHandlers } from "./use-fill-handle";
/**
* This add-on's own state slice: the in-progress drag preview rect, plus the live pointerdown
* handler the overlay plugin wires onto its rendered handle square. Deliberately NOT part of the
* core store — core knows nothing about fill past the generic `overlayPlugins` seam and the
* optional interaction callbacks it renders/dispatches through. `onPointerDown` is registered
* here (not read directly by the plugin closure) because the plugin is created by `useDataGridFill`
* ABOVE `DataGridProvider`, while the real handler comes from `FillHandleTracker`, which must
* render INSIDE `DataGridRoot`'s subtree (see fill-tracker.tsx) — the store is what bridges them.
*/
export type FillStoreState = {
fillPreview: GridRect | null;
setFillPreview(rect: GridRect | null): void;
onPointerDown: FillHandleHandlers["onPointerDown"] | null;
setOnPointerDown(handler: FillHandleHandlers["onPointerDown"] | null): void;
};
export type FillStoreApi = StoreApi<FillStoreState>;
/** One store instance per `useDataGridFill()` call, mirroring core's one-store-per-grid pattern and `data-grid-presence`'s own local store. */
export function createFillStore(): FillStoreApi {
return createStore<FillStoreState>((set) => ({
fillPreview: null,
setFillPreview(rect) {
set({ fillPreview: rect });
},
onPointerDown: null,
setOnPointerDown(handler) {
set({ onPointerDown: handler });
},
}));
}
/** The in-progress fill-drag preview rect, or `null` when no drag is active. Consumed by the overlay plugin — never by row/cell subscriptions. */
export function useFillPreview(store: FillStoreApi): GridRect | null {
return useStore(store, (s) => s.fillPreview);
}
/** The live pointerdown handler for the rendered fill handle, or `null` before `FillHandleTracker` mounts. */
export function useFillOnPointerDown(store: FillStoreApi): FillHandleHandlers["onPointerDown"] | null {
return useStore(store, (s) => s.onPointerDown);
}
registry/default/blocks/data-grid-fill/fill-tracker.tsx
"use client";
import { useEffect, useMemo, type ReactNode } from "react";
import {
useDataGridActions,
useDataGridReadOnly,
useDataGridRootContext,
type InteractionLayout,
} from "@/registry/default/components/data-grid/data-grid";
import { useFillHandle, type UseFillHandleOptions } from "./use-fill-handle";
import type { FillStoreApi } from "./fill-store";
/** Reconstructs the `InteractionLayout` `useGridInteraction`/pointer math needs from the shared root context — `DataGridRoot` computes the same shape internally (root.tsx's own `interactionLayout`) but doesn't expose it directly, since only this tracker (and root itself) need it. */
function useInteractionLayoutFromContext(): InteractionLayout {
const ctx = useDataGridRootContext();
const { layout, rowHeight, headerHeight, pinnedTopHeight, pinnedBottomHeight } = ctx;
return useMemo(() => {
// the marker column (when present) is always pinned-left before every data column — same
// derivation as root.tsx's own interactionLayout memo.
let pinnedLeftWidth = layout.markerWidth;
let pinnedRightWidth = 0;
for (let i = 0; i < layout.widths.length; i++) {
if (layout.pins[i] === "left") pinnedLeftWidth += layout.widths[i]!;
if (layout.pins[i] === "right") pinnedRightWidth += layout.widths[i]!;
}
return {
trackLefts: layout.trackLefts,
trackRights: layout.trackRights,
rowHeight,
dataRowTop: headerHeight + pinnedTopHeight,
pinnedBottomHeight,
pinnedLeftWidth,
pinnedRightWidth,
pins: layout.pins,
};
}, [layout, rowHeight, headerHeight, pinnedTopHeight, pinnedBottomHeight]);
}
/** Props for {@link FillHandleTracker} — everything `useFillHandle` needs except `scrollRef`/`layout` (read from context here, only available inside `DataGridRoot`'s own subtree). */
export type FillHandleTrackerProps = Pick<UseFillHandleOptions, "disabled" | "onFill" | "detectSeries"> & {
fillStore: FillStoreApi;
};
/**
* Runs the actual fill-handle pointer/keymap engine — rendered as a child ANYWHERE inside
* `<DataGridRoot>` (alongside `DataGridHeader`/`DataGridBody`), because `scrollRef` and the column
* layout `useFillHandle` needs for pointer math only exist inside `DataGridRoot`'s own subtree,
* one level below where `DataGridRoot` itself calls `useGridInteraction`. `DataGridRoot` can't
* take `fillDown`/`fillRight`/`cancelFillDrag` as ordinary props from above the provider; this
* component closes that ordering gap by registering its handlers into the store instead, the same
* way `scrollToCellImpl` does for `useDataGridScrollToCell`. Renders nothing itself —
* `useDataGridFill()`'s `plugin` paints the actual preview/handle DOM via the overlay-plugin seam.
*/
export function FillHandleTracker(props: FillHandleTrackerProps): ReactNode {
const layout = useInteractionLayoutFromContext();
const { scrollRef } = useDataGridRootContext();
const actions = useDataGridActions();
// the grid's own readOnly disables fill too: core's write actions no-op there, so the handle,
// the drag, and the mod+D/mod+R shortcuts would all be dead UI without this.
const gridReadOnly = useDataGridReadOnly();
const disabled = props.disabled === true || gridReadOnly;
const handlers = useFillHandle({ ...props, disabled, scrollRef, layout });
useEffect(() => {
actions._registerFillHandlers({ fillDown: handlers.fillDown, fillRight: handlers.fillRight, cancelFillDrag: handlers.cancelFillDrag });
return () => actions._registerFillHandlers(null);
}, [actions, handlers]);
// The plugin (created above DataGridProvider, see use-data-grid-fill.ts) reads this back out of
// the SAME fillStore to wire onto its rendered handle square — the only way the two ends meet.
useEffect(() => {
props.fillStore.getState().setOnPointerDown(handlers.onPointerDown);
return () => props.fillStore.getState().setOnPointerDown(null);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- fillStore identity is stable for the component's lifetime (useState initializer in useDataGridFill); only handlers.onPointerDown's identity should re-trigger this.
}, [handlers.onPointerDown]);
return null;
}
registry/default/blocks/data-grid-fill/fill.ts
import type { GridRect } from "@/registry/default/components/data-grid/data-grid";
export { computeFillTarget, type AllowedFillDirections, type ComputeFillTargetOptions } from "./compute-fill-target";
export { detectSeries, type SeriesDescriptor } from "./detect-series";
export { generateFill, type FillDirection, type GenerateFillOptions } from "./generate-fill";
export { fillDirection } from "./fill-direction";
/** Re-expresses `rect` relative to `origin`'s top-left — `generateFill`'s `target` contract. */
export function rectRelativeTo(rect: GridRect, origin: GridRect): GridRect {
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
}
registry/default/blocks/data-grid-fill/generate-fill.ts
import type { GridRect } from "@/registry/default/components/data-grid/data-grid";
import { detectSeries, type SeriesDescriptor } from "./detect-series";
/** Direction a fill drag or extrapolation extends in. */
export type FillDirection = "up" | "down" | "left" | "right";
/** Options for {@link generateFill}. */
export type GenerateFillOptions = {
/** Modifier held (Excel's Ctrl-drag) — forces modulo tiling even when a series is detected. */
forceCopy?: boolean;
/**
* Series detector replacing the built-in `detectSeries` (arithmetic, zero-padded,
* prefix+number). Called per source row/column along the fill axis; `null` falls back to
* tiling.
*/
detect?: (values: readonly string[]) => SeriesDescriptor | null;
};
function directionAxis(direction: FillDirection): "vertical" | "horizontal" {
return direction === "up" || direction === "down" ? "vertical" : "horizontal";
}
function fillCell(
source: string[][],
series: (SeriesDescriptor | null)[],
axis: "vertical" | "horizontal",
absCol: number,
absRow: number,
srcW: number,
srcH: number
): string {
if (axis === "vertical") {
const s = series[absCol];
// absRow is already source-relative (0 = source's first row), so it doubles as the series index
if (s) return s.extrapolate(absRow);
const wrappedRow = ((absRow % srcH) + srcH) % srcH;
// source is rectangular (srcH x srcW) by contract; wrappedRow/absCol are both in-bounds mod srcH/srcW
return source[wrappedRow]![absCol]!;
}
const s = series[absRow];
if (s) return s.extrapolate(absCol);
const wrappedCol = ((absCol % srcW) + srcW) % srcW;
return source[absRow]![wrappedCol]!;
}
/**
* Generates fill values for `target` (a rect relative to `source`'s own
* top-left, i.e. `target.x/y` may be negative for up/left fills or `>=
* source width/height` for down/right fills) by extending `source` in
* `direction`. Vertical fills detect a series per source column; horizontal
* fills detect a series per source row. `forceCopy` or an undetected series
* falls back to modulo tiling, wrapping so an upward/leftward fill tiles from
* the far edge of the source (glide-behavior-spec.md §6).
*/
export function generateFill(
source: string[][],
target: GridRect,
direction: FillDirection,
opts: GenerateFillOptions = {}
): string[][] {
const srcH = source.length;
const srcW = source[0]?.length ?? 0;
const axis = directionAxis(direction);
const forceCopy = opts.forceCopy === true;
const detect = opts.detect ?? detectSeries;
// source is rectangular (srcH x srcW) by contract, so row/col indices below are always in-bounds
const series: (SeriesDescriptor | null)[] =
axis === "vertical"
? Array.from({ length: srcW }, (_, col) =>
forceCopy ? null : detect(Array.from({ length: srcH }, (_, row) => source[row]![col]!))
)
: Array.from({ length: srcH }, (_, row) => (forceCopy ? null : detect(source[row]!)));
const result: string[][] = [];
for (let row = 0; row < target.height; row++) {
const absRow = target.y + row;
const rowValues: string[] = [];
for (let col = 0; col < target.width; col++) {
const absCol = target.x + col;
rowValues.push(fillCell(source, series, axis, absCol, absRow, srcW, srcH));
}
result.push(rowValues);
}
return result;
}
registry/default/blocks/data-grid-fill/use-data-grid-fill.tsx
"use client";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useDataGridEditing, useDataGridReadOnly, useDataGridSelection, type OverlayPlugin } from "@/registry/default/components/data-grid/data-grid";
import { createFillStore, useFillOnPointerDown, useFillPreview, type FillStoreApi } from "./fill-store";
import { renderFillOverlay } from "./fill-overlay";
import { FillHandleTracker as FillHandleTrackerImpl } from "./fill-tracker";
import type { FillArgs, UseFillHandleOptions } from "./use-fill-handle";
/** Options for {@link useDataGridFill} — the parts of `useFillHandle`'s options that don't need `scrollRef`/`layout` (only available inside `DataGridRoot`'s own subtree, see fill-tracker.tsx). */
export type UseDataGridFillOptions = Pick<UseFillHandleOptions, "disabled" | "onFill" | "detectSeries">;
/** Return value of {@link useDataGridFill}. */
export type UseDataGridFillResult = {
/** Stable for the component's lifetime. Put it in a memoized `overlayPlugins` array, e.g. `useMemo(() => [plugin], [plugin])`; an inline `[plugin]` literal trips the identity dev warning. */
plugin: OverlayPlugin;
/**
* Render this as a child ANYWHERE inside `<DataGridRoot>`, alongside `DataGridHeader`/
* `DataGridBody` — it needs `scrollRef`/column layout, which only exist inside that subtree.
* Renders nothing itself; `plugin` (above) paints the actual preview/handle DOM. Stable identity
* for the hook's lifetime (a fresh component type per render would remount the tracker subtree,
* dropping an in-progress drag — see fill-tracker-identity.test.tsx); `disabled`/`onFill`
* still stay live via a ref this closure writes to on every render.
*/
FillHandleTracker: () => ReactNode;
};
/**
* The fill handle, as a single hook. Owns its own store — a plain Zustand vanilla store local to
* this hook instance, NOT part of the grid's own store, for the in-progress drag-preview rect and
* the live pointerdown handler: core stays entirely unaware of fill past the generic
* `overlayPlugins` seam it renders through and the `fillHandlers` registration slot
* `FillHandleTracker` writes into (mirroring `scrollToCellImpl`).
*
* Two pieces, not one, because fill (unlike `data-grid-presence`) needs real pointer/scroll
* coordinates: `plugin` is pure rendering (paints from the store, works from anywhere `overlayPlugins`
* reaches) but `FillHandleTracker` needs `scrollRef` and column layout, which only exist inside
* `DataGridRoot`'s own subtree — a level below where `overlayPlugins` must already be wired (on
* `DataGridProvider`, ABOVE `DataGridRoot`). Consumer API is otherwise the same hook-first shape as
* `useDataGridPresence`.
*/
export function useDataGridFill(options: UseDataGridFillOptions): UseDataGridFillResult {
const [fillStore] = useState(() => createFillStore());
// Kept live via a ref (not hook args) so FillHandleTracker below can stay a component with a
// fixed identity for the hook's lifetime — reading options.current on every render instead of
// closing over disabled/onFill directly, which would force a fresh closure per call.
const optionsRef = useRef(options);
optionsRef.current = options;
// The plugin calls `useFillPreview`/`useFillOnPointerDown`/`useDataGridSelection`/
// `useDataGridEditing` ITSELF when invoked (inside DataGridOverlays's own render, see
// overlays.tsx) — a plain function call still runs its hooks against the calling component's
// fiber, so this is what subscribes DataGridOverlays (and ONLY DataGridOverlays) to fill state —
// exactly the same atomic selectors the built-in overlay layer reads. This hook's
// OWN component never subscribes to any of them, preserving the zero-cell-render contract:
// re-rendering here would re-render everything under it, including DataGridProvider, on every drag frame.
const plugin = useMemo<OverlayPlugin>(() => makeFillPlugin(fillStore, optionsRef), [fillStore]);
// Stable across the hook's lifetime — see UseDataGridFillResult.FillHandleTracker's doc for why
// a fresh component type per render is a bug (React remounts on type-reference change).
const trackerComponent = useMemo<() => ReactNode>(() => {
return function BoundFillHandleTracker() {
const { disabled, onFill, detectSeries } = optionsRef.current;
return <FillHandleTrackerImpl fillStore={fillStore} disabled={disabled} onFill={onFill} detectSeries={detectSeries} />;
};
}, [fillStore]);
return {
plugin,
FillHandleTracker: trackerComponent,
};
}
function makeFillPlugin(fillStore: FillStoreApi, optionsRef: { current: UseDataGridFillOptions }): OverlayPlugin {
return (ctx) => {
const fillPreview = useFillPreview(fillStore);
const onPointerDown = useFillOnPointerDown(fillStore);
const selection = useDataGridSelection();
const editing = useDataGridEditing();
// either disable source vetoes the fill, so the handle must vanish with it (live via
// optionsRef.current, the same pattern FillHandleTracker uses for its handler).
const disabled = useDataGridReadOnly() || optionsRef.current.disabled === true;
const state = { fillPreview, onPointerDown, primaryRange: selection.current?.range ?? null, editing: editing !== null, disabled };
return renderFillOverlay(state, ctx);
};
}
export type { FillArgs, UseFillHandleOptions };
export type { FillHandleHandlers } from "./use-fill-handle";
registry/default/blocks/data-grid-fill/use-fill-handle.ts
"use client";
import { useCallback, useEffect, useMemo, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import {
applyInlineScrollDelta,
combineRects,
getCellValue,
isDev,
inlineAutoScrollStep,
candidateRowIds,
pointerToCoord,
flashCellKey,
resolveBulkWrites,
snapshotBulkBatch,
useBulkGeneration,
useDataGridActions,
useDataGridStoreApi,
type BulkCandidate,
type BulkWrite,
type DataGridStoreState,
type GridRect,
type InteractionLayout,
} from "@/registry/default/components/data-grid/data-grid";
import { computeFillTarget, fillDirection, generateFill, rectRelativeTo, type SeriesDescriptor } from "./fill";
import type { FillStoreApi } from "./fill-store";
/** rAF-throttled auto-scroll step (px), matching `useGridInteraction`'s drag auto-scroll. */
const AUTO_SCROLL_STEP = 16;
/** Distance (px) from a viewport edge at which drag auto-scroll kicks in. */
const AUTO_SCROLL_ZONE = 24;
/** Args passed to the `onFill` consumer hook. */
export type FillArgs = {
source: GridRect;
target: GridRect;
values: string[][];
/** Call to veto the fill entirely — nothing is applied and the selection doesn't expand. */
preventDefault: () => void;
};
/** Options for {@link useFillHandle}. */
export type UseFillHandleOptions = {
scrollRef: RefObject<HTMLElement | null>;
layout: InteractionLayout;
/** Turns the whole fill feature off (handle hidden, `fillDown`/`fillRight` no-op). Default `false`; a read-only grid disables fill regardless of this option. */
disabled?: boolean;
onFill?: (args: FillArgs) => void;
/** Replaces the built-in series detector (`detectSeries`) — the add-on's default remains the built-in one. */
detectSeries?: (values: readonly string[]) => SeriesDescriptor | null;
/** This add-on's local fill-preview store — owns the in-progress drag rect (kept out of core). */
fillStore: FillStoreApi;
};
/** Reads one cell's text value via its column's cell type `toText`, for use as a fill source. */
function cellText(s: DataGridStoreState, viewRow: number, colIndex: number): string {
const column = s.visibleColumns[colIndex];
if (!column) return "";
const dataRowIndex = s.viewIndex[viewRow];
const row = dataRowIndex === undefined ? undefined : s.data[dataRowIndex];
if (row === undefined) return "";
const cellType = s.cellTypes[column.type ?? "text"];
if (!cellType) return "";
// explicit TData=unknown: row's `undefined`-narrowed type ({} | null) would otherwise drive inference instead of column's own already-unknown TData.
const value = getCellValue<unknown, typeof column>(row, column);
return cellType.toText(value, column.options);
}
/** Reads `rect` as a 2D string grid via each cell type's `toText` — the fill algorithm's raw input. */
export function readRectAsText(s: DataGridStoreState, rect: GridRect): string[][] {
const out: string[][] = [];
for (let row = rect.y; row < rect.y + rect.height; row++) {
const line: string[] = [];
for (let col = rect.x; col < rect.x + rect.width; col++) line.push(cellText(s, row, col));
out.push(line);
}
return out;
}
/**
* Builds the unvalidated candidate cells for filling `strip` (the new cells only, absolute view
* coords) by extending `source`: reads source text, runs `generateFill`, then converts each
* destination value back through its own column's `fromText`, skipping readOnly columns and rows
* that don't resolve. Also returns `filled`, `generateFill`'s raw text grid, which `onFill`
* needs BEFORE any validation runs (it can veto the fill outright). Exported for direct unit
* testing; not part of the public hook surface.
*/
export function buildFillCandidates(
s: DataGridStoreState,
source: GridRect,
strip: GridRect,
opts: { forceCopy?: boolean; detect?: (values: readonly string[]) => SeriesDescriptor | null } = {},
): { candidates: BulkCandidate[]; filled: string[][] } {
const sourceValues = readRectAsText(s, source);
const direction = fillDirection(source, strip);
const relativeStrip = rectRelativeTo(strip, source);
const filled = generateFill(sourceValues, relativeStrip, direction, opts);
const candidates: BulkCandidate[] = [];
let skippedHoleRows = 0;
for (let row = 0; row < strip.height; row++) {
const viewRow = strip.y + row;
const dataRowIndex = s.viewIndex[viewRow];
if (dataRowIndex === undefined) continue;
const dataRow = s.data[dataRowIndex];
if (dataRow === undefined) {
// an unloaded hole of a lazy grid: the row is real (in viewIndex) but has no row object
skippedHoleRows++;
continue;
}
const rowId = s.getRowId(dataRow, dataRowIndex);
for (let col = 0; col < strip.width; col++) {
const colIndex = strip.x + col;
const column = s.visibleColumns[colIndex];
if (!column) continue;
if (typeof column.readOnly === "function" ? column.readOnly(dataRow) : Boolean(column.readOnly)) continue;
const cellType = s.cellTypes[column.type ?? "text"];
if (!cellType) continue;
// filled is generateFill's strip.height x strip.width output; row/col loop bounds match exactly
const value = cellType.fromText(filled[row]![col]!, column.options);
candidates.push({ viewRow, columnId: column.id, value, validate: column.validate, row: dataRow, rowId });
}
}
if (skippedHoleRows > 0 && isDev()) {
console.warn(
`[data-grid-fill] the fill target skipped ${skippedHoleRows} unloaded row(s) (lazy grid): unloaded cells are never filled and the selection still expands over them. Fill over a lazy grid covers loaded rows only.`,
);
}
return { candidates, filled };
}
/**
* The fill write list plus `generateFill`'s raw text grid. `writes` is a Promise when a filled
* column's schema validates asynchronously — the hook holds the batch until it resolves, then
* applies the passing cells as one `applyCellUpdates`.
*/
export function buildFillWrites(
s: DataGridStoreState,
source: GridRect,
strip: GridRect,
opts: { forceCopy?: boolean; detect?: (values: readonly string[]) => SeriesDescriptor | null } = {},
): { writes: BulkWrite[] | Promise<BulkWrite[]>; filled: string[][] } {
const { candidates, filled } = buildFillCandidates(s, source, strip, opts);
return { writes: resolveBulkWrites(candidates), filled };
}
/** Handlers for the fill-handle drag gesture (see fill-overlay.tsx's handle square) and the fillDown/fillRight shortcuts. */
export type FillHandleHandlers = {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
/** mod+D: fills the selection's top row downward across the rest of the range (no-op when height <= 1). */
fillDown: () => void;
/** mod+R: fills the selection's left column rightward across the rest of the range (no-op when width <= 1). */
fillRight: () => void;
/** Escape: discards an in-progress drag without applying any writes; no-op when no drag is active. */
cancelFillDrag: () => void;
};
/**
* Drives the fill-handle drag: pointerdown on the handle starts a document-level drag (capture +
* rAF-throttled move + auto-scroll, mirroring `useGridInteraction`'s range drag), live-updates
* the add-on's local `fillStore` via `computeFillTarget` (orthogonal axis snap), and on release
* runs the shared fill pipeline (`buildFillWrites`) as one `applyCellUpdates` batch, expanding the
* selection to `combineRects(source, strip)`. Alt held at release forces plain-copy tiling over
* series inference.
*/
export function useFillHandle(options: UseFillHandleOptions): FillHandleHandlers {
const { scrollRef, layout, onFill, detectSeries, fillStore } = options;
const disabled = options.disabled === true;
const actions = useDataGridActions();
const storeApi = useDataGridStoreApi();
// One counter for this add-on's fill surface: a newer fill supersedes a still-validating one.
const guard = useBulkGeneration();
const layoutRef = useRef(layout);
layoutRef.current = layout;
const callbacksRef = useRef({ onFill, detectSeries });
callbacksRef.current = { onFill, detectSeries };
const dragRef = useRef<{ pointerId: number; source: GridRect } | null>(null);
const rafRef = useRef<number | null>(null);
const lastPointerRef = useRef<{ clientX: number; clientY: number; altKey: boolean } | null>(null);
const captureElementRef = useRef<Element | null>(null);
const documentListenersRef = useRef<(() => void) | null>(null);
const stopLoop = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}, []);
const runFrame = useCallback(() => {
const scrollElement = scrollRef.current;
const pointer = lastPointerRef.current;
const drag = dragRef.current;
if (!scrollElement || !pointer || !drag) {
rafRef.current = null;
return;
}
const rect = scrollElement.getBoundingClientRect();
if (pointer.clientY < rect.top + AUTO_SCROLL_ZONE) scrollElement.scrollTop -= AUTO_SCROLL_STEP;
else if (pointer.clientY > rect.bottom - AUTO_SCROLL_ZONE) scrollElement.scrollTop += AUTO_SCROLL_STEP;
// Inline-axis edge test + step, mirroring core's own drag auto-scroll (use-grid-interaction.ts).
const direction = layoutRef.current.direction ?? "ltr";
const inlineStep = inlineAutoScrollStep(pointer.clientX, rect, AUTO_SCROLL_ZONE, direction);
if (inlineStep !== 0) applyInlineScrollDelta(scrollElement, inlineStep * AUTO_SCROLL_STEP, direction);
const s = storeApi.getState();
const rowCount = s.viewIndex.length;
const colCount = s.visibleColumns.length;
const coord = pointerToCoord(pointer.clientX, pointer.clientY, scrollElement, layoutRef.current, rowCount);
const strip = computeFillTarget(drag.source, coord, { allowedDirections: "orthogonal", rowCount, colCount });
fillStore.getState().setFillPreview(strip);
rafRef.current = requestAnimationFrame(runFrame);
}, [fillStore, scrollRef, storeApi]);
/**
* Runs the shared fill pipeline: builds candidates for `strip` (extending `source`), fires
* `onFill` (which can veto), validates, applies one `applyCellUpdates` batch, and expands
* the selection to `combineRects(source, strip)`.
*
* With an async schema on a filled column the write batch is HELD: the selection still expands
* right away (the gesture's own feedback, unrelated to validation), and the cells commit in one
* batch once every validator resolves. `guard` drops that resolution when a newer fill has started
* or the rows it targeted are gone.
*/
const runFill = useCallback(
(source: GridRect, strip: GridRect, opts: { forceCopy?: boolean } = {}) => {
const s = storeApi.getState();
const { onFill, detectSeries } = callbacksRef.current;
const { candidates, filled } = buildFillCandidates(s, source, strip, { ...opts, detect: detectSeries });
let prevented = false;
onFill?.({ source, target: strip, values: filled, preventDefault: () => (prevented = true) });
if (prevented) return;
const writes = resolveBulkWrites(candidates);
if (writes instanceof Promise) {
const token = guard.begin();
const snapshot = snapshotBulkBatch(s, candidateRowIds(s, candidates));
void writes.then((resolved) => {
const current = storeApi.getState();
if (!guard.isCurrent(token, current, snapshot)) return;
const applied = guard.reresolve(current, resolved);
actions.applyCellUpdates(applied, "fill");
// Pulse the cells that actually landed — the re-resolved batch, not the held one.
actions.flashCells(applied.map((w) => flashCellKey(w.viewRow, w.columnId)));
});
} else {
actions.applyCellUpdates(writes, "fill");
actions.flashCells(writes.map((w) => flashCellKey(w.viewRow, w.columnId)));
}
// expand selection to combineRects(source, strip): re-derive via the plain selectCell/extendTo
// primitives (anchor at source's top-left corner, extend to the combined rect's far corner).
const combined = combineRects(source, strip);
actions.selectCell({ col: combined.x, row: combined.y });
actions.extendTo({ col: combined.x + combined.width - 1, row: combined.y + combined.height - 1 });
},
[actions, guard, storeApi],
);
/** Releases pointer capture and clears all drag refs/listeners/rAF loop; returns the drag that was active, if any. */
const teardownDrag = useCallback(() => {
const drag = dragRef.current;
const pointerId = drag?.pointerId;
const captureEl = captureElementRef.current as (Element & { hasPointerCapture?: (id: number) => boolean; releasePointerCapture?: (id: number) => void }) | null;
if (captureEl && pointerId !== undefined && captureEl.hasPointerCapture?.(pointerId)) {
captureEl.releasePointerCapture?.(pointerId);
}
captureElementRef.current = null;
dragRef.current = null;
lastPointerRef.current = null;
stopLoop();
documentListenersRef.current?.();
documentListenersRef.current = null;
return drag;
}, [stopLoop]);
const endDrag = useCallback(() => {
const pointer = lastPointerRef.current;
const drag = teardownDrag();
if (!drag) return;
const strip = fillStore.getState().fillPreview;
fillStore.getState().setFillPreview(null);
if (!strip) return;
runFill(drag.source, strip, { forceCopy: pointer?.altKey === true });
}, [fillStore, runFill, teardownDrag]);
/** Escape mid-drag: tears down the drag and clears the preview without applying any writes. */
const cancelFillDrag = useCallback(() => {
const drag = teardownDrag();
if (!drag) return;
fillStore.getState().setFillPreview(null);
}, [fillStore, teardownDrag]);
const beginDrag = useCallback(
(source: GridRect, pointerId: number, captureElement?: Element) => {
dragRef.current = { pointerId, source };
captureElementRef.current = captureElement ?? null;
if (rafRef.current === null) rafRef.current = requestAnimationFrame(runFrame);
if (!documentListenersRef.current) {
const onMove = (event: PointerEvent) => {
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
lastPointerRef.current = { clientX: event.clientX, clientY: event.clientY, altKey: event.altKey };
};
const onUp = (event: PointerEvent) => {
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
if (lastPointerRef.current) lastPointerRef.current.altKey = event.altKey;
endDrag();
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
documentListenersRef.current = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
};
}
},
[runFrame, endDrag],
);
useEffect(() => () => documentListenersRef.current?.(), []);
useEffect(() => stopLoop, [stopLoop]);
const onPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0 || disabled) return;
const s = storeApi.getState();
if (!s.selection.current) return;
event.preventDefault();
event.stopPropagation();
lastPointerRef.current = { clientX: event.clientX, clientY: event.clientY, altKey: event.altKey };
event.currentTarget.setPointerCapture?.(event.pointerId);
beginDrag(s.selection.current.range, event.pointerId, event.currentTarget);
},
[beginDrag, disabled, storeApi],
);
const fillDown = useCallback(() => {
if (disabled) return;
const range = storeApi.getState().selection.current?.range;
if (!range || range.height <= 1) return;
const source: GridRect = { x: range.x, y: range.y, width: range.width, height: 1 };
const strip: GridRect = { x: range.x, y: range.y + 1, width: range.width, height: range.height - 1 };
runFill(source, strip);
}, [disabled, runFill, storeApi]);
const fillRight = useCallback(() => {
if (disabled) return;
const range = storeApi.getState().selection.current?.range;
if (!range || range.width <= 1) return;
const source: GridRect = { x: range.x, y: range.y, width: 1, height: range.height };
const strip: GridRect = { x: range.x + 1, y: range.y, width: range.width - 1, height: range.height };
runFill(source, strip);
}, [disabled, runFill, storeApi]);
// Stable object identity when none of the four callbacks changed — the add-on's own plugin
// closure and DataGridRoot's context value depend on this reference, so a fresh literal here
// would defeat that memo on every render.
return useMemo(
() => ({ onPointerDown, fillDown, fillRight, cancelFillDrag }),
[onPointerDown, fillDown, fillRight, cancelFillDrag],
);
}