blob: 254301db1b564998720fecf77d50f8b53d853a3b [file] [log] [blame] [edit]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {classNames} from '../base/classnames';
import {MithrilEvent} from '../base/mithril_utils';
import {Icons} from '../base/semantic_icons';
import {exists} from '../base/utils';
import {Button} from './button';
import {MenuItem, PopupMenu} from './menu';
import {PopupPosition} from './popup';
import {VirtualScrollHelper} from './virtual_scroll_helper';
import {HTMLAttrs} from './common';
const DEFAULT_ROW_HEIGHT = 24;
const COL_WIDTH_INITIAL_MAX_PX = 600;
const COL_WIDTH_MIN_PX = 50;
const CELL_PADDING_PX = 5;
export type SortDirection = 'ASC' | 'DESC';
export type CellAlignment = 'left' | 'center' | 'right';
export type ReorderPosition = 'before' | 'after';
export function renderSortMenuItems(
sorted: SortDirection | undefined,
sort: (direction: SortDirection | undefined) => void,
) {
return [
sorted !== 'DESC' &&
m(MenuItem, {
label: 'Sort: highest first',
icon: Icons.SortedDesc,
onclick: () => sort('DESC'),
}),
sorted !== 'ASC' &&
m(MenuItem, {
label: 'Sort: lowest first',
icon: Icons.SortedAsc,
onclick: () => sort('ASC'),
}),
sorted !== undefined &&
m(MenuItem, {
label: 'Unsort',
icon: Icons.Close,
onclick: () => sort(undefined),
}),
];
}
export interface GridHeaderCellAttrs extends m.Attributes {
readonly sort?: SortDirection;
readonly onSort?: (direction: SortDirection) => void;
readonly menuItems?: m.Children;
readonly subContent?: m.Children;
}
export class GridHeaderCell implements m.ClassComponent<GridHeaderCellAttrs> {
view({attrs, children}: m.Vnode<GridHeaderCellAttrs>) {
const {sort, onSort, menuItems, subContent, ...htmlAttrs} = attrs;
const renderSortButton = () => {
if (!onSort) return undefined;
const nextDirection: SortDirection = (() => {
if (!sort) return 'ASC';
if (sort === 'ASC') return 'DESC';
if (sort === 'DESC') return 'ASC';
return 'ASC';
})();
return m(Button, {
className: classNames(
'pf-grid-header-cell__sort-button',
!sort && 'pf-grid-cell--hint',
!sort && 'pf-visible-on-hover',
),
ariaLabel: 'Sort column',
rounded: true,
icon: sort === 'DESC' ? Icons.SortDesc : Icons.SortAsc,
onclick: (e: MouseEvent) => {
onSort(nextDirection);
e.stopPropagation();
},
});
};
const renderMenu = () => {
if (menuItems === undefined) return undefined;
return m(
PopupMenu,
{
trigger: m(Button, {
className:
'pf-visible-on-hover pf-grid-header-cell__menu-button pf-grid--no-measure',
icon: Icons.ContextMenuAlt,
rounded: true,
ariaLabel: 'Column menu',
}),
},
menuItems,
);
};
return m(
'.pf-grid-header-cell',
{
...htmlAttrs,
},
[
m(
'.pf-grid-header-cell__main-content',
m(
'.pf-grid-header-cell__title',
m('.pf-grid-header-cell__title-wrapper', children),
renderSortButton(),
),
renderMenu(),
),
subContent !== undefined &&
m('.pf-grid-header-cell__sub-content', subContent),
],
);
}
}
export interface GridCellAttrs extends HTMLAttrs {
readonly menuItems?: m.Children;
readonly align?: CellAlignment;
readonly nullish?: boolean;
readonly padding?: boolean;
readonly wrap?: boolean;
}
export class GridCell implements m.ClassComponent<GridCellAttrs> {
view({attrs, children}: m.Vnode<GridCellAttrs>) {
const {
menuItems,
align = 'left',
nullish,
className,
padding = true,
wrap,
...rest
} = attrs;
const cell = m(
'.pf-grid-cell',
{
...rest,
className: classNames(
className,
align && `pf-grid-cell--align-${align}`,
padding && 'pf-grid-cell--padded',
nullish && 'pf-grid-cell--nullish',
wrap && 'pf-grid-cell--wrap',
),
},
children,
);
if (Boolean(menuItems)) {
return m(
PopupMenu,
{
trigger: cell,
isContextMenu: true,
position: PopupPosition.Bottom,
},
menuItems,
);
} else {
return cell;
}
}
}
/**
* Row data with cells and optional styling.
*/
export type GridRow = ReadonlyArray<m.Children>;
/**
* Column definition for Grid.
*/
export interface GridColumn {
readonly key: string;
readonly maxInitialWidthPx?: number;
readonly header?: m.Children;
readonly minWidth?: number;
readonly thickRightBorder?: boolean;
readonly reorderable?: {readonly handle: string};
}
/**
* Partial row data for virtual scrolling with paginated data.
* When using this, virtualization must be enabled.
*/
export interface PartialRowData {
readonly offset: number;
readonly total: number;
readonly data: ReadonlyArray<GridRow>;
readonly onLoadData: (offset: number, limit: number) => void;
}
/**
* Row data can be either:
* - Full dataset (array)
* - Partial/paginated dataset (object with offset, total, data, and load callback)
*/
export type GridRowData = ReadonlyArray<GridRow> | PartialRowData;
/**
* Virtual scrolling configuration.
* Required when using PartialRowData, optional otherwise.
*/
export interface GridVirtualization {
readonly rowHeightPx: number;
}
/**
* Imperative API for Grid component.
* Provides methods to control the grid programmatically.
*/
export interface GridApi {
/**
* Auto-fit a column to its content width.
* @param columnKey The key of the column to auto-fit
*/
autoFitColumn(columnKey: string): void;
/**
* Auto-fit all columns to their content widths.
*/
autoFitAllColumns(): void;
}
/**
* Attributes for the Grid component.
*/
export interface GridAttrs {
readonly columns: ReadonlyArray<GridColumn>;
readonly rowData: GridRowData;
readonly virtualization?: GridVirtualization;
readonly fillHeight?: boolean;
readonly className?: string;
readonly onRowHover?: (rowIndex: number) => void;
readonly onRowOut?: () => void;
readonly onColumnReorder?: (
from: string | number | undefined,
to: string | number | undefined,
position: ReorderPosition,
) => void;
readonly onReady?: (api: GridApi) => void;
}
/**
* Grid is a purely presentational component that renders tabular data with
* virtual scrolling and column resizing. It provides the layout structure but
* does NO automatic wrapping or transformation of content.
*
* Key features:
* - Virtual scrolling for efficient rendering of large datasets
* - Automatic column sizing based on content
* - Manual column resizing via drag handles
* - Double-click to auto-resize columns
*
* IMPORTANT: Grid is completely data-agnostic:
* - Headers must be provided as GridHeaderCell components (for sorting, menus, reordering)
* - Cells must be provided as GridCell components (for alignment, menus)
* - Grid does NO automatic wrapping or injection of components
* - Parent component is responsible for ALL content rendering
*
* For automatic features like sorting and filtering, use DataGrid instead.
*
* # Row Data API
*
* Grid supports two modes for providing row data:
*
* ## 1. Full Dataset (Array)
* When you have all rows in memory, pass them as a simple array:
* ```typescript
* rowData: [
* [m(GridCell, '1'), m(GridCell, 'Alice')],
* [m(GridCell, '2'), m(GridCell, 'Bob')],
* ]
* ```
*
* ## 2. Partial/Paginated Dataset (PartialRowData)
* For large datasets where you load data on-demand:
* ```typescript
* rowData: {
* data: [...], // Current page of rows
* total: 1000000, // Total number of rows
* offset: 0, // Current offset
* onLoadData: (offset, limit) => {
* // Load and set data for the requested range
* }
* }
* ```
* When using PartialRowData, virtualization MUST be enabled.
*
* # Virtualization
*
* Virtualization is optional for full datasets but required for partial data:
* ```typescript
* virtualization: {
* rowHeightPx: 24 // Height of each row in pixels
* }
* ```
*
* # Complete Examples
*
* Simple grid with full dataset (no virtualization):
* ```typescript
* m(Grid, {
* columns: [
* {key: 'id', header: m(GridHeaderCell, 'ID')},
* {key: 'name', header: m(GridHeaderCell, 'Name')},
* ],
* rowData: [
* [m(GridCell, {align: 'right'}, '1'), m(GridCell, 'Alice')],
* [m(GridCell, {align: 'right'}, '2'), m(GridCell, 'Bob')],
* ],
* fillHeight: true,
* })
* ```
*
* Grid with full dataset and DOM virtualization:
* ```typescript
* m(Grid, {
* columns: [...],
* rowData: [...1000 rows...],
* virtualization: {
* rowHeightPx: 24, // Enables virtual scrolling
* },
* fillHeight: true,
* })
* ```
*
* Grid with partial/paginated data (virtualization required):
* ```typescript
* m(Grid, {
* columns: [...],
* rowData: {
* data: currentPageRows,
* total: 1000000,
* offset: currentOffset,
* onLoadData: (offset, limit) => {
* // Fetch and update currentPageRows, currentOffset
* },
* },
* virtualization: {
* rowHeightPx: 24, // Required for PartialRowData
* },
* fillHeight: true,
* })
* ```
*/
function isPartialRowData(rowData: GridRowData): rowData is PartialRowData {
return !Array.isArray(rowData);
}
export class Grid implements m.ClassComponent<GridAttrs> {
private sizedColumns: Set<string> = new Set();
private renderBounds?: {rowStart: number; rowEnd: number};
private columnDragState: Map<
string,
{count: number; position: ReorderPosition}
> = new Map();
private fieldToId: Map<string, number> = new Map();
private nextId = 0;
private getColumnId(field: string): number {
if (!this.fieldToId.has(field)) {
this.fieldToId.set(field, this.nextId++);
}
return this.fieldToId.get(field)!;
}
view({attrs}: m.Vnode<GridAttrs>) {
const {
columns,
rowData,
virtualization,
fillHeight = false,
className,
} = attrs;
// Validate: PartialRowData requires virtualization
if (isPartialRowData(rowData) && virtualization === undefined) {
throw new Error(
'Grid: virtualization is required when using PartialRowData',
);
}
// Extract row information
const rows = isPartialRowData(rowData) ? rowData.data : rowData;
const totalRows = isPartialRowData(rowData)
? rowData.total
: rowData.length;
const rowOffset = isPartialRowData(rowData) ? rowData.offset : 0;
// Virtualization settings
const isVirtualized = virtualization !== undefined;
const rowHeight = virtualization?.rowHeightPx ?? DEFAULT_ROW_HEIGHT;
// Render the grid structure inline
return m(
'.pf-grid',
{
className: classNames(fillHeight && 'pf-grid--fill-height', className),
ref: 'scroll-container',
role: 'table',
},
m(
'.pf-grid__header',
m(
'.pf-grid__row',
{
role: 'row',
},
columns.map((column) => {
return this.renderHeaderCell(column, attrs.onColumnReorder);
}),
),
),
isVirtualized
? this.renderVirtualizedGridBody(
totalRows,
rowHeight,
columns,
rows,
rowOffset,
attrs,
)
: this.renderGridBody(columns, rows, attrs),
);
}
private renderVirtualizedGridBody(
totalRows: number,
rowHeight: number,
columns: ReadonlyArray<GridColumn>,
rows: ReadonlyArray<GridRow>,
rowOffset: number,
attrs: GridAttrs,
) {
return m(
'.pf-grid__body',
{
ref: 'slider',
style: {
height: `${totalRows * rowHeight}px`,
// Ensure the puck cannot escape the slider and affect the height of
// the scrollable region.
overflowY: 'hidden',
},
},
m(
'.pf-grid__puck',
{
style: {
transform: `translateY(${
this.renderBounds?.rowStart !== undefined
? this.renderBounds.rowStart * rowHeight
: 0
}px)`,
},
},
this.renderRows(
columns,
rows,
rowOffset,
rowHeight,
attrs.onRowHover,
attrs.onRowOut,
),
),
);
}
private renderGridBody(
columns: ReadonlyArray<GridColumn>,
rows: ReadonlyArray<GridRow>,
attrs: GridAttrs,
) {
return m(
'.pf-grid__body',
this.renderAllRows(columns, rows, attrs.onRowHover, attrs.onRowOut),
);
}
oncreate(vnode: m.VnodeDOM<GridAttrs, this>) {
const {virtualization, columns, rowData} = vnode.attrs;
// Extract rows from rowData
const rows = isPartialRowData(rowData) ? rowData.data : rowData;
if (rows.length > 0) {
// Check if there are new columns that need sizing
const newColumns = columns.filter(
(column) => !this.sizedColumns.has(column.key),
);
if (newColumns.length > 0) {
this.measureAndApplyWidths(
vnode.dom as HTMLElement,
newColumns.map((col) => {
const {
key,
minWidth = COL_WIDTH_MIN_PX,
maxInitialWidthPx = COL_WIDTH_INITIAL_MAX_PX,
} = col;
return {
key,
minWidth,
maxWidth: maxInitialWidthPx,
};
}),
);
}
}
// Only set up virtual scrolling if virtualization is enabled
if (virtualization === undefined) {
return;
}
const rowHeight = virtualization.rowHeightPx;
const onLoadData = isPartialRowData(rowData)
? rowData.onLoadData
: undefined;
const scrollContainer: HTMLElement = (vnode.dom as HTMLElement)!;
const slider: HTMLElement = (vnode.dom as HTMLElement).querySelector(
'[ref="slider"]',
)!;
new VirtualScrollHelper(slider, scrollContainer, [
{
overdrawPx: 500,
tolerancePx: 250,
callback: (rect) => {
const rowStart = Math.floor(rect.top / rowHeight);
const rowCount = Math.ceil(rect.height / rowHeight);
this.renderBounds = {rowStart, rowEnd: rowStart + rowCount};
m.redraw();
},
},
{
overdrawPx: 2000,
tolerancePx: 1000,
callback: (rect) => {
const rowStart = Math.floor(rect.top / rowHeight);
const rowEnd = Math.ceil(rect.bottom / rowHeight);
if (onLoadData !== undefined) {
onLoadData(rowStart, rowEnd - rowStart);
}
m.redraw();
},
},
]);
// Call onReady callback with imperative API
if (vnode.attrs.onReady) {
vnode.attrs.onReady({
autoFitColumn: (columnKey: string) => {
const gridDom = vnode.dom as HTMLElement;
const column = columns.find((c) => c.key === columnKey);
if (!column) return;
this.measureAndApplyWidths(gridDom, [
{
key: column.key,
minWidth: column.minWidth ?? COL_WIDTH_MIN_PX,
maxWidth: Infinity,
},
]);
m.redraw();
},
autoFitAllColumns: () => {
const gridDom = vnode.dom as HTMLElement;
this.measureAndApplyWidths(
gridDom,
columns.map((column) => ({
key: column.key,
minWidth: column.minWidth ?? COL_WIDTH_MIN_PX,
maxWidth: Infinity,
})),
);
m.redraw();
},
});
}
}
onupdate(vnode: m.VnodeDOM<GridAttrs, this>) {
const {columns, rowData} = vnode.attrs;
// Extract rows from rowData
const rows = isPartialRowData(rowData) ? rowData.data : rowData;
if (rows.length > 0) {
// Check if there are new columns that need sizing
const newColumns = columns.filter(
(column) => !this.sizedColumns.has(column.key),
);
if (newColumns.length > 0) {
this.measureAndApplyWidths(
vnode.dom as HTMLElement,
newColumns.map((col) => {
const {
key,
minWidth = COL_WIDTH_MIN_PX,
maxInitialWidthPx = COL_WIDTH_INITIAL_MAX_PX,
} = col;
return {
key,
minWidth,
maxWidth: maxInitialWidthPx,
};
}),
);
}
}
}
private measureAndApplyWidths(
gridDom: HTMLElement,
columns: ReadonlyArray<{
readonly key: string;
readonly minWidth: number;
readonly maxWidth: number;
}>,
): void {
const gridClone = gridDom.cloneNode(true) as HTMLElement;
gridDom.appendChild(gridClone);
// Hide any elements that are not part of the measurement - these are
// elements with class .pf-grid--no-measure
const noMeasureElements = gridClone.querySelectorAll(
'.pf-grid--no-measure',
);
noMeasureElements.forEach((el) => {
(el as HTMLElement).style.display = 'none';
});
// Now read the actual widths (this will cause a reflow)
// Find all the cells in this column (header + data rows)
const allCells = gridClone.querySelectorAll(`.pf-grid__cell-container`);
// Only continue if we have more cells than just the header
if (allCells.length <= columns.length) {
gridClone.remove();
return;
}
columns.forEach((column) => {
const columnId = this.getColumnId(column.key);
// Clear the existing width to allow natural sizing
gridClone.style.setProperty(`--pf-grid-col-${columnId}`, 'fit-content');
// Find all the cells in this column
const cellsInThisColumn = Array.from(allCells).filter(
(cell) => (cell as HTMLElement).dataset['columnId'] === `${columnId}`,
);
const widths = cellsInThisColumn.map((c) => {
return c.scrollWidth;
});
const maxCellWidth = Math.max(...widths);
const unboundedWidth = maxCellWidth + CELL_PADDING_PX;
const width = Math.min(
column.maxWidth,
Math.max(column.minWidth, unboundedWidth),
);
gridDom.style.setProperty(`--pf-grid-col-${columnId}`, `${width}px`);
// Store the width
this.sizedColumns.add(column.key);
});
gridClone.remove();
}
private renderRows(
columns: ReadonlyArray<GridColumn>,
rows: ReadonlyArray<GridRow>,
rowOffset: number,
rowHeight: number,
onRowHover?: (rowIndex: number) => void,
onRowOut?: () => void,
): m.Children {
if (this.renderBounds === undefined) {
return undefined;
}
const {rowStart, rowEnd} = this.renderBounds;
const displayRowCount = rowEnd - rowStart;
const indices = Array.from(
{length: displayRowCount},
(_, i) => rowStart + i,
);
return indices
.map((rowIndex) => {
const relativeIndex = rowIndex - rowOffset;
const row =
relativeIndex >= 0 && relativeIndex < rows.length
? rows[relativeIndex]
: undefined;
if (row !== undefined) {
return m(
'.pf-grid__row',
{
key: rowIndex,
role: 'row',
style: {
height: `${rowHeight}px`,
},
onmouseenter: onRowHover ? () => onRowHover(rowIndex) : undefined,
onmouseleave: onRowOut,
},
columns.map((column, index) => {
const children = row[index];
const columnId = this.getColumnId(column.key);
return this.renderCell(
children,
columnId,
column.thickRightBorder,
);
}),
);
} else {
// Return empty spacer instead if row is not present
return m('.pf-grid__row', {
key: rowIndex,
role: 'row',
style: {
height: `${rowHeight}px`,
},
});
}
})
.filter(exists);
}
private renderAllRows(
columns: ReadonlyArray<GridColumn>,
rows: ReadonlyArray<GridRow>,
onRowHover?: (rowIndex: number) => void,
onRowOut?: () => void,
): m.Children {
return rows.map((row, rowIndex) => {
return m(
'.pf-grid__row',
{
key: rowIndex,
role: 'row',
onmouseenter: onRowHover ? () => onRowHover(rowIndex) : undefined,
onmouseleave: onRowOut,
},
columns.map((column, index) => {
const children = row[index];
const columnId = this.getColumnId(column.key);
return this.renderCell(children, columnId, column.thickRightBorder);
}),
);
});
}
private renderCell(
children: m.Children,
columnId: number,
thickRightBorder?: boolean,
): m.Children {
return m(
'.pf-grid__cell-container',
{
'style': {
width: `var(--pf-grid-col-${columnId})`,
},
'role': 'cell',
'data-column-id': columnId,
'className': classNames(
thickRightBorder && 'pf-grid__cell-container--border-right-thick',
),
},
children,
);
}
private renderHeaderCell(
column: GridColumn,
onColumnReorder?: (
from: string | number | undefined,
to: string | number | undefined,
position: ReorderPosition,
) => void,
): m.Children {
const columnId = this.getColumnId(column.key);
const renderResizeHandle = () => {
return m('.pf-grid__resize-handle', {
onpointerdown: (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Find the nearest header cell to get the starting width
const headerCell = (e.currentTarget as HTMLElement).closest(
'.pf-grid__cell-container',
);
if (!headerCell) return;
const startX = e.clientX;
const startWidth = headerCell.scrollWidth;
const gridDom = (e.currentTarget as HTMLElement).closest(
'.pf-grid',
) as HTMLElement | null;
if (gridDom === null) return;
const handlePointerMove = (e: MouseEvent) => {
const delta = e.clientX - startX;
const minWidth = column.minWidth ?? COL_WIDTH_MIN_PX;
const newWidth = Math.max(minWidth, startWidth + delta);
// Set the css variable for the column being resized
gridDom.style.setProperty(
`--pf-grid-col-${columnId}`,
`${newWidth}px`,
);
};
const handlePointerUp = () => {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
},
oncontextmenu: (e: MouseEvent) => {
// Prevent right click, as this can interfere with mouse/pointer
// events
e.preventDefault();
},
ondblclick: (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Auto-resize this column by measuring actual DOM
const target = e.currentTarget as HTMLElement;
const headerCell = target.parentElement as HTMLElement;
const gridDom = headerCell.closest('.pf-grid') as HTMLElement | null;
if (gridDom === null) return;
this.measureAndApplyWidths(gridDom, [
{
key: column.key,
minWidth: column.minWidth ?? COL_WIDTH_MIN_PX,
// No max - columns can grow as wide as needed on double-click
maxWidth: Infinity,
},
]);
},
});
};
const reorderHandle = column.reorderable?.handle;
const dragOverState = this.columnDragState.get(column.key) ?? {
count: 0,
position: 'after' as ReorderPosition,
};
return m(
'.pf-grid__cell-container',
{
'role': 'columnheader',
'ariaLabel': column.key,
'data-column-id': columnId,
'key': column.key,
'style': {
width: `var(--pf-grid-col-${columnId})`,
},
'draggable': column.reorderable !== undefined,
'className': classNames(
column.thickRightBorder &&
'pf-grid__cell-container--border-right-thick',
dragOverState.count > 0 && 'pf-grid__cell-container--drag-over',
dragOverState.count > 0 &&
`pf-grid__cell-container--drag-over-${dragOverState.position}`,
),
'ondragstart': (e: MithrilEvent<DragEvent>) => {
if (!reorderHandle) return;
e.redraw = false;
e.dataTransfer!.setData(
reorderHandle,
JSON.stringify({key: column.key}),
);
},
'ondragenter': (e: MithrilEvent<DragEvent>) => {
if (reorderHandle && e.dataTransfer!.types.includes(reorderHandle)) {
const state = this.columnDragState.get(column.key) ?? {
count: 0,
position: 'after' as ReorderPosition,
};
this.columnDragState.set(column.key, {
...state,
count: state.count + 1,
});
}
},
'ondragleave': (e: MithrilEvent<DragEvent>) => {
if (reorderHandle && e.dataTransfer!.types.includes(reorderHandle)) {
const state = this.columnDragState.get(column.key);
if (state) {
this.columnDragState.set(column.key, {
...state,
count: state.count - 1,
});
}
}
},
'ondragover': (e: MithrilEvent<DragEvent>) => {
e.preventDefault();
if (reorderHandle && e.dataTransfer!.types.includes(reorderHandle)) {
e.dataTransfer!.dropEffect = 'move';
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const position: ReorderPosition =
e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
const state = this.columnDragState.get(column.key) ?? {
count: 0,
position: 'after' as ReorderPosition,
};
if (state.position !== position) {
this.columnDragState.set(column.key, {...state, position});
}
} else {
e.dataTransfer!.dropEffect = 'none';
}
},
'ondrop': (e: MithrilEvent<DragEvent>) => {
this.columnDragState.set(column.key, {count: 0, position: 'after'});
if (reorderHandle && onColumnReorder) {
const data = e.dataTransfer!.getData(reorderHandle);
if (data) {
e.preventDefault();
const {key: from} = JSON.parse(data);
const to = column.key;
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const position =
e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
onColumnReorder(from, to, position);
}
}
},
},
column.header ?? column.key,
renderResizeHandle(),
);
}
}