import { booleanAttribute, ChangeDetectionStrategy, Component, computed, effect, inject, input, model, } 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'; const DEFAULT_LINK_ACTIVE: IsActiveMatchOptions = { paths: 'exact', queryParams: 'exact', fragment: 'ignored', matrixParams: 'ignored', }; /** * Compact segmented control for view modes and filters. * * Selected option uses a **solid brand pill** (white label) on a neutral track — * same pattern as the portal shipping-mode toggle. With exactly two options the * pill slides; with more options each selected chip fills in place. * * ## Router mode (URL is source of truth) * * Set `routerLink` (and optional `queryParams`) on items. The selected pill * follows `Router.isActive` — including **cold loads**. Defaults to exact * path + query matching (sibling routes / density query params). * * Routed segments navigate with `{ scroll: 'manual' }` by default so the page * does not jump to the top. Pass `[keepScroll]="false"` to use the app’s * normal router scroll behaviour. * * ## Local mode * * Omit `routerLink` and bind `[(activeId)]`. * * @example * ```html * * ``` */ @Component({ selector: 'africanies-segment', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent], styles: ` /* Track p-1 (0.25×2) + 0.25 gap between pills — keep right inset equal to left. */ :host .africanies-segment-thumb { width: calc((100% - 0.75rem) / 2); } `, template: `
@if (useSlidingThumb()) { } @for (item of items(); track item.id) { @if (item.routerLink !== null && item.routerLink !== undefined) { @if (item.icon; as icon) { } {{ item.label }} } @else { } }
`, }) /** * Compact segmented control for view modes and filters. */ export class SegmentComponent { private readonly router = inject(Router); protected readonly modeColor = inject(ModeColorService); /** Segment options. */ readonly items = input.required(); /** * Selected 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 group. */ readonly ariaLabel = input('Segments'); /** * Match options for routed items. Defaults to exact path + query params. */ readonly linkActiveOptions = input(DEFAULT_LINK_ACTIVE); /** * Keep the viewport where it is when a routed segment 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 }); 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 }, ); /** Two equal options → portal-style sliding brand pill. */ protected readonly useSlidingThumb = computed( () => this.items().length === 2, ); /** Index of the active option (0 / 1 for the sliding thumb). */ protected readonly activeIndex = computed(() => { const list = this.items(); const idx = list.findIndex((item) => this.isItemActive(item)); return idx < 0 ? 0 : idx; }); constructor() { 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; } /** * Sliding thumb fill. STN / SFN options keep their own brand color so the * pill matches mode identity; other pairs follow the active theme primary. */ protected thumbFillClass(): string { const active = this.items()[this.activeIndex()]; return this.brandFillFor(active?.id ?? null); } /** * Option chrome: sliding mode keeps labels transparent over the thumb; * multi-option mode paints a solid selected chip in place. * * @param item - Option being rendered. * @returns Class string for the control. */ protected optionChrome(item: AfricaniesNavItem): string { const base = 'relative z-10 inline-flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-center text-body-sm no-underline transition-colors duration-150'; const active = this.isItemActive(item); if (this.useSlidingThumb()) { return ( base + ' w-full ' + (active ? 'font-semibold text-white' : 'font-medium text-ink hover:text-ink dark:text-neutral-300 dark:hover:text-white') ); } if (active) { return `${base} font-semibold text-white shadow-sm ${this.brandFillFor(item.id)}`; } return ( base + ' font-medium text-ink hover:bg-white/70 hover:text-ink dark:text-neutral-300 dark:hover:bg-white/10 dark:hover:text-white' ); } /** * Solid brand fill for a selected option / sliding thumb. * * Light + dark use the same SFN green / STN orange primaries so the control * stays mode-identifiable on both surfaces (portal primary, not soft tint). * Class strings are full literals so Tailwind keeps them. * * @param id - Option id (`stn` / `sfn` get fixed brands; else theme primary). * @returns Background utility classes. */ protected brandFillFor(id: string | null): string { if (id === 'stn') { return 'bg-import dark:bg-import'; } if (id === 'sfn') { return 'bg-export dark:bg-export'; } return this.modeColor.classes().activeFill; } 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 * 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()); } }