mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01: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:
parent
e8936eb4c6
commit
debaef2941
@ -1,3 +1,5 @@
|
|||||||
export * from "./tabs.module";
|
export * from "./tabs.module";
|
||||||
export * from "./tab-group.component";
|
export * from "./tab-group/tab-group.component";
|
||||||
export * from "./tab-item.component";
|
export * from "./tab-group/tab.component";
|
||||||
|
export * from "./tab-nav-bar/tab-nav-bar.component";
|
||||||
|
export * from "./tab-nav-bar/tab-link.component";
|
||||||
|
14
libs/components/src/tabs/shared/tab-header.component.ts
Normal file
14
libs/components/src/tabs/shared/tab-header.component.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component used for styling the tab header/background for both content and navigation tabs
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: "bit-tab-header",
|
||||||
|
host: {
|
||||||
|
class:
|
||||||
|
"tw-h-16 tw-pl-4 tw-bg-background-alt tw-flex tw-items-end tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300",
|
||||||
|
},
|
||||||
|
template: `<ng-content></ng-content>`,
|
||||||
|
})
|
||||||
|
export class TabHeaderComponent {}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { Directive } from "@angular/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive used for styling the container for bit tab labels
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
selector: "[bitTabListContainer]",
|
||||||
|
host: {
|
||||||
|
class: "tw-inline-flex tw-flex-wrap tw-leading-5",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class TabListContainerDirective {}
|
85
libs/components/src/tabs/shared/tab-list-item.directive.ts
Normal file
85
libs/components/src/tabs/shared/tab-list-item.directive.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { FocusableOption } from "@angular/cdk/a11y";
|
||||||
|
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive used for styling tab header items for both nav links (anchor tags)
|
||||||
|
* and content tabs (button tags)
|
||||||
|
*/
|
||||||
|
@Directive({ selector: "[bitTabListItem]" })
|
||||||
|
export class TabListItemDirective implements FocusableOption {
|
||||||
|
@Input() active: boolean;
|
||||||
|
@Input() disabled: boolean;
|
||||||
|
|
||||||
|
@HostBinding("attr.disabled")
|
||||||
|
get disabledAttr() {
|
||||||
|
return this.disabled || null; // native disabled attr must be null when false
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private elementRef: ElementRef) {}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.elementRef.nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.elementRef.nativeElement.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostBinding("class")
|
||||||
|
get classList(): string[] {
|
||||||
|
return this.baseClassList
|
||||||
|
.concat(this.active ? this.activeClassList : [])
|
||||||
|
.concat(this.disabled ? this.disabledClassList : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
get baseClassList(): string[] {
|
||||||
|
return [
|
||||||
|
"tw-block",
|
||||||
|
"tw-relative",
|
||||||
|
"tw-py-2",
|
||||||
|
"tw-px-4",
|
||||||
|
"tw-font-semibold",
|
||||||
|
"tw-transition",
|
||||||
|
"tw-rounded-t",
|
||||||
|
"tw-border-0",
|
||||||
|
"tw-border-x",
|
||||||
|
"tw-border-t-4",
|
||||||
|
"tw-border-transparent",
|
||||||
|
"tw-border-solid",
|
||||||
|
"tw-bg-transparent",
|
||||||
|
"tw-text-main",
|
||||||
|
"hover:tw-underline",
|
||||||
|
"hover:tw-text-main",
|
||||||
|
"focus-visible:tw-z-10",
|
||||||
|
"focus-visible:tw-outline-none",
|
||||||
|
"focus-visible:tw-ring-2",
|
||||||
|
"focus-visible:tw-ring-primary-700",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get disabledClassList(): string[] {
|
||||||
|
return [
|
||||||
|
"!tw-bg-secondary-100",
|
||||||
|
"!tw-text-muted",
|
||||||
|
"hover:!tw-text-muted",
|
||||||
|
"!tw-no-underline",
|
||||||
|
"tw-cursor-not-allowed",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeClassList(): string[] {
|
||||||
|
return [
|
||||||
|
"tw--mb-px",
|
||||||
|
"tw-border-x-secondary-300",
|
||||||
|
"tw-border-t-primary-500",
|
||||||
|
"tw-border-b",
|
||||||
|
"tw-border-b-background",
|
||||||
|
"tw-bg-background",
|
||||||
|
"!tw-text-primary-500",
|
||||||
|
"hover:tw-border-t-primary-700",
|
||||||
|
"hover:!tw-text-primary-700",
|
||||||
|
"focus-visible:tw-border-t-primary-700",
|
||||||
|
"focus-visible:!tw-text-primary-700",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +0,0 @@
|
|||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
class="tw-inline-flex tw-flex-wrap tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-leading-5"
|
|
||||||
>
|
|
||||||
<ng-content></ng-content>
|
|
||||||
</div>
|
|
@ -1,7 +0,0 @@
|
|||||||
import { Component } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "bit-tab-group",
|
|
||||||
templateUrl: "./tab-group.component.html",
|
|
||||||
})
|
|
||||||
export class TabGroupComponent {}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +0,0 @@
|
|||||||
<ng-container [ngSwitch]="disabled">
|
|
||||||
<a
|
|
||||||
*ngSwitchCase="false"
|
|
||||||
role="tab"
|
|
||||||
[class]="baseClassList"
|
|
||||||
[routerLink]="route"
|
|
||||||
[routerLinkActive]="activeClassList"
|
|
||||||
#rla="routerLinkActive"
|
|
||||||
[attr.aria-selected]="rla.isActive"
|
|
||||||
>
|
|
||||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
*ngSwitchCase="true"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
[class]="baseClassList"
|
|
||||||
disabled
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #content>
|
|
||||||
<ng-content></ng-content>
|
|
||||||
</ng-template>
|
|
@ -1,54 +0,0 @@
|
|||||||
import { Component, Input } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "bit-tab-item",
|
|
||||||
templateUrl: "./tab-item.component.html",
|
|
||||||
})
|
|
||||||
export class TabItemComponent {
|
|
||||||
@Input() route: string; // ['/route']
|
|
||||||
@Input() disabled = false;
|
|
||||||
|
|
||||||
get baseClassList(): string[] {
|
|
||||||
return [
|
|
||||||
"tw-block",
|
|
||||||
"tw-relative",
|
|
||||||
"tw-py-2",
|
|
||||||
"tw-px-4",
|
|
||||||
"tw-font-semibold",
|
|
||||||
"tw-transition",
|
|
||||||
"tw-rounded-t",
|
|
||||||
"tw-border-0",
|
|
||||||
"tw-border-x",
|
|
||||||
"tw-border-t-4",
|
|
||||||
"tw-border-transparent",
|
|
||||||
"tw-border-solid",
|
|
||||||
"!tw-text-main",
|
|
||||||
"hover:tw-underline",
|
|
||||||
"hover:!tw-text-main",
|
|
||||||
"focus-visible:tw-z-10",
|
|
||||||
"focus-visible:tw-outline-none",
|
|
||||||
"focus-visible:tw-ring-2",
|
|
||||||
"focus-visible:tw-ring-primary-700",
|
|
||||||
"disabled:tw-bg-transparent",
|
|
||||||
"disabled:!tw-text-muted/60",
|
|
||||||
"disabled:tw-no-underline",
|
|
||||||
"disabled:tw-cursor-not-allowed",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeClassList(): string {
|
|
||||||
return [
|
|
||||||
"tw--mb-px",
|
|
||||||
"tw-border-x-secondary-300",
|
|
||||||
"tw-border-t-primary-500",
|
|
||||||
"tw-border-b",
|
|
||||||
"tw-border-b-background",
|
|
||||||
"tw-bg-background",
|
|
||||||
"!tw-text-primary-500",
|
|
||||||
"hover:tw-border-t-primary-700",
|
|
||||||
"hover:!tw-text-primary-700",
|
|
||||||
"focus-visible:tw-border-t-primary-700",
|
|
||||||
"focus-visible:!tw-text-primary-700",
|
|
||||||
].join(" ");
|
|
||||||
}
|
|
||||||
}
|
|
13
libs/components/src/tabs/tab-nav-bar/tab-link.component.html
Normal file
13
libs/components/src/tabs/tab-nav-bar/tab-link.component.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<a
|
||||||
|
bitTabListItem
|
||||||
|
[routerLink]="disabled ? null : route"
|
||||||
|
routerLinkActive
|
||||||
|
#rla="routerLinkActive"
|
||||||
|
[active]="rla.isActive"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[attr.aria-disabled]="disabled"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
|
role="link"
|
||||||
|
>
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</a>
|
51
libs/components/src/tabs/tab-nav-bar/tab-link.component.ts
Normal file
51
libs/components/src/tabs/tab-nav-bar/tab-link.component.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { FocusableOption } from "@angular/cdk/a11y";
|
||||||
|
import { AfterViewInit, Component, HostListener, Input, OnDestroy, ViewChild } from "@angular/core";
|
||||||
|
import { RouterLinkActive } from "@angular/router";
|
||||||
|
import { Subject, takeUntil } from "rxjs";
|
||||||
|
|
||||||
|
import { TabListItemDirective } from "../shared/tab-list-item.directive";
|
||||||
|
|
||||||
|
import { TabNavBarComponent } from "./tab-nav-bar.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-tab-link",
|
||||||
|
templateUrl: "tab-link.component.html",
|
||||||
|
})
|
||||||
|
export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestroy {
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
@ViewChild(TabListItemDirective) tabItem: TabListItemDirective;
|
||||||
|
@ViewChild("rla") routerLinkActive: RouterLinkActive;
|
||||||
|
|
||||||
|
@Input() route: string;
|
||||||
|
@Input() disabled = false;
|
||||||
|
|
||||||
|
@HostListener("keydown", ["$event"]) onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.code === "Space") {
|
||||||
|
this.tabItem.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get active() {
|
||||||
|
return this.routerLinkActive?.isActive ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private _tabNavBar: TabNavBarComponent) {}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.tabItem.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
// The active state of tab links are tracked via the routerLinkActive directive
|
||||||
|
// We need to watch for changes to tell the parent nav group when the tab is active
|
||||||
|
this.routerLinkActive.isActiveChange
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((_) => this._tabNavBar.updateActiveLink());
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
<bit-tab-header>
|
||||||
|
<nav bitTabListContainer [attr.aria-label]="label" (keydown)="keyManager.onKeydown($event)">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</nav>
|
||||||
|
</bit-tab-header>
|
@ -0,0 +1,43 @@
|
|||||||
|
import { FocusKeyManager } from "@angular/cdk/a11y";
|
||||||
|
import {
|
||||||
|
AfterContentInit,
|
||||||
|
Component,
|
||||||
|
ContentChildren,
|
||||||
|
forwardRef,
|
||||||
|
Input,
|
||||||
|
QueryList,
|
||||||
|
} from "@angular/core";
|
||||||
|
|
||||||
|
import { TabLinkComponent } from "./tab-link.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-tab-nav-bar",
|
||||||
|
templateUrl: "tab-nav-bar.component.html",
|
||||||
|
})
|
||||||
|
export class TabNavBarComponent implements AfterContentInit {
|
||||||
|
@ContentChildren(forwardRef(() => TabLinkComponent)) tabLabels: QueryList<TabLinkComponent>;
|
||||||
|
@Input() label = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TabLinkComponent>;
|
||||||
|
|
||||||
|
ngAfterContentInit(): void {
|
||||||
|
this.keyManager = new FocusKeyManager(this.tabLabels)
|
||||||
|
.withHorizontalOrientation("ltr")
|
||||||
|
.withWrap()
|
||||||
|
.withHomeAndEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveLink() {
|
||||||
|
// Keep the keyManager in sync with active tabs
|
||||||
|
const items = this.tabLabels.toArray();
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].active) {
|
||||||
|
this.keyManager.updateActiveItem(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,37 @@
|
|||||||
|
import { PortalModule } from "@angular/cdk/portal";
|
||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
import { RouterModule } from "@angular/router";
|
import { RouterModule } from "@angular/router";
|
||||||
|
|
||||||
import { TabGroupComponent } from "./tab-group.component";
|
import { TabHeaderComponent } from "./shared/tab-header.component";
|
||||||
import { TabItemComponent } from "./tab-item.component";
|
import { TabListContainerDirective } from "./shared/tab-list-container.directive";
|
||||||
|
import { TabListItemDirective } from "./shared/tab-list-item.directive";
|
||||||
|
import { TabBodyComponent } from "./tab-group/tab-body.component";
|
||||||
|
import { TabGroupComponent } from "./tab-group/tab-group.component";
|
||||||
|
import { TabLabelDirective } from "./tab-group/tab-label.directive";
|
||||||
|
import { TabComponent } from "./tab-group/tab.component";
|
||||||
|
import { TabLinkComponent } from "./tab-nav-bar/tab-link.component";
|
||||||
|
import { TabNavBarComponent } from "./tab-nav-bar/tab-nav-bar.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, RouterModule],
|
imports: [CommonModule, RouterModule, PortalModule],
|
||||||
exports: [TabGroupComponent, TabItemComponent],
|
exports: [
|
||||||
declarations: [TabGroupComponent, TabItemComponent],
|
TabGroupComponent,
|
||||||
|
TabComponent,
|
||||||
|
TabLabelDirective,
|
||||||
|
TabNavBarComponent,
|
||||||
|
TabLinkComponent,
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
TabGroupComponent,
|
||||||
|
TabComponent,
|
||||||
|
TabLabelDirective,
|
||||||
|
TabListContainerDirective,
|
||||||
|
TabListItemDirective,
|
||||||
|
TabHeaderComponent,
|
||||||
|
TabNavBarComponent,
|
||||||
|
TabLinkComponent,
|
||||||
|
TabBodyComponent,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TabsModule {}
|
export class TabsModule {}
|
||||||
|
@ -3,8 +3,8 @@ import { Component } from "@angular/core";
|
|||||||
import { RouterModule } from "@angular/router";
|
import { RouterModule } from "@angular/router";
|
||||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||||
|
|
||||||
import { TabGroupComponent } from "./tab-group.component";
|
import { TabGroupComponent } from "./tab-group/tab-group.component";
|
||||||
import { TabItemComponent } from "./tab-item.component";
|
import { TabsModule } from "./tabs.module";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "bit-tab-active-dummy",
|
selector: "bit-tab-active-dummy",
|
||||||
@ -36,8 +36,6 @@ export default {
|
|||||||
decorators: [
|
decorators: [
|
||||||
moduleMetadata({
|
moduleMetadata({
|
||||||
declarations: [
|
declarations: [
|
||||||
TabGroupComponent,
|
|
||||||
TabItemComponent,
|
|
||||||
ActiveDummyComponent,
|
ActiveDummyComponent,
|
||||||
ItemTwoDummyComponent,
|
ItemTwoDummyComponent,
|
||||||
ItemThreeDummyComponent,
|
ItemThreeDummyComponent,
|
||||||
@ -45,6 +43,7 @@ export default {
|
|||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
TabsModule,
|
||||||
RouterModule.forRoot(
|
RouterModule.forRoot(
|
||||||
[
|
[
|
||||||
{ path: "", redirectTo: "active", pathMatch: "full" },
|
{ path: "", redirectTo: "active", pathMatch: "full" },
|
||||||
@ -66,19 +65,63 @@ export default {
|
|||||||
},
|
},
|
||||||
} as Meta;
|
} as Meta;
|
||||||
|
|
||||||
const TabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
|
const ContentTabGroupTemplate: Story<TabGroupComponent> = (args: any) => ({
|
||||||
props: args,
|
props: args,
|
||||||
template: `
|
template: `
|
||||||
<bit-tab-group>
|
<bit-tab-group label="Main Content Tabs" class="tw-text-main">
|
||||||
<bit-tab-item [route]="['active']">Active</bit-tab-item>
|
<bit-tab label="First Tab">First Tab Content</bit-tab>
|
||||||
<bit-tab-item [route]="['item-2']">Item 2</bit-tab-item>
|
<bit-tab label="Second Tab">Second Tab Content</bit-tab>
|
||||||
<bit-tab-item [route]="['item-3']">Item 3</bit-tab-item>
|
<bit-tab>
|
||||||
<bit-tab-item [route]="['disabled']" [disabled]="true">Disabled</bit-tab-item>
|
<ng-template bitTabLabel>
|
||||||
|
<i class="bwi bwi-search tw-pr-1"></i> Template Label
|
||||||
|
</ng-template>
|
||||||
|
Template Label Content
|
||||||
|
</bit-tab>
|
||||||
|
<bit-tab [disabled]="true" label="Disabled">
|
||||||
|
Disabled Content
|
||||||
|
</bit-tab>
|
||||||
</bit-tab-group>
|
</bit-tab-group>
|
||||||
<div class="tw-bg-transparent tw-text-semibold tw-text-center !tw-text-main tw-py-10">
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ContentTabs = ContentTabGroupTemplate.bind({});
|
||||||
|
|
||||||
|
const NavTabGroupTemplate: Story<TabGroupComponent> = (args: TabGroupComponent) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<bit-tab-nav-bar label="Main">
|
||||||
|
<bit-tab-link [route]="['active']">Active</bit-tab-link>
|
||||||
|
<bit-tab-link [route]="['item-2']">Item 2</bit-tab-link>
|
||||||
|
<bit-tab-link [route]="['item-3']">Item 3</bit-tab-link>
|
||||||
|
<bit-tab-link [route]="['disable']" [disabled]="true">Disabled</bit-tab-link>
|
||||||
|
</bit-tab-nav-bar>
|
||||||
|
<div class="tw-bg-transparent tw-text-semibold tw-text-center tw-text-main tw-py-10">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TabGroup = TabGroupTemplate.bind({});
|
export const NavigationTabs = NavTabGroupTemplate.bind({});
|
||||||
|
|
||||||
|
const PreserveContentTabGroupTemplate: Story<TabGroupComponent> = (args: any) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<bit-tab-group label="Preserve Content Tabs" [preserveContent]="true" class="tw-text-main">
|
||||||
|
<bit-tab label="Text Tab">
|
||||||
|
<p>
|
||||||
|
Play the video in the other tab and switch back to hear the video is still playing.
|
||||||
|
</p>
|
||||||
|
</bit-tab>
|
||||||
|
<bit-tab label="Video Tab">
|
||||||
|
<iframe
|
||||||
|
width="560"
|
||||||
|
height="315"
|
||||||
|
src="https://www.youtube.com/embed/H0-yWbe5XG4"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
</bit-tab>
|
||||||
|
</bit-tab-group>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PreserveContentTabs = PreserveContentTabGroupTemplate.bind({});
|
||||||
|
Loading…
Reference in New Issue
Block a user