| // 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 './grid.scss'; | |
| import m from 'mithril'; | |
| import {classNames} from '../base/classnames'; | |
| import {isEmptyVnodes, type 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 type {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, | |
| ); | |
| }; | |
| const nextSortDirection: SortDirection = (() => { | |
| if (!sort) return hintSortDirection || 'ASC'; | |
| return sort === 'ASC' ? 'DESC' : 'ASC'; | |
| })(); | |
| return m( | |
| '.pf-grid-header-cell', | |
| { | |
| ...htmlAttrs, | |
| role: 'columnheader', | |
| }, | |
| [ | |
| m( | |
| '.pf-grid-header-cell__main-content', | |
| m( | |
| '.pf-grid-header-cell__title', | |
| { | |
| onclick: onSort | |
| ? (e: MouseEvent) => { | |
| onSort(nextSortDirection); | |
| e.stopPropagation(); | |
| } | |
| : undefined, | |
| style: onSort ? {cursor: 'pointer'} : undefined, | |
| }, | |
| 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; | |
| readonly actionButtons?: m.Children; | |
| } | |
| 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, | |
| actionButtons, | |
| ...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`, | |
| }, | |
| }); | |
| }; | |
| const cellMenu = | |
| !isEmptyVnodes(menuItems) && | |
| m( | |
| PopupMenu, | |
| { | |
| trigger: m(Button, { | |
| className: 'pf-visible-on-hover', | |
| icon: Icons.ContextMenuAlt, | |
| rounded: true, | |
| ariaLabel: 'Cell menu', | |
| }), | |
| position: PopupPosition.Bottom, | |
| }, | |
| menuItems, | |
| ); | |
| const cellActions = [actionButtons, cellMenu]; | |
| 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(cellActions) && | |
| m('.pf-grid-cell__actions.pf-visible-on-hover', cellActions), | |
| ); | |
| } | |
| } | |
| /** | |
| * 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(), | |
| ); | |
| } | |
| } |