-
+
diff --git a/libs/components/src/stories/button-docs.stories.mdx b/libs/components/src/stories/button-docs.stories.mdx
index 0c40e780775..cdcdb87f9db 100644
--- a/libs/components/src/stories/button-docs.stories.mdx
+++ b/libs/components/src/stories/button-docs.stories.mdx
@@ -1,4 +1,4 @@
-import { Meta, Story, Source } from "@storybook/addon-docs";
+import { Meta, Story } from "@storybook/addon-docs";
diff --git a/libs/components/src/stories/table-docs.stories.mdx b/libs/components/src/stories/table-docs.stories.mdx
new file mode 100644
index 00000000000..139574a18fe
--- /dev/null
+++ b/libs/components/src/stories/table-docs.stories.mdx
@@ -0,0 +1,104 @@
+import { Meta, Story, Source } from "@storybook/addon-docs";
+
+
+
+# Table
+
+## Overview
+
+All tables should have a visible horizontal header and label for each column.
+
+
+
+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
+
+
+
+
Header 1
+
Header 2
+
Header 3
+
+
+
+
+
Cell 1
+
Cell 2
+
Cell 3
+
+
+
+```
+
+## 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();
+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$`.
+
+
+
+### 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
+
Id
+
Name
+```
+
+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
+
+
+
+
+
Id
+
Name
+
Other
+
+
+
+
+
{{ r.id }}
+
{{ r.name }}
+
{{ r.other }}
+
+
+
+
+```
+
+## 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
diff --git a/libs/components/src/table/index.ts b/libs/components/src/table/index.ts
index 02fed986ac7..1981f1a6613 100644
--- a/libs/components/src/table/index.ts
+++ b/libs/components/src/table/index.ts
@@ -1 +1,2 @@
export * from "./table.module";
+export * from "./table-data-source";
diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts
new file mode 100644
index 00000000000..9f53f956da3
--- /dev/null
+++ b/libs/components/src/table/sortable.component.ts
@@ -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: `
+
+ `,
+})
+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",
+ ];
+ }
+}
diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts
new file mode 100644
index 00000000000..d5f4473a8f7
--- /dev/null
+++ b/libs/components/src/table/table-data-source.ts
@@ -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 extends DataSource {
+ private readonly _data: BehaviorSubject;
+ private readonly _sort: BehaviorSubject;
+ private readonly _renderData = new BehaviorSubject([]);
+
+ 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 {
+ 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)[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);
+ });
+ }
+}
diff --git a/libs/components/src/table/table.component.html b/libs/components/src/table/table.component.html
index 11108d8ec1a..57e3853f976 100644
--- a/libs/components/src/table/table.component.html
+++ b/libs/components/src/table/table.component.html
@@ -5,6 +5,8 @@
-
+
diff --git a/libs/components/src/table/table.component.ts b/libs/components/src/table/table.component.ts
index 8099c35b929..40a2da70927 100644
--- a/libs/components/src/table/table.component.ts
+++ b/libs/components/src/table/table.component.ts
@@ -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) {}
+}
@Component({
selector: "bit-table",
templateUrl: "./table.component.html",
})
-export class TableComponent {}
+export class TableComponent implements OnDestroy, AfterContentChecked {
+ @Input() dataSource: TableDataSource;
+
+ @ContentChild(TableBodyDirective) templateVariable: TableBodyDirective;
+
+ protected rows: Observable;
+
+ 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();
+ }
+ }
+}
diff --git a/libs/components/src/table/table.module.ts b/libs/components/src/table/table.module.ts
index 2ca88a349f0..753e4362e6f 100644
--- a/libs/components/src/table/table.module.ts
+++ b/libs/components/src/table/table.module.ts
@@ -3,11 +3,18 @@ import { NgModule } from "@angular/core";
import { CellDirective } from "./cell.directive";
import { RowDirective } from "./row.directive";
-import { TableComponent } from "./table.component";
+import { SortableComponent } from "./sortable.component";
+import { TableBodyDirective, TableComponent } from "./table.component";
@NgModule({
imports: [CommonModule],
- declarations: [TableComponent, CellDirective, RowDirective],
- exports: [TableComponent, CellDirective, RowDirective],
+ declarations: [
+ TableComponent,
+ CellDirective,
+ RowDirective,
+ SortableComponent,
+ TableBodyDirective,
+ ],
+ exports: [TableComponent, CellDirective, RowDirective, SortableComponent, TableBodyDirective],
})
export class TableModule {}
diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts
index 961636dd9cc..60e80117bef 100644
--- a/libs/components/src/table/table.stories.ts
+++ b/libs/components/src/table/table.stories.ts
@@ -1,12 +1,14 @@
+import { ScrollingModule } from "@angular/cdk/scrolling";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
+import { TableDataSource } from "./table-data-source";
import { TableModule } from "./table.module";
export default {
title: "Component Library/Table",
decorators: [
moduleMetadata({
- imports: [TableModule],
+ imports: [TableModule, ScrollingModule],
}),
],
argTypes: {
@@ -34,7 +36,7 @@ const Template: Story = (args) => ({