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