[go: nahoru, domu]

blob: d03635141f0a5702056155e5d2eb8b42a2ce8ebb [file] [log] [blame]
Jack Franklinf3ebb142020-11-05 15:11:231// Copyright (c) 2020 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import * as LitHtml from '../../third_party/lit-html/lit-html.js';
6
Jack Franklincedccc02020-11-19 09:45:247import {calculateColumnWidthPercentageFromWeighting, calculateFirstFocusableCell, Cell, CellPosition, Column, getRowEntryForColumnId, handleArrowKeyNavigation, keyIsArrowKey, renderCellValue, Row, SortDirection, SortState} from './DataGridUtils.js';
Jack Franklinf3ebb142020-11-05 15:11:238
9export interface DataGridData {
10 columns: Column[];
11 rows: Row[];
12 activeSort: SortState|null;
13}
14
15export class ColumnHeaderClickEvent extends Event {
16 data: {
17 column: Column,
18 columnIndex: number,
Tim van der Lippe0ebbf492020-12-03 12:13:2119 };
Jack Franklinf3ebb142020-11-05 15:11:2320
21 constructor(column: Column, columnIndex: number) {
Jack Franklin222aba72020-11-09 10:33:0522 super('column-header-click');
Jack Franklinf3ebb142020-11-05 15:11:2323 this.data = {
24 column,
25 columnIndex,
26 };
27 }
28}
29
Jack Franklin4c630d22020-11-13 10:39:0530export class BodyCellFocusedEvent extends Event {
31 /**
32 * Although the DataGrid cares only about the focused cell, and has no concept
33 * of a focused row, many components that render a data grid want to know what
34 * row is active, so on the cell focused event we also send the row that the
35 * cell is part of.
36 */
37 data: {
38 cell: Cell,
39 row: Row,
Tim van der Lippe0ebbf492020-12-03 12:13:2140 };
Jack Franklin4c630d22020-11-13 10:39:0541
42 constructor(cell: Cell, row: Row) {
43 super('cell-focused');
44 this.data = {
45 cell,
46 row,
47 };
48 }
49}
50
Jack Franklinf3ebb142020-11-05 15:11:2351const KEYS_TREATED_AS_CLICKS = new Set([' ', 'Enter']);
52
53export class DataGrid extends HTMLElement {
54 private readonly shadow = this.attachShadow({mode: 'open'});
Jack Franklin92fe8ff2020-12-03 10:51:2355 private columns: readonly Column[] = [];
56 private rows: readonly Row[] = [];
Jack Franklinf3ebb142020-11-05 15:11:2357 private sortState: Readonly<SortState>|null = null;
58 /**
59 * Following guidance from
60 * https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html, we
61 * allow a single cell inside the table to be focusable, such that when a user
62 * tabs in they select that cell. IMPORTANT: if the data-grid has sortable
63 * columns, the user has to be able to navigate to the headers to toggle the
64 * sort. [0,0] is considered the first cell INCLUDING the column header
65 * Therefore if a user is on the first header cell, the position is considered [0, 0],
66 * and if a user is on the first body cell, the position is considered [0, 1].
67 *
68 * We set the selectable cell to the first tbody value by default, but then on the
69 * first render if any of the columns are sortable we'll set the active cell
70 * to [0, 0].
71 */
72 private focusableCell: CellPosition = [0, 1];
73 private hasRenderedAtLeastOnce = false;
74
75 get data(): DataGridData {
76 return {
77 columns: this.columns as Column[],
78 rows: this.rows as Row[],
79 activeSort: this.sortState,
80 };
81 }
82
83 set data(data: DataGridData) {
84 this.columns = data.columns;
85 this.rows = data.rows;
86 this.sortState = data.activeSort;
87
88 /**
89 * On first render, now we have data, we can figure out which cell is the
90 * focusable cell for the table.
91 *
92 * If any columns are sortable, we pick [0, 0], which is the first cell of
93 * the columns row. However, if any columns are hidden, we adjust
94 * accordingly. e.g., if the first column is hidden, we'll set the starting
95 * index as [1, 0].
96 *
97 * If the columns aren't sortable, we pick the first visible body row as the
98 * index.
99 *
100 * We only do this on the first render; otherwise if we re-render and the
101 * user has focused a cell, this logic will reset it.
102 */
103 if (!this.hasRenderedAtLeastOnce) {
Jack Franklin16b1e212020-11-06 11:21:21104 this.focusableCell = calculateFirstFocusableCell({columns: this.columns, rows: this.rows});
Jack Franklinf3ebb142020-11-05 15:11:23105 }
106
107 if (this.hasRenderedAtLeastOnce) {
Jack Franklinf3ebb142020-11-05 15:11:23108 const [selectedColIndex, selectedRowIndex] = this.focusableCell;
109 const columnOutOfBounds = selectedColIndex > this.columns.length;
110 const rowOutOfBounds = selectedRowIndex > this.rows.length;
111
Jack Franklin16b1e212020-11-06 11:21:21112 /** If the row or column was removed, so the user is out of bounds, we
113 * move them to the last focusable cell, which should be close to where
114 * they were. */
Jack Franklinf3ebb142020-11-05 15:11:23115 if (columnOutOfBounds || rowOutOfBounds) {
116 this.focusableCell = [
117 columnOutOfBounds ? this.columns.length : selectedColIndex,
118 rowOutOfBounds ? this.rows.length : selectedRowIndex,
119 ];
Jack Franklin16b1e212020-11-06 11:21:21120 } else {
121 /** If the user was on some cell that is now hidden, the logic to figure out the best cell to move them to is complex. We're deferring this for now and instead reset them back to the first focusable cell. */
122 this.focusableCell = calculateFirstFocusableCell({columns: this.columns, rows: this.rows});
Jack Franklinf3ebb142020-11-05 15:11:23123 }
124 }
125
126 this.render();
127 }
128
129 private scrollToBottomIfRequired() {
130 if (this.hasRenderedAtLeastOnce === false) {
131 // On the first render we don't want to assume the user wants to scroll to the bottom;
132 return;
133 }
134
135 const focusableCell = this.getCurrentlyFocusableCell();
136 if (focusableCell && focusableCell === this.shadow.activeElement) {
137 // The user has a cell (and indirectly, a row) selected so we don't want
138 // to mess with their scroll
139 return;
140 }
141
142 // Scroll to the bottom, but pick the last visible row, not the last entry
143 // of this.rows, which might be hidden.
144 const lastVisibleRow = this.shadow.querySelector('tbody tr:not(.hidden):last-child');
145 if (lastVisibleRow) {
146 lastVisibleRow.scrollIntoView();
147 }
148 }
149
150 private getCurrentlyFocusableCell() {
151 const [columnIndex, rowIndex] = this.focusableCell;
152 const cell = this.shadow.querySelector<HTMLTableCellElement>(
153 `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`);
154 return cell;
155 }
156
157 private focusCell([newColumnIndex, newRowIndex]: CellPosition) {
158 const [currentColumnIndex, currentRowIndex] = this.focusableCell;
159 const newCellIsCurrentlyFocusedCell = (currentColumnIndex === newColumnIndex && currentRowIndex === newRowIndex);
160
161 if (!newCellIsCurrentlyFocusedCell) {
162 this.focusableCell = [newColumnIndex, newRowIndex];
Jack Franklinf3ebb142020-11-05 15:11:23163 }
164
165 const cellElement = this.getCurrentlyFocusableCell();
166 if (!cellElement) {
167 throw new Error('Unexpected error: could not find cell marked as focusable');
168 }
169 /* The cell may already be focused if the user clicked into it, but we also
170 * add arrow key support, so in the case where we're programatically moving the
171 * focus, ensure we actually focus the cell.
172 */
Jack Franklin94a329b2020-12-01 17:09:18173 cellElement.focus();
174 this.render();
Jack Franklinf3ebb142020-11-05 15:11:23175 }
176
177 private onTableKeyDown(event: KeyboardEvent) {
178 const key = event.key;
179
180 if (KEYS_TREATED_AS_CLICKS.has(key)) {
181 const focusedCell = this.getCurrentlyFocusableCell();
182 const [focusedColumnIndex, focusedRowIndex] = this.focusableCell;
183 const activeColumn = this.columns[focusedColumnIndex];
184 if (focusedCell && focusedRowIndex === 0 && activeColumn && activeColumn.sortable) {
185 this.onColumnHeaderClick(activeColumn, focusedColumnIndex);
186 }
187 }
188
189 if (!keyIsArrowKey(key)) {
190 return;
191 }
192
193 const nextFocusedCell = handleArrowKeyNavigation({
194 key: key,
195 currentFocusedCell: this.focusableCell,
196 columns: this.columns,
197 rows: this.rows,
198 });
199 this.focusCell(nextFocusedCell);
200 }
201
202 private onColumnHeaderClick(col: Column, index: number) {
203 this.dispatchEvent(new ColumnHeaderClickEvent(col, index));
204 }
205
206 /**
207 * Applies the aria-sort label to a column's th.
208 * Guidance on values of attribute taken from
209 * https://www.w3.org/TR/wai-aria-practices/examples/grid/dataGrids.html.
210 */
211 private ariaSortForHeader(col: Column) {
212 if (col.sortable && (!this.sortState || this.sortState.columnId !== col.id)) {
213 // Column is sortable but is not currently sorted
214 return 'none';
215 }
216
217 if (this.sortState && this.sortState.columnId === col.id) {
218 return this.sortState.direction === SortDirection.ASC ? 'ascending' : 'descending';
219 }
220
221 // Column is not sortable, so don't apply any label
222 return undefined;
223 }
224
Jack Franklincde3c1e2020-11-16 12:50:18225
226 private renderFillerRow() {
Jack Franklincfda4f92020-11-20 09:40:10227 const visibleColumns = this.columns.filter(col => col.visible);
Jack Franklinfe956992020-11-11 11:49:37228 const emptyCells = visibleColumns.map((col, colIndex) => {
229 const emptyCellClasses = LitHtml.Directives.classMap({
230 firstVisibleColumn: colIndex === 0,
231 });
Jack Franklincde3c1e2020-11-16 12:50:18232 return LitHtml.html`<td tabindex="-1" class=${emptyCellClasses}></td>`;
Jack Franklinfe956992020-11-11 11:49:37233 });
Jack Franklincde3c1e2020-11-16 12:50:18234 return LitHtml.html`<tr tabindex="-1" class="filler-row">${emptyCells}</tr>`;
Jack Franklinfe956992020-11-11 11:49:37235 }
236
Jack Franklinf3ebb142020-11-05 15:11:23237 private render() {
Jack Franklincfda4f92020-11-20 09:40:10238 const indexOfFirstVisibleColumn = this.columns.findIndex(col => col.visible);
Jack Franklinf3ebb142020-11-05 15:11:23239 const anyColumnsSortable = this.columns.some(col => col.sortable === true);
240 // Disabled until https://crbug.com/1079231 is fixed.
241 // clang-format off
242 LitHtml.render(LitHtml.html`
243 <style>
244 :host {
Jack Franklin94a329b2020-12-01 17:09:18245 --table-divider-color: var(--color-details-hairline);
246 --toolbar-bg-color: var(--color-background-elevation-2);
247 --selected-row-color: var(--color-background-elevation-1);
Jack Franklinf3ebb142020-11-05 15:11:23248
249 height: 100%;
250 display: block;
251 }
252 /* Ensure that vertically we don't overflow */
253 .wrapping-container {
254 overflow-y: scroll;
255 /* Use max-height instead of height to ensure that the
256 table does not use more space than necessary. */
257 height: 100%;
258 position: relative;
259 }
260
261 table {
262 border-spacing: 0;
263 width: 100%;
Jack Franklinfe956992020-11-11 11:49:37264 height: 100%;
Jack Franklinf3ebb142020-11-05 15:11:23265 /* To make sure that we properly hide overflowing text
266 when horizontal space is too narrow. */
267 table-layout: fixed;
268 }
269
270 tr {
271 outline: none;
272 }
273
274
Jack Franklin94a329b2020-12-01 17:09:18275 tbody tr {
276 background-color: var(--color-background);
Jack Franklinf3ebb142020-11-05 15:11:23277 }
278
Jack Franklin94a329b2020-12-01 17:09:18279 tbody tr.selected {
280 background-color: var(--selected-row-color);
Jack Franklinf3ebb142020-11-05 15:11:23281 }
282
283 td, th {
284 padding: 1px 4px;
285 /* Divider between each cell, except the first one (see below) */
286 border-left: 1px solid var(--table-divider-color);
Jack Franklin94a329b2020-12-01 17:09:18287 color: var(--color-text-primary);
Jack Franklinf3ebb142020-11-05 15:11:23288 line-height: 18px;
289 height: 18px;
290 user-select: text;
291 /* Ensure that text properly cuts off if horizontal space is too narrow */
292 white-space: nowrap;
293 text-overflow: ellipsis;
Jack Franklin77043d92020-11-09 15:14:19294 overflow: hidden;
Jack Franklinf3ebb142020-11-05 15:11:23295 }
296 /* There is no divider before the first cell */
297 td.firstVisibleColumn, th.firstVisibleColumn {
298 border-left: none;
299 }
300
301 th {
302 font-weight: normal;
303 text-align: left;
304 border-bottom: 1px solid var(--table-divider-color);
305 position: sticky;
306 top: 0;
307 z-index: 2;
308 background-color: var(--toolbar-bg-color);
309 }
310
311 .hidden {
312 display: none;
313 }
314
Jack Franklin7a44efd2020-11-18 15:41:42315 .filler-row {
316 /**
317 * The filler row is only there to stylistically fill grid lines down to the
318 * bottom, we don't want users to be able to focus into it (it's got tabIndex
319 * of -1) nor should they be able to click into it.
320 */
321 pointer-events: none;
322 }
323
Jack Franklincde3c1e2020-11-16 12:50:18324 .filler-row td {
325 /* By making the filler row cells 100% they take up any extra height,
326 * leaving the cells with content to be the regular height, and the
327 * final filler row to be as high as it needs to be to fill the empty
328 * space.
329 */
330 height: 100%;
331 }
332
Jack Franklinf3ebb142020-11-05 15:11:23333 [aria-sort]:hover {
334 cursor: pointer;
335 }
336
337 [aria-sort="descending"]::after {
338 content: " ";
339 border-left: 0.3em solid transparent;
340 border-right: 0.3em solid transparent;
341 border-top: 0.3em solid black;
342 position: absolute;
343 right: 0.5em;
344 top: 0.6em;
345 }
346 [aria-sort="ascending"]::after {
347 content: " ";
348 border-bottom: 0.3em solid black;
349 border-left: 0.3em solid transparent;
350 border-right: 0.3em solid transparent;
351 position: absolute;
352 right: 0.5em;
353 top: 0.6em;
354 }
355 </style>
356 <div class="wrapping-container">
357 <table
358 aria-rowcount=${this.rows.length}
359 aria-colcount=${this.columns.length}
360 @keydown=${this.onTableKeyDown}
361 >
362 <colgroup>
Jack Franklincfda4f92020-11-20 09:40:10363 ${this.columns.filter(col => col.visible).map(col => {
Jack Franklinf3ebb142020-11-05 15:11:23364 const width = calculateColumnWidthPercentageFromWeighting(this.columns, col.id);
365 const style = `width: ${width}%`;
366 return LitHtml.html`<col style=${style}>`;
367 })}
368 </colgroup>
369 <thead>
370 <tr>
371 ${this.columns.map((col, columnIndex) => {
372 const thClasses = LitHtml.Directives.classMap({
Jack Franklincfda4f92020-11-20 09:40:10373 hidden: !col.visible,
Jack Franklinf3ebb142020-11-05 15:11:23374 firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
375 });
376 const cellIsFocusableCell = anyColumnsSortable && columnIndex === this.focusableCell[0] && this.focusableCell[1] === 0;
377
378 return LitHtml.html`<th class=${thClasses}
379 data-grid-header-cell=${col.id}
380 @click=${() => {
381 this.focusCell([columnIndex, 0]);
382 this.onColumnHeaderClick(col, columnIndex);
383 }}
Jack Franklin77043d92020-11-09 15:14:19384 title=${col.title}
Jack Franklinf3ebb142020-11-05 15:11:23385 aria-sort=${LitHtml.Directives.ifDefined(this.ariaSortForHeader(col))}
386 aria-colindex=${columnIndex + 1}
387 data-row-index='0'
388 data-col-index=${columnIndex}
389 tabindex=${LitHtml.Directives.ifDefined(anyColumnsSortable ? (cellIsFocusableCell ? '0' : '-1') : undefined)}
390 >${col.title}</th>`;
391 })}
392 </tr>
393 </thead>
394 <tbody>
Jack Franklincde3c1e2020-11-16 12:50:18395 ${this.rows.map((row, rowIndex) => {
Jack Franklinf3ebb142020-11-05 15:11:23396 const focusableCell = this.getCurrentlyFocusableCell();
397 const [,focusableCellRowIndex] = this.focusableCell;
398
399 // Remember that row 0 is considered the header row, so the first tbody row is row 1.
400 const tableRowIndex = rowIndex + 1;
401
402 // Have to check for focusableCell existing as this runs on the
403 // first render before it's ever been created.
Jack Franklin94a329b2020-12-01 17:09:18404 const rowIsSelected = focusableCell ? focusableCell === this.shadow.activeElement && tableRowIndex === focusableCellRowIndex : false;
Jack Franklinf3ebb142020-11-05 15:11:23405
406 const rowClasses = LitHtml.Directives.classMap({
407 selected: rowIsSelected,
408 hidden: row.hidden === true,
409 });
410 return LitHtml.html`
411 <tr
412 aria-rowindex=${rowIndex + 1}
413 class=${rowClasses}
414 >${this.columns.map((col, columnIndex) => {
Jack Franklin4c630d22020-11-13 10:39:05415 const cell = getRowEntryForColumnId(row, col.id);
Jack Franklinf3ebb142020-11-05 15:11:23416 const cellClasses = LitHtml.Directives.classMap({
Jack Franklincfda4f92020-11-20 09:40:10417 hidden: !col.visible,
Jack Franklinf3ebb142020-11-05 15:11:23418 firstVisibleColumn: columnIndex === indexOfFirstVisibleColumn,
419 });
420 const cellIsFocusableCell = columnIndex === this.focusableCell[0] && tableRowIndex === this.focusableCell[1];
Jack Franklin4c630d22020-11-13 10:39:05421 const cellOutput = renderCellValue(cell);
Jack Franklinf3ebb142020-11-05 15:11:23422 return LitHtml.html`<td
423 class=${cellClasses}
Jack Franklincedccc02020-11-19 09:45:24424 title=${cell.title || String(cell.value)}
Jack Franklinf3ebb142020-11-05 15:11:23425 tabindex=${cellIsFocusableCell ? '0' : '-1'}
426 aria-colindex=${columnIndex + 1}
427 data-row-index=${tableRowIndex}
428 data-col-index=${columnIndex}
429 data-grid-value-cell-for-column=${col.id}
Jack Franklin4c630d22020-11-13 10:39:05430 @focus=${() => {
431 this.dispatchEvent(new BodyCellFocusedEvent(cell, row));
432 }}
Jack Franklinf3ebb142020-11-05 15:11:23433 @click=${() => {
434 this.focusCell([columnIndex, tableRowIndex]);
435 }}
Jack Franklin184f98a2020-11-11 09:54:33436 >${cellOutput}</td>`;
Jack Franklinf3ebb142020-11-05 15:11:23437 })}
438 `;
439 })}
Jack Franklincde3c1e2020-11-16 12:50:18440 ${this.renderFillerRow()}
Jack Franklinf3ebb142020-11-05 15:11:23441 </tbody>
442 </table>
443 </div>
444 `, this.shadow, {
445 eventContext: this,
446 });
447 // clang-format on
448
449 this.scrollToBottomIfRequired();
450 this.hasRenderedAtLeastOnce = true;
451 }
452}
453
454customElements.define('devtools-data-grid', DataGrid);
455
456declare global {
Tim van der Lippe75c2c9c2020-12-01 12:50:53457 // eslint-disable-next-line @typescript-eslint/no-unused-vars
Jack Franklinf3ebb142020-11-05 15:11:23458 interface HTMLElementTagNameMap {
459 'devtools-data-grid': DataGrid;
460 }
461}