import { NgTemplateOutlet } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, computed, contentChildren, effect, ElementRef, inject, input, model, viewChildren, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { type IsActiveMatchOptions, NavigationEnd, Router, } from '@angular/router'; import { filter, map, startWith } from 'rxjs'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import { ModeColorService } from '@africanies/africanies-theme'; import type { AfricaniesNavItem } from '../nav-item'; import { isNavItemActive } from '../nav-router.util'; import { isModifiedClick, navigateNavItem, navItemHref, } from '../navigate-nav-item'; import { TabDefDirective } from './tab-def.directive'; const DEFAULT_LINK_ACTIVE: IsActiveMatchOptions = { paths: 'subset', queryParams: 'ignored', fragment: 'ignored', matrixParams: 'ignored', }; /** * Tab list for page / section navigation. * * ## Router mode (URL is source of truth) * * Set `routerLink` on items. Active styling and `activeId` follow the * consumer’s Router — including **cold loads** (`/shipments/docs` highlights * Docs). Pair with a `` for panel content. * * Routed tabs navigate with `{ scroll: 'manual' }` by default so the page * does not jump to the top when only a child panel changes. Pass * `[keepScroll]="false"` to use the app’s normal router scroll behaviour. * * ## Local mode * * Omit `routerLink` and bind `[(activeId)]`. Project panels with * ``. * * @example * ```html * * * * ``` */ @Component({ selector: 'africanies-tabs', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent, NgTemplateOutlet], template: `
@for (item of items(); track item.id) { @if (item.routerLink !== null && item.routerLink !== undefined) { @if (item.icon; as icon) { } {{ item.label }} } @else { } }
@if (hasPanels()) { @if (activePanel(); as panel) {
} }
`, }) /** * Tab list for page / section navigation. */ export class TabsComponent { private readonly router = inject(Router); protected readonly modeColor = inject(ModeColorService); private readonly tabDefs = contentChildren(TabDefDirective); private readonly tabEls = viewChildren>('tabEl'); /** Tab definitions. */ readonly items = input.required(); /** * Selected tab id. For routed items this is **synced from the Router** * (including cold URL loads). For local items, bind `[(activeId)]`. */ readonly activeId = model(null); /** Accessible name for the tablist. */ readonly ariaLabel = input('Tabs'); /** * Match options for routed items (same contract as `Router.isActive`). * Defaults to subset path matching, ignoring query/fragment. */ readonly linkActiveOptions = input(DEFAULT_LINK_ACTIVE); /** * Keep the viewport where it is when a routed tab navigates (default). * Uses Angular’s `{ scroll: 'manual' }` so app-wide scroll restoration does * not jump to the top. Set false to use normal router scrolling. */ readonly keepScroll = input(true, { transform: booleanAttribute }); /** Re-evaluate active state on every successful navigation (and initial URL). */ private readonly url = toSignal( this.router.events.pipe( filter((e): e is NavigationEnd => e instanceof NavigationEnd), map(() => this.router.url), startWith(this.router.url), ), { initialValue: this.router.url }, ); private readonly panelMap = computed(() => { const map = new Map(); for (const def of this.tabDefs()) { map.set(def.africaniesTabDef(), def.template); } return map; }); protected readonly hasPanels = computed(() => this.panelMap().size > 0); protected readonly activePanel = computed(() => { const id = this.activeId(); if (!id) { return null; } return this.panelMap().get(id) ?? null; }); protected readonly tabChrome = computed( () => 'inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-body-sm transition-colors -mb-px no-underline', ); protected readonly activeTabClass = computed(() => { const colors = this.modeColor.classes(); return `${colors.text} border-current font-semibold`; }); protected readonly inactiveTabClass = computed( () => 'border-transparent font-medium text-neutral-600 hover:text-ink dark:text-neutral-400 dark:hover:text-white', ); constructor() { // Keep activeId aligned with the URL whenever any item is router-backed. effect(() => { this.url(); const options = this.linkActiveOptions(); const routed = this.items().filter((i) => i.routerLink != null); if (routed.length === 0) { return; } const match = routed.find((i) => isNavItemActive(this.router, i, options), ); if (match && this.activeId() !== match.id) { this.activeId.set(match.id); } }); } protected hrefFor(item: AfricaniesNavItem): string | null { return navItemHref(this.router, item); } protected isItemActive(item: AfricaniesNavItem): boolean { if (item.routerLink != null) { this.url(); return isNavItemActive(this.router, item, this.linkActiveOptions()); } return this.activeId() === item.id; } protected selectLocal(item: AfricaniesNavItem): void { if (item.disabled || item.routerLink != null) { return; } this.activeId.set(item.id); } /** * Primary click navigates in-app without scrolling to top. Modified clicks * (cmd/ctrl/middle) keep the native link behaviour for new tabs. * @param event * @param item */ protected onRoutedClick(event: MouseEvent, item: AfricaniesNavItem): void { if (item.disabled) { event.preventDefault(); return; } if (isModifiedClick(event)) { return; } event.preventDefault(); void navigateNavItem(this.router, item, this.keepScroll()); } /** * Arrow-key navigation for the tablist. Local buttons also change selection * (ARIA tabs); routed links only move focus. * @param event */ protected onTablistKeydown(event: KeyboardEvent): void { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { return; } const els = this.tabEls() .map((r) => r.nativeElement) .filter( (el) => !el.hasAttribute('disabled') && el.getAttribute('aria-disabled') !== 'true', ); if (els.length === 0) { return; } const current = document.activeElement as HTMLElement | null; const idx = els.findIndex((el) => el === current); if (idx < 0) { return; } event.preventDefault(); const delta = event.key === 'ArrowRight' ? 1 : -1; const next = (idx + delta + els.length) % els.length; const nextEl = els[next]; if (!nextEl) { return; } nextEl.focus(); if (nextEl.tagName === 'BUTTON') { const id = nextEl.id.replace(/^africanies-tab-/, ''); const item = this.items().find((t) => t.id === id); if (item) { this.selectLocal(item); } } } }