blob: 72041974779dbe80c2f4ed7cf831b3a696cd9c6b [file] [log] [blame]
// Copyright (c) 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Host from '../../../core/host/host.js';
import * as Platform from '../../../core/platform/platform.js';
import * as UI from '../../legacy/legacy.js';
import * as LitHtml from '../../lit-html/lit-html.js';
import * as ComponentHelpers from '../helpers/helpers.js';
import * as Coordinator from '../render_coordinator/render_coordinator.js';
import dataGridStyles from './dataGrid.css.js';
import {BodyCellFocusedEvent, ColumnHeaderClickEvent, ContextMenuHeaderResetClickEvent} from './DataGridEvents.js';
const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
import {addColumnVisibilityCheckboxes, addSortableColumnItems} from './DataGridContextMenuUtils.js';
import {
calculateColumnWidthPercentageFromWeighting,
calculateFirstFocusableCell,
getCellTitleFromCellContent,
getRowEntryForColumnId,
handleArrowKeyNavigation,
renderCellValue,
SortDirection,
type CellPosition,
type Column,
type Row,
type SortState,
} from './DataGridUtils.js';
import * as i18n from '../../../core/i18n/i18n.js';
const UIStrings = {
/**
*@description A context menu item in the Data Grid of a data grid
*/
sortBy: 'Sort By',
/**
*@description A context menu item in data grids to reset the columns to their default weight
*/
resetColumns: 'Reset Columns',
/**
*@description A context menu item in data grids to list header options.
*/
headerOptions: 'Header Options',
/**
*@description Text for screen reader to announce when focusing on a sortable column in data grid.
*@example {ascending} PH1
*/
enterToSort: 'Column sort state: {PH1}. Press enter to apply sorting filter',
/**
*@description The current sort state of a column in data grid
*/
sortAsc: 'ascending',
/**
*@description The current sort state of a column in data grid
*/
sortDesc: 'descending',
/**
*@description The current sort state of a column in data grid
*/
sortNone: 'none',
};
const str_ = i18n.i18n.registerUIStrings('ui/components/data_grid/DataGrid.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface DataGridContextMenusConfiguration {
headerRow?: (menu: UI.ContextMenu.ContextMenu, columns: readonly Column[]) => void;
bodyRow?: (menu: UI.ContextMenu.ContextMenu, columns: readonly Column[], row: Readonly<Row>) => void;
}
export interface DataGridData {
columns: Column[];
rows: Row[];
activeSort: SortState|null;
contextMenus?: DataGridContextMenusConfiguration;
label?: string;
paddingRowsCount?: number;
showScrollbar?: boolean;
}
const enum UserScrollState {
NOT_SCROLLED = 'NOT_SCROLLED',
MANUAL_SCROLL_NOT_BOTTOM = 'MANUAL_SCROLL_NOT_BOTTOM',
SCROLLED_TO_BOTTOM = 'SCROLLED_TO_BOTTOM',
}
const KEYS_TREATED_AS_CLICKS = new Set([' ', 'Enter']);
const ROW_HEIGHT_PIXELS = 18;
export class DataGrid extends HTMLElement {
static readonly litTagName = LitHtml.literal`devtools-data-grid`;
readonly #shadow = this.attachShadow({mode: 'open'});
#columns: readonly Column[] = [];
#rows: readonly Row[] = [];
#sortState: Readonly<SortState>|null = null;
#isRendering = false;
#userScrollState: UserScrollState = UserScrollState.NOT_SCROLLED;
#contextMenus?: DataGridContextMenusConfiguration = undefined;
#label?: string = undefined;
#paddingRowsCount = 10;
#showScrollbar?: boolean = false;
#currentResize: {
rightCellCol: HTMLTableColElement,
leftCellCol: HTMLTableColElement,
leftCellColInitialPercentageWidth: number,
rightCellColInitialPercentageWidth: number,
initialLeftCellWidth: number,
initialRightCellWidth: number,
initialMouseX: number,
documentForCursorChange: Document,
cursorToRestore: string,
}|null = null;
// Because we only render a subset of rows, we need a way to look up the
// actual row index from the original dataset. We could use this.rows[index]
// but that's O(n) and will slow as the dataset grows. A weakmap makes the
// lookup constant.
readonly #rowIndexMap = new WeakMap<Row, number>();
readonly #resizeObserver = new ResizeObserver(() => {
void this.#alignScrollHandlers();
});
// Thie have to be bound as they are put onto the global document, not onto
// this element, so LitHtml does not bind them for us.
#boundOnResizePointerMove = this.#onResizePointerMove.bind(this);
/**
* Following guidance from
* https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html, we
* allow a single cell inside the table to be focusable, such that when a user
* tabs in they select that cell. IMPORTANT: if the data-grid has sortable
* columns, the user has to be able to navigate to the headers to toggle the
* sort. [0,0] is considered the first cell INCLUDING the column header
* Therefore if a user is on the first header cell, the position is considered [0, 0],
* and if a user is on the first body cell, the position is considered [0, 1].
*
* We set the selectable cell to the first tbody value by default, but then on the
* first render if any of the columns are sortable we'll set the active cell
* to [0, 0].
*/
#cellToFocusIfUserTabsIn: CellPosition = [0, 1];
#cellUserHasFocused: CellPosition|null = null;
#hasRenderedAtLeastOnce = false;
#userHasFocusInDataGrid = false;
#scheduleRender = false;
connectedCallback(): void {
this.#shadow.adoptedStyleSheets = [dataGridStyles];
ComponentHelpers.SetCSSProperty.set(this, '--table-row-height', `${ROW_HEIGHT_PIXELS}px`);
}
get data(): DataGridData {
return {
columns: this.#columns as Column[],
rows: this.#rows as Row[],
activeSort: this.#sortState,
contextMenus: this.#contextMenus,
label: this.#label,
paddingRowsCount: this.#paddingRowsCount,
showScrollbar: this.#showScrollbar,
};
}
set data(data: DataGridData) {
this.#columns = data.columns;
this.#rows = data.rows;
this.#rows.forEach((row, index) => {
this.#rowIndexMap.set(row, index);
});
this.#sortState = data.activeSort;
this.#contextMenus = data.contextMenus;
this.#label = data.label;
this.#showScrollbar = data.showScrollbar;
/**
* On first render, now we have data, we can figure out which cell is the
* focusable cell for the table.
*
* If any columns are sortable, we pick [0, 0], which is the first cell of
* the columns row. However, if any columns are hidden, we adjust
* accordingly. e.g., if the first column is hidden, we'll set the starting
* index as [1, 0].
*
* If the columns aren't sortable, we pick the first visible body row as the
* index.
*
* We only do this on the first render; otherwise if we re-render and the
* user has focused a cell, this logic will reset it.
*/
if (!this.#hasRenderedAtLeastOnce) {
this.#cellToFocusIfUserTabsIn = calculateFirstFocusableCell({columns: this.#columns, rows: this.#rows});
}
if (data.paddingRowsCount !== undefined) {
this.#paddingRowsCount = data.paddingRowsCount;
}
if (this.#hasRenderedAtLeastOnce && this.#userHasCellFocused()) {
const [selectedColIndex, selectedRowIndex] = this.#tabbableCell();
const columnOutOfBounds = selectedColIndex > this.#columns.length;
const rowOutOfBounds = selectedRowIndex > this.#rows.length;
/** If the row or column was removed, so the user is out of bounds, we
* move them to the last focusable cell, which should be close to where
* they were. */
if (columnOutOfBounds || rowOutOfBounds) {
this.#cellUserHasFocused = [
columnOutOfBounds ? this.#columns.length : selectedColIndex,
rowOutOfBounds ? this.#rows.length : selectedRowIndex,
];
}
}
void this.#render();
}
#shouldAutoScrollToBottom(): boolean {
/**
* If the user's last scroll took them to the bottom, then we assume they
* want to automatically scroll.
*/
if (this.#userScrollState === UserScrollState.SCROLLED_TO_BOTTOM) {
return true;
}
/**
* If the user does not have focus in the data grid (e.g. they haven't
* selected a cell), we automatically scroll, as long as the user hasn't
* manually scrolled the data-grid to somewhere that isn't the bottom.
*/
if (!this.#userHasFocusInDataGrid && this.#userScrollState !== UserScrollState.MANUAL_SCROLL_NOT_BOTTOM) {
return true;
}
/**
* Else, the user has focused a cell, or their last scroll action took them
* not to the bottom, so we assume that they don't want to be auto-scrolled.
*/
return false;
}
#scrollToBottomIfRequired(): void {
if (this.#hasRenderedAtLeastOnce === false || !this.#shouldAutoScrollToBottom()) {
return;
}
const wrapper = this.#shadow.querySelector('.wrapping-container');
if (!wrapper) {
return;
}
void coordinator.scroll(() => {
const scrollHeight = wrapper.scrollHeight;
wrapper.scrollTo(0, scrollHeight);
});
}
#engageResizeObserver(): void {
if (!this.#hasRenderedAtLeastOnce) {
this.#resizeObserver.observe(this.#shadow.host);
}
}
#userHasCellFocused(): boolean {
return this.#cellUserHasFocused !== null;
}
#getTableElementForCellUserHasFocused(): HTMLTableCellElement|null {
if (!this.#cellUserHasFocused) {
return null;
}
const [columnIndex, rowIndex] = this.#cellUserHasFocused;
const cell = this.#shadow.querySelector<HTMLTableCellElement>(
`[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`);
return cell;
}
#focusTableCellInDOM(cell: HTMLTableCellElement): void {
cell.focus();
}
#focusCellIfRequired([newColumnIndex, newRowIndex]: CellPosition): void {
this.#userHasFocusInDataGrid = true;
if (this.#cellUserHasFocused && this.#cellUserHasFocused[0] === newColumnIndex &&
this.#cellUserHasFocused[1] === newRowIndex) {
// The cell is already active and focused so we don't need to do anything.
return;
}
this.#cellUserHasFocused = [newColumnIndex, newRowIndex];
void this.#render();
const tableCell = this.#getTableElementForCellUserHasFocused();
if (!tableCell) {
// Return in case the cell is out of bounds and we do nothing
return;
}
/* The cell may already be focused if the user clicked into it, but we also
* add arrow key support, so in the case where we're programatically moving the
* focus, ensure we actually focus the cell.
*/
this.#focusTableCellInDOM(tableCell);
// If it's a sortable column header, screen reader announce the information for sorting
if (newRowIndex === 0 && this.#columns[newColumnIndex].sortable) {
const localizedSortState = this.#getLocalizedSortState(this.#columns[newColumnIndex]);
UI.ARIAUtils.alert(i18nString(UIStrings.enterToSort, {PH1: localizedSortState || ''}));
}
}
#getLocalizedSortState(col: Column): string|undefined {
const currentSortLabel = this.#ariaSortForHeader(col);
switch (currentSortLabel) {
case 'ascending':
return UIStrings.sortAsc;
case 'descending':
return UIStrings.sortDesc;
case 'none':
return UIStrings.sortNone;
}
return undefined;
}
#onTableKeyDown(event: KeyboardEvent): void {
const key = event.key;
if (!this.#cellUserHasFocused) {
return;
}
if (KEYS_TREATED_AS_CLICKS.has(key)) {
const [focusedColumnIndex, focusedRowIndex] = this.#cellUserHasFocused;
const activeColumn = this.#columns[focusedColumnIndex];
if (focusedRowIndex === 0 && activeColumn && activeColumn.sortable) {
this.#onColumnHeaderClick(activeColumn, focusedColumnIndex);
}
}
if (!Platform.KeyboardUtilities.keyIsArrowKey(key)) {
return;
}
const nextFocusedCell = handleArrowKeyNavigation({
key: key,
currentFocusedCell: this.#cellUserHasFocused,
columns: this.#columns,
rows: this.#rows,
});
event.preventDefault();
this.#focusCellIfRequired(nextFocusedCell);
}
#onColumnHeaderClick(col: Column, index: number): void {
this.dispatchEvent(new ColumnHeaderClickEvent(col, index));
}
/**
* Applies the aria-sort label to a column's th.
* Guidance on values of attribute taken from
* https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html.
*/
#ariaSortForHeader(col: Column): string|undefined {
if (col.sortable && (!this.#sortState || this.#sortState.columnId !== col.id)) {
// Column is sortable but is not currently sorted
return 'none';
}
if (this.#sortState && this.#sortState.columnId === col.id) {
return this.#sortState.direction === SortDirection.ASC ? 'ascending' : 'descending';
}
// Column is not sortable, so don't apply any label
return undefined;
}
#renderEmptyFillerRow(numberOfVisibleRows: number): LitHtml.TemplateResult {
const emptyCells = this.#columns.map((col, colIndex) => {
if (!col.visible) {
return LitHtml.nothing;
}
const emptyCellClasses = LitHtml.Directives.classMap({
firstVisibleColumn: colIndex === 0,
});
return LitHtml.html`<td aria-hidden="true" class=${emptyCellClasses} data-filler-row-column-index=${
colIndex}></td>`;
});
const emptyRowClasses = LitHtml.Directives.classMap({
'filler-row': true,
'padding-row': true,
'empty-table': numberOfVisibleRows === 0,
});
return LitHtml.html`<tr aria-hidden="true" class=${emptyRowClasses}>${emptyCells}</tr>`;
}
#cleanUpAfterResizeColumnComplete(): void {
if (!this.#currentResize) {
return;
}
this.#currentResize.documentForCursorChange.body.style.cursor = this.#currentResize.cursorToRestore;
this.#currentResize = null;
// Realign the scroll handlers now the table columns have been resized.
void this.#alignScrollHandlers();
}
#onResizePointerDown(event: PointerEvent): void {
if (event.buttons !== 1 || (Host.Platform.isMac() && event.ctrlKey)) {
// Ensure we only react to a left click drag mouse down event.
// On Mac we ignore Ctrl-click which can be used to bring up context menus, etc.
return;
}
event.preventDefault();
const resizerElement = event.target as HTMLElement;
if (!resizerElement) {
return;
}
const leftColumnIndex = resizerElement.dataset.columnIndex;
if (!leftColumnIndex) {
return;
}
const leftColumnIndexAsNumber = globalThis.parseInt(leftColumnIndex, 10);
/* To find the cell to the right we can't just go +1 as it might be hidden,
* so find the next index that is visible.
*/
const rightColumnIndexAsNumber = this.#columns.findIndex((column, index) => {
return index > leftColumnIndexAsNumber && column.visible === true;
});
const leftCell = this.#shadow.querySelector(`td[data-filler-row-column-index="${leftColumnIndexAsNumber}"]`);
const rightCell = this.#shadow.querySelector(`td[data-filler-row-column-index="${rightColumnIndexAsNumber}"]`);
if (!leftCell || !rightCell) {
return;
}
// We query for the <col> elements as they are the elements that we put the actual width on.
const leftCellCol =
this.#shadow.querySelector<HTMLTableColElement>(`col[data-col-column-index="${leftColumnIndexAsNumber}"]`);
const rightCellCol =
this.#shadow.querySelector<HTMLTableColElement>(`col[data-col-column-index="${rightColumnIndexAsNumber}"]`);
if (!leftCellCol || !rightCellCol) {
return;
}
const targetDocumentForCursorChange = (event.target as Node).ownerDocument;
if (!targetDocumentForCursorChange) {
return;
}
// We now store values that we'll make use of in the mousemouse event to calculate how much to resize the table by.
this.#currentResize = {
leftCellCol,
rightCellCol,
leftCellColInitialPercentageWidth: globalThis.parseInt(leftCellCol.style.width, 10),
rightCellColInitialPercentageWidth: globalThis.parseInt(rightCellCol.style.width, 10),
initialLeftCellWidth: leftCell.clientWidth,
initialRightCellWidth: rightCell.clientWidth,
initialMouseX: event.x,
documentForCursorChange: targetDocumentForCursorChange,
cursorToRestore: resizerElement.style.cursor,
};
targetDocumentForCursorChange.body.style.cursor = 'col-resize';
resizerElement.setPointerCapture(event.pointerId);
resizerElement.addEventListener('pointermove', this.#boundOnResizePointerMove);
}
#onResizePointerMove(event: PointerEvent): void {
event.preventDefault();
if (!this.#currentResize) {
return;
}
const MIN_CELL_WIDTH_PERCENTAGE = 10;
const MAX_CELL_WIDTH_PERCENTAGE = (this.#currentResize.leftCellColInitialPercentageWidth +
this.#currentResize.rightCellColInitialPercentageWidth) -
MIN_CELL_WIDTH_PERCENTAGE;
const deltaOfMouseMove = event.x - this.#currentResize.initialMouseX;
const absoluteDelta = Math.abs(deltaOfMouseMove);
const percentageDelta =
(absoluteDelta / (this.#currentResize.initialLeftCellWidth + this.#currentResize.initialRightCellWidth)) * 100;
let newLeftColumnPercentage;
let newRightColumnPercentage;
if (deltaOfMouseMove > 0) {
/**
* A positive delta means the user moved their mouse to the right, so we
* want to make the right column smaller, and the left column larger.
*/
newLeftColumnPercentage = Platform.NumberUtilities.clamp(
this.#currentResize.leftCellColInitialPercentageWidth + percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
MAX_CELL_WIDTH_PERCENTAGE);
newRightColumnPercentage = Platform.NumberUtilities.clamp(
this.#currentResize.rightCellColInitialPercentageWidth - percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
MAX_CELL_WIDTH_PERCENTAGE);
} else if (deltaOfMouseMove < 0) {
/**
* Negative delta means the user moved their mouse to the left, which
* means we want to make the right column larger, and the left column
* smaller.
*/
newLeftColumnPercentage = Platform.NumberUtilities.clamp(
this.#currentResize.leftCellColInitialPercentageWidth - percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
MAX_CELL_WIDTH_PERCENTAGE);
newRightColumnPercentage = Platform.NumberUtilities.clamp(
this.#currentResize.rightCellColInitialPercentageWidth + percentageDelta, MIN_CELL_WIDTH_PERCENTAGE,
MAX_CELL_WIDTH_PERCENTAGE);
}
if (!newLeftColumnPercentage || !newRightColumnPercentage) {
// The delta was 0, so nothing to do.
return;
}
// We limit the values to two decimal places to not work with huge decimals.
// It also prevents stuttering if the user barely moves the mouse, as the
// browser won't try to move the column by 0.0000001% or similar.
this.#currentResize.leftCellCol.style.width = newLeftColumnPercentage.toFixed(2) + '%';
this.#currentResize.rightCellCol.style.width = newRightColumnPercentage.toFixed(2) + '%';
}
#onResizePointerUp(event: PointerEvent): void {
event.preventDefault();
const resizer = event.target as HTMLElement;
if (!resizer) {
return;
}
resizer.releasePointerCapture(event.pointerId);
resizer.removeEventListener('pointermove', this.#boundOnResizePointerMove);
this.#cleanUpAfterResizeColumnComplete();
}
#renderResizeForCell(column: Column, position: CellPosition): LitHtml.LitTemplate {
/**
* A resizer for a column is placed at the far right of the _previous column
* cell_. So when we get called with [1, 0] that means this dragger is
* resizing column 1, but the dragger itself is located within column 0. We
* need the column to the left because when you resize a column you're not
* only resizing it but also the column to its left.
*/
const [columnIndex] = position;
const lastVisibleColumnIndex = this.#getIndexOfLastVisibleColumn();
// If we are in the very last column, there is no column to the right to resize, so don't render a resizer.
if (columnIndex === lastVisibleColumnIndex || !column.visible) {
return LitHtml.nothing;
}
return LitHtml.html`<span class="cell-resize-handle"
@pointerdown=${this.#onResizePointerDown}
@pointerup=${this.#onResizePointerUp}
data-column-index=${columnIndex}
></span>`;
}
#getIndexOfLastVisibleColumn(): number {
let index = this.#columns.length - 1;
for (; index > -1; index--) {
const col = this.#columns[index];
if (col.visible) {
break;
}
}
return index;
}
/**
* This function is called when the user right clicks on the header row of the
* data grid.
*/
#onHeaderContextMenu(event: MouseEvent): void {
if (event.button !== 2 && event.button !== -1) {
// -1 = right click invoked by keyboard, for example 'Shift + F10'.
// 2 = secondary button = right click. We only show context menus if the
// user has right clicked.
return;
}
const menu = new UI.ContextMenu.ContextMenu(event);
addColumnVisibilityCheckboxes(this, menu);
const sortMenu = menu.defaultSection().appendSubMenuItem(i18nString(UIStrings.sortBy));
addSortableColumnItems(this, sortMenu);
menu.defaultSection().appendItem(i18nString(UIStrings.resetColumns), () => {
this.dispatchEvent(new ContextMenuHeaderResetClickEvent());
});
if (this.#contextMenus && this.#contextMenus.headerRow) {
// Let the user append things to the menu
this.#contextMenus.headerRow(menu, this.#columns);
}
void menu.show();
}
#onBodyRowContextMenu(event: MouseEvent): void {
if (event.button !== 2 && event.button !== -1) {
// -1 = right click invoked by keyboard, for example 'Shift + F10'.
// 2 = secondary button = right click. We only show context menus if the
// user has right clicked.
return;
}
/**
* We now make sure that the event came from an HTML element with a
* data-row-index attribute, else we bail.
*/
if (!event.target || !(event.target instanceof HTMLElement)) {
return;
}
const rowIndexAttribute = event.target.dataset.rowIndex;
if (!rowIndexAttribute) {
return;
}
const rowIndex = parseInt(rowIndexAttribute, 10);
// rowIndex - 1 here because in the UI the 0th row is the column headers.
const rowThatWasClicked = this.#rows[rowIndex - 1];
const menu = new UI.ContextMenu.ContextMenu(event);
const sortMenu = menu.defaultSection().appendSubMenuItem(i18nString(UIStrings.sortBy));
addSortableColumnItems(this, sortMenu);
const headerOptionsMenu = menu.defaultSection().appendSubMenuItem(i18nString(UIStrings.headerOptions));
addColumnVisibilityCheckboxes(this, headerOptionsMenu);
headerOptionsMenu.defaultSection().appendItem(i18nString(UIStrings.resetColumns), () => {
this.dispatchEvent(new ContextMenuHeaderResetClickEvent());
});
if (this.#contextMenus && this.#contextMenus.bodyRow) {
this.#contextMenus.bodyRow(menu, this.#columns, rowThatWasClicked);
}
void menu.show();
}
#onScroll(event: Event): void {
const wrapper = event.target as HTMLElement;
if (!wrapper) {
return;
}
// Need to Math.round because on high res screens we can end up with decimal
// point numbers for scroll positions.
const userIsAtBottom = Math.round(wrapper.scrollTop + wrapper.clientHeight) === Math.round(wrapper.scrollHeight);
this.#userScrollState =
userIsAtBottom ? UserScrollState.SCROLLED_TO_BOTTOM : UserScrollState.MANUAL_SCROLL_NOT_BOTTOM;
void this.#render();
}
#alignScrollHandlers(): Promise<void> {
return coordinator.read(() => {
const columnHeaders = this.#shadow.querySelectorAll<HTMLElement>('th:not(.hidden)');
const handlers = this.#shadow.querySelectorAll<HTMLElement>('.cell-resize-handle');
const table = this.#shadow.querySelector<HTMLTableElement>('table');
if (!table) {
return;
}
columnHeaders.forEach(async (header, index) => {
const columnWidth = header.clientWidth;
const columnLeftOffset = header.offsetLeft;
if (handlers[index]) {
const handlerWidth = handlers[index].clientWidth;
void coordinator.write(() => {
/**
* Render the resizer at the far right of the column; we subtract
* its width so it sits on the inner edge of the column.
*/
handlers[index].style.left = `${columnLeftOffset + columnWidth - handlerWidth}px`;
});
}
});
});
}
/**
* Calculates the index of the first row we want to render, and the last row we want to render.
* Pads in each direction by PADDING_ROWS_COUNT so we render some rows that are off scren.
*/
#calculateTopAndBottomRowIndexes(): Promise<{topVisibleRow: number, bottomVisibleRow: number}> {
return coordinator.read(() => {
const wrapper = this.#shadow.querySelector('.wrapping-container');
// On first render we don't have a wrapper, so we can't get at its
// scroll/height values. So we default to the inner height of the window as
// the limit for rendering. This means we may over-render by a few rows, but
// better that than either render everything, or rendering too few rows.
let scrollTop = 0;
let clientHeight = window.innerHeight;
if (wrapper) {
scrollTop = wrapper.scrollTop;
clientHeight = wrapper.clientHeight;
}
const padding = ROW_HEIGHT_PIXELS * this.#paddingRowsCount;
let topVisibleRow = Math.floor((scrollTop - padding) / ROW_HEIGHT_PIXELS);
let bottomVisibleRow = Math.ceil((scrollTop + clientHeight + padding) / ROW_HEIGHT_PIXELS);
topVisibleRow = Math.max(0, topVisibleRow);
bottomVisibleRow = Math.min(this.#rows.filter(r => !r.hidden).length, bottomVisibleRow);
return {
topVisibleRow,
bottomVisibleRow,
};
});
}
#onFocusOut(): void {
/**
* When any element in the data-grid loses focus, we set this to false. If
* the user then focuses another cell, that code will set the focus to true.
* We need to know if the user is focused because if they are and they've
* scrolled their focused cell out of rendering view and back in, we want to
* refocus it. But if they aren't focused and that happens, we don't, else
* we can steal focus away from the user if they are typing into an input
* box to filter the data-grid, for example.
*/
this.#userHasFocusInDataGrid = false;
}
#tabbableCell(): CellPosition {
/**
* If the user has selected a cell, this is the cell that should be
* "tabbable" if the user tabs out and into the data-grid. If the user
* hasn't selected a cell, we fallback to the default cell that we set as
* tabbable when we render.
*/
return this.#cellUserHasFocused || this.#cellToFocusIfUserTabsIn;
}
/**
* Renders the data-grid table. Note that we do not render all rows; the
* performance cost are too high once you have a large enough table. Instead
* we calculate the size of the container we are rendering into, and then
* render only the rows required to fill that table (plus a bit extra for
* padding).
*/
async #render(): Promise<void> {
if (this.#isRendering) {
// If we receive a request to render during a previous render call, we block
// the newly requested render (since we could receive a lot of them in quick
// succession), but we do ensure that at the end of the current render we
// go again with the latest data.
this.#scheduleRender = true;
return;
}
this.#isRendering = true;
const {topVisibleRow, bottomVisibleRow} = await this.#calculateTopAndBottomRowIndexes();
const nonHiddenRows = this.#rows.filter(row => !row.hidden);
const renderableRows = nonHiddenRows.filter((_, idx) => idx >= topVisibleRow && idx <= bottomVisibleRow);
const indexOfFirstVisibleColumn = this.#columns.findIndex(col => col.visible);
const anyColumnsSortable = this.#columns.some(col => col.sortable === true);
const containerClassMap = {
'wrapping-container': true,
'show-scrollbar': this.#showScrollbar === true,
};
await coordinator.write(() => {
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
LitHtml.render(LitHtml.html`
${this.#columns.map((col, columnIndex) => {
/**
* We render the resizers outside of the table. One is rendered for each
* column, and they are positioned absolutely at the right position. They
* have 100% height so they sit over the entire table and can be grabbed
* by the user.
*/
return this.#renderResizeForCell(col, [columnIndex, 0]);
})}
<div class=${LitHtml.Directives.classMap(containerClassMap)} @scroll=${this.#onScroll} @focusout=${this.#onFocusOut}>
<table
aria-label=${LitHtml.Directives.ifDefined(this.#label)}
aria-rowcount=${this.#rows.length}
aria-colcount=${this.#columns.length}
@keydown=${this.#onTableKeyDown}
>
<colgroup>
${this.#columns.map((col, colIndex) => {
const width = calculateColumnWidthPercentageFromWeighting(this.#columns, col.id);
const style = `width: ${width}%`;
if (!col.visible) {
return LitHtml.nothing;
}
return LitHtml.html`<col style=${style} data-col-column-index=${colIndex}>`;
})}
</colgroup>
<thead>
<tr @contextmenu=${this.#onHeaderContextMenu}>
${this.#columns.map((col, columnIndex) => {
const thClasses = LitHtml.Directives.classMap({
hidden: !col.visible,
firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
sortable: anyColumnsSortable,
});
const tabbableCell = this.#tabbableCell();
const cellIsFocusableCell = anyColumnsSortable && columnIndex === tabbableCell[0] && tabbableCell[1] === 0;
return LitHtml.html`<th class=${thClasses}
style=${LitHtml.Directives.ifDefined(col.styles ? LitHtml.Directives.styleMap(col.styles) : undefined)}
data-grid-header-cell=${col.id}
@focus=${(): void => {
this.#focusCellIfRequired([columnIndex, 0]);
}}
@click=${(): void => {
/**
* We use click here rather than focus because if you've
* clicked on the header to sort, you've also focused it. If
* you then click it again to change the sorting, this
* doesn't emit a focus event as the cell is already
* focused.
*/
this.#onColumnHeaderClick(col, columnIndex);
}}
title=${col.title}
aria-sort=${LitHtml.Directives.ifDefined(this.#ariaSortForHeader(col))}
aria-colindex=${columnIndex + 1}
data-row-index='0'
data-col-index=${columnIndex}
tabindex=${LitHtml.Directives.ifDefined(anyColumnsSortable ? (cellIsFocusableCell ? '0' : '-1') : undefined)}
>${col.titleElement || col.title}</th>`;
})}
</tr>
</thead>
<tbody>
<tr class="filler-row-top padding-row" style=${LitHtml.Directives.styleMap({
height: `${topVisibleRow * ROW_HEIGHT_PIXELS}px`,
})} aria-hidden="true"></tr>
${LitHtml.Directives.repeat(renderableRows, row => this.#rowIndexMap.get(row), (row): LitHtml.TemplateResult => {
const rowIndex = this.#rowIndexMap.get(row);
if (rowIndex === undefined) {
throw new Error('Trying to render a row that has no index in the rowIndexMap');
}
const tabbableCell = this.#tabbableCell();
// Remember that row 0 is considered the header row, so the first tbody row is row 1.
const tableRowIndex = rowIndex + 1;
// Check for cellUserHasFocused instead of tabbableCell so that we
// don't highlight the active cell before they've even clicked it.
const rowIsSelected = this.#cellUserHasFocused ? tableRowIndex === this.#cellUserHasFocused[1] : false;
const rowClasses = LitHtml.Directives.classMap({
selected: rowIsSelected,
hidden: row.hidden === true,
});
return LitHtml.html`
<tr
aria-rowindex=${rowIndex + 1}
class=${rowClasses}
style=${LitHtml.Directives.ifDefined(row.styles ? LitHtml.Directives.styleMap(row.styles) : undefined)}
@contextmenu=${this.#onBodyRowContextMenu}
>${this.#columns.map((col, columnIndex) => {
const cell = getRowEntryForColumnId(row, col.id);
const cellClasses = LitHtml.Directives.classMap({
hidden: !col.visible,
firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
});
const cellIsFocusableCell = columnIndex === tabbableCell[0] && tableRowIndex === tabbableCell[1];
const cellOutput = col.visible ? renderCellValue(cell) : null;
return LitHtml.html`<td
class=${cellClasses}
style=${LitHtml.Directives.ifDefined(col.styles ? LitHtml.Directives.styleMap(col.styles) : undefined)}
tabindex=${cellIsFocusableCell ? '0' : '-1'}
aria-colindex=${columnIndex + 1}
title=${cell.title || getCellTitleFromCellContent(String(cell.value))}
data-row-index=${tableRowIndex}
data-col-index=${columnIndex}
data-grid-value-cell-for-column=${col.id}
@focus=${(): void => {
this.#focusCellIfRequired([columnIndex, tableRowIndex]);
this.dispatchEvent(new BodyCellFocusedEvent(cell, row));
}}
>${cellOutput}</td>`;
})}
`;
})}
${this.#renderEmptyFillerRow(renderableRows.length)}
<tr class="filler-row-bottom padding-row" style=${LitHtml.Directives.styleMap({
height: `${Math.max(0, nonHiddenRows.length - bottomVisibleRow) * ROW_HEIGHT_PIXELS}px`,
})} aria-hidden="true"></tr>
</tbody>
</table>
</div>
`, this.#shadow, {
host: this,
});
});
// clang-format on
// This ensures if the user has a cell focused, but then scrolls so that
// the focused cell is now not rendered, that when it then gets scrolled
// back in, that it becomes rendered.
// However, if the cell is a column header, we don't do this, as that
// can never be not-rendered.
const tabbableCell = this.#tabbableCell();
const currentlyFocusedRowIndex = tabbableCell[1];
const tabbableCellElement = this.#getTableElementForCellUserHasFocused();
if (this.#userHasFocusInDataGrid && currentlyFocusedRowIndex > 0 && tabbableCellElement) {
this.#focusTableCellInDOM(tabbableCellElement);
}
this.#scrollToBottomIfRequired();
this.#engageResizeObserver();
if (this.#hasRenderedAtLeastOnce) {
// We may have had a cell's width change on a re-render, or it may have
// been hidden entirely, so we need to ensure that the resize handlers are
// re-positioned correctly if so.
// We don't have to do this on first render as it will fire when the resize observer is engaged.
void this.#alignScrollHandlers();
}
this.#isRendering = false;
this.#hasRenderedAtLeastOnce = true;
// If we've received more data mid-render we will do one extra render at
// the end with the most recent data.
if (this.#scheduleRender) {
this.#scheduleRender = false;
void this.#render();
}
}
}
ComponentHelpers.CustomElements.defineComponent('devtools-data-grid', DataGrid);
declare global {
interface HTMLElementTagNameMap {
'devtools-data-grid': DataGrid;
}
}