Add tabs.component.ts
Create the following file in your project.
import { Directionality } from '@angular/cdk/bidi';
import { NgTemplateOutlet } from '@angular/common';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
contentChildren,
inject,
input,
isDevMode,
output,
signal,
type TemplateRef,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { ClassValue } from 'clsx';
import {
tabButtonVariants,
tabContainerVariants,
tabNavVariants,
type ZardTabVariants,
} from '@/shared/components/tabs/tabs.variants';
import { ZardIdDirective } from '@/shared/core';
import { mergeClasses } from '@/shared/utils/merge-classes';
@Component({
selector: 'z-tab',
template: `
<ng-template #content>
<ng-content />
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'tab',
},
exportAs: 'zTab',
})
export class ZardTabComponent {
readonly label = input.required<string>();
readonly zIcon = input<string | undefined>(undefined);
readonly zDisabled = input(false, { transform: booleanAttribute });
readonly contentTemplate = viewChild.required<TemplateRef<unknown>>('content');
}
@Component({
selector: 'z-tab-group',
imports: [NgTemplateOutlet, NgIcon],
template: `
<nav
[class]="navClasses()"
role="tablist"
[attr.aria-orientation]="zOrientation()"
[attr.data-variant]="zVariant()"
>
@for (tab of tabs(); track $index; let index = $index) {
<button
type="button"
role="tab"
[attr.id]="tabId(index)"
[attr.aria-selected]="activeTabIndex() === index"
[attr.data-active]="activeTabIndex() === index ? '' : null"
[attr.tabindex]="activeTabIndex() === index ? 0 : -1"
[attr.aria-controls]="panelId(index)"
[disabled]="zDisabled() || tab.zDisabled()"
(click)="setActiveTab(index)"
(keydown)="onKeydown($event)"
[class]="buttonClasses()"
>
@if (tab.zIcon()) {
<ng-icon [name]="tab.zIcon()!" />
}
{{ tab.label() }}
</button>
}
</nav>
<div class="flex-1">
@for (tab of tabs(); track $index; let index = $index) {
<div
role="tabpanel"
[attr.id]="panelId(index)"
[attr.aria-labelledby]="tabId(index)"
[attr.tabindex]="0"
[hidden]="activeTabIndex() !== index"
class="focus-visible:ring-primary/50 outline-none focus-visible:ring-2"
>
<ng-container [ngTemplateOutlet]="tab.contentTemplate()" />
</div>
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'tab-group',
'[class]': 'containerClasses()',
'[attr.data-orientation]': 'zOrientation()',
},
hostDirectives: [ZardIdDirective],
exportAs: 'zTabGroup',
})
export class ZardTabGroupComponent {
/** Scopes the tab and panel ids, which repeat for every group on the page when built from the index alone. */
private readonly uniqueId = inject(ZardIdDirective);
private readonly directionality = inject(Directionality, { optional: true });
private readonly tabComponents = contentChildren(ZardTabComponent, { descendants: true });
protected readonly tabs = computed(() => this.tabComponents());
protected readonly activeTabIndex = signal<number>(0);
protected tabId(index: number): string {
return `${this.uniqueId.id()}-tab-${index}`;
}
protected panelId(index: number): string {
return `${this.uniqueId.id()}-tabpanel-${index}`;
}
protected readonly zTabChange = output<{
index: number;
label: string;
tab: ZardTabComponent;
}>();
protected readonly zDeselect = output<{
index: number;
label: string;
tab: ZardTabComponent;
}>();
readonly zVariant = input<ZardTabVariants['zVariant']>('default');
readonly zOrientation = input<ZardTabVariants['zOrientation']>('horizontal');
readonly zDisabled = input(false, { transform: booleanAttribute });
readonly class = input<ClassValue>('');
protected setActiveTab(index: number) {
const currentTab = this.tabs()[this.activeTabIndex()];
if (index !== this.activeTabIndex() && currentTab) {
this.zDeselect.emit({
index: this.activeTabIndex(),
label: currentTab.label(),
tab: currentTab,
});
}
this.activeTabIndex.set(index);
const activeTabComponent = this.tabs()[index];
if (activeTabComponent) {
this.zTabChange.emit({
index,
label: activeTabComponent.label(),
tab: activeTabComponent,
});
}
}
protected readonly containerClasses = computed(() =>
mergeClasses(tabContainerVariants({ zOrientation: this.zOrientation() }), this.class()),
);
protected readonly navClasses = computed(() => tabNavVariants({ zVariant: this.zVariant() }));
protected readonly buttonClasses = computed(() => tabButtonVariants());
/**
* Roving arrow-key navigation. In a horizontal list the keys follow the visual order, so ArrowRight moves to the
* previous tab in RTL. Moving focus also activates the tab (automatic activation).
*/
protected onKeydown(event: KeyboardEvent): void {
const target = this.nextTabIndex(event.key);
if (target === undefined) {
return;
}
event.preventDefault();
this.setActiveTab(target);
const buttons =
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>(':scope > button') ?? [];
buttons[target]?.focus();
}
private nextTabIndex(key: string): number | undefined {
const tabs = this.tabs();
if (this.zDisabled()) {
return undefined;
}
const enabled = tabs.map((_, index) => index).filter(index => !tabs[index].zDisabled());
if (enabled.length === 0) {
return undefined;
}
if (key === 'Home') {
return enabled[0];
}
if (key === 'End') {
return enabled[enabled.length - 1];
}
const rtl = this.directionality?.value === 'rtl';
let step = 0;
if (this.zOrientation() === 'vertical') {
step = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : 0;
} else if (key === 'ArrowRight') {
step = rtl ? -1 : 1;
} else if (key === 'ArrowLeft') {
step = rtl ? 1 : -1;
}
if (step === 0) {
return undefined;
}
const position = enabled.indexOf(this.activeTabIndex());
const from = position === -1 ? (step > 0 ? -1 : 0) : position;
return enabled[(from + step + enabled.length) % enabled.length];
}
selectTabByIndex(index: number): void {
if (index >= 0 && index < this.tabs().length) {
this.setActiveTab(index);
} else {
if (isDevMode()) {
console.warn(`Index ${index} outside the range of available tabs.`);
}
}
}
}