table component improvement (#13801)

* hyperlink column

* fixed width for image only button - old behavior
This commit is contained in:
Alan Ren
2020-12-14 20:28:43 -08:00
committed by GitHub
parent 1f630b9767
commit ae6494f3e4
25 changed files with 605 additions and 788 deletions

View File

@@ -38,7 +38,7 @@ export interface HyperlinkCellValue {
export interface CssIconCellValue {
iconCssClass: string,
ariaLabel: string
title: string
}
@@ -115,7 +115,7 @@ export function textFormatter(row: number | undefined, cell: any | undefined, va
export function iconCssFormatter(row: number | undefined, cell: any | undefined, value: any, columnDef: any | undefined, dataContext: any | undefined): string {
if (isCssIconCellValue(value)) {
return `<div role='image' aria-label="${escape(value.ariaLabel)}" class="grid-cell-value-container icon codicon slick-icon-cell-content ${value.iconCssClass}"></div>`;
return `<div role="image" title="${escape(value.title)}" aria-label="${escape(value.title)}" class="grid-cell-value-container icon codicon slick-icon-cell-content ${value.iconCssClass}"></div>`;
}
return textFormatter(row, cell, value, columnDef, dataContext);
}

View File

@@ -163,8 +163,7 @@
color: #FFFFFF;
}
.slick-icon-cell-content,
.slick-button-cell-content {
.slick-icon-cell-content {
background-position: 5px center !important;
background-repeat: no-repeat;
background-size: 16px !important;
@@ -175,9 +174,3 @@
display: flex;
align-items: center;
}
.slick-button-cell-content {
cursor: pointer;
border-width: 0px;
padding: 0px;
}

View File

@@ -3,133 +3,44 @@
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { TextWithIconColumnDefinition } from 'sql/base/browser/ui/table/plugins/textWithIconColumn';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { Emitter } from 'vs/base/common/event';
import { KeyCode } from 'vs/base/common/keyCodes';
import 'vs/css!./media/buttonColumn.plugin';
import 'vs/css!./media/iconColumn';
import { BaseClickableColumn, getIconCellValue, IconColumnOptions } from 'sql/base/browser/ui/table/plugins/tableColumn';
import { escape } from 'sql/base/common/strings';
export interface ButtonColumnDefinition<T extends Slick.SlickData> extends TextWithIconColumnDefinition<T> {
}
export interface ButtonColumnOptions {
/**
* The CSS class of the icon (either a common icon or one added by the user of this column) to display in the button
*/
export interface ButtonCellValue {
iconCssClass?: string;
/**
* The aria-label title of the button
*/
title?: string;
/**
* The unique ID used by SlickGrid
*/
id?: string;
/**
* Whether the column is sortable or not
*/
sortable?: boolean;
title: string;
}
export interface ButtonClickEventArgs<T extends Slick.SlickData> {
item: T;
position: { x: number, y: number };
row: number;
column: number;
export interface ButtonColumnOptions extends IconColumnOptions {
/**
* Whether to show the text.
*/
showText?: boolean
}
export class ButtonColumn<T extends Slick.SlickData> implements Slick.Plugin<T> {
private _handler = new Slick.EventHandler();
private _definition: ButtonColumnDefinition<T>;
private _grid!: Slick.Grid<T>;
private _onClick = new Emitter<ButtonClickEventArgs<T>>();
public onClick = this._onClick.event;
export class ButtonColumn<T extends Slick.SlickData> extends BaseClickableColumn<T> {
constructor(private options: ButtonColumnOptions) {
this._definition = {
id: options.id,
resizable: false,
name: '',
super();
}
public get definition(): Slick.Column<T> {
return {
id: this.options.id || this.options.title || this.options.field,
width: this.options.showText === true ? this.options.width : 26,
formatter: (row: number, cell: number, value: any, columnDef: Slick.Column<T>, dataContext: T): string => {
return this.formatter(row, cell, value, columnDef, dataContext);
const iconValue = getIconCellValue(this.options, dataContext);
const escapedTitle = escape(iconValue.title ?? '');
const iconCssClasses = iconValue.iconCssClass ? `codicon icon slick-plugin-icon ${iconValue.iconCssClass}` : '';
const buttonTypeCssClass = this.options.showText ? 'slick-plugin-button slick-plugin-text-button' : 'slick-plugin-button slick-plugin-image-only-button';
const buttonText = this.options.showText ? escapedTitle : '';
return `<button tabindex=-1 class="${iconCssClasses} ${buttonTypeCssClass}" title="${escapedTitle}" aria-label="${escapedTitle}">${buttonText}</button>`;
},
width: 30,
selectable: false,
iconCssClassField: options.iconCssClass,
sortable: options.sortable
name: this.options.name,
resizable: this.options.showText === true, // Image only button has fixed width.
selectable: false
};
}
public init(grid: Slick.Grid<T>): void {
this._grid = grid;
this._handler.subscribe(grid.onClick, (e: DOMEvent, args: Slick.OnClickEventArgs<T>) => this.handleClick(args));
this._handler.subscribe(grid.onKeyDown, (e: DOMEvent, args: Slick.OnKeyDownEventArgs<T>) => this.handleKeyboardEvent(e as KeyboardEvent, args));
this._handler.subscribe(grid.onActiveCellChanged, (e: DOMEvent, args: Slick.OnActiveCellChangedEventArgs<T>) => { this.handleActiveCellChanged(args); });
}
public destroy(): void {
this._handler.unsubscribeAll();
}
private handleActiveCellChanged(args: Slick.OnActiveCellChangedEventArgs<T>): void {
if (this.isCurrentColumn(args.cell)) {
const cellElement = this._grid.getActiveCellNode();
if (cellElement && cellElement.children) {
const button = cellElement.children[0] as HTMLButtonElement;
button.focus();
}
}
}
private handleClick(args: Slick.OnClickEventArgs<T>): void {
if (this.isCurrentColumn(args.cell)) {
// SlickGrid will automatically set active cell on mouse click event,
// during the process of setting active cell, blur event will be triggered and handled in a setTimeout block,
// on Windows platform, the context menu is html based which will respond the focus related events and hide the context menu.
// If we call the fireClickEvent directly the menu will be set to hidden immediately, to workaround the issue we need to wrap it in a setTimeout block.
setTimeout(() => {
this.fireClickEvent();
}, 0);
}
}
private handleKeyboardEvent(e: KeyboardEvent, args: Slick.OnKeyDownEventArgs<T>): void {
let event = new StandardKeyboardEvent(e);
if ((event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) && this.isCurrentColumn(args.cell)) {
event.stopPropagation();
event.preventDefault();
this.fireClickEvent();
}
}
public get definition(): ButtonColumnDefinition<T> {
return this._definition;
}
private fireClickEvent(): void {
const activeCell = this._grid.getActiveCell();
const activeCellPosition = this._grid.getActiveCellPosition();
if (activeCell && activeCellPosition) {
this._onClick.fire({
row: activeCell.row,
column: activeCell.cell,
item: this._grid.getDataItem(activeCell.row),
position: {
x: (activeCellPosition.left + activeCellPosition.right) / 2,
y: (activeCellPosition.bottom + activeCellPosition.top) / 2
}
});
}
}
private isCurrentColumn(columnIndex: number): boolean {
return this._grid?.getColumns()[columnIndex]?.id === this.definition.id;
}
private formatter(row: number, cell: number, value: any, columnDef: Slick.Column<T>, dataContext: T): string {
const buttonColumn = columnDef as ButtonColumnDefinition<T>;
// tabindex=-1 means it is only focusable programatically, when the button column cell becomes active, we will set to focus to the button inside it, the tab navigation experience is smooth.
// Otherwise, if we set tabindex to 0, the focus will go to the button first and then the first cell of the table.
return `<button tabindex=-1 class="codicon icon slick-button-cell-content ${buttonColumn.iconCssClassField}" aria-label="${this.options.title}"></button>`;
}
}

View File

@@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./media/hyperlinkColumn.plugin';
import 'vs/css!./media/iconColumn';
import { BaseClickableColumn, getIconCellValue, IconColumnOptions } from 'sql/base/browser/ui/table/plugins/tableColumn';
import { escape } from 'sql/base/common/strings';
export interface HyperlinkCellValue {
iconCssClass?: string;
title: string;
url?: string;
}
export interface HyperlinkColumnOptions extends IconColumnOptions {
}
export class HyperlinkColumn<T extends Slick.SlickData> extends BaseClickableColumn<T> {
constructor(private options: HyperlinkColumnOptions) {
super();
}
public get definition(): Slick.Column<T> {
return {
id: this.options.id || this.options.title || this.options.field,
width: this.options.width,
formatter: (row: number, cell: number, value: any, columnDef: Slick.Column<T>, dataContext: T): string => {
const iconValue = getIconCellValue(this.options, dataContext);
const escapedTitle = escape(iconValue.title ?? '');
const cellValue = dataContext[this.options.field] as HyperlinkCellValue;
const cssClasses = iconValue.iconCssClass ? `codicon icon slick-plugin-icon ${iconValue.iconCssClass}` : '';
const urlPart = cellValue?.url ? `href="${encodeURI(cellValue.url)}" target="blank"` : '';
return `<a ${urlPart} class="slick-hyperlink-cell ${cssClasses}" tabindex=-1 title="${escapedTitle}" aria-label="${escapedTitle}">${escapedTitle}</a>`;
},
name: this.options.name,
resizable: true,
selectable: false
};
}
}

View File

@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.slick-plugin-button {
cursor: pointer;
height: 100%;
text-align: center;
padding-right: 0px;
padding-top: 0px;
padding-bottom: 0px;
}
.slick-plugin-button.slick-plugin-text-button {
border-width: 1px;
width: 100%;
}
.slick-plugin-icon.slick-plugin-button.slick-plugin-text-button {
text-align: left;
padding-left: 0px;
width: 100%;
}
.slick-plugin-button.slick-plugin-image-only-button {
border-width: 0px;
background-color: transparent;
}

View File

@@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.slick-hyperlink-cell {
cursor: pointer;
display: block;
width: 100%;
height: 100%;
text-align: left;
}

View File

@@ -0,0 +1,11 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.slick-plugin-icon {
padding-left: 26px;
background-size: 16px !important;
background-position: 5px 50% !important;
background-repeat: no-repeat;
}

View File

@@ -0,0 +1,173 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { Emitter } from 'vs/base/common/event';
import { KeyCode } from 'vs/base/common/keyCodes';
export interface TableColumn<T extends Slick.SlickData> {
readonly definition: Slick.Column<T>;
}
export interface TableCellClickEventArgs<T extends Slick.SlickData> {
item: T;
position: { x: number, y: number };
row: number;
column: number;
}
export abstract class BaseClickableColumn<T extends Slick.SlickData> implements Slick.Plugin<T>, TableColumn<T> {
private _handler = new Slick.EventHandler();
private _grid!: Slick.Grid<T>;
private _onClick = new Emitter<TableCellClickEventArgs<T>>();
public onClick = this._onClick.event;
constructor() {
}
public init(grid: Slick.Grid<T>): void {
this._grid = grid;
this._handler.subscribe(grid.onClick, (e: DOMEvent, args: Slick.OnClickEventArgs<T>) => this.handleClick(args));
this._handler.subscribe(grid.onKeyDown, (e: DOMEvent, args: Slick.OnKeyDownEventArgs<T>) => this.handleKeyboardEvent(e as KeyboardEvent, args));
this._handler.subscribe(grid.onActiveCellChanged, (e: DOMEvent, args: Slick.OnActiveCellChangedEventArgs<T>) => { this.handleActiveCellChanged(args); });
}
public destroy(): void {
this._handler.unsubscribeAll();
}
/**
* Returns the column definition.
* Note when implementing this abstract getter:
* Make sure to set the tabindex to -1 for the element returned by the formatter. tabindex=-1 means it is only focusable programatically, when the cell becomes active, we will set to focus to the element inside it, the tab navigation experience is smooth.
* Otherwise, if we set tabindex to 0, the focus will go to the element first and then the first cell of the table.
*/
public abstract get definition(): Slick.Column<T>;
private handleActiveCellChanged(args: Slick.OnActiveCellChangedEventArgs<T>): void {
if (this.isCurrentColumn(args.cell)) {
const cellElement = this._grid.getActiveCellNode();
if (cellElement && cellElement.children) {
const element = cellElement.children[0] as HTMLElement;
element.focus();
}
}
}
private handleClick(args: Slick.OnClickEventArgs<T>): void {
if (this.isCurrentColumn(args.cell)) {
// SlickGrid will automatically set active cell on mouse click event,
// during the process of setting active cell, blur event will be triggered and handled in a setTimeout block,
// on Windows platform, the context menu is html based which will respond the focus related events and hide the context menu.
// If we call the fireClickEvent directly the menu will be set to hidden immediately, to workaround the issue we need to wrap it in a setTimeout block.
setTimeout(() => {
this.fireClickEvent();
}, 0);
}
}
private handleKeyboardEvent(e: KeyboardEvent, args: Slick.OnKeyDownEventArgs<T>): void {
let event = new StandardKeyboardEvent(e);
if ((event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) && this.isCurrentColumn(args.cell)) {
event.stopPropagation();
event.preventDefault();
this.fireClickEvent();
}
}
private fireClickEvent(): void {
const activeCell = this._grid.getActiveCell();
const activeCellPosition = this._grid.getActiveCellPosition();
if (activeCell && activeCellPosition) {
this._onClick.fire({
row: activeCell.row,
column: activeCell.cell,
item: this._grid.getDataItem(activeCell.row),
position: {
x: (activeCellPosition.left + activeCellPosition.right) / 2,
y: (activeCellPosition.bottom + activeCellPosition.top) / 2
}
});
}
}
private isCurrentColumn(columnIndex: number): boolean {
return this._grid.getColumns()[columnIndex]?.id === this.definition.id;
}
}
/**
* Definition for table column.
*/
export interface BaseTableColumnOptions {
/**
* Id of the column.
*/
id?: string,
/**
* Width of the column in px.
*/
width?: number,
/**
* Column header text.
*/
name?: string,
/**
* The property name in the data object to pull content from. (This is assumed to be on the root of the data object.)
*/
field?: string,
/**
* Whether the column is resizable. Default is true.
*/
resizable?: boolean,
/**
* The CSS class for the column header.
*/
headerCssClass?: string;
}
/**
* Definition for table column with icon
*/
export interface IconColumnOptions extends BaseTableColumnOptions {
/**
* The icon class to use for all the cells in this column. If the 'field' is provided, the cell values will overwrite this value.
*/
iconCssClass?: string;
/**
* The title for all the cells. If the 'field' is provided, the cell values will overwrite this value.
*/
title?: string
}
export interface IconCellValue {
/**
* The icon css class.
*/
iconCssClass: string;
/**
* The title of the cell.
*/
title: string
}
export function getIconCellValue(options: IconColumnOptions, dataContext: Slick.SlickData): IconCellValue {
if (options.field && dataContext[options.field]) {
const cellValue = dataContext[options.field];
if (typeof cellValue === 'string') {
return {
iconCssClass: '',
title: cellValue
};
} else {
return cellValue as IconCellValue;
}
} else {
return {
iconCssClass: options.iconCssClass!,
title: options.title!
};
}
}

View File

@@ -4,48 +4,28 @@
*--------------------------------------------------------------------------------------------*/
import { escape } from 'sql/base/common/strings';
import { getIconCellValue, IconColumnOptions, TableColumn } from 'sql/base/browser/ui/table/plugins/tableColumn';
/**
* Definition for column with icon on the left of text.
*/
export interface TextWithIconColumnDefinition<T extends Slick.SlickData> extends Slick.Column<T> {
iconCssClassField?: string;
export interface TextWithIconColumnOptions extends IconColumnOptions {
}
export interface TextWithIconColumnOptions<T extends Slick.SlickData> {
iconCssClassField?: string;
field?: string;
width?: number;
id?: string;
resizable?: boolean;
name?: string;
headerCssClass?: string;
formatter?: Slick.Formatter<T>
}
export class TextWithIconColumn<T extends Slick.SlickData> implements TableColumn<T> {
constructor(private options: TextWithIconColumnOptions) {
}
export class TextWithIconColumn<T extends Slick.SlickData> {
private _definition: TextWithIconColumnDefinition<T>;
constructor(options: TextWithIconColumnOptions<T>) {
this._definition = {
id: options.id,
field: options.field,
resizable: options.resizable,
formatter: options.formatter ?? this.formatter,
width: options.width,
name: options.name,
iconCssClassField: options.iconCssClassField,
public get definition(): Slick.Column<T> {
return {
id: this.options.id || this.options.field,
field: this.options.field,
resizable: this.options.resizable,
formatter: (row: number, cell: number, value: any, columnDef: Slick.Column<T>, dataContext: T): string => {
const iconValue = getIconCellValue(this.options, dataContext);
return `<div class="icon codicon slick-icon-cell-content ${iconValue.iconCssClass ?? ''}">${escape(iconValue.title ?? '')}</div>`;
},
width: this.options.width,
name: this.options.name,
cssClass: 'slick-icon-cell',
headerCssClass: options.headerCssClass
headerCssClass: this.options.headerCssClass
};
}
private formatter(row: number, cell: number, value: any, columnDef: Slick.Column<T>, dataContext: T): string {
const iconColumn = columnDef as TextWithIconColumnDefinition<T>;
return `<div class="icon codicon slick-icon-cell-content ${iconColumn.iconCssClassField ? dataContext[iconColumn.iconCssClassField] : ''}">${escape(value)}</div>`;
}
public get definition(): TextWithIconColumnDefinition<T> {
return this._definition;
}
}