mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 10:13:31 +00:00
[EC 456] Component Library Content Switching Tabs (#3452)
* [EC-456] Rename bitTabItem -> bitTab * [EC-456] Use templateRefs or text for tab label content * [EC-456] Add bit-tab-nav-bar component * [EC-456] Finish content tab switching and nav tabs * [EC-456] Undo accidental eslintrc.json change * [EC-456] Fix directive/component selector naming convention * [EC-456] Cleanup unnecessary InjectionTokens and simplify template label property * [EC-456] Cleanup one more unnecessary InjectionToken * [EC-456] Cleanup tab styles to better match Figma. Add internal tab header component for styling header background according to Figma. * [EC-456] Add sub-folders for nav, content, and shared tab components/directives * [EC-456] Code/style cleanup * [EC-456] Remove underscore from protected members * [EC-456] Cleanup tab stories and forgotten any type. * [EC-456] Fix dark theme story tab content text color * [EC-456] Add missing padding to tab header * [EC-456] Add tab content padding to align with tab headers * [EC-456] Move bottom tab border to header to span entire content area * [EC-456] Force text-main tab label color * [EC-456] Undo text-main change
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<ng-template [cdkPortalOutlet]="tabContent"></ng-template>
|
||||
45
libs/components/src/tabs/tab-group/tab-body.component.ts
Normal file
45
libs/components/src/tabs/tab-group/tab-body.component.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-body",
|
||||
templateUrl: "tab-body.component.html",
|
||||
})
|
||||
export class TabBodyComponent {
|
||||
private _firstRender: boolean;
|
||||
|
||||
@Input() content: TemplatePortal;
|
||||
@Input() preserveContent = false;
|
||||
|
||||
@HostBinding("attr.hidden") get hidden() {
|
||||
return !this.active || null;
|
||||
}
|
||||
|
||||
@Input()
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
set active(value: boolean) {
|
||||
this._active = value;
|
||||
if (this._active) {
|
||||
this._firstRender = true;
|
||||
}
|
||||
}
|
||||
private _active: boolean;
|
||||
|
||||
/**
|
||||
* The tab content to render.
|
||||
* Inactive tabs that have never been rendered/active do not have their
|
||||
* content rendered by default for performance. If `preserveContent` is `true`
|
||||
* then the content persists after the first time content is rendered.
|
||||
*/
|
||||
get tabContent() {
|
||||
if (this.active) {
|
||||
return this.content;
|
||||
}
|
||||
if (this.preserveContent && this._firstRender) {
|
||||
return this.content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
libs/components/src/tabs/tab-group/tab-group.component.html
Normal file
44
libs/components/src/tabs/tab-group/tab-group.component.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<bit-tab-header>
|
||||
<div
|
||||
bitTabListContainer
|
||||
role="tablist"
|
||||
[attr.aria-label]="label"
|
||||
(keydown)="keyManager.onKeydown($event)"
|
||||
>
|
||||
<button
|
||||
bitTabListItem
|
||||
*ngFor="let tab of tabs; let i = index"
|
||||
type="button"
|
||||
role="tab"
|
||||
[id]="getTabLabelId(i)"
|
||||
[active]="tab.isActive"
|
||||
[disabled]="tab.disabled"
|
||||
[attr.aria-selected]="selectedIndex === i"
|
||||
[attr.tabindex]="selectedIndex === i ? 0 : -1"
|
||||
(click)="selectTab(i)"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
||||
|
||||
<ng-template #content>
|
||||
<ng-template [ngIf]="tab.templateLabel" [ngIfElse]="tabTextLabel">
|
||||
<ng-container [ngTemplateOutlet]="tab.templateLabel.templateRef"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #tabTextLabel>{{ tab.textLabel }}</ng-template>
|
||||
</ng-template>
|
||||
</button>
|
||||
</div>
|
||||
</bit-tab-header>
|
||||
<div class="tw-px-4 tw-pt-5">
|
||||
<bit-tab-body
|
||||
role="tabpanel"
|
||||
*ngFor="let tab of tabs; let i = index"
|
||||
[id]="getTabContentId(i)"
|
||||
[attr.tabindex]="selectedIndex === i ? 0 : -1"
|
||||
[attr.labeledby]="getTabLabelId(i)"
|
||||
[active]="tab.isActive"
|
||||
[content]="tab.content"
|
||||
[preserveContent]="preserveContent"
|
||||
>
|
||||
</bit-tab-body>
|
||||
</div>
|
||||
187
libs/components/src/tabs/tab-group/tab-group.component.ts
Normal file
187
libs/components/src/tabs/tab-group/tab-group.component.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { FocusKeyManager } from "@angular/cdk/a11y";
|
||||
import { coerceNumberProperty } from "@angular/cdk/coercion";
|
||||
import {
|
||||
AfterContentChecked,
|
||||
AfterContentInit,
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ContentChildren,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from "@angular/core";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { TabListItemDirective } from "../shared/tab-list-item.directive";
|
||||
|
||||
import { TabComponent } from "./tab.component";
|
||||
|
||||
/** Used to generate unique ID's for each tab component */
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab-group",
|
||||
templateUrl: "./tab-group.component.html",
|
||||
})
|
||||
export class TabGroupComponent
|
||||
implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy
|
||||
{
|
||||
private readonly _groupId: number;
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private _indexToSelect: number | null = 0;
|
||||
|
||||
/**
|
||||
* Aria label for the tab list menu
|
||||
*/
|
||||
@Input() label = "";
|
||||
|
||||
/**
|
||||
* Keep the content of off-screen tabs in the DOM.
|
||||
* Useful for keeping <audio> or <video> elements from re-initializing
|
||||
* after navigating between tabs.
|
||||
*/
|
||||
@Input() preserveContent = false;
|
||||
|
||||
@ContentChildren(TabComponent) tabs: QueryList<TabComponent>;
|
||||
@ViewChildren(TabListItemDirective) tabLabels: QueryList<TabListItemDirective>;
|
||||
|
||||
/** The index of the active tab. */
|
||||
@Input()
|
||||
get selectedIndex(): number | null {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
set selectedIndex(value: number) {
|
||||
this._indexToSelect = coerceNumberProperty(value, null);
|
||||
}
|
||||
private _selectedIndex: number | null = null;
|
||||
|
||||
/** Output to enable support for two-way binding on `[(selectedIndex)]` */
|
||||
@Output() readonly selectedIndexChange: EventEmitter<number> = new EventEmitter<number>();
|
||||
|
||||
/** Event emitted when the tab selection has changed. */
|
||||
@Output() readonly selectedTabChange: EventEmitter<BitTabChangeEvent> =
|
||||
new EventEmitter<BitTabChangeEvent>();
|
||||
|
||||
/**
|
||||
* Focus key manager for keeping tab controls accessible.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role#keyboard_interactions
|
||||
*/
|
||||
keyManager: FocusKeyManager<TabListItemDirective>;
|
||||
|
||||
constructor() {
|
||||
this._groupId = nextId++;
|
||||
}
|
||||
|
||||
protected getTabContentId(id: number): string {
|
||||
return `bit-tab-content-${this._groupId}-${id}`;
|
||||
}
|
||||
|
||||
protected getTabLabelId(id: number): string {
|
||||
return `bit-tab-label-${this._groupId}-${id}`;
|
||||
}
|
||||
|
||||
selectTab(index: number) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* After content is checked, the tab group knows what tabs are defined and which index
|
||||
* should be currently selected.
|
||||
*/
|
||||
ngAfterContentChecked(): void {
|
||||
const indexToSelect = (this._indexToSelect = this._clampTabIndex(this._indexToSelect));
|
||||
|
||||
if (this._selectedIndex != indexToSelect) {
|
||||
const isFirstRun = this._selectedIndex == null;
|
||||
|
||||
if (!isFirstRun) {
|
||||
this.selectedTabChange.emit({
|
||||
index: indexToSelect,
|
||||
tab: this.tabs.toArray()[indexToSelect],
|
||||
});
|
||||
}
|
||||
|
||||
// These values need to be updated after change detection as
|
||||
// the checked content may have references to them.
|
||||
Promise.resolve().then(() => {
|
||||
this.tabs.forEach((tab, index) => (tab.isActive = index === indexToSelect));
|
||||
|
||||
if (!isFirstRun) {
|
||||
this.selectedIndexChange.emit(indexToSelect);
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update the _selectedIndex and keyManager active item
|
||||
this._selectedIndex = indexToSelect;
|
||||
if (this.keyManager) {
|
||||
this.keyManager.setActiveItem(indexToSelect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.keyManager = new FocusKeyManager(this.tabLabels)
|
||||
.withHorizontalOrientation("ltr")
|
||||
.withWrap()
|
||||
.withHomeAndEnd();
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
// Subscribe to any changes in the number of tabs, in order to be able
|
||||
// to re-render content when new tabs are added or removed.
|
||||
this.tabs.changes.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||
const indexToSelect = this._clampTabIndex(this._indexToSelect);
|
||||
|
||||
// If the selected tab didn't explicitly change, keep the previously
|
||||
// selected tab selected/active
|
||||
if (indexToSelect === this._selectedIndex) {
|
||||
const tabs = this.tabs.toArray();
|
||||
let selectedTab: TabComponent | undefined;
|
||||
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
if (tabs[i].isActive) {
|
||||
// Set both _indexToSelect and _selectedIndex to avoid firing a change
|
||||
// event which could cause an infinite loop if adding a tab within the
|
||||
// selectedIndexChange event
|
||||
this._indexToSelect = this._selectedIndex = i;
|
||||
selectedTab = tabs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No active tab found and a tab does exist means the active tab
|
||||
// was removed, so a new active tab must be set manually
|
||||
if (!selectedTab && tabs[indexToSelect]) {
|
||||
tabs[indexToSelect].isActive = true;
|
||||
this.selectedTabChange.emit({
|
||||
index: indexToSelect,
|
||||
tab: tabs[indexToSelect],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private _clampTabIndex(index: number): number {
|
||||
return Math.min(this.tabs.length - 1, Math.max(index || 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
export class BitTabChangeEvent {
|
||||
/**
|
||||
* The currently selected tab index
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* The currently selected tab
|
||||
*/
|
||||
tab: TabComponent;
|
||||
}
|
||||
22
libs/components/src/tabs/tab-group/tab-label.directive.ts
Normal file
22
libs/components/src/tabs/tab-group/tab-label.directive.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Directive, TemplateRef } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Used to identify template based tab labels (allows complex labels instead of just plaintext)
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* <bit-tab>
|
||||
* <ng-template bitTabLabel>
|
||||
* <i class="bwi bwi-search"></i> Search
|
||||
* </ng-template>
|
||||
*
|
||||
* <p>Tab Content</p>
|
||||
* </bit-tab>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitTabLabel]",
|
||||
})
|
||||
export class TabLabelDirective {
|
||||
constructor(public templateRef: TemplateRef<unknown>) {}
|
||||
}
|
||||
1
libs/components/src/tabs/tab-group/tab.component.html
Normal file
1
libs/components/src/tabs/tab-group/tab.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<ng-template><ng-content></ng-content></ng-template>
|
||||
42
libs/components/src/tabs/tab-group/tab.component.ts
Normal file
42
libs/components/src/tabs/tab-group/tab.component.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
Component,
|
||||
ContentChild,
|
||||
Input,
|
||||
OnInit,
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TabLabelDirective } from "./tab-label.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-tab",
|
||||
templateUrl: "./tab.component.html",
|
||||
host: {
|
||||
role: "tabpanel",
|
||||
},
|
||||
})
|
||||
export class TabComponent implements OnInit {
|
||||
@Input() disabled = false;
|
||||
|
||||
@Input("label") textLabel = "";
|
||||
|
||||
@ViewChild(TemplateRef, { static: true }) implicitContent: TemplateRef<unknown>;
|
||||
@ContentChild(TabLabelDirective) templateLabel: TabLabelDirective;
|
||||
|
||||
private _contentPortal: TemplatePortal | null = null;
|
||||
|
||||
get content(): TemplatePortal | null {
|
||||
return this._contentPortal;
|
||||
}
|
||||
|
||||
isActive: boolean;
|
||||
|
||||
constructor(private _viewContainerRef: ViewContainerRef) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this._contentPortal = new TemplatePortal(this.implicitContent, this._viewContainerRef);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user