chrome-devtools-frontend
Version:
Chrome DevTools UI
944 lines (842 loc) • 36.2 kB
text/typescript
// 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 Common from '../../common/common.js';
import * as ComponentHelpers from '../../component_helpers/component_helpers.js';
import * as Host from '../../host/host.js';
import * as Platform from '../../platform/platform.js';
import * as Coordinator from '../../render_coordinator/render_coordinator.js';
import * as LitHtml from '../../third_party/lit-html/lit-html.js';
// eslint-disable-next-line rulesdir/es_modules_import
import * as UI from '../../ui/ui.js';
const {ls} = Common;
const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
import {addColumnVisibilityCheckboxes, addSortableColumnItems} from './DataGridContextMenuUtils.js';
import {calculateColumnWidthPercentageFromWeighting, calculateFirstFocusableCell, Cell, CellPosition, Column, ContextMenuHeaderResetClickEvent, getRowEntryForColumnId, handleArrowKeyNavigation, renderCellValue, Row, SortDirection, SortState} from './DataGridUtils.js';
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;
}
export class ColumnHeaderClickEvent extends Event {
data: {
column: Column,
columnIndex: number,
};
constructor(column: Column, columnIndex: number) {
super('column-header-click');
this.data = {
column,
columnIndex,
};
}
}
export class NewUserFilterTextEvent extends Event {
data: {filterText: string};
constructor(filterText: string) {
super('new-user-filter-text', {
composed: true,
});
this.data = {
filterText,
};
}
}
export class BodyCellFocusedEvent extends Event {
/**
* Although the DataGrid cares only about the focused cell, and has no concept
* of a focused row, many components that render a data grid want to know what
* row is active, so on the cell focused event we also send the row that the
* cell is part of.
*/
data: {
cell: Cell,
row: Row,
};
constructor(cell: Cell, row: Row) {
super('cell-focused', {
composed: true,
});
this.data = {
cell,
row,
};
}
}
const KEYS_TREATED_AS_CLICKS = new Set([' ', 'Enter']);
const ROW_HEIGHT_PIXELS = 18;
const PADDING_ROWS_COUNT = 10;
export class DataGrid extends HTMLElement {
private readonly shadow = this.attachShadow({mode: 'open'});
private columns: readonly Column[] = [];
private rows: readonly Row[] = [];
private sortState: Readonly<SortState>|null = null;
private scheduledRender = false;
private contextMenus?: DataGridContextMenusConfiguration = undefined;
private 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.
private readonly rowIndexMap = new WeakMap<Row, number>();
private readonly resizeObserver = new ResizeObserver(() => {
this.alignScrollHandlers();
});
// These have to be bound as they are put onto the global document, not onto
// this element, so LitHtml does not bind them for us.
private boundOnResizePointerUp = this.onResizePointerUp.bind(this);
private boundOnResizePointerMove = this.onResizePointerMove.bind(this);
private boundOnResizePointerDown = this.onResizePointerDown.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].
*/
private focusableCell: CellPosition = [0, 1];
private hasRenderedAtLeastOnce = false;
private userHasFocusInDataGrid = false;
private userHasScrolled = false;
private enqueuedRender = false;
constructor() {
super();
this.shadow.adoptedStyleSheets = [
...ComponentHelpers.GetStylesheet.getStyleSheets('ui/inspectorScrollbars.css', {enableLegacyPatching: false}),
];
}
connectedCallback(): void {
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,
};
}
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;
/**
* 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.focusableCell = calculateFirstFocusableCell({columns: this.columns, rows: this.rows});
}
if (this.hasRenderedAtLeastOnce) {
const [selectedColIndex, selectedRowIndex] = this.focusableCell;
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.focusableCell = [
columnOutOfBounds ? this.columns.length : selectedColIndex,
rowOutOfBounds ? this.rows.length : selectedRowIndex,
];
}
}
this.render();
}
private scrollToBottomIfRequired(): void {
if (this.hasRenderedAtLeastOnce === false || this.userHasFocusInDataGrid || this.userHasScrolled) {
// On the first render we don't want to assume the user wants to scroll to the bottom.
// And if they have focused a cell we don't want to scroll them away from it.
// If they have scrolled the table manually we also won't scroll and disrupt their scroll position.
return;
}
const focusableCell = this.getCurrentlyFocusableCell();
if (focusableCell && focusableCell === this.shadow.activeElement) {
// The user has a cell (and indirectly, a row) selected so we don't want
// to mess with their scroll
return;
}
coordinator.read(() => {
const wrapper = this.shadow.querySelector('.wrapping-container');
if (!wrapper) {
return;
}
const scrollHeight = wrapper.scrollHeight;
coordinator.scroll(() => {
wrapper.scrollTo(0, scrollHeight);
});
});
}
private engageResizeObserver(): void {
if (!this.hasRenderedAtLeastOnce) {
this.resizeObserver.observe(this.shadow.host);
}
}
private getCurrentlyFocusableCell(): HTMLTableCellElement|null {
const [columnIndex, rowIndex] = this.focusableCell;
const cell = this.shadow.querySelector<HTMLTableCellElement>(
`[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`);
return cell;
}
private focusCell([newColumnIndex, newRowIndex]: CellPosition): void {
this.userHasFocusInDataGrid = true;
const [currentColumnIndex, currentRowIndex] = this.focusableCell;
const newCellIsCurrentlyFocusedCell = (currentColumnIndex === newColumnIndex && currentRowIndex === newRowIndex);
if (!newCellIsCurrentlyFocusedCell) {
this.focusableCell = [newColumnIndex, newRowIndex];
this.render();
}
const cellElement = this.getCurrentlyFocusableCell();
if (!cellElement) {
// 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.
*/
coordinator.write(() => {
cellElement.focus();
});
}
private onTableKeyDown(event: KeyboardEvent): void {
const key = event.key;
if (KEYS_TREATED_AS_CLICKS.has(key)) {
const focusedCell = this.getCurrentlyFocusableCell();
const [focusedColumnIndex, focusedRowIndex] = this.focusableCell;
const activeColumn = this.columns[focusedColumnIndex];
if (focusedCell && focusedRowIndex === 0 && activeColumn && activeColumn.sortable) {
this.onColumnHeaderClick(activeColumn, focusedColumnIndex);
}
}
if (!Platform.KeyboardUtilities.keyIsArrowKey(key)) {
return;
}
const nextFocusedCell = handleArrowKeyNavigation({
key: key,
currentFocusedCell: this.focusableCell,
columns: this.columns,
rows: this.rows,
});
event.preventDefault();
this.focusCell(nextFocusedCell);
}
private 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.
*/
private 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;
}
private renderEmptyFillerRow(): 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 tabindex="-1" class=${emptyCellClasses} data-filler-row-column-index=${colIndex}></td>`;
});
return LitHtml.html`<tr tabindex="-1" class="filler-row padding-row">${emptyCells}</tr>`;
}
private 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.
this.alignScrollHandlers();
}
private 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);
}
private 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) + '%';
}
private 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();
}
private renderResizeForCell(column: Column, position: CellPosition): LitHtml.TemplateResult {
/**
* 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 as LitHtml.TemplateResult;
}
return LitHtml.html`<span class="cell-resize-handle"
=${this.boundOnResizePointerDown}
=${this.boundOnResizePointerUp}
data-column-index=${columnIndex}
></span>`;
}
private 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.
*/
private onHeaderContextMenu(event: MouseEvent): void {
if (event.button !== 2) {
// 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(ls`Sort By`);
addSortableColumnItems(this, sortMenu);
menu.defaultSection().appendItem(ls`Reset Columns`, () => {
this.dispatchEvent(new ContextMenuHeaderResetClickEvent());
});
if (this.contextMenus && this.contextMenus.headerRow) {
// Let the user append things to the menu
this.contextMenus.headerRow(menu, this.columns);
}
menu.show();
}
private onBodyRowContextMenu(event: MouseEvent): void {
if (event.button !== 2) {
// 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(ls`Sort By`);
addSortableColumnItems(this, sortMenu);
const headerOptionsMenu = menu.defaultSection().appendSubMenuItem(ls`Header Options`);
addColumnVisibilityCheckboxes(this, headerOptionsMenu);
headerOptionsMenu.defaultSection().appendItem(ls`Reset Columns`, () => {
this.dispatchEvent(new ContextMenuHeaderResetClickEvent());
});
if (this.contextMenus && this.contextMenus.bodyRow) {
this.contextMenus.bodyRow(menu, this.columns, rowThatWasClicked);
}
menu.show();
}
private onScroll(): void {
this.render();
}
private onWheel(): void {
this.userHasScrolled = true;
}
private alignScrollHandlers(): Promise<void> {
return coordinator.read(() => {
const columnHeaders = this.shadow.querySelectorAll('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 {right} = header.getBoundingClientRect();
if (handlers[index]) {
/**
* 40px here because the handler is 20px wide, and we use the right
* boundary of the cell to position it. So we move it back 20px
* because it's 20px wide, but then need to pull it back another 20px
* so it sits over The very right hand edge of the column.
*/
coordinator.write(() => {
handlers[index].style.left = `${right - 40}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.
*/
private 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 * PADDING_ROWS_COUNT;
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,
};
});
}
private 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;
}
/**
* 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).
*/
private render(): void {
if (this.scheduledRender) {
// 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.enqueuedRender = true;
return;
}
this.scheduledRender = true;
coordinator.read(async () => {
const {topVisibleRow, bottomVisibleRow} = await this.calculateTopAndBottomRowIndexes();
const renderableRows =
this.rows.filter(row => !row.hidden).filter((_, idx) => idx >= topVisibleRow && idx <= bottomVisibleRow);
const indexOfFirstVisibleColumn = this.columns.findIndex(col => col.visible);
const anyColumnsSortable = this.columns.some(col => col.sortable === true);
await coordinator.write(() => {
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
LitHtml.render(LitHtml.html`
<style>
:host {
height: 100%;
display: block;
position: relative;
}
/* Ensure that vertically we don't overflow */
.wrapping-container {
overflow-y: scroll;
/* Use max-height instead of height to ensure that the
table does not use more space than necessary. */
height: 100%;
}
table {
border-spacing: 0;
width: 100%;
height: 100%;
/* To make sure that we properly hide overflowing text
when horizontal space is too narrow. */
table-layout: fixed;
}
tr {
outline: none;
}
tbody tr {
background-color: var(--color-background);
}
tbody tr.selected {
background-color: var(--color-background-elevation-1);
}
td,
th {
padding: 1px 4px;
/* Divider between each cell, except the first one (see below) */
border-left: 1px solid var(--color-details-hairline);
color: var(--color-text-primary);
line-height: var(--table-row-height);
height: var(--table-row-height);
user-select: text;
/* Ensure that text properly cuts off if horizontal space is too narrow */
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
th {
font-weight: normal;
text-align: left;
border-bottom: 1px solid var(--color-details-hairline);
position: sticky;
top: 0;
z-index: 2;
background-color: var(--color-background-elevation-1);
}
td:focus,
th:focus {
outline: var(--color-primary) auto 1px;
}
.cell-resize-handle {
top: 0;
height: 100%;
z-index: 3;
width: 20px;
cursor: col-resize;
position: absolute;
}
/* There is no divider before the first cell */
td.firstVisibleColumn,
th.firstVisibleColumn {
border-left: none;
}
.hidden {
display: none;
}
.filler-row td {
/* By making the filler row cells 100% they take up any extra height,
* leaving the cells with content to be the regular height, and the
* final filler row to be as high as it needs to be to fill the empty
* space.
*/
height: 100%;
pointer-events: none;
}
[aria-sort]:hover {
cursor: pointer;
}
[aria-sort="descending"]::after {
content: " ";
border-left: 0.3em solid transparent;
border-right: 0.3em solid transparent;
border-top: 0.3em solid var(--color-text-primary);
position: absolute;
right: 0.5em;
top: 0.6em;
}
[aria-sort="ascending"]::after {
content: " ";
border-bottom: 0.3em solid var(--color-text-primary);
border-left: 0.3em solid transparent;
border-right: 0.3em solid transparent;
position: absolute;
right: 0.5em;
top: 0.6em;
}
</style>
${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="wrapping-container" =${this.onScroll} =${this.onWheel} =${this.onFocusOut}>
<table
aria-rowcount=${this.rows.length}
aria-colcount=${this.columns.length}
=${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 =${this.onHeaderContextMenu}>
${this.columns.map((col, columnIndex) => {
const thClasses = LitHtml.Directives.classMap({
hidden: !col.visible,
firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
});
const cellIsFocusableCell = anyColumnsSortable && columnIndex === this.focusableCell[0] && this.focusableCell[1] === 0;
return LitHtml.html`<th class=${thClasses}
data-grid-header-cell=${col.id}
=${(): void => {
this.focusCell([columnIndex, 0]);
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.title}</th>`;
})}
</tr>
</thead>
<tbody>
<tr class="filler-row-top padding-row" style=${LitHtml.Directives.styleMap({
height: `${topVisibleRow * ROW_HEIGHT_PIXELS}px`,
})}></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 focusableCell = this.getCurrentlyFocusableCell();
const [,focusableCellRowIndex] = this.focusableCell;
// Remember that row 0 is considered the header row, so the first tbody row is row 1.
const tableRowIndex = rowIndex + 1;
// Have to check for focusableCell existing as this runs on the
// first render before it's ever been created.
const rowIsSelected = focusableCell ? focusableCell === this.shadow.activeElement && tableRowIndex === focusableCellRowIndex : false;
const rowClasses = LitHtml.Directives.classMap({
selected: rowIsSelected,
hidden: row.hidden === true,
});
return LitHtml.html`
<tr
aria-rowindex=${rowIndex + 1}
class=${rowClasses}
=${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 === this.focusableCell[0] && tableRowIndex === this.focusableCell[1];
const cellOutput = col.visible ? renderCellValue(cell) : null;
return LitHtml.html`<td
class=${cellClasses}
tabindex=${cellIsFocusableCell ? '0' : '-1'}
aria-colindex=${columnIndex + 1}
title=${cell.title || String(cell.value).substr(0, 20)}
data-row-index=${tableRowIndex}
data-col-index=${columnIndex}
data-grid-value-cell-for-column=${col.id}
=${(): void => {
this.dispatchEvent(new BodyCellFocusedEvent(cell, row));
}}
=${(): void => {
this.focusCell([columnIndex, tableRowIndex]);
}}
>${cellOutput}</td>`;
})}
`;
})}
${this.renderEmptyFillerRow()}
<tr class="filler-row-bottom padding-row" style=${LitHtml.Directives.styleMap({
height: `${Math.max(0, renderableRows.length - bottomVisibleRow) * ROW_HEIGHT_PIXELS}px`,
})}></tr>
</tbody>
</table>
</div>
`, this.shadow, {
eventContext: 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 currentlyFocusedRowIndex = this.focusableCell[1];
if (this.userHasFocusInDataGrid && currentlyFocusedRowIndex > 0) {
this.focusCell(this.focusableCell);
}
this.scrollToBottomIfRequired();
this.engageResizeObserver();
this.scheduledRender = 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.enqueuedRender) {
this.enqueuedRender = false;
this.render();
}
});
}
}
customElements.define('devtools-data-grid', DataGrid);
declare global {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLElementTagNameMap {
'devtools-data-grid': DataGrid;
}
}