shadimage
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 · light
zustand

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],
  );
}

More from gridcn

Browse all components in this library →