diff --git a/packages/core/API.md b/packages/core/API.md
index 00e18d738..60cb16c5f 100644
--- a/packages/core/API.md
+++ b/packages/core/API.md
@@ -15,7 +15,7 @@ const portalRef = useRef(null);
<>
{
createPortal(
-
,
+ ,
document.body
)
}
@@ -1019,10 +1019,73 @@ Highlight regions are regions on the grid which get drawn with a background colo
## fillHandle
```ts
-fillHandle?: boolean;
+fillHandle?: boolean | Partial;
```
-Controls the visibility of the fill handle used for filling cells with the mouse.
+Controls the presence of the fill handle used for filling cells with the mouse.
+
+**Configuration for the fill-handle (the small drag handle that appears in the
+bottom-right of the current selection).**
+
+```ts
+interface FillHandleConfig {
+ /** Shape of the handle. Defaults to "square". */
+ shape?: "square" | "circle";
+ /** Width/height (or diameter for circles) in CSS pixels. Defaults to `4`. */
+ size?: number;
+ /** Horizontal offset from the bottom-right corner of the cell (px). */
+ offsetX?: number;
+ /** Vertical offset from the bottom-right corner of the cell (px). Default is `-2`. */
+ offsetY?: number;
+ /** Stroke width (px) of the outline that surrounds the handle. Defaults to `0`. */
+ outline?: number;
+}
+```
+
+---
+
+## allowedFillDirections
+
+```ts
+allowedFillDirections?: "horizontal" | "vertical" | "orthogonal" | "any"; // default "orthogonal"
+```
+
+Controls which directions the fill-handle may extend when the user drags it.
+
+- "horizontal": Only left/right expansion is allowed.
+- "vertical": Only up/down expansion is allowed.
+- "orthogonal": Expands to the closest orthogonal edge (up/down/left/right). This is the default.
+- "any": Expands freely in both axes (forms a rectangle to the pointer).
+
+---
+
+## onFillPattern
+
+```ts
+onFillPattern?: (event: FillPatternEventArgs) => void;
+
+interface FillPatternEventArgs extends PreventableEvent {
+ patternSource: Rectangle;
+ fillDestination: Rectangle;
+}
+```
+
+Emitted whenever the user initiates a pattern fill using the fill handle. The event provides both the source
+pattern region and the destination region about to be filled. Call `event.preventDefault()` to cancel the fill.
+
+Example: prevent filling into protected regions
+
+```ts
+ {
+ const { fillDestination } = e;
+ if (/* your condition */ false) {
+ e.preventDefault();
+ }
+ }}
+ {...props}
+/>
+```
---
diff --git a/packages/core/src/docs/examples/fill-handle.stories.tsx b/packages/core/src/docs/examples/fill-handle.stories.tsx
index 658512040..65b65e875 100644
--- a/packages/core/src/docs/examples/fill-handle.stories.tsx
+++ b/packages/core/src/docs/examples/fill-handle.stories.tsx
@@ -9,7 +9,8 @@ import {
defaultProps,
clearCell,
} from "../../data-editor/stories/utils.js";
-import { GridCellKind } from "../../internal/data-grid/data-grid-types.js";
+import { GridCellKind, type FillHandleDirection } from "../../internal/data-grid/data-grid-types.js";
+import type { FillPatternEventArgs } from "../../index.js";
import { SimpleThemeWrapper } from "../../stories/story-utils.js";
export default {
@@ -34,9 +35,39 @@ export default {
),
],
+ argTypes: {
+ fillHandleEnabled: { control: "boolean", name: "fillHandle enabled" },
+ shape: { control: { type: "inline-radio" }, options: ["square", "circle"], name: "shape" },
+ size: { control: { type: "number" }, name: "size" },
+ offsetX: { control: { type: "number" }, name: "offsetX" },
+ offsetY: { control: { type: "number" }, name: "offsetY" },
+ outline: { control: { type: "number" }, name: "outline" },
+ allowedFillDirections: {
+ control: { type: "inline-radio" },
+ options: ["horizontal", "vertical", "orthogonal", "any"],
+ name: "allowedFillDirections",
+ },
+ },
+ args: {
+ fillHandleEnabled: true,
+ shape: "square",
+ size: 4,
+ offsetX: -2,
+ offsetY: -2,
+ outline: 0,
+ allowedFillDirections: "orthogonal",
+ },
};
-export const FillHandle: React.VFC = () => {
+export const FillHandle: React.VFC<{
+ fillHandleEnabled: boolean;
+ shape: "square" | "circle";
+ size: number;
+ offsetX: number;
+ offsetY: number;
+ outline: number;
+ allowedFillDirections: FillHandleDirection;
+}> = ({ fillHandleEnabled, shape, size, offsetX, offsetY, outline, allowedFillDirections }) => {
const { cols, getCellContent, setCellValueRaw, setCellValue } = useMockDataGenerator(60, false);
const [numRows, setNumRows] = React.useState(50);
@@ -72,7 +103,8 @@ export const FillHandle: React.VFC = () => {
columns={cols}
rowMarkers={"both"}
onPaste={true}
- fillHandle={true}
+ fillHandle={fillHandleEnabled ? { shape, size, offsetX, offsetY, outline } : false}
+ allowedFillDirections={allowedFillDirections}
keybindings={{ downFill: true, rightFill: true }}
onCellEdited={setCellValue}
trailingRowOptions={{
diff --git a/packages/core/src/internal/data-grid/data-grid-types.ts b/packages/core/src/internal/data-grid/data-grid-types.ts
index cdaa1bf9a..af64d3aef 100644
--- a/packages/core/src/internal/data-grid/data-grid-types.ts
+++ b/packages/core/src/internal/data-grid/data-grid-types.ts
@@ -526,6 +526,36 @@ export type CompactSelectionRanges = readonly Slice[];
export type FillHandleDirection = "horizontal" | "vertical" | "orthogonal" | "any";
+/**
+ * Configuration options for the fill-handle (the little drag square in the bottom-right of a selection).
+ *
+ * `shape` – Either a square or a circle. Default is `square`.
+ * `size` – Width/height (or diameter) in CSS pixels. Default is `4`.
+ * `offsetX` – Horizontal offset from the bottom-right corner of the cell (positive is →). Default is `-2`.
+ * `offsetY` – Vertical offset from the bottom-right corner of the cell (positive is ↓). Default is `-2`.
+ * `outline` – Width of the outline stroke in CSS pixels. Default is `0`.
+ */
+export type FillHandleConfig = {
+ readonly shape: "square" | "circle";
+ readonly size: number;
+ readonly offsetX: number;
+ readonly offsetY: number;
+ readonly outline: number;
+};
+
+export type FillHandle = boolean | Partial;
+
+/**
+ * Default configuration used when `fillHandle` is simply `true`.
+ */
+export const DEFAULT_FILL_HANDLE: Readonly = {
+ shape: "square",
+ size: 4,
+ offsetX: -2,
+ offsetY: -2,
+ outline: 0,
+} as const;
+
function mergeRanges(input: CompactSelectionRanges) {
if (input.length === 0) {
return [];
@@ -561,7 +591,7 @@ export class CompactSelection {
static create = (items: CompactSelectionRanges) => {
return new CompactSelection(mergeRanges(items));
- }
+ };
static empty = (): CompactSelection => {
return emptyCompactSelection ?? (emptyCompactSelection = new CompactSelection([]));
diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx
index 18099b731..883c541eb 100644
--- a/packages/core/src/internal/data-grid/data-grid.tsx
+++ b/packages/core/src/internal/data-grid/data-grid.tsx
@@ -23,6 +23,8 @@ import {
booleanCellIsEditable,
type InnerGridColumn,
type DrawCellCallback,
+ type FillHandle,
+ DEFAULT_FILL_HANDLE,
} from "./data-grid-types.js";
import { CellSet } from "./cell-set.js";
import { SpriteManager, type SpriteMap } from "./data-grid-sprites.js";
@@ -145,7 +147,7 @@ export interface DataGridProps {
* @defaultValue false
* @group Editing
*/
- readonly fillHandle: boolean | undefined;
+ readonly fillHandle: FillHandle | undefined;
readonly disabledRows: CompactSelection | undefined;
/**
@@ -314,13 +316,15 @@ type DamageUpdateList = readonly {
// newValue: GridCell,
}[];
-const fillHandleClickSize = 6;
-
export interface DataGridRef {
focus: () => void;
getBounds: (col?: number, row?: number) => Rectangle | undefined;
damage: (cells: DamageUpdateList) => void;
- getMouseArgsForPosition: (posX: number, posY: number, ev?: MouseEvent | TouchEvent) => GridMouseEventArgs | undefined;
+ getMouseArgsForPosition: (
+ posX: number,
+ posY: number,
+ ev?: MouseEvent | TouchEvent
+ ) => GridMouseEventArgs | undefined;
}
const getRowData = (cell: InnerGridCell, getCellRenderer?: GetCellRendererCallback) => {
@@ -446,7 +450,10 @@ const DataGrid: React.ForwardRefRenderFunction = (p,
}, [cellYOffset, cellXOffset, translateX, translateY, enableFirefoxRescaling, enableSafariRescaling]);
const mappedColumns = useMappedColumns(columns, freezeColumns);
- const stickyX = React.useMemo(() => fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0,[mappedColumns, dragAndDropState, fixedShadowX]);
+ const stickyX = React.useMemo(
+ () => (fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0),
+ [mappedColumns, dragAndDropState, fixedShadowX]
+ );
// row: -1 === columnHeader, -2 === groupHeader
const getBoundsForItem = React.useCallback(
@@ -653,18 +660,29 @@ const DataGrid: React.ForwardRefRenderFunction = (p,
const isEdge = bounds !== undefined && bounds.x + bounds.width - posX < edgeDetectionBuffer;
let isFillHandle = false;
- if (fillHandle && selection.current !== undefined) {
+ const drawFill = fillHandle !== false && fillHandle !== undefined;
+ if (drawFill && selection.current !== undefined) {
+ const fill =
+ typeof fillHandle === "object"
+ ? { ...DEFAULT_FILL_HANDLE, ...fillHandle }
+ : DEFAULT_FILL_HANDLE;
+
+ const fillHandleClickSize = fill.size;
+ const half = fillHandleClickSize / 2;
+
const fillHandleLocation = rectBottomRight(selection.current.range);
- const fillHandleCellBounds = getBoundsForItem(canvas, fillHandleLocation[0], fillHandleLocation[1]);
+ const fillBounds = getBoundsForItem(canvas, fillHandleLocation[0], fillHandleLocation[1]);
- if (fillHandleCellBounds !== undefined) {
- const handleLogicalCenterX = fillHandleCellBounds.x + fillHandleCellBounds.width - 2;
- const handleLogicalCenterY = fillHandleCellBounds.y + fillHandleCellBounds.height - 2;
+ if (fillBounds !== undefined) {
+ // Handle center sits exactly on the bottom-right corner of the cell.
+ // Offset by half pixel to align with grid lines.
+ const centerX = fillBounds.x + fillBounds.width + fill.offsetX - half + 0.5;
+ const centerY = fillBounds.y + fillBounds.height + fill.offsetY - half + 0.5;
- //check if posX and posY are within fillHandleClickSize from handleLogicalCenter
+ // Check if posX and posY are within fillHandleClickSize from handleLogicalCenter
isFillHandle =
- Math.abs(handleLogicalCenterX - posX) < fillHandleClickSize &&
- Math.abs(handleLogicalCenterY - posY) < fillHandleClickSize;
+ Math.abs(centerX - posX) < fillHandleClickSize &&
+ Math.abs(centerY - posY) < fillHandleClickSize;
}
}
@@ -1710,9 +1728,9 @@ const DataGrid: React.ForwardRefRenderFunction = (p,
}
return getMouseArgsForPosition(canvasRef.current, posX, posY, ev);
- }
+ },
}),
- [canvasRef, damage, getBoundsForItem]
+ [canvasRef, damage, getBoundsForItem, getMouseArgsForPosition]
);
const lastFocusedSubdomNode = React.useRef- ();
diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.ts b/packages/core/src/internal/data-grid/render/data-grid-render.ts
index 4946b1557..310c3511d 100644
--- a/packages/core/src/internal/data-grid/render/data-grid-render.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid-render.ts
@@ -443,7 +443,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) {
const selectionCurrent = selection.current;
if (
- fillHandle &&
+ (fillHandle !== false && fillHandle !== undefined) &&
drawFocus &&
selectionCurrent !== undefined &&
damage.has(rectBottomRight(selectionCurrent.range))
diff --git a/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts b/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts
index 864dd4f4d..e8f07bc1d 100644
--- a/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts
@@ -1,6 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable unicorn/no-for-loop */
-import { type GridSelection, type InnerGridCell, type Item } from "../data-grid-types.js";
+import { type GridSelection, type InnerGridCell, type Item, type FillHandle, DEFAULT_FILL_HANDLE } from "../data-grid-types.js";
import { getStickyWidth, type MappedGridColumn, computeBounds, getFreezeTrailingHeight } from "./data-grid-lib.js";
import { type FullTheme } from "../../../common/styles.js";
import { blend, withAlpha } from "../color-parser.js";
@@ -188,11 +188,16 @@ export function drawFillHandle(
getCellContent: (cell: Item) => InnerGridCell,
freezeTrailingRows: number,
hasAppendRow: boolean,
- fillHandle: boolean,
+ fillHandle: FillHandle,
rows: number
): (() => void) | undefined {
if (selectedCell.current === undefined) return undefined;
+ const drawFill = fillHandle !== false && fillHandle !== undefined;
+ if (!drawFill) return undefined;
+
+ const fill = typeof fillHandle === "object" ? { ...DEFAULT_FILL_HANDLE, ...fillHandle } : DEFAULT_FILL_HANDLE;
+
const range = selectedCell.current.range;
const currentItem = selectedCell.current.cell;
const fillHandleTarget = [range.x + range.width - 1, range.y + range.height - 1];
@@ -261,7 +266,7 @@ export function drawFillHandle(
}
}
- const doHandle = row === fillHandleRow && isFillHandleCol && fillHandle;
+ const doHandle = row === fillHandleRow && isFillHandleCol && drawFill;
if (doHandle) {
drawHandleCb = () => {
@@ -270,10 +275,50 @@ export function drawFillHandle(
ctx.rect(clipX, 0, width - clipX, height);
ctx.clip();
}
+ // Draw a larger, outlined fill handle similar to Excel / Google Sheets.
+ const size = fill.size;
+ const half = size / 2;
+
+ // Place the handle so its center sits on the bottom-right corner of the cell,
+ // plus any configured offsets (fill.offsetX, fill.offsetY).
+ // Offset by half pixel to align with grid lines.
+ const hx = cellX + cellWidth + fill.offsetX - half + 0.5;
+ const hy = drawY + rh + fill.offsetY - half + 0.5;
+
ctx.beginPath();
- ctx.rect(cellX + cellWidth - 4, drawY + rh - 4, 4, 4);
+ if (fill.shape === "circle") {
+ ctx.arc(hx + half, hy + half, half, 0, Math.PI * 2);
+ } else {
+ ctx.rect(hx, hy, size, size);
+ }
+
+ // Fill
ctx.fillStyle = col.themeOverride?.accentColor ?? theme.accentColor;
ctx.fill();
+
+ // Outline (drawn so it doesn't eat into the filled area)
+ if (fill.outline > 0) {
+ ctx.lineWidth = fill.outline;
+ ctx.strokeStyle = theme.bgCell;
+ if (fill.shape === "circle") {
+ ctx.beginPath();
+ ctx.arc(
+ hx + half,
+ hy + half,
+ half + fill.outline / 2,
+ 0,
+ Math.PI * 2
+ );
+ ctx.stroke();
+ } else {
+ ctx.strokeRect(
+ hx - fill.outline / 2,
+ hy - fill.outline / 2,
+ size + fill.outline,
+ size + fill.outline
+ );
+ }
+ }
};
}
return drawHandleCb !== undefined;
diff --git a/packages/core/src/internal/data-grid/render/draw-grid-arg.ts b/packages/core/src/internal/data-grid/render/draw-grid-arg.ts
index 9221f91f7..7e3539ee1 100644
--- a/packages/core/src/internal/data-grid/render/draw-grid-arg.ts
+++ b/packages/core/src/internal/data-grid/render/draw-grid-arg.ts
@@ -13,6 +13,7 @@ import type {
DrawHeaderCallback,
CellList,
DrawCellCallback,
+ FillHandle,
} from "../data-grid-types.js";
import type { CellSet } from "../cell-set.js";
import type { EnqueueCallback } from "../use-animation-queue.js";
@@ -52,7 +53,7 @@ export interface DrawGridArg {
readonly isFocused: boolean;
readonly drawFocus: boolean;
readonly selection: GridSelection;
- readonly fillHandle: boolean;
+ readonly fillHandle: FillHandle;
readonly freezeTrailingRows: number;
readonly hasAppendRow: boolean;
readonly hyperWrapping: boolean;
diff --git a/packages/core/test/math.test.ts b/packages/core/test/math.test.ts
index 1c7e275ce..2130b6b5e 100644
--- a/packages/core/test/math.test.ts
+++ b/packages/core/test/math.test.ts
@@ -1,6 +1,6 @@
-import { expect, describe, it } from "vitest";
-import type { Rectangle } from "../src/internal/data-grid/data-grid-types.js";
+import { describe, expect, it } from "vitest";
import { getClosestRect, hugRectToTarget, splitRectIntoRegions } from "../src/common/math.js";
+import type { FillHandleDirection, Rectangle } from "../src/internal/data-grid/data-grid-types.js";
describe("getClosestRect", () => {
const testRect: Rectangle = { x: 10, y: 10, width: 5, height: 5 };
@@ -48,6 +48,38 @@ describe("getClosestRect", () => {
)
).to.deep.equal({ x: 11, y: 10, width: 10, height: 1 });
});
+
+ it("respects FillHandleDirection: horizontal", () => {
+ const dir: FillHandleDirection = "horizontal";
+ // Horizontal should ignore vertical displacement and extend left/right only
+ expect(getClosestRect(testRect, 5, 50, dir)).to.deep.equal({ x: 5, y: 10, width: 5, height: 5 });
+ expect(getClosestRect(testRect, 20, 50, dir)).to.deep.equal({ x: 15, y: 10, width: 6, height: 5 });
+ // Inside (horizontally) returns undefined after y is forced to rect.y
+ expect(getClosestRect(testRect, 12, 5, dir)).to.be.undefined;
+ });
+
+ it("respects FillHandleDirection: vertical", () => {
+ const dir: FillHandleDirection = "vertical";
+ // Vertical should ignore horizontal displacement and extend up/down only
+ expect(getClosestRect(testRect, 50, 5, dir)).to.deep.equal({ x: 10, y: 5, width: 5, height: 5 });
+ expect(getClosestRect(testRect, 50, 20, dir)).to.deep.equal({ x: 10, y: 15, width: 5, height: 6 });
+ // Inside (vertically) returns undefined after x is forced to rect.x
+ expect(getClosestRect(testRect, 12, 12, dir)).to.be.undefined;
+ });
+
+ it("respects FillHandleDirection: orthogonal (default)", () => {
+ const dir: FillHandleDirection = "orthogonal";
+ // Should choose nearest edge in either axis
+ expect(getClosestRect(testRect, 12, 5, dir)).to.deep.equal({ x: 10, y: 5, width: 5, height: 5 });
+ expect(getClosestRect(testRect, 20, 12, dir)).to.deep.equal({ x: 15, y: 10, width: 6, height: 5 });
+ });
+
+ it("respects FillHandleDirection: any", () => {
+ const dir: FillHandleDirection = "any";
+ // Should form a rectangle combining original and the pointer position (free expansion)
+ expect(getClosestRect(testRect, 20, 20, dir)).to.deep.equal({ x: 10, y: 10, width: 11, height: 11 });
+ expect(getClosestRect(testRect, 5, 5, dir)).to.deep.equal({ x: 5, y: 5, width: 10, height: 10 });
+ });
});
describe("hugRectToTarget", () => {