mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[SM-74] TableDataSource for sorting (#4079)
* 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>
This commit is contained in:
parent
23897ae5fb
commit
344a054ba2
@ -40,7 +40,7 @@
|
|||||||
<th bitCell style="width: 50px"></th>
|
<th bitCell style="width: 50px"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body formArrayName="items">
|
<ng-template body formArrayName="items">
|
||||||
<tr
|
<tr
|
||||||
bitRow
|
bitRow
|
||||||
*ngFor="let item of selectionList.selectedItems; let i = index"
|
*ngFor="let item of selectionList.selectedItems; let i = index"
|
||||||
@ -134,5 +134,5 @@
|
|||||||
<tr *ngIf="selectionList.selectedItems.length == 0">
|
<tr *ngIf="selectionList.selectedItems.length == 0">
|
||||||
<td bitCell>{{ emptySelectionText }}</td>
|
<td bitCell>{{ emptySelectionText }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
@ -75,7 +75,7 @@
|
|||||||
<th bitCell>{{ "event" | i18n }}</th>
|
<th bitCell>{{ "event" | i18n }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr bitRow *ngFor="let e of events" alignContent="top">
|
<tr bitRow *ngFor="let e of events" alignContent="top">
|
||||||
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date: "medium" }}</td>
|
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date: "medium" }}</td>
|
||||||
<td bitCell>
|
<td bitCell>
|
||||||
@ -86,7 +86,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td bitCell [innerHTML]="e.message"></td>
|
<td bitCell [innerHTML]="e.message"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
<button
|
<button
|
||||||
#moreBtn
|
#moreBtn
|
||||||
|
@ -16,12 +16,12 @@
|
|||||||
<th bitCell>{{ "error" | i18n }}</th>
|
<th bitCell>{{ "error" | i18n }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr bitRow *ngFor="let detail of data.details">
|
<tr bitRow *ngFor="let detail of data.details">
|
||||||
<td bitCell>{{ detail.name }}</td>
|
<td bitCell>{{ detail.name }}</td>
|
||||||
<td bitCell>{{ detail.errorMessage }}</td>
|
<td bitCell>{{ detail.errorMessage }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</sm-no-items>
|
</sm-no-items>
|
||||||
|
|
||||||
<bit-table *ngIf="projects?.length >= 1">
|
<bit-table *ngIf="projects?.length >= 1" [dataSource]="dataSource">
|
||||||
<ng-container header>
|
<ng-container header>
|
||||||
<tr>
|
<tr>
|
||||||
<th bitCell class="tw-w-0">
|
<th bitCell class="tw-w-0">
|
||||||
@ -25,8 +25,8 @@
|
|||||||
{{ "all" | i18n }}
|
{{ "all" | i18n }}
|
||||||
</label>
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<th bitCell colspan="2">{{ "name" | i18n }}</th>
|
<th bitCell colspan="2" bitSortable="name" default>{{ "name" | i18n }}</th>
|
||||||
<th bitCell>{{ "lastEdited" | i18n }}</th>
|
<th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
|
||||||
<th bitCell class="tw-w-0">
|
<th bitCell class="tw-w-0">
|
||||||
<button
|
<button
|
||||||
bitIconButton="bwi-ellipsis-v"
|
bitIconButton="bwi-ellipsis-v"
|
||||||
@ -38,8 +38,8 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body let-rows$>
|
||||||
<tr bitRow *ngFor="let project of projects; index as i">
|
<tr bitRow *ngFor="let project of rows$ | async">
|
||||||
<td bitCell>
|
<td bitCell>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -78,7 +78,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
|
||||||
<bit-menu #tableMenu>
|
<bit-menu #tableMenu>
|
||||||
|
@ -2,6 +2,8 @@ import { SelectionModel } from "@angular/cdk/collections";
|
|||||||
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
|
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
|
||||||
import { Subject, takeUntil } from "rxjs";
|
import { Subject, takeUntil } from "rxjs";
|
||||||
|
|
||||||
|
import { TableDataSource } from "@bitwarden/components";
|
||||||
|
|
||||||
import { ProjectListView } from "../../models/view/project-list.view";
|
import { ProjectListView } from "../../models/view/project-list.view";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -9,6 +11,8 @@ import { ProjectListView } from "../../models/view/project-list.view";
|
|||||||
templateUrl: "./projects-list.component.html",
|
templateUrl: "./projects-list.component.html",
|
||||||
})
|
})
|
||||||
export class ProjectsListComponent implements OnDestroy {
|
export class ProjectsListComponent implements OnDestroy {
|
||||||
|
protected dataSource = new TableDataSource<ProjectListView>();
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get projects(): ProjectListView[] {
|
get projects(): ProjectListView[] {
|
||||||
return this._projects;
|
return this._projects;
|
||||||
@ -16,6 +20,7 @@ export class ProjectsListComponent implements OnDestroy {
|
|||||||
set projects(projects: ProjectListView[]) {
|
set projects(projects: ProjectListView[]) {
|
||||||
this.selection.clear();
|
this.selection.clear();
|
||||||
this._projects = projects;
|
this._projects = projects;
|
||||||
|
this.dataSource.data = projects;
|
||||||
}
|
}
|
||||||
private _projects: ProjectListView[];
|
private _projects: ProjectListView[];
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
<th bitCell></th>
|
<th bitCell></th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body *ngIf="selectedProjects != null">
|
<ng-template body *ngIf="selectedProjects != null">
|
||||||
<tr bitRow *ngFor="let e of selectedProjects">
|
<tr bitRow *ngFor="let e of selectedProjects">
|
||||||
<td bitCell>{{ e.name }}</td>
|
<td bitCell>{{ e.name }}</td>
|
||||||
<td bitCell class="tw-w-0">
|
<td bitCell class="tw-w-0">
|
||||||
@ -57,7 +57,7 @@
|
|||||||
></button>
|
></button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
</bit-tab>
|
</bit-tab>
|
||||||
</bit-tab-group>
|
</bit-tab-group>
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr bitRow *ngFor="let token of tokens">
|
<tr bitRow *ngFor="let token of tokens">
|
||||||
<td bitCell>
|
<td bitCell>
|
||||||
<input
|
<input
|
||||||
@ -73,5 +73,5 @@
|
|||||||
</button>
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
@ -29,13 +29,13 @@
|
|||||||
<th bitCell>{{ "permissions" | i18n }}</th>
|
<th bitCell>{{ "permissions" | i18n }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr>
|
<tr>
|
||||||
<!-- TODO once access is implement display selected access -->
|
<!-- TODO once access is implement display selected access -->
|
||||||
<td bitCell>example</td>
|
<td bitCell>example</td>
|
||||||
<td bitCell>example</td>
|
<td bitCell>example</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
</div>
|
</div>
|
||||||
<div bitDialogFooter class="tw-flex tw-gap-2">
|
<div bitDialogFooter class="tw-flex tw-gap-2">
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr bitRow *ngFor="let serviceAccount of serviceAccounts">
|
<tr bitRow *ngFor="let serviceAccount of serviceAccounts">
|
||||||
<td bitCell>
|
<td bitCell>
|
||||||
<input
|
<input
|
||||||
@ -83,7 +83,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
|
||||||
<bit-menu #tableMenu>
|
<bit-menu #tableMenu>
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr bitRow *ngFor="let secret of secrets">
|
<tr bitRow *ngFor="let secret of secrets">
|
||||||
<td bitCell>
|
<td bitCell>
|
||||||
<input
|
<input
|
||||||
@ -96,7 +96,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
|
||||||
<bit-menu #tableMenu>
|
<bit-menu #tableMenu>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Meta, Story, Source } from "@storybook/addon-docs";
|
import { Meta, Story } from "@storybook/addon-docs";
|
||||||
|
|
||||||
<Meta title="Documentation/Button" />
|
<Meta title="Documentation/Button" />
|
||||||
|
|
||||||
|
104
libs/components/src/stories/table-docs.stories.mdx
Normal file
104
libs/components/src/stories/table-docs.stories.mdx
Normal file
@ -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 @@
|
|||||||
export * from "./table.module";
|
export * from "./table.module";
|
||||||
|
export * from "./table-data-source";
|
||||||
|
125
libs/components/src/table/sortable.component.ts
Normal file
125
libs/components/src/table/sortable.component.ts
Normal file
@ -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",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
164
libs/components/src/table/table-data-source.ts
Normal file
164
libs/components/src/table/table-data-source.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,8 @@
|
|||||||
<ng-content select="[header]"></ng-content>
|
<ng-content select="[header]"></ng-content>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ng-content select="[body]"></ng-content>
|
<ng-container
|
||||||
|
*ngTemplateOutlet="templateVariable.template; context: { $implicit: rows }"
|
||||||
|
></ng-container>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -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({
|
@Component({
|
||||||
selector: "bit-table",
|
selector: "bit-table",
|
||||||
templateUrl: "./table.component.html",
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,11 +3,18 @@ import { NgModule } from "@angular/core";
|
|||||||
|
|
||||||
import { CellDirective } from "./cell.directive";
|
import { CellDirective } from "./cell.directive";
|
||||||
import { RowDirective } from "./row.directive";
|
import { RowDirective } from "./row.directive";
|
||||||
import { TableComponent } from "./table.component";
|
import { SortableComponent } from "./sortable.component";
|
||||||
|
import { TableBodyDirective, TableComponent } from "./table.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
declarations: [TableComponent, CellDirective, RowDirective],
|
declarations: [
|
||||||
exports: [TableComponent, CellDirective, RowDirective],
|
TableComponent,
|
||||||
|
CellDirective,
|
||||||
|
RowDirective,
|
||||||
|
SortableComponent,
|
||||||
|
TableBodyDirective,
|
||||||
|
],
|
||||||
|
exports: [TableComponent, CellDirective, RowDirective, SortableComponent, TableBodyDirective],
|
||||||
})
|
})
|
||||||
export class TableModule {}
|
export class TableModule {}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
|
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||||
|
|
||||||
|
import { TableDataSource } from "./table-data-source";
|
||||||
import { TableModule } from "./table.module";
|
import { TableModule } from "./table.module";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Component Library/Table",
|
title: "Component Library/Table",
|
||||||
decorators: [
|
decorators: [
|
||||||
moduleMetadata({
|
moduleMetadata({
|
||||||
imports: [TableModule],
|
imports: [TableModule, ScrollingModule],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
@ -34,7 +36,7 @@ const Template: Story = (args) => ({
|
|||||||
<th bitCell>Header 3</th>
|
<th bitCell>Header 3</th>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container body>
|
<ng-template body>
|
||||||
<tr bitRow [alignContent]="alignRowContent">
|
<tr bitRow [alignContent]="alignRowContent">
|
||||||
<td bitCell>Cell 1</td>
|
<td bitCell>Cell 1</td>
|
||||||
<td bitCell>Cell 2 <br> Multiline Cell</td>
|
<td bitCell>Cell 2 <br> Multiline Cell</td>
|
||||||
@ -50,9 +52,8 @@ const Template: Story = (args) => ({
|
|||||||
<td bitCell>Cell 8</td>
|
<td bitCell>Cell 8</td>
|
||||||
<td bitCell>Cell 9</td>
|
<td bitCell>Cell 9</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
</ng-template>
|
||||||
</bit-table>
|
</bit-table>
|
||||||
|
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,3 +61,75 @@ export const Default = Template.bind({});
|
|||||||
Default.args = {
|
Default.args = {
|
||||||
alignRowContent: "baseline",
|
alignRowContent: "baseline",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const data = new TableDataSource<{ id: number; name: string; other: string }>();
|
||||||
|
|
||||||
|
data.data = [...Array(5).keys()].map((i) => ({
|
||||||
|
id: i,
|
||||||
|
name: `name-${i}`,
|
||||||
|
other: `other-${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DataSourceTemplate: Story = (args) => ({
|
||||||
|
props: {
|
||||||
|
dataSource: data,
|
||||||
|
sortFn: (a: any, b: any) => a.id - b.id,
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<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 body let-rows$>
|
||||||
|
<tr bitRow *ngFor="let r of rows$ | async">
|
||||||
|
<td bitCell>{{ r.id }}</td>
|
||||||
|
<td bitCell>{{ r.name }}</td>
|
||||||
|
<td bitCell>{{ r.other }}</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</bit-table>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DataSource = DataSourceTemplate.bind({});
|
||||||
|
|
||||||
|
const data2 = new TableDataSource<{ id: number; name: string; other: string }>();
|
||||||
|
|
||||||
|
data2.data = [...Array(100).keys()].map((i) => ({
|
||||||
|
id: i,
|
||||||
|
name: `name-${i}`,
|
||||||
|
other: `other-${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ScrollableTemplate: Story = (args) => ({
|
||||||
|
props: {
|
||||||
|
dataSource: data2,
|
||||||
|
sortFn: (a: any, b: any) => a.id - b.id,
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<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 body 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>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Scrollable = ScrollableTemplate.bind({});
|
||||||
|
Loading…
Reference in New Issue
Block a user