Browse Source
* Initial draft of a table data source * Improve table data source * Migrate projects table for demo * Update existing tables * Fix access selector * remove sortDirection from custom fn * a11y improvements * update icons; make button full width * update storybook docs * apply code review changes * fix: add table body to projects list * Fix error on create secret. Fix project list setting projects on getter. Copy table data on set. Fix documentation * Change signature to protected, rename method to not start with underscore * add hover and focus effects Co-authored-by: William Martin <contact@willmartian.com>pull/4460/head
19 changed files with 557 additions and 33 deletions
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
import { Meta, Story, Source } from "@storybook/addon-docs"; |
||||
|
||||
<Meta title="Documentation/Table" /> |
||||
|
||||
# Table |
||||
|
||||
## Overview |
||||
|
||||
All tables should have a visible horizontal header and label for each column. |
||||
|
||||
<Story id="component-library-table--default" /> |
||||
|
||||
The below code is the absolute minimum required to create a table. However we stronly advice you to |
||||
use the `dataSource` input to provide a data source for your table. This allows you to easily sort |
||||
data. |
||||
|
||||
```html |
||||
<bit-table> |
||||
<ng-container header> |
||||
<tr> |
||||
<th bitCell>Header 1</th> |
||||
<th bitCell>Header 2</th> |
||||
<th bitCell>Header 3</th> |
||||
</tr> |
||||
</ng-container> |
||||
<ng-template body> |
||||
<tr bitRow> |
||||
<td bitCell>Cell 1</td> |
||||
<td bitCell>Cell 2</td> |
||||
<td bitCell>Cell 3</td> |
||||
</tr> |
||||
</ng-template> |
||||
</bit-table> |
||||
``` |
||||
|
||||
## Data Source |
||||
|
||||
Bitwarden provides a data source for tables that can be used in place of a traditional data array. |
||||
The `TableDataSource` implements sorting and will in the future also support filtering. This allows |
||||
the `bitTable` component to focus on rendering while offloading the data management to the data |
||||
source. |
||||
|
||||
```ts |
||||
// External data source |
||||
const data: T[]; |
||||
|
||||
const dataSource = new TableDataSource<T>(); |
||||
dataSource.data = data; |
||||
``` |
||||
|
||||
We use the `dataSource` as an input to the `bit-table` component, and access the rows to render |
||||
within the `ng-template`which provides access to the rows using `let-rows$`. |
||||
|
||||
<Source id="component-library-table--data-source" /> |
||||
|
||||
### Sortable |
||||
|
||||
We provide a simple component for displaying sortable column headers. The `bitSortable` component |
||||
wires up to the `TableDataSource` and will automatically sort the data when clicked and display |
||||
an indicator for which column is currently sorted. The dafault sorting can be specified by setting |
||||
the `default`. |
||||
|
||||
```html |
||||
<th bitCell bitSortable="id" default>Id</th> |
||||
<th bitCell bitSortable="name" default>Name</th> |
||||
``` |
||||
|
||||
It's also possible to define a custom sorting function by setting the `fn` input. |
||||
|
||||
```ts |
||||
const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1); |
||||
``` |
||||
|
||||
### Virtual Scrolling |
||||
|
||||
It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount |
||||
of data. This is easily done by wrapping the table in the `cdk-virtual-scroll-viewport` component, |
||||
specify a `itemSize`, set `scrollWindow` to `true` and replace `*ngFor` with `*cdkVirtualFor`. |
||||
|
||||
```html |
||||
<cdk-virtual-scroll-viewport scrollWindow itemSize="47"> |
||||
<bit-table [dataSource]="dataSource"> |
||||
<ng-container header> |
||||
<tr> |
||||
<th bitCell bitSortable="id" default>Id</th> |
||||
<th bitCell bitSortable="name">Name</th> |
||||
<th bitCell bitSortable="other" [fn]="sortFn">Other</th> |
||||
</tr> |
||||
</ng-container> |
||||
<ng-template let-rows$> |
||||
<tr bitRow *cdkVirtualFor="let r of rows$"> |
||||
<td bitCell>{{ r.id }}</td> |
||||
<td bitCell>{{ r.name }}</td> |
||||
<td bitCell>{{ r.other }}</td> |
||||
</tr> |
||||
</ng-template> |
||||
</bit-table> |
||||
</cdk-virtual-scroll-viewport> |
||||
``` |
||||
|
||||
## Accessibility |
||||
|
||||
- Always incude a row or column header with your table; this allows screen readers to better contextualize the data |
||||
- Avoid spanning data across cells |
||||
@ -1 +1,2 @@
@@ -1 +1,2 @@
|
||||
export * from "./table.module"; |
||||
export * from "./table-data-source"; |
||||
|
||||
@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion"; |
||||
import { Component, HostBinding, Input, OnInit } from "@angular/core"; |
||||
|
||||
import type { SortFn } from "./table-data-source"; |
||||
import { TableComponent } from "./table.component"; |
||||
|
||||
@Component({ |
||||
selector: "th[bitSortable]", |
||||
template: ` |
||||
<button |
||||
class="tw-group" |
||||
[ngClass]="classList" |
||||
[attr.aria-pressed]="isActive" |
||||
(click)="setActive()" |
||||
> |
||||
<ng-content></ng-content> |
||||
<i class="bwi tw-ml-2" [ngClass]="icon"></i> |
||||
</button> |
||||
`,
|
||||
}) |
||||
export class SortableComponent implements OnInit { |
||||
/** |
||||
* Mark the column as sortable and specify the key to sort by |
||||
*/ |
||||
@Input() bitSortable: string; |
||||
|
||||
private _default: boolean; |
||||
/** |
||||
* Mark the column as the default sort column |
||||
*/ |
||||
@Input() set default(value: boolean | "") { |
||||
this._default = coerceBooleanProperty(value); |
||||
} |
||||
|
||||
/** |
||||
* Custom sorting function |
||||
* |
||||
* @example |
||||
* fn = (a, b) => a.name.localeCompare(b.name) |
||||
*/ |
||||
@Input() fn: SortFn; |
||||
|
||||
constructor(private table: TableComponent) {} |
||||
|
||||
ngOnInit(): void { |
||||
if (this._default && !this.isActive) { |
||||
this.setActive(); |
||||
} |
||||
} |
||||
|
||||
@HostBinding("attr.aria-sort") get ariaSort() { |
||||
if (!this.isActive) { |
||||
return undefined; |
||||
} |
||||
return this.sort.direction === "asc" ? "ascending" : "descending"; |
||||
} |
||||
|
||||
protected setActive() { |
||||
if (this.table.dataSource) { |
||||
const direction = this.isActive && this.direction === "asc" ? "desc" : "asc"; |
||||
this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn }; |
||||
} |
||||
} |
||||
|
||||
private get sort() { |
||||
return this.table.dataSource?.sort; |
||||
} |
||||
|
||||
get isActive() { |
||||
return this.sort?.column === this.bitSortable; |
||||
} |
||||
|
||||
get direction() { |
||||
return this.sort?.direction; |
||||
} |
||||
|
||||
get icon() { |
||||
if (!this.isActive) { |
||||
return "bwi-chevron-up tw-opacity-0 group-hover:tw-opacity-100 group-focus-visible:tw-opacity-100"; |
||||
} |
||||
return this.direction === "asc" ? "bwi-chevron-up" : "bwi-angle-down"; |
||||
} |
||||
|
||||
get classList() { |
||||
return [ |
||||
// Offset to border and padding
|
||||
"-tw-m-1.5", |
||||
|
||||
// Below is copied from BitIconButtonComponent
|
||||
"tw-font-semibold", |
||||
"tw-border", |
||||
"tw-border-solid", |
||||
"tw-rounded", |
||||
"tw-transition", |
||||
"hover:tw-no-underline", |
||||
"focus:tw-outline-none", |
||||
|
||||
"tw-bg-transparent", |
||||
"!tw-text-muted", |
||||
"tw-border-transparent", |
||||
"hover:tw-bg-transparent-hover", |
||||
"hover:tw-border-primary-700", |
||||
"focus-visible:before:tw-ring-primary-700", |
||||
"disabled:tw-opacity-60", |
||||
"disabled:hover:tw-border-transparent", |
||||
"disabled:hover:tw-bg-transparent", |
||||
|
||||
// Workaround for box-shadow with transparent offset issue:
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/3595
|
||||
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
|
||||
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
|
||||
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
|
||||
"tw-relative", |
||||
"before:tw-content-['']", |
||||
"before:tw-block", |
||||
"before:tw-absolute", |
||||
"before:-tw-inset-[3px]", |
||||
"before:tw-rounded-md", |
||||
"before:tw-transition", |
||||
"before:tw-ring", |
||||
"before:tw-ring-transparent", |
||||
"focus-visible:tw-z-10", |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
|
||||
import { _isNumberValue } from "@angular/cdk/coercion"; |
||||
import { DataSource } from "@angular/cdk/collections"; |
||||
import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; |
||||
|
||||
export type SortDirection = "asc" | "desc"; |
||||
export type SortFn = (a: any, b: any) => number; |
||||
export type Sort = { |
||||
column?: string; |
||||
direction: SortDirection; |
||||
fn?: SortFn; |
||||
}; |
||||
|
||||
// Loosely based on CDK TableDataSource
|
||||
// https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
|
||||
export class TableDataSource<T> extends DataSource<T> { |
||||
private readonly _data: BehaviorSubject<T[]>; |
||||
private readonly _sort: BehaviorSubject<Sort>; |
||||
private readonly _renderData = new BehaviorSubject<T[]>([]); |
||||
|
||||
private _renderChangesSubscription: Subscription | null = null; |
||||
|
||||
constructor() { |
||||
super(); |
||||
this._data = new BehaviorSubject([]); |
||||
this._sort = new BehaviorSubject({ direction: "asc" }); |
||||
} |
||||
|
||||
get data() { |
||||
return this._data.value; |
||||
} |
||||
|
||||
set data(data: T[]) { |
||||
this._data.next(data ? [...data] : []); |
||||
} |
||||
|
||||
set sort(sort: Sort) { |
||||
this._sort.next(sort); |
||||
} |
||||
|
||||
get sort() { |
||||
return this._sort.value; |
||||
} |
||||
|
||||
connect(): Observable<readonly T[]> { |
||||
if (!this._renderChangesSubscription) { |
||||
this.updateChangeSubscription(); |
||||
} |
||||
|
||||
return this._renderData; |
||||
} |
||||
|
||||
disconnect(): void { |
||||
this._renderChangesSubscription?.unsubscribe(); |
||||
this._renderChangesSubscription = null; |
||||
} |
||||
|
||||
private updateChangeSubscription() { |
||||
const orderedData = combineLatest([this._data, this._sort]).pipe( |
||||
map(([data]) => this.orderData(data)) |
||||
); |
||||
|
||||
this._renderChangesSubscription?.unsubscribe(); |
||||
this._renderChangesSubscription = orderedData.subscribe((data) => this._renderData.next(data)); |
||||
} |
||||
|
||||
private orderData(data: T[]): T[] { |
||||
if (!this.sort) { |
||||
return data; |
||||
} |
||||
|
||||
return this.sortData(data, this.sort); |
||||
} |
||||
|
||||
/** |
||||
* Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
|
||||
* License: MIT |
||||
* Copyright (c) 2022 Google LLC. |
||||
* |
||||
* Data accessor function that is used for accessing data properties for sorting through |
||||
* the default sortData function. |
||||
* This default function assumes that the sort header IDs (which defaults to the column name) |
||||
* matches the data's properties (e.g. column Xyz represents data['Xyz']). |
||||
* May be set to a custom function for different behavior. |
||||
* @param data Data object that is being accessed. |
||||
* @param sortHeaderId The name of the column that represents the data. |
||||
*/ |
||||
protected sortingDataAccessor(data: T, sortHeaderId: string): string | number { |
||||
const value = (data as unknown as Record<string, any>)[sortHeaderId]; |
||||
|
||||
if (_isNumberValue(value)) { |
||||
const numberValue = Number(value); |
||||
|
||||
return numberValue < Number.MAX_SAFE_INTEGER ? numberValue : value; |
||||
} |
||||
|
||||
return value; |
||||
} |
||||
|
||||
/** |
||||
* Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
|
||||
* License: MIT |
||||
* Copyright (c) 2022 Google LLC. |
||||
* |
||||
* Gets a sorted copy of the data array based on the state of the MatSort. Called |
||||
* after changes are made to the filtered data or when sort changes are emitted from MatSort. |
||||
* By default, the function retrieves the active sort and its direction and compares data |
||||
* by retrieving data using the sortingDataAccessor. May be overridden for a custom implementation |
||||
* of data ordering. |
||||
* @param data The array of data that should be sorted. |
||||
* @param sort The connected MatSort that holds the current sort state. |
||||
*/ |
||||
protected sortData(data: T[], sort: Sort): T[] { |
||||
const column = sort.column; |
||||
const direction = sort.direction; |
||||
if (!column) { |
||||
return data; |
||||
} |
||||
|
||||
return data.sort((a, b) => { |
||||
// If a custom sort function is provided, use it instead of the default.
|
||||
if (sort.fn) { |
||||
return sort.fn(a, b) * (direction === "asc" ? 1 : -1); |
||||
} |
||||
|
||||
let valueA = this.sortingDataAccessor(a, column); |
||||
let valueB = this.sortingDataAccessor(b, column); |
||||
|
||||
// If there are data in the column that can be converted to a number,
|
||||
// it must be ensured that the rest of the data
|
||||
// is of the same type so as not to order incorrectly.
|
||||
const valueAType = typeof valueA; |
||||
const valueBType = typeof valueB; |
||||
|
||||
if (valueAType !== valueBType) { |
||||
if (valueAType === "number") { |
||||
valueA += ""; |
||||
} |
||||
if (valueBType === "number") { |
||||
valueB += ""; |
||||
} |
||||
} |
||||
|
||||
// If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if
|
||||
// one value exists while the other doesn't. In this case, existing value should come last.
|
||||
// This avoids inconsistent results when comparing values to undefined/null.
|
||||
// If neither value exists, return 0 (equal).
|
||||
let comparatorResult = 0; |
||||
if (valueA != null && valueB != null) { |
||||
// Check if one value is greater than the other; if equal, comparatorResult should remain 0.
|
||||
if (valueA > valueB) { |
||||
comparatorResult = 1; |
||||
} else if (valueA < valueB) { |
||||
comparatorResult = -1; |
||||
} |
||||
} else if (valueA != null) { |
||||
comparatorResult = 1; |
||||
} else if (valueB != null) { |
||||
comparatorResult = -1; |
||||
} |
||||
|
||||
return comparatorResult * (direction === "asc" ? 1 : -1); |
||||
}); |
||||
} |
||||
} |
||||
@ -1,7 +1,50 @@
@@ -1,7 +1,50 @@
|
||||
import { Component } from "@angular/core"; |
||||
import { isDataSource } from "@angular/cdk/collections"; |
||||
import { |
||||
AfterContentChecked, |
||||
Component, |
||||
ContentChild, |
||||
Directive, |
||||
Input, |
||||
OnDestroy, |
||||
TemplateRef, |
||||
} from "@angular/core"; |
||||
import { Observable } from "rxjs"; |
||||
|
||||
import { TableDataSource } from "./table-data-source"; |
||||
|
||||
@Directive({ |
||||
selector: "ng-template[body]", |
||||
}) |
||||
export class TableBodyDirective { |
||||
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||
constructor(public readonly template: TemplateRef<any>) {} |
||||
} |
||||
|
||||
@Component({ |
||||
selector: "bit-table", |
||||
templateUrl: "./table.component.html", |
||||
}) |
||||
export class TableComponent {} |
||||
export class TableComponent implements OnDestroy, AfterContentChecked { |
||||
@Input() dataSource: TableDataSource<any>; |
||||
|
||||
@ContentChild(TableBodyDirective) templateVariable: TableBodyDirective; |
||||
|
||||
protected rows: Observable<readonly any[]>; |
||||
|
||||
private _initialized = false; |
||||
|
||||
ngAfterContentChecked(): void { |
||||
if (!this._initialized && isDataSource(this.dataSource)) { |
||||
this._initialized = true; |
||||
|
||||
const dataStream = this.dataSource.connect(); |
||||
this.rows = dataStream; |
||||
} |
||||
} |
||||
|
||||
ngOnDestroy(): void { |
||||
if (isDataSource(this.dataSource)) { |
||||
this.dataSource.disconnect(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue