| // 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 {isEmptyVnodes, 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; |
| readonly hintSortDirection?: SortDirection; |
| } |
| |
| export class GridHeaderCell implements m.ClassComponent<GridHeaderCellAttrs> { |
| view({attrs, children}: m.Vnode<GridHeaderCellAttrs>) { |
| const { |
| sort, |
| onSort, |
| menuItems, |
| subContent, |
| hintSortDirection, |
| ...htmlAttrs |
| } = attrs; |
| |
| const renderSortButton = () => { |
| if (!onSort) return undefined; |
| |
| const nextDirection: SortDirection = (() => { |
| if (!sort) return hintSortDirection || 'ASC'; |
| if (sort === 'ASC') return 'DESC'; |
| if (sort === 'DESC') return 'ASC'; |
| return 'ASC'; |
| })(); |
| |
| const sortIconDirection: SortDirection | undefined = (() => { |
| if (!sort) return hintSortDirection; |
| return sort; |
| })(); |
| |
| 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: sortIconDirection === 'DESC' ? Icons.SortDesc : Icons.SortAsc, |
| onclick: (e: MouseEvent) => { |
| onSort(nextDirection); |
| e.stopPropagation(); |
| }, |
| }); |
| }; |
| |
| const renderMenu = () => { |
| if (isEmptyVnodes(menuItems)) return undefined; |
| return m( |
| PopupMenu, |
| { |
| trigger: m(Button, { |
| className: 'pf-visible-on-hover pf-grid-header-cell__menu-button', |
| icon: Icons.ContextMenuAlt, |
| rounded: true, |
| ariaLabel: 'Column menu', |
| }), |
| }, |
| menuItems, |
| ); |
| }; |
| |
| return m( |
| '.pf-grid-header-cell', |
| { |
| ...htmlAttrs, |
| role: 'columnheader', |
| }, |
| [ |
| 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; |
| readonly label?: string; |
| readonly indent?: number; |
| readonly chevron?: 'expanded' | 'collapsed' | 'leaf'; |
| readonly onChevronClick?: () => void; |
| } |
| |
| export class GridCell implements m.ClassComponent<GridCellAttrs> { |
| view({attrs, children}: m.Vnode<GridCellAttrs>) { |
| const { |
| menuItems, |
| align = 'left', |
| nullish, |
| className, |
| padding = true, |
| wrap, |
| indent, |
| chevron, |
| onChevronClick, |
| ...htmlAttrs |
| } = attrs; |
| |
| const renderChevron = () => { |
| if (chevron === undefined) return undefined; |
| |
| const icon = chevron === 'expanded' ? Icons.ExpandDown : Icons.GoForward; |
| const ariaLabel = chevron === 'expanded' ? 'Collapse row' : 'Expand row'; |
| |
| return m(Button, { |
| className: classNames( |
| 'pf-grid-cell__chevron', |
| chevron === 'leaf' && 'pf-grid-cell__chevron--leaf', |
| ), |
| icon, |
| rounded: true, |
| ariaLabel, |
| onclick: (e: MouseEvent) => { |
| if (onChevronClick) { |
| onChevronClick(); |
| e.stopPropagation(); |
| } |
| }, |
| }); |
| }; |
| |
| const renderIndent = () => { |
| if (indent === undefined || indent === 0) return undefined; |
| |
| return m('.pf-grid-cell__indent', { |
| style: { |
| width: `${indent * 16}px`, |
| }, |
| }); |
| }; |
| |
| return m( |
| '.pf-grid-cell', |
| { |
| ...htmlAttrs, |
| className: classNames( |
| className, |
| chevron ? 'pf-grid-cell--align-left' : `pf-grid-cell--align-${align}`, |
| padding && 'pf-grid-cell--padded', |
| nullish && 'pf-grid-cell--nullish', |
| wrap && 'pf-grid-cell--wrap', |
| ), |
| role: 'cell', |
| }, |
| renderIndent(), |
| renderChevron(), |
| m('.pf-grid-cell__content', children), |
| !isEmptyVnodes(menuItems) && |
| m( |
| PopupMenu, |
| { |
| trigger: m(Button, { |
| className: 'pf-visible-on-hover pf-grid-cell__menu-button', |
| icon: Icons.ContextMenuAlt, |
| rounded: true, |
| ariaLabel: 'Cell menu', |
| }), |
| position: PopupPosition.Bottom, |
| }, |
| menuItems, |
| ), |
| ); |
| } |
| } |
| |
| /** |
| * Row data with cells and optional styling. |
| */ |
| export type GridRow = ReadonlyArray<m.Children>; |
| |
| /** |
| * Column definition for Grid. |
| */ |
| export interface GridColumn { |
| // A unique key identifying this column - used to store cached column widths |
| // and for reordering callbacks. |
| readonly key: string; |
| |
| // If defined, sets a fixed width for the column in pixels. The column will |
| // not be resizable and will not auto-size. |
| readonly widthPx?: number; |
| |
| // Controls the minimum width of the column in pixels. |
| readonly minWidthPx?: number; |
| |
| // Controls the maximum initial width when auto-sizing columns. This is the |
| // auto-size that is performed once the first time we see a column and we have |
| // rows. |
| readonly maxInitialWidthPx?: number; |
| |
| // If defined, the column can be reordered among other columns with the same |
| // reorderGroup. |
| readonly reorderable?: {readonly reorderGroup: string}; |
| |
| // Renders this column with a thicker right border, useful for visually |
| // grouping columns. |
| readonly thickRightBorder?: boolean; |
| |
| // Content to put in the column header. |
| readonly header?: m.Children; |
| } |
| |
| /** |
| * 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. |
| */ |
| /** |
| * Configuration for the Grid component. |
| * Grid is a low-level presentation component - consumers must wrap content |
| * in GridHeaderCell and GridCell components. |
| */ |
| export interface GridAttrs { |
| /** |
| * Column definitions for the grid. |
| * Each column specifies a key, optional header content, and display options. |
| * |
| * @example |
| * columns: [ |
| * { |
| * key: 'id', |
| * header: m(GridHeaderCell, {sort: 'ASC'}, 'ID'), |
| * minWidth: 100, |
| * }, |
| * { |
| * key: 'name', |
| * header: m(GridHeaderCell, {menuItems: [...]}, 'Name'), |
| * }, |
| * ] |
| */ |
| readonly columns: ReadonlyArray<GridColumn>; |
| |
| /** |
| * Row data to display in the grid. |
| * Can be either a full array of rows or a partial/paginated dataset. |
| * |
| * Full dataset (array): |
| * - Use when all data fits in memory |
| * - Virtualization is optional |
| * |
| * Partial dataset (PartialRowData): |
| * - Use for large datasets with on-demand loading |
| * - Virtualization is required |
| * |
| * @example Full dataset |
| * rowData: [ |
| * [m(GridCell, '1'), m(GridCell, 'Alice')], |
| * [m(GridCell, '2'), m(GridCell, 'Bob')], |
| * ] |
| * |
| * @example Partial/paginated dataset |
| * rowData: { |
| * data: currentRows, |
| * total: 1000000, |
| * offset: 0, |
| * onLoadData: (offset, limit) => { |
| * // Load data for requested range |
| * }, |
| * } |
| */ |
| readonly rowData: GridRowData; |
| |
| /** |
| * Virtual scrolling configuration. |
| * When enabled, only visible rows are rendered for better performance. |
| * Required when using PartialRowData, optional for full datasets. |
| * |
| * @example |
| * virtualization: { |
| * rowHeightPx: 24, // Fixed height for each row |
| * } |
| */ |
| readonly virtualization?: GridVirtualization; |
| |
| /** |
| * Whether the grid should expand to fill its parent container's height. |
| * When true, the grid will take up all available vertical space. |
| * Default = false. |
| * |
| * @example |
| * fillHeight: true |
| */ |
| readonly fillHeight?: boolean; |
| |
| /** |
| * Optional CSS class name to apply to the grid root element. |
| * Used for custom styling. |
| * |
| * @example |
| * className: 'my-custom-grid' |
| */ |
| readonly className?: string; |
| |
| /** |
| * Callback fired when the user hovers over a row. |
| * Receives the absolute row index (not relative to current page). |
| * Use with virtualized grids to implement row highlighting or preview features. |
| * |
| * @param rowIndex The absolute index of the hovered row |
| * |
| * @example |
| * onRowHover: (rowIndex) => { |
| * console.log(`Hovering row ${rowIndex}`); |
| * } |
| */ |
| readonly onRowHover?: (rowIndex: number) => void; |
| |
| /** |
| * Callback fired when the user's mouse leaves a row. |
| * Pairs with onRowHover for implementing hover effects. |
| * |
| * @example |
| * onRowOut: () => { |
| * console.log('Left row'); |
| * } |
| */ |
| readonly onRowOut?: () => void; |
| |
| /** |
| * Callback fired when columns are reordered via drag-and-drop. |
| * Only called if column.reorderable is set on columns. |
| * |
| * @param from The key of the column being moved |
| * @param to The key of the target column |
| * @param position Whether to place before or after the target |
| * |
| * @example |
| * onColumnReorder: (from, to, position) => { |
| * const newOrder = reorderArray(columnOrder, from, to, position); |
| * setColumnOrder(newOrder); |
| * } |
| */ |
| readonly onColumnReorder?: ( |
| from: string | number | undefined, |
| to: string | number | undefined, |
| position: ReorderPosition, |
| ) => void; |
| |
| /** |
| * Callback fired when the grid is fully initialized. |
| * Receives an API object for programmatic control of the grid. |
| * Use this to access methods like autoFitColumn() and autoFitAllColumns(). |
| * |
| * @param api The grid's imperative API |
| * |
| * @example |
| * onReady: (api) => { |
| * // Auto-fit all columns on mount |
| * api.autoFitAllColumns(); |
| * } |
| */ |
| readonly onReady?: (api: GridApi) => void; |
| |
| /** |
| * Content to display when the grid has no rows. |
| * Typically used to show a helpful message or call-to-action. |
| * |
| * @example |
| * emptyState: m(EmptyState, { |
| * icon: 'inbox', |
| * title: 'No data available', |
| * }) |
| */ |
| readonly emptyState?: m.Children; |
| } |
| |
| /** |
| * 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); |
| } |
| |
| type ColumnAutosizeState = 'header-only' | 'sized'; |
| |
| export class Grid implements m.ClassComponent<GridAttrs> { |
| private columnAutosizeState = new Map<string, ColumnAutosizeState>(); |
| private renderBounds?: {rowStart: number; rowEnd: number}; |
| private hasRenderedBodyRows = false; |
| private fieldToId: Map<string, number> = new Map(); |
| private nextId = 0; |
| private boundHandleCopy = this.handleCopy.bind(this); |
| |
| // Grid-level drag state for column reordering |
| private dragState?: { |
| fromKey: string; |
| handle: string; |
| targetKey?: string; |
| position: ReorderPosition; |
| }; |
| |
| // Store column refs for hit testing during drag |
| private columnRefs: Map<string, {left: number; width: number}> = new Map(); |
| |
| // Find which column is at a given x position within the grid |
| // Only returns columns that have a matching reorderable handle |
| private findColumnAtX( |
| x: number, |
| columns: ReadonlyArray<GridColumn>, |
| ): {key: string; position: ReorderPosition} | undefined { |
| if (!this.dragState) return undefined; |
| |
| const handle = this.dragState.handle; |
| |
| for (const column of columns) { |
| // Only consider columns with matching handle |
| if (column.reorderable?.reorderGroup !== handle) continue; |
| |
| const bounds = this.columnRefs.get(column.key); |
| if (bounds && x >= bounds.left && x < bounds.left + bounds.width) { |
| const midpoint = bounds.left + bounds.width / 2; |
| const position: ReorderPosition = x < midpoint ? 'before' : 'after'; |
| return {key: column.key, position}; |
| } |
| } |
| return undefined; |
| } |
| |
| // Update column bounds from the header row |
| private updateColumnBounds(gridDom: HTMLElement): void { |
| const headerCells = gridDom.querySelectorAll( |
| '.pf-grid__header .pf-grid__cell-container', |
| ); |
| headerCells.forEach((cell) => { |
| const htmlCell = cell as HTMLElement; |
| const key = htmlCell.dataset['columnKey']; |
| if (key) { |
| const rect = htmlCell.getBoundingClientRect(); |
| this.columnRefs.set(key, {left: rect.left, width: rect.width}); |
| } |
| }); |
| } |
| |
| private handleCopy(e: ClipboardEvent): void { |
| const selection = window.getSelection(); |
| if (!selection || selection.rangeCount === 0) return; |
| |
| const range = selection.getRangeAt(0); |
| const container = range.commonAncestorContainer; |
| |
| // Find the grid element |
| const gridElement = |
| container.nodeType === Node.ELEMENT_NODE |
| ? (container as Element).closest('.pf-grid') |
| : (container.parentElement?.closest('.pf-grid') as Element | null); |
| |
| if (!gridElement) return; |
| |
| // Clone the selection's content |
| const fragment = range.cloneContents(); |
| const tempDiv = document.createElement('div'); |
| tempDiv.appendChild(fragment); |
| |
| // Remove all button elements to exclude them from the copy |
| const buttons = tempDiv.querySelectorAll('button'); |
| buttons.forEach((button) => button.remove()); |
| |
| // Find all rows in the cloned content |
| const rows = Array.from( |
| tempDiv.querySelectorAll('.pf-grid__row'), |
| ) as HTMLElement[]; |
| |
| if (rows.length === 0) return; |
| |
| // Extract text from cells in TSV format |
| const tsvRows = rows |
| .map((row) => { |
| const cells = Array.from( |
| row.querySelectorAll('.pf-grid__cell-container'), |
| ) as HTMLElement[]; |
| const cellTexts = cells |
| .map((cell) => cell.textContent?.trim() || '') |
| .filter((text) => text.length > 0); |
| return cellTexts.join('\t'); |
| }) |
| .filter((row) => row.length > 0); |
| |
| if (tsvRows.length > 0) { |
| const tsvData = tsvRows.join('\n'); |
| e.clipboardData?.setData('text/plain', tsvData); |
| e.preventDefault(); |
| } |
| } |
| private getColumnId(field: string): number { |
| if (!this.fieldToId.has(field)) { |
| this.fieldToId.set(field, this.nextId++); |
| } |
| return this.fieldToId.get(field)!; |
| } |
| |
| private setColumnWidth( |
| gridDom: HTMLElement, |
| columnKey: string, |
| widthPx: number, |
| ): void { |
| const columnId = this.getColumnId(columnKey); |
| gridDom.style.setProperty(`--pf-grid-col-${columnId}`, `${widthPx}px`); |
| } |
| |
| private clearColumnWidth(gridDom: HTMLElement, columnKey: string): void { |
| const columnId = this.getColumnId(columnKey); |
| gridDom.style.setProperty(`--pf-grid-col-${columnId}`, 'fit-content'); |
| } |
| |
| 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; |
| |
| // Check if any columns are reorderable |
| const hasReorderableColumns = columns.some((c) => c.reorderable); |
| |
| // Render the grid structure inline |
| return m( |
| '.pf-grid', |
| { |
| className: classNames( |
| fillHeight && 'pf-grid--fill-height', |
| className, |
| this.dragState && 'pf-grid--dragging', |
| ), |
| ref: 'scroll-container', |
| role: 'table', |
| // Grid-level drag handlers |
| ondragover: hasReorderableColumns |
| ? (e: MithrilEvent<DragEvent>) => { |
| if (!this.dragState) return; |
| e.preventDefault(); |
| e.dataTransfer!.dropEffect = 'move'; |
| |
| // Update column bounds on drag (handles scrolling) |
| const gridDom = e.currentTarget as HTMLElement; |
| this.updateColumnBounds(gridDom); |
| |
| // Find which column we're over |
| const hit = this.findColumnAtX(e.clientX, columns); |
| if (hit) { |
| const needsRedraw = |
| this.dragState.targetKey !== hit.key || |
| this.dragState.position !== hit.position; |
| this.dragState.targetKey = hit.key; |
| this.dragState.position = hit.position; |
| if (needsRedraw) { |
| m.redraw(); |
| } |
| } |
| } |
| : undefined, |
| ondrop: hasReorderableColumns |
| ? (e: MithrilEvent<DragEvent>) => { |
| if (!this.dragState || !attrs.onColumnReorder) return; |
| e.preventDefault(); |
| |
| const {fromKey, targetKey, position} = this.dragState; |
| if (targetKey && fromKey !== targetKey) { |
| attrs.onColumnReorder(fromKey, targetKey, position); |
| } |
| this.dragState = undefined; |
| } |
| : undefined, |
| ondragend: hasReorderableColumns |
| ? () => { |
| this.dragState = undefined; |
| m.redraw(); |
| } |
| : undefined, |
| }, |
| m( |
| '.pf-grid__header', |
| m( |
| '.pf-grid__row', |
| { |
| role: 'row', |
| }, |
| columns.map((column) => { |
| return this.renderHeaderCell(column); |
| }), |
| ), |
| ), |
| isVirtualized |
| ? this.renderVirtualizedGridBody( |
| totalRows, |
| rowHeight, |
| columns, |
| rows, |
| rowOffset, |
| attrs, |
| ) |
| : this.renderGridBody(columns, rows, attrs), |
| totalRows === 0 && |
| attrs.emptyState !== undefined && |
| m('.pf-grid__empty-state', attrs.emptyState), |
| ); |
| } |
| |
| 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({attrs, dom}: m.VnodeDOM<GridAttrs, this>) { |
| const {virtualization, columns, rowData, onReady} = attrs; |
| |
| // Add copy event handler for spreadsheet-friendly formatting |
| const gridDom = dom as HTMLElement; |
| gridDom.addEventListener('copy', this.boundHandleCopy); |
| |
| this.maybeAutosizeColumns(gridDom, columns); |
| |
| // Only set up virtual scrolling if virtualization is enabled |
| if (virtualization) { |
| const rowHeight = virtualization.rowHeightPx; |
| const onLoadData = isPartialRowData(rowData) |
| ? rowData.onLoadData |
| : undefined; |
| |
| const scrollContainer: HTMLElement = gridDom!; |
| const slider: HTMLElement = gridDom.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 (onReady) { |
| onReady({ |
| autoFitColumn: (columnKey: string) => { |
| const column = columns.find((c) => c.key === columnKey); |
| if (!column) return; |
| const {minWidthPx = COL_WIDTH_MIN_PX} = column; |
| const columnWidths = this.measureColumns(gridDom, [column.key]); |
| const width = columnWidths.get(column.key); |
| if (width !== undefined) { |
| this.setColumnWidth( |
| gridDom, |
| column.key, |
| Math.max(width, minWidthPx), |
| ); |
| } |
| }, |
| autoFitAllColumns: () => { |
| const columnWidths = this.measureColumns( |
| gridDom, |
| columns.map(({key}) => key), |
| ); |
| for (const column of columns) { |
| const {minWidthPx = COL_WIDTH_MIN_PX} = column; |
| const width = columnWidths.get(column.key); |
| if (width !== undefined) { |
| this.setColumnWidth( |
| gridDom, |
| column.key, |
| Math.max(width, minWidthPx), |
| ); |
| } |
| } |
| }, |
| }); |
| } |
| } |
| |
| onupdate(vnode: m.VnodeDOM<GridAttrs, this>) { |
| const {columns} = vnode.attrs; |
| this.maybeAutosizeColumns(vnode.dom as HTMLElement, columns); |
| } |
| |
| onremove(vnode: m.VnodeDOM<GridAttrs, this>) { |
| const gridDom = vnode.dom as HTMLElement; |
| gridDom.removeEventListener('copy', this.boundHandleCopy); |
| } |
| |
| // Check if any columns need resizing and update them |
| private maybeAutosizeColumns( |
| gridDom: HTMLElement, |
| columns: readonly GridColumn[], |
| ): void { |
| // Handle fixed-width columns first (no measurement needed) |
| for (const column of columns) { |
| if ( |
| column.widthPx !== undefined && |
| this.columnAutosizeState.get(column.key) !== 'sized' |
| ) { |
| this.setColumnWidth(gridDom, column.key, column.widthPx); |
| this.columnAutosizeState.set(column.key, 'sized'); |
| } |
| } |
| |
| // Find auto-sized columns that need measurement: |
| // - Skip fixed-width columns (handled above) |
| // - Skip 'sized' columns (already done) |
| // - Skip 'header-only' columns if we haven't rendered body rows yet |
| const columnsToMeasure = columns.filter((col) => { |
| if (col.widthPx !== undefined) return false; |
| const state = this.columnAutosizeState.get(col.key); |
| if (state === 'sized') return false; |
| if (state === 'header-only' && !this.hasRenderedBodyRows) return false; |
| return true; |
| }); |
| |
| if (columnsToMeasure.length === 0) return; |
| |
| const columnWidths = this.measureColumns( |
| gridDom, |
| columnsToMeasure.map(({key}) => key), |
| ); |
| |
| for (const column of columnsToMeasure) { |
| const { |
| key, |
| minWidthPx = COL_WIDTH_MIN_PX, |
| maxInitialWidthPx = COL_WIDTH_INITIAL_MAX_PX, |
| } = column; |
| const measuredWidth = columnWidths.get(key); |
| if (measuredWidth === undefined) continue; |
| |
| const width = Math.min( |
| Math.max(measuredWidth, minWidthPx), |
| maxInitialWidthPx, |
| ); |
| this.setColumnWidth(gridDom, key, width); |
| this.columnAutosizeState.set( |
| key, |
| this.hasRenderedBodyRows ? 'sized' : 'header-only', |
| ); |
| } |
| } |
| |
| // Measures the natural width of each column by cloning the grid, setting |
| // columns to fit-content, and measuring the max cell width per column. |
| private measureColumns( |
| gridDom: HTMLElement, |
| columns: readonly string[], |
| ): Map<string, number> { |
| const gridClone = gridDom.cloneNode(true) as HTMLElement; |
| gridDom.appendChild(gridClone); |
| |
| // Show any elements that are normally visible only on hover - this takes |
| // into account the menu buttons, sort buttons, etc. |
| const invisibleElements = gridClone.querySelectorAll( |
| '.pf-visible-on-hover', |
| ); |
| invisibleElements.forEach((el) => { |
| (el as HTMLElement).style.display = 'block'; |
| }); |
| |
| // Find all the cells in this column (header + data rows) |
| const allCells = gridClone.querySelectorAll('.pf-grid__cell-container'); |
| |
| // Clear any previously set widths to allow natural sizing |
| columns.forEach((columnKey) => { |
| this.clearColumnWidth(gridClone, columnKey); |
| }); |
| |
| // Measure all the cells we have available (this will cause a reflow) |
| const columnKeyWidthTuples = columns.map((key) => { |
| const id = this.getColumnId(key); |
| |
| // Find all the cells in this column and find the max width |
| const maxWidth = Array.from(allCells) |
| .filter((cell) => (cell as HTMLElement).dataset['columnId'] === `${id}`) |
| .map((c) => c.scrollWidth) |
| .reduce((acc, width) => Math.max(acc, width), 0); |
| |
| return [key, maxWidth + CELL_PADDING_PX] as const; |
| }); |
| |
| gridClone.remove(); |
| |
| return new Map(columnKeyWidthTuples); |
| } |
| |
| 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, |
| ); |
| |
| // Generate a list of rows that should be rendered |
| const renderableRows = indices.map((rowIndex) => { |
| const relativeIndex = rowIndex - rowOffset; |
| const row = |
| relativeIndex >= 0 && relativeIndex < rows.length |
| ? rows[relativeIndex] |
| : undefined; |
| return [rowIndex, row] as const; |
| }); |
| |
| const renderedRows = renderableRows |
| .map(([rowIndex, row]) => { |
| 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.key, |
| 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); |
| |
| if (!this.hasRenderedBodyRows) { |
| // Check if any rows have content in them so we know when to trigger our |
| // initial column autosize |
| const hasContent = renderableRows.some( |
| ([_, row]) => row !== undefined && row.length > 0, |
| ); |
| |
| if (hasContent) { |
| this.hasRenderedBodyRows = true; |
| } |
| } |
| |
| return renderedRows; |
| } |
| |
| private renderAllRows( |
| columns: ReadonlyArray<GridColumn>, |
| rows: ReadonlyArray<GridRow>, |
| onRowHover?: (rowIndex: number) => void, |
| onRowOut?: () => void, |
| ): m.Children { |
| if (rows.length > 0) { |
| this.hasRenderedBodyRows = true; |
| } |
| 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.key, |
| column.thickRightBorder, |
| ); |
| }), |
| ); |
| }); |
| } |
| |
| private renderCell( |
| children: m.Children, |
| columnId: number, |
| columnKey: string, |
| thickRightBorder?: boolean, |
| ): m.Children { |
| // Check if this column is the drag target (findColumnAtX already filters by handle) |
| const isDragTarget = |
| this.dragState && |
| this.dragState.targetKey === columnKey && |
| this.dragState.fromKey !== columnKey; |
| |
| return m( |
| '.pf-grid__cell-container', |
| { |
| 'style': { |
| width: `var(--pf-grid-col-${columnId})`, |
| }, |
| 'data-column-id': columnId, |
| 'className': classNames( |
| thickRightBorder && 'pf-grid__cell-container--border-right-thick', |
| isDragTarget && |
| `pf-grid__cell-container--drag-over-${this.dragState!.position}`, |
| ), |
| }, |
| children, |
| ); |
| } |
| |
| private renderHeaderCell(column: GridColumn): m.Children { |
| const { |
| key, |
| reorderable, |
| thickRightBorder, |
| header, |
| minWidthPx = COL_WIDTH_MIN_PX, |
| widthPx, |
| } = column; |
| |
| const columnId = this.getColumnId(column.key); |
| const isFixedWidth = exists(widthPx); |
| |
| 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.minWidthPx ?? COL_WIDTH_MIN_PX; |
| const newWidth = Math.max(minWidth, startWidth + delta); |
| this.setColumnWidth(gridDom, column.key, newWidth); |
| }; |
| |
| 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; |
| |
| const width = this.measureColumns(gridDom, [column.key]).get( |
| column.key, |
| )!; |
| this.setColumnWidth(gridDom, column.key, Math.max(width, minWidthPx)); |
| }, |
| }); |
| }; |
| |
| const reorderHandle = reorderable?.reorderGroup; |
| |
| // Check if this column is the drag target |
| const isDragTarget = |
| this.dragState && |
| this.dragState.targetKey === key && |
| this.dragState.fromKey !== key; |
| |
| return m( |
| '.pf-grid__cell-container', |
| { |
| 'data-column-id': columnId, |
| 'data-column-key': key, |
| 'key': key, |
| 'style': { |
| width: `var(--pf-grid-col-${columnId})`, |
| }, |
| 'draggable': reorderable !== undefined, |
| 'className': classNames( |
| thickRightBorder && 'pf-grid__cell-container--border-right-thick', |
| isDragTarget && |
| `pf-grid__cell-container--drag-over-${this.dragState!.position}`, |
| ), |
| // Only ondragstart on header - other handlers are at grid level |
| 'ondragstart': (e: MithrilEvent<DragEvent>) => { |
| if (!reorderHandle) return; |
| e.dataTransfer!.setData(reorderHandle, JSON.stringify({key: key})); |
| e.dataTransfer!.effectAllowed = 'move'; |
| // Initialize grid-level drag state |
| this.dragState = { |
| fromKey: key, |
| handle: reorderHandle, |
| targetKey: undefined, |
| position: 'after', |
| }; |
| }, |
| }, |
| header, |
| !isFixedWidth && renderResizeHandle(), |
| ); |
| } |
| } |