Skip to main content
cngx-src documentation

CngxTabGroup

ComponentPrimaryOnPushNo encapsulationv0.1.0WCAG AA

projects/ui/tabs/tab-group.component.ts

Import#

import { CngxTabGroup } from '@cngx/ui/tabs'

Description#

CNGX tab-group organism.
Thin shell over CngxTabGroupPresenter and CngxFocusRestore via hostDirectives. The APG tablist keyboard model (automatic activation: arrow keys move focus AND select; Home/End jump to first/last; the active tab is the lone tab stop) lives in createTabKeyboardNav, deriving the roving stop from the presenter's activeId rather than a competing index. Material variant lives at [cngxMatTabs] in @cngx/ui/mat-tabs.

All ARIA attrs are signal-driven - never one-shot bindings. CngxLiveRegion is mounted as a child <span> rather than a host directive: its role="status" would clobber the wrapper's role="group" landmark.

See CngxMatTabs for the Material <mat-tab-group> variant with [cngxMatTabError].

https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-commit-action/optimistic-pessimistic-commits-with-bridge-directives https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-error-aggregation/per-tab-error-badges https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-skins/all-skins-side-by-side https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-skins/contained https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-skins/line https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-skins/pill https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-skins/pill-vs-toggle-group https://cngxjs.github.io/cngx/examples/#/ui/tabs/tab-skins/vertical-skins

Metadata#

Host#

Providers#

CNGX_TAB_PANEL_HOST
useExisting CngxTabGroup

Dependencies#

CNGX_TAB_GROUP_HOSTinject()presenter

Relationships

Index#

Inputs#

addable#boolean | undefined
input()

Whether the group renders an add-tab button after the strip. Cascade input ?? config ?? false. When on, the (tabAdd) output fires for the consumer to append a tab to its data.

ariaLabel#string | undefined
input()
default undefined, { alias: 'aria-label' }
ariaLabelledBy#string | undefined
input()
default undefined, { alias: 'aria-labelledby' }
closable#boolean | undefined
input()

Whether tabs render a close affordance. Cascade input ?? config ?? false. A per-CngxTab [closable] override wins over this group default. When on, each closable tab gets a close button (and Delete on the focused tab closes it); the (tabClose) output fires with the tab id and index for the consumer to remove from its data.

fitted#boolean | undefined
input()

Stretch tabs to fill the strip width (horizontal only); reflects [data-fitted]. Cascade input ?? config ?? false.

input()

Icon layout. Cascade input ?? config ?? 'start', reflected onto [data-icon-layout]. Orthogonal to skin and orientation.

input()

Panel render strategy. Cascade input ?? config ?? 'eager', reflected onto [data-panel-mode]. 'eager' (default) renders every panel's content up front (byte-identical to before this input existed); 'lazy' keep-alives content after first activation; 'lazy-destroy' renders only the active panel's content. The panel <div> always stays in the DOM regardless.

input()

Visual skin. Cascade input ?? config ?? 'line', resolved by hostAttrs and reflected onto [data-skin]. The skin is a pure CSS concern - structure, slots, ARIA, and keyboard behaviour are identical across all values. Public (consumer-facing input, like ariaLabel); the resolved value, not the raw input, is what the host binding reads.

input()

Tab-cluster alignment when not fitted (horizontal only); reflects [data-tab-align]. Cascade input ?? config ?? 'start'.

Instance Properties#

activeId#Signal
Readonly
this.presenter.activeId
activeIndex#Signal
Readonly
this.presenter.activeIndex
ProtectedReadonly

AT-announcement bundle from @cngx/common/tabs/announcements/. Owns role descriptions, resolved aria-label, live-region phrasing, and the prior-active-index linkedSignal driving the success-arm direction prefix.

createTabGroupAnnouncements({
  presenter: this.presenter,
  i18n: this.i18n,
  config: this.config,
  ariaLabel: this.ariaLabel,
  ariaLabelledBy: this.ariaLabelledBy,
})
config#unknown
ProtectedReadonly
injectTabsConfig()
ProtectedReadonly

Dismissable / addable affordance surface (close + add resolution, close interaction, focus restoration). Level-2 helper keeps the cascade + handlers off the organism class (LOC guard).

createTabDismissals({
  host: this.presenter,
  config: this.config,
  i18n: this.i18n,
  closable: this.closable,
  addable: this.addable,
  hostElement: this.hostElement,
  injector: this.injector,
})
glyphs#unknown
ProtectedReadonly

Default-glyph table for the template's fallback spans.

CNGX_TABS_GLYPHS
ProtectedReadonly

Resolved skin / icon-layout cascade (input ?? config ?? default), read by the [data-skin] / [data-icon-layout] host bindings. The Level-2 helper keeps the cascade off the organism class (LOC guard).

createTabsHostAttrs({
  skin: this.skin,
  iconLayout: this.iconLayout,
  panelMode: this.panelMode,
  fitted: this.fitted,
  tabAlign: this.tabAlign,
  config: this.config,
})
i18n#unknown
ProtectedReadonly
injectTabsI18n()
ProtectedReadonly

APG tablist keyboard model (automatic activation). Owns arrow / Home / End resolution, the host.select() activation, and focus movement; derives each tab's roving tabindex from the presenter's activeId (single source of truth). Level-2 helper keeps the keyboard logic off the organism class (LOC guard).

createTabKeyboardNav({
  host: this.presenter,
  hostElement: this.hostElement,
})
orientation#Signal<"horizontal" | "vertical">
Readonly
this.presenter.orientation
Readonly
this.presenter.tabs
ProtectedReadonly

Resolved 3-stage cascade for errorBadge, rejectionIcon, busySpinner, icon. null -> render built-in default (or, for icon, render nothing).

createTabGroupTemplateBindings({
  errorBadgeSlot: this.errorBadgeSlot,
  rejectionIconSlot: this.rejectionIconSlot,
  busySpinnerSlot: this.busySpinnerSlot,
  iconSlot: this.iconSlot,
  closeIconSlot: this.closeIconSlot,
  addIconSlot: this.addIconSlot,
  config: this.config,
})

Methods#

busySpinnerContextFor#CngxTabBusySpinnerContext
Protected
busySpinnerContextFor(tab: CngxTabHandle)

Context for *cngxTabBusySpinner. Allocates fresh per CD - intendedIndex is reactive, and *ngTemplateOutlet only re-evaluates let-* bindings when the context reference changes (Object.is input-diff); a cached mutated object would leave consumer bindings stale. intendedIndex is non-undefined by gate

  • isTabBusy(tab) returning true requires it.
@paramtabCngxTabHandle
clearLastFailed#void

Clear the presenter's lastFailedIndex rejection flag - exposed so template-ref consumers (#tg="cngxTabGroup") can dismiss the decoration without injecting the host token.

contentTemplateFor#TemplateRef | null
contentTemplateFor(id: string)
@paramidstring
errorBadgeContextFor#CngxTabErrorBadgeContext
Protected
errorBadgeContextFor(tab: CngxTabHandle)

WeakMap-cached context for *cngxTabErrorBadge. Stable ref per tab.

@paramtabCngxTabHandle
handleHeaderClick#void
Protected
handleHeaderClick(tab: CngxTabHandle)
@paramtabCngxTabHandle
handleTabKeydown#void
Protected
handleTabKeydown(tab: CngxTabHandle, event: KeyboardEvent)

Single (keydown) entry for a tab button: APG navigation (arrows / Home / End, automatic activation) first, then Delete-to-close when navigation did not consume the event.

@paramtabCngxTabHandle
@parameventKeyboardEvent
iconContextFor#CngxTabIconContext
Protected
iconContextFor(tab: CngxTabHandle, index: number)

Context for *cngxTabIcon. Allocates fresh per CD - active is reactive (selection), and *ngTemplateOutlet only re-evaluates let-* bindings when the context reference changes (Object.is input-diff); a WeakMap-cached object with a mutated active would leave consumer bindings stale. Same pattern as busySpinnerContextFor.

@paramtabCngxTabHandle
@paramindexnumber
isSelected#boolean
Protected
isSelected(tab: CngxTabHandle)
@paramtabCngxTabHandle
isTabBusy#boolean
Protected
isTabBusy(tab: CngxTabHandle)
@paramtabCngxTabHandle
labelTemplateFor#TemplateRef | null
labelTemplateFor(id: string)
@paramidstring
panelTabindex#"0" | null
Protected
panelTabindex(tab: CngxTabHandle)

APG tabpanel tabindex: 0 only when the selected panel has no focusable descendant (so its content is keyboard-reachable from the tablist), else null - never a redundant tab stop. Inactive panels are null (they are [hidden]).

@paramtabCngxTabHandle
rejectionIconContextFor#CngxTabRejectionIconContext
Protected
rejectionIconContextFor(failedIndex: number)

Context for *cngxTabRejectionIcon. originLabel matches the liveAnnouncement priority chain so visual + SR phrasing align. Allocates fresh per CD for the same reason as busySpinnerContextFor.

@paramfailedIndexnumber
selectById#void
selectById(id: string)
@paramidstring
shouldRenderContent#boolean
Protected
shouldRenderContent(tab: CngxTabHandle)

Whether a tab's panel content should be rendered now. The panel <div> is always in the DOM (the aria-controls target); only its inner content is gated. eager is byte-identical to the original behaviour (everything rendered, visibility toggled via [hidden]).

@paramtabCngxTabHandle
showErrorBadge#boolean
Protected
showErrorBadge(tab: CngxTabHandle)

true when the tab is in error - the folded hasError (direct [error] flag OR the aggregator's reveal-gated shouldShow()), so a direct-error tab lights the badge without an aggregator while deferred-reveal aggregators stay silent until revealed.

@paramtabCngxTabHandle
subLabelTemplateFor#TemplateRef | null
subLabelTemplateFor(id: string)
@paramidstring
tabDescriptorId#string
Protected
tabDescriptorId(tab: CngxTabHandle)
@paramtabCngxTabHandle
tabHeaderId#string
Protected
tabHeaderId(tab: CngxTabHandle)
@paramtabCngxTabHandle
tabPanelId#string
Protected
tabPanelId(tab: CngxTabHandle)
@paramtabCngxTabHandle
tabTabindex#"0" | unknown
Protected
tabTabindex(tab: CngxTabHandle)

Roving tabindex for a tab button: 0 for the active tab, -1 for the rest, so the tablist is a single tab stop (APG) and Tab lands on the active tab. Derived in keyboard from activeId.

@paramtabCngxTabHandle

HostBindings#

BindingExpression
[attr.aria-roledescription]announcements.tabsRoleDescription()
[attr.aria-orientation]presenter.orientation()
[attr.data-orientation]presenter.orientation()
[attr.data-skin]hostAttrs.resolvedSkin()
[attr.data-icon-layout]hostAttrs.resolvedIconLayout()
[attr.data-panel-mode]hostAttrs.resolvedPanelMode()
[attr.data-fitted]hostAttrs.resolvedFitted() ? '' : null
[attr.data-tab-align]hostAttrs.resolvedTabAlign()
[attr.aria-label]announcements.resolvedAriaLabel()
[attr.aria-labelledby]ariaLabelledBy()
[class.cngx-tabs]true

Tabs: UX and Accessibility

Tabs slice one surface into parallel views and show one at a time, so a dense screen becomes a short row of labelled panels the user can flip between in any order. The pattern earns its keep when the same subject has several independent facets - an account's Profile, Security, and Billing - and the user wants to glance at one without scrolling past the rest. There is nothing to "finish": tabs are a way to organise content, not a task to complete.

This document is about the pattern, not the CNGX API. It explains when to reach for tabs, how they differ from a stepper, how to make them keyboard- and screen-reader-complete, and the mistakes that surface once the happy path is left behind. For the component inputs and outputs, see the Info and API tabs.

When tabs are the right tool

Reach for tabs when all of these hold:

  • The content splits into a small set of named sections that belong to the same subject.
  • The sections are independent: the user can read them in any order, and one does not gate another.
  • Only one section needs to be visible at a time, and switching is cheap and frequent.

Typical homes: settings and account pages, a record's detail / activity / related views, a dashboard that groups widgets by theme.

Do not use tabs when the parts must be done in order or one depends on another - that is a stepper, not a tab group. Do not use tabs to hide content the user needs side by side, and do not pile so many tabs into a row that the labels become cryptic; past a handful, a list or navigation is clearer than a strip that scrolls or collapses into a "More" menu.

Tabs versus stepper

This is the distinction people get wrong most often, because both render as a row of labels with one panel visible at a time. The difference is about intent and order, and it drives every other decision (ARIA roles, keyboard model, validation).

- Tabs Stepper / Wizard
Mental model Parallel views of the same thing Sequential stages of one task
Order None; pick any tab, any time Ordered; progress matters
Dependency Tabs are independent Later steps may depend on earlier ones
Completion There is nothing to "finish" There is a final submit / done state
Progress Not communicated Core to the pattern (position, completed, remaining)
Validation Per tab, if at all Often gates moving forward
ARIA role tablist / tab / tabpanel Ordered list of steps; current step marked aria-current="step"
Keyboard Arrow keys move between tabs immediately Tab/Shift+Tab through controls; Next/Back buttons advance

Rule of thumb: if the user could reasonably visit the parts in any order and nothing is ever "submitted", you want tabs. If there is a start, an end, and an order in between, you want a stepper. Do not borrow stepper semantics for tabs, or tab semantics for a stepper; the roles tell assistive tech how the thing behaves, and the wrong role misleads.

Anatomy

Required parts:

  • Tab strip (role="tablist"): the row (or column) of tab buttons. One is selected.
  • Tab (role="tab"): a button that selects its panel. Carries aria-selected and points at its panel with aria-controls.
  • Tab panel (role="tabpanel"): the content of the selected tab, labelled by its tab via aria-labelledby. Inactive panels are hidden, not removed.

Optional but valuable:

  • Overflow "More" menu: when the tabs do not fit, the clipped ones collapse into a menu so they stay reachable instead of being lost off-screen.
  • Per-tab accessories: an error badge, a busy spinner, a close button - each communicating one piece of state without leaving the strip.

UX guidance

  • Name tabs by their content, not by ordinal. "Billing" beats "Tab 3"; the label is the thing the user scans for and the accessible name AT reads.
  • Keep the selected tab obviously selected, with more than colour. The active state must survive colour-blindness and forced-colours.
  • Keep switching cheap. Tabs imply the user will flip back and forth; do not lose entered data or scroll position when they leave and return to a tab.
  • Do not overload the strip. A handful of tabs reads at a glance; a dozen that scroll or collapse is a sign the content wants a different navigation.
  • Design the full lifecycle up front: empty, loading, and error states share the same panel container as the default view. A tab that only handles the happy path falls apart the first time a panel's data is slow or invalid.
  • Match the visual skin to context, not novelty. In CNGX the skin (line, contained, pill, ...) is pure CSS - it changes nothing about structure, keyboard, or ARIA - so pick the one that reads best and move on.

Communicating state

A tab can carry state that lives on the strip while its panel is hidden: this field is invalid, this tab is loading, this switch was rejected. Communicate it explicitly and never by colour alone, and keep the two failure channels separate so they never fight for the same surface.

Channel What went wrong Surface
Validation The tab's own content is invalid (a required field, a bad value) An error badge on the tab header plus an SR descriptor; bind [error] directly or wire [errorAggregator] for multi-source forms
Commit / async The switch was rejected (a guard or a server said no on activate) The rolled-back tab is decorated via *cngxTabRejectionIcon and the reason is announced through a toast / banner

The badge and the rejection icon are visual cues, never the only channel: the error message rides the tab's aria-describedby descriptor, and the rejection is announced through a live region. Validation owns the error badge, commit owns the rejection icon, and each renders on its own surface without overwriting the other.

Accessibility

A11y is not a later audit pass; it is part of the design from the first implementation. Build on semantic HTML first and add ARIA only where the semantics fall short.

Keyboard

The whole tab group must be operable from the keyboard alone.

Key Action
Tab Move into the tablist (lands on the selected tab), then on to the active panel
Arrow keys Move between tabs along the strip's axis and select as you go (automatic activation)
Home / End Jump to the first / last tab
Enter / Space Activate the focused tab (redundant under automatic activation, still expected)

Notes that matter in practice:

  • The selected tab is the strip's only tab stop (roving tabindex), so Tab never walks through every tab - it lands on the active one and the next Tab moves to the panel.
  • Under automatic activation, an arrow press moves focus and selects in one step, so focus and selection never diverge. This is the right model for tabs precisely because switching is cheap and side-effect-free; a stepper, where moving may validate, deliberately does not do this.
  • Disabled tabs are skipped by arrow navigation but stay in the strip and the "of N" count.
  • The active tab is scrolled into view when it would otherwise be clipped by overflow, so keyboard navigation never strands focus off-screen.

Screen reader

  • Give the tab group an accessible name (aria-label or aria-labelledby) so it is not just an unnamed region in the page outline.
  • The accessible name of each tab comes from its label, and the group announces position with it ("Tab 2 of 5: Settings") so the user hears where they are without counting.
  • Mark the selected tab with aria-selected, and tie each tab to its panel both ways (aria-controls on the tab, aria-labelledby on the panel) so AT can move between them.
  • Announce state changes - selection, errors, loading - through a live region rather than relying on a visual change a screen-reader user cannot see.
  • Keep status regions (the error descriptor, the live region) present in the DOM and toggle their content, rather than adding and removing them, so assistive tech has something stable to observe.

Visual

  • Never encode selection, error, or busy state in colour alone; pair it with a shape, icon, or text.
  • Keep the focus ring visible and high-contrast, and readable at 200% zoom.
  • Keep touch targets comfortable for coarse pointers; a cramped tab strip is hard to hit on mobile.

Overflow and skins

When the tabs do not fit the available width, the runaway tabs must not simply vanish. A "More" affordance (a menu-button: aria-haspopup="menu", keyboard-navigable, with virtual focus over the rows) keeps every tab reachable, and picking a hidden tab scrolls it back into the strip. Skins change only the CSS layer (via a host attribute); structure, ARIA, and keyboard behaviour are identical across every skin, so the choice is purely cosmetic and never an accessibility decision.

Routing

Tabs often map to URLs, and there are two distinct levels - pick by how much of the router you actually need.

  • Reflect the active tab in the URL while the content stays inline, so a link is shareable and a refresh stays on the tab. This is URL state sync (a #tab= fragment or ?tab= query param); the panels are still plain templates.
  • Make each tab a real route whose component renders into a <router-outlet>, so you get lazy loading, resolvers, CanDeactivate guards, deep links, and browser back/forward between tabs. The tabs stay role="tab" buttons driving navigation centrally - they are not anchors. When you need genuine link semantics (open-in-new-tab, middle-click), use a link-based navigation pattern instead.

Either way the tablist a11y - roles, keyboard, the named region - stays intact.

State

  • Keep the canonical state small and derive the rest. A single source of truth for "which tab is active" avoids a second index drifting out of sync with the URL or the selection.
  • Persist enough context that the user can leave a tab and return without losing entered data or scroll position.
  • Separate the kinds of state: what belongs in the URL (the active tab, when it is link-worthy), what is a stored preference, and what is transient in-memory interaction.

Mobile and responsive

  • Keep the pattern stable across breakpoints; the selected tab must never get hidden by a layout rearrangement.
  • Let the strip overflow into a "More" menu rather than wrapping into an unreadable grid of labels.
  • Avoid fixed panel heights when content varies in length, and plan the empty, loading, and error states inside the same panel container so a slow tab does not collapse the layout.

Quick checklist

  • Keyboard-only: can you reach the strip, move between tabs with the arrow keys, and Tab on to the panel?
  • One screen-reader pass: is the group named, the selected tab announced with its position, and are errors and loading announced?
  • Is selection (and any error / busy state) communicated without relying on colour?
  • Do clipped tabs stay reachable through an overflow affordance instead of disappearing?
  • Does entered data and scroll position survive switching away and back?
  • Are tabs really the right pattern here, or is the content actually ordered (a stepper) or side-by-side (not tabs at all)?

Structural CSS shared by every tab-group skin.
Layout-only - no colors, sizes, borders, or fonts beyond what is controlled via --cngx-tab-* custom properties.
CngxTabGroup (in @cngx/ui/tabs)references this file via styleUrls; ng-packagr inlines it at publish time so consumers of @cngx/ui/tabs do not need @cngx/common/tabs at runtime.
Headless directive use ([cngxTabGroup] on consumer-authored DOM) deliberately gets no styling here - consumers bring their own visual opinion. Selectors are class-prefixed (.cngx-tabs__*) and apply globally under the component's encapsulation: None.

State modifiers

Driven by ARIA attributes set by the organism:

  • [aria-selected="true"] - active tab, paints the underline indicator via an inset box-shadow so layout does not shift between resting and active
  • [aria-disabled="true"] - cursor: not-allowed, opacity dim via --cngx-tab-disabled-opacity
  • [aria-busy="true"] - cursor: progress while a commit is pending - :focus-visible - outline ring via --cngx-tab-focus-ring
  • .cngx-tab--rejected - persistent failed-target signal: outline ring + one-shot background pulse

Orientation

  • [data-orientation="vertical"] on the host - flips the strip into a left-column grid and changes scroll-snap to the Y axis

Slots

  • .cngx-tabs__strip-wrapper - outer row container
  • .cngx-tabs__strip - the clip box (programmatic scroll only; the <cngx-tab-overflow> molecule is the user access path to tabs that do not fit)
  • .cngx-tabs__tab - one tab button
  • .cngx-tabs__label - shrinkable label inside the button
  • .cngx-tabs__badge - inline error badge; ships its own real-error-tone background so it does not collapse to currentColor (mirrors the stepper precedent)
  • .cngx-tabs__busy-spinner - pending-commit spinner
  • .cngx-tabs__rejection-icon - persistent ! glyph in a circle
  • .cngx-tabs__panels / __panel - panel container + body
  • .cngx-sr-only - locally-duplicated screen-reader utility

Inheritance

  • --cngx-tab-active-indicator-color -> --cngx-color-primary (also pinned via :root delegation so the brand accent paints the underline in both light and dark modes without a 4-block)
  • Strip / tab geometry tokens (--cngx-tab-gap, --cngx-tab-padding, --cngx-tab-strip-padding) - leaf
  • State-tone tokens (--cngx-tab-error-badge-color,
    --cngx-tab-rejection-icon-color, --cngx-tab-rejected-outline, --cngx-tab-rejected-pulse-bg) - leaf, all pinned at the foundation's danger tone so they read against either surface

Reduced motion

Two hooks: under prefers-reduced-motion: reduce the busy spinner is force-stopped; the rejection pulse is gated behind prefers-reduced-motion: no-preference so it never runs when the user has opted out.

Thematic skins for <cngx-tab-group>. Pairs with the structural tabs-base.css in @cngx/common/tabs (which owns layout, scroll- snap, sizes, base color / background, sr-only) - this file owns the per-skin treatment, hover affordance, strip border, and the per-tab error badge. Tokens defined in tabs-base.css read with bare var(--cngx-tab-*) (no second fallback) because the structural cascade provides the default.

Skins

Selected by the [skin] input via the [data-skin] host attribute. Every skin renders the identical structure, slots, ARIA, and keyboard model - only the paint changes. Each value is a CSS @scope block.

  • line (default) - a primary underline ink-bar under the active tab; subtle strip border on the trailing edge.
  • contained - square folder tabs, the active one fused with the panel into one box, with a primary accent bar on its leading edge. The only skin whose scope spans the strip AND the panel.
  • segmented - the tabs share a muted track; the active tab lifts onto a raised surface with a soft shadow.
  • pill - rounded solid fills with gaps; the active pill takes the primary fill, resting pills tint on hover.
  • pill-outline - the same pill geometry, but the active tab reads as a tinted, outlined chip rather than a solid fill.

Orientation variants

On the host:

  • default (horizontal) - strip border-bottom, indicator / accent on the bottom (or top) edge of each tab.
  • [data-orientation="vertical"] - strip border-inline-end, indicator / accent on the inline edges; the segmented track and pill column stack vertically.

Slots

  • .cngx-tabs__strip - tab strip with border on the trailing edge
  • .cngx-tabs__tab - per-tab <button> with transparent indicator border and color/border-color transition
  • .cngx-tabs__badge - inline error-count disc next to a tab

All transitions short-circuit under prefers-reduced-motion.

Index#

Layout

--cngx-tab-gap#*
Default value 0.5rem

Gap between adjacent tab buttons and between a tab's icon / label / badge slots.

--cngx-tab-strip-padding#*
Default value 0.5rem 0

Padding shorthand of the tab strip (outer container).

--cngx-tab-padding#*
Default value 0.5rem 0.75rem

Padding shorthand of an individual tab button.

--cngx-tab-label-line-height#*
Default value 1.4

Line height of the primary label line. Also drives the leading height the row-layout icon aligns to, so a stacked sub-label grows downward while the icon stays beside the primary label.

--cngx-tab-sublabel-font-size#*
Default value 0.8125rem

Font size of a tab's optional secondary label line. inherits: true so a consumer override set on the cngx-tab-group host cascades down to the .cngx-tabs__sublabel descendant - with inherits: false the host-level override is silently dropped.

Surface

--cngx-tab-strip-bg#<color>
Default value transparent

Background of the tab strip.

--cngx-tab-bg#<color>
Default value transparent

Background of an individual tab button.

--cngx-tab-color#*
Default value currentColor

Text color of a resting tab button.

--cngx-tab-sublabel-color#*
Default value color-mix(in oklab, currentColor 65%, transparent)

Text color of a tab's optional secondary label line. Defaults to a translucent tint of the tab's own text colour so it reads as a muted second line on any surface and in either colour scheme.

--cngx-tab-strip-border#*
Default value 1px solid oklch(0.87 0 0)

Border shorthand of the tab strip's bottom (or inline-end for vertical orientation) edge. inherits: true so a themed value assigned on the host reaches the strip (the registered token's initial-value would otherwise win over any var() fallback).

--cngx-tab-indicator-thickness#<length>
Default value 2px

Thickness of the active-tab ink-bar indicator.

State / Active

--cngx-tab-active-color#*
Default value currentColor

Text color of the active (selected) tab.

--cngx-tab-active-indicator-color#<color>
Default value oklch(0.66 0.19 50)

Underline color of the active tab - a 2px box-shadow inset stripe along the inline-end edge of the tab button.
Falls back to --cngx-color-primary so the brand accent paints the selection indicator without competing with text contrast.

See: [[--cngx-color-primary]]

--cngx-tab-active-indicator-width#<length>
Default value 2px

Thickness of the active-tab underline indicator.

--cngx-tab-active-indicator-color#*
Default value currentColor

Color of the ink-bar indicator under the active tab.

State / Disabled

--cngx-tab-disabled-opacity#<number>
Default value 0.55

Opacity multiplier applied to disabled tabs.

State / Focus

--cngx-tab-focus-ring#*
Default value 2px solid currentColor

Focus-ring outline shorthand.

--cngx-tab-focus-offset#<length>
Default value 2px

Outline offset of the focus ring.

State / Error

--cngx-tab-error-badge-color#<color>
Default value oklch(0.45 0.15 25)

Background color of the inline error badge. inherits: true so a themed value set on the cngx-tab-group / cngx-tab-nav host (the Material bridge sets it there) reaches the descendant badge - with inherits: false the host value never cascades down and the badge keeps its initial tone. The default theme is unchanged: the initial value inherits identically when nothing sets the token above.

--cngx-tab-badge-size#*
Default value 1.25rem

Hit-target diameter of the per-tab error badge.

--cngx-tab-error-badge-text-color#<color>
Default value oklch(1 0 0)

Text color of the error-badge glyph.

--cngx-tab-badge-font-size#*
Default value 0.75em

Font-size of the error-badge glyph.

--cngx-tab-badge-font-weight#<number>
Default value 700

Font-weight of the error-badge glyph.

State / Busy

--cngx-tab-busy-spinner-size#*
Default value 1rem

Diameter of the busy spinner shown while a tab's commit is pending.

--cngx-tab-busy-spinner-color#*
Default value currentColor

Stroke color of the busy spinner.

State / Rejected

--cngx-tab-rejection-icon-size#*
Default value 1.25rem

Diameter of the persistent rejection icon (! glyph in a circle).

--cngx-tab-rejection-icon-color#<color>
Default value oklch(0.45 0.15 25)

Background color of the rejection icon disc. inherits: true so the themed disc tone set on the host (the Material bridge) reaches the descendant .cngx-tabs__rejection-icon - mirrors --cngx-tab-error-badge-color. Default theme unchanged.

--cngx-tab-rejection-icon-text#<color>
Default value oklch(1 0 0)

Text color of the rejection icon glyph.

--cngx-tab-rejection-icon-font-size#*
Default value 0.875rem

Font-size of the rejection icon glyph.

--cngx-tab-rejected-bg#<color>
Default value oklch(0.45 0.15 25 / 0.1)

Background wash of a rejected tab - a soft danger tint that settles in after the pulse, so the failed target stays legible without a harsh box. Static by default so it reads against either surface; the Material bridge remaps it onto the error role.

--cngx-tab-rejected-radius#<length>
Default value 0.375rem

Corner radius of the rejected-tab wash, so the tint reads as a soft highlight rather than a hard block.

--cngx-tab-rejected-accent-width#<length>
Default value 2px

Thickness of the rejected tab's bottom accent bar - echoes the active indicator's language in the danger tone instead of a full outline ring.

--cngx-tab-rejected-outline#*
Default value none

Outline shorthand of the rejected tab. none by default - the wash plus the bottom accent carry the signal; set this to restore a full ring.

--cngx-tab-rejected-outline-offset#<length>
Default value 1px

Outline offset of the rejected-tab outline.

--cngx-tab-rejected-pulse-duration#<time>
Default value 600ms

Duration of the rejected-tab pulse animation.

--cngx-tab-rejected-pulse-bg#<color>
Default value oklch(0.45 0.15 25 / 0.18)

Background tint used at the peak of the pulse animation.

State / Dismissable

--cngx-tab-affordance-size#*
Default value 1.25rem

Hit-target size of a tab's close button and the add-tab button.

--cngx-tab-affordance-radius#*
Default value 50%

Corner radius of the close / add affordance buttons.

--cngx-tab-affordance-hover-bg#*
Default value color-mix(in oklch, currentColor 14%, transparent)

Hover/focus fill of the close / add affordance buttons. Defaults to a translucent tint of the current text colour, so it adapts to any surface and either colour scheme without a 4-block.

Motion

--cngx-tab-transition-duration#<time>
Default value 0.15s

Transition duration for color + border-color crossfade on hover / active changes.

State / Hover

--cngx-tab-hover-color#*
Default value currentColor

Text color of the hover state.

Skin

--cngx-tab-radius#*
Default value 0.5rem

Corner radius of the contained / pill skin surfaces. Ignored by the line skin. inherits: true so a host-level override cascades onto the tab buttons and panel.

--cngx-tab-active-bg#<color>
Default value oklch(1 0 0)

Surface that the active tab fuses with under the contained skin (the panel surface) and the active-pill fill under the pill skin. Set from --cngx-color-surface / --cngx-color-primary via the per-skin :scope assignment.

--cngx-tab-inactive-bg#<color>
Default value oklch(0.97 0 0)

Resting-tab surface under the contained skin - kept visibly set back from the active tab so tabs read as controls, not headings.

--cngx-tab-surface-border-color#<color>
Default value oklch(0.87 0 0)

Border color of the contained skin's tab + panel box.

--cngx-tab-surface-border-width#<length>
Default value 1px

Border width of the contained skin's tab + panel box. Drives both the border and the negative overlap that fuses adjacent tabs and the active tab with the panel (via calc(... * -1)), so a thicker border keeps the seams aligned.

--cngx-tab-pill-active-color#*
Default value currentColor

Text color of the active pill (paints against --cngx-tab-active-bg).

--cngx-tab-pill-hover-bg#<color>
Default value oklch(0.95 0 0)

Resting-pill hover fill under the pill skin.

--cngx-tab-segmented-track-bg#<color>
Default value oklch(0.96 0 0)

Muted track the tabs sit in under the segmented skin.

--cngx-tab-segmented-hover-bg#<color>
Default value oklch(0.93 0 0)

Resting-tab hover fill under the segmented skin - a faint lift off the track, kept short of the raised active surface.

--cngx-tab-segmented-active-shadow#*
Default value 0 1px 2px oklch(0 0 0 / 0.06)

Drop shadow that raises the active tab off the track under the segmented skin.

--cngx-tab-pill-outline-color#<color>
Default value oklch(0.66 0.19 50)

Accent color of the pill-outline skin - paints the active tab's text and border, and the resting-tab hover border.

--cngx-tab-pill-outline-bg#<color>
Default value color-mix(in oklch, oklch(0.66 0.19 50) 12%, transparent)

Tinted fill behind the active tab under the pill-outline skin.

--cngx-tab-pill-radius#<length>
Default value 100px

Corner radius of the pill / pill-outline skins - a full pill shape by default. Overridable for a softer rounded-rectangle.

--cngx-tab-contained-accent-color#<color>
Default value oklch(0.66 0.19 50)

Color of the contained skin's top (inline-start for vertical) accent bar under the active tab.

--cngx-tab-contained-accent-thickness#<length>
Default value 3px

Thickness of the contained skin's active-tab accent bar.

Material theme coverage across all skins

import { ChangeDetectionStrategy, Component, ViewEncapsulation, signal } from '@angular/core';

import { CngxTab, CngxTabContent } from '@cngx/common/tabs';
import { CngxTabGroup, CngxTabOverflow } from '@cngx/ui/tabs';

/**
 * Every `CngxTabGroup` skin rendered against a Material 3 palette, plus the
 * `CngxTabOverflow` "More" popover styled as a Material menu.
 *
 * The example's stylesheet builds a real M3 theme in SCSS: `mat.theme` emits
 * the `--mat-sys-*` system tokens, then the published
 * `@cngx/themes/material/tabs-theme` bridge routes every tab token onto its
 * Material counterpart:
 *
 * ```scss
 * @use '@angular/material' as mat;
 * @use '@cngx/themes/material/tabs-theme' as tabs;
 *
 * $theme: mat.define-theme((color: (theme-type: light, primary: mat.$azure-palette)));
 * html {
 *   @include mat.theme($theme);
 *   @include tabs.theme($theme);
 * }
 * ```
 *
 * So each skin inherits the active indicator / label (primary), surfaces
 * (surface) and error tones (error) from the Material palette with no per-skin
 * overrides, and the overflow popover reads as a `mat-menu`. The shared
 * `[(activeIndex)]` keeps every group in lock-step - the skin is purely
 * visual. `ViewEncapsulation.None` lets the global `html` theme and the
 * `:where(cngx-tab-group)` / `:where(.cngx-tab-overflow__panel)` bridge rules
 * reach the tabs and the top-layer popover.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [CngxTabGroup, CngxTabOverflow, CngxTab, CngxTabContent],
  styleUrl: './skins-coverage.component.scss',
  template: `
    <div class="coverage">
      @for (skin of skins; track skin.id) {
        <section>
          <h3>{{ skin.id }}</h3>
          <cngx-tab-group [skin]="skin.id" [(activeIndex)]="active" [attr.aria-label]="skin.label">
            <div cngxTab [label]="'Overview'">
              <ng-template cngxTabContent><p>Overview content.</p></ng-template>
            </div>
            <div cngxTab [label]="'Activity'">
              <ng-template cngxTabContent><p>Activity content.</p></ng-template>
            </div>
            <div cngxTab [label]="'Settings'">
              <ng-template cngxTabContent><p>Settings content.</p></ng-template>
            </div>
          </cngx-tab-group>
        </section>
      }

      <section>
        <h3>overflow - "More" menu</h3>
        <div class="coverage__narrow">
          <cngx-tab-group [(activeIndex)]="overflowActive" aria-label="Overflow - Material menu">
            <div cngxTab [label]="'Profile'">
              <ng-template cngxTabContent><p>Profile</p></ng-template>
            </div>
            <div cngxTab [label]="'Account'">
              <ng-template cngxTabContent><p>Account</p></ng-template>
            </div>
            <div cngxTab [label]="'Notifications'">
              <ng-template cngxTabContent><p>Notifications</p></ng-template>
            </div>
            <div cngxTab [label]="'Privacy'">
              <ng-template cngxTabContent><p>Privacy</p></ng-template>
            </div>
            <div cngxTab [label]="'Sessions'">
              <ng-template cngxTabContent><p>Sessions</p></ng-template>
            </div>
            <div cngxTab [label]="'Tokens'">
              <ng-template cngxTabContent><p>API tokens</p></ng-template>
            </div>
            <div cngxTab [label]="'Billing'">
              <ng-template cngxTabContent><p>Billing</p></ng-template>
            </div>
            <div cngxTab [label]="'Danger zone'">
              <ng-template cngxTabContent><p>Destructive actions</p></ng-template>
            </div>
            <cngx-tab-overflow></cngx-tab-overflow>
          </cngx-tab-group>
        </div>
      </section>
    </div>
  `,
})
export class SkinsCoverageExample {
  protected readonly active = signal(0);
  protected readonly overflowActive = signal(0);

  protected readonly skins = [
    { id: 'line', label: 'Line skin' },
    { id: 'contained', label: 'Contained skin' },
    { id: 'segmented', label: 'Segmented skin' },
    { id: 'pill', label: 'Pill skin' },
    { id: 'pill-outline', label: 'Pill-outline skin' },
  ] as const;
}
export { SkinsCoverageExample as AppComponent };

Overflow "More" menu under resize

import { ChangeDetectionStrategy, Component, ViewEncapsulation, signal } from '@angular/core';

import { CngxTab, CngxTabContent } from '@cngx/common/tabs';
import { CngxTabGroup, CngxTabOverflow } from '@cngx/ui/tabs';

/**
 * `CngxTabOverflow` under a live width constraint, Material-themed.
 *
 * Drag the frame's resize handle (bottom-right corner): an
 * `IntersectionObserver` rooted on the tab strip re-derives the hidden set
 * as the width crosses each tab's threshold, and the clipped tabs collapse
 * into the "More" popover - itself painted as a Material `mat-menu` by the
 * `@cngx/themes/material/tabs-theme` bridge. Picking a hidden tab delegates
 * back to the panel host, so keyboard and pointer paths converge. The whole
 * thing is the runnable counterpart to the static overflow stories - the
 * resize gesture is the point.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [CngxTabGroup, CngxTabOverflow, CngxTab, CngxTabContent],
  styleUrl: './overflow-resize.component.scss',
  template: `
    <p class="hint">
      Drag the bottom-right handle to resize. Tabs that no longer fit collapse
      into the "More" menu.
    </p>
    <div class="frame">
      <cngx-tab-group [(activeIndex)]="active" aria-label="Workspace sections">
        @for (tab of tabs; track tab) {
          <div cngxTab [label]="tab">
            <ng-template cngxTabContent><p>{{ tab }} content.</p></ng-template>
          </div>
        }
        <cngx-tab-overflow></cngx-tab-overflow>
      </cngx-tab-group>
    </div>
  `,
})
export class OverflowResizeExample {
  protected readonly active = signal(0);
  protected readonly tabs = [
    'Profile',
    'Account',
    'Notifications',
    'Privacy',
    'Sessions',
    'Tokens',
    'Billing',
    'Danger zone',
  ];
}
export { OverflowResizeExample as AppComponent };

Async commit-action (optimistic / pessimistic)

import { ChangeDetectionStrategy, Component, ViewEncapsulation, signal } from '@angular/core';
import { Observable } from 'rxjs';

import { CngxTab, CngxTabContent, type CngxTabsCommitAction } from '@cngx/common/tabs';
import { CngxTabGroup } from '@cngx/ui/tabs';

/**
 * Tabs as an async navigation guard - `[commitAction]` gates each switch.
 *
 * The action returns an `Observable<boolean>` that resolves after a delay.
 * **Optimistic** flips the panel immediately and rolls back if the action
 * rejects; **pessimistic** blocks the switch until it resolves. Toggle the
 * mode and "simulate error" to exercise all four quadrants:
 *
 * - while a commit is pending the target tab shows the busy spinner
 *   (`aria-busy`),
 * - a rejection lights the rejection decoration and announces the rollback,
 * - optimistic rolls the active tab back; pessimistic never moved it.
 *
 * No explicit state machine and no `[state]` binding - the presenter's commit
 * lifecycle drives every visual. Themed via the Material tabs-theme bridge.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [CngxTabGroup, CngxTab, CngxTabContent],
  styleUrl: './async-commit.component.scss',
  template: `
    <div class="controls">
      <div class="seg" role="group" aria-label="Commit mode">
        <button type="button" [class.seg--on]="mode() === 'optimistic'" (click)="mode.set('optimistic')">
          optimistic
        </button>
        <button type="button" [class.seg--on]="mode() === 'pessimistic'" (click)="mode.set('pessimistic')">
          pessimistic
        </button>
      </div>
      <label class="flag">
        <input type="checkbox" [checked]="shouldFail()" (change)="shouldFail.set($any($event.target).checked)" />
        simulate error
      </label>
    </div>

    <cngx-tab-group
      [(activeIndex)]="active"
      [commitAction]="commitAction"
      [commitMode]="mode()"
      aria-label="Async tab navigation"
    >
      <div cngxTab [label]="'Profile'">
        <ng-template cngxTabContent><p>Profile content.</p></ng-template>
      </div>
      <div cngxTab [label]="'Account'">
        <ng-template cngxTabContent><p>Account content.</p></ng-template>
      </div>
      <div cngxTab [label]="'Notifications'">
        <ng-template cngxTabContent><p>Notification preferences.</p></ng-template>
      </div>
    </cngx-tab-group>

    <p class="status">Active tab: {{ active() }} - mode: {{ mode() }}</p>
  `,
})
export class AsyncCommitExample {
  protected readonly active = signal(0);
  protected readonly mode = signal<'optimistic' | 'pessimistic'>('optimistic');
  protected readonly shouldFail = signal(false);

  /** Resolves `true` after 600ms, or errors when "simulate error" is on. */
  protected readonly commitAction: CngxTabsCommitAction = (from, to) =>
    new Observable<boolean>((subscriber) => {
      const fail = this.shouldFail();
      const handle = setTimeout(() => {
        if (fail) {
          subscriber.error(new Error('Server refused tab ' + from + ' -> ' + to));
        } else {
          subscriber.next(true);
          subscriber.complete();
        }
      }, 600);
      return () => clearTimeout(handle);
    });
}
export { AsyncCommitExample as AppComponent };

Dismissable + addable tabs

import { ChangeDetectionStrategy, Component, ViewEncapsulation, signal } from '@angular/core';

import { CngxTab, type CngxTabCloseEvent, CngxTabContent } from '@cngx/common/tabs';
import { CngxTabGroup } from '@cngx/ui/tabs';

/**
 * Dismissable + addable tabs (APG deletable-tabs pattern), Material-themed.
 *
 * `[closable]` adds a close button to each tab (Delete on a focused tab also
 * closes it); `[addable]` adds an add-tab button. The library moves the
 * active tab to a surviving neighbour and restores focus - the consumer owns
 * the data: `(tabClose)` removes from the signal array, `(tabAdd)` appends.
 * The pinned **Home** tab sets `[closable]="false"`, so it never shows a
 * close button. Tabs are derived from a `signal` array - Ableitung statt
 * Verwaltung.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [CngxTabGroup, CngxTab, CngxTabContent],
  styleUrl: './dynamic-tabs.component.scss',
  template: `
    <cngx-tab-group
      skin="contained"
      [closable]="true"
      [addable]="true"
      [(activeIndex)]="active"
      aria-label="Open documents"
      (tabClose)="onClose($event)"
      (tabAdd)="onAdd()"
    >
      <div cngxTab [label]="'Home'" [closable]="false">
        <ng-template cngxTabContent><p>Pinned home tab - no close button.</p></ng-template>
      </div>
      @for (doc of docs(); track doc.id) {
        <div cngxTab [id]="doc.id" [label]="doc.label">
          <ng-template cngxTabContent><p>{{ doc.label }} content.</p></ng-template>
        </div>
      }
    </cngx-tab-group>

    <p class="status">Open documents: {{ docs().length }}</p>
  `,
})
export class DynamicTabsExample {
  protected readonly active = signal(0);
  protected readonly docs = signal([
    { id: 'doc-1', label: 'Document 1' },
    { id: 'doc-2', label: 'Document 2' },
  ]);
  private seq = 2;

  protected onClose(event: CngxTabCloseEvent): void {
    this.docs.update((list) => list.filter((doc) => doc.id !== event.id));
  }

  protected onAdd(): void {
    this.seq += 1;
    const id = 'doc-' + this.seq;
    this.docs.update((list) => [...list, { id, label: 'Document ' + this.seq }]);
    // Home sits at index 0; the new document is the last tab.
    this.active.set(this.docs().length);
  }
}
export { DynamicTabsExample as AppComponent };

Data-driven tabs via HTTP (Material)

import { HttpClient, type HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, signal } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

import { CngxTab, CngxTabContent, type CngxTabsCommitAction } from '@cngx/common/tabs';
import { CngxTabGroup } from '@cngx/ui/tabs';
import { CngxToastOn, CngxToastOutlet } from '@cngx/ui/feedback';

import { type Section } from './backend';

// Re-export ships app.config.ts (HttpClient + fake-backend interceptor) into
// the StackBlitz manifest.
export { appConfig } from './app.config';

/**
 * Data-driven tabs over a real `HttpClient`, Material-themed.
 *
 * The tab set is loaded from `GET /api/sections`, and `[commitAction]` issues a
 * real `PUT /api/sections/<id>/activate` per switch - so the busy spinner and
 * the rejection decoration are driven by genuine async HTTP, not a `setTimeout`.
 * Switching to **Billing** is refused by the server (409): pessimistic mode
 * keeps the active tab put and the rejection icon lights up. This needs
 * `provideHttpClient` + an interceptor, so it only works as a runnable
 * playground - not as a static `<example-url>` story.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [CngxTabGroup, CngxTab, CngxTabContent, CngxToastOn, CngxToastOutlet],
  styleUrl: './http-commit-material.component.scss',
  template: `
    @if (sections().length) {
      <cngx-tab-group
        [(activeIndex)]="active"
        [commitAction]="commit"
        commitMode="pessimistic"
        aria-label="Workspace sections"
        cngxToastOn
        toastError="Could not switch section"
        [toastErrorDetail]="true"
      >
        @for (section of sections(); track section.id) {
          <div cngxTab [label]="section.label">
            <ng-template cngxTabContent><p>{{ section.body }}</p></ng-template>
          </div>
        }
      </cngx-tab-group>
    } @else {
      <p class="loading">Loading sections from /api/sections…</p>
    }

    <cngx-toast-outlet />
  `,
})
export class HttpCommitMaterialExample {
  private readonly http = inject(HttpClient);
  protected readonly sections = signal<Section[]>([]);
  protected readonly active = signal(0);

  constructor() {
    this.http.get<Section[]>('/api/sections').subscribe((sections) => this.sections.set(sections));
  }

  /**
   * Commit a switch by PUT-ing to the API; a non-2xx response rejects it. The
   * `HttpErrorResponse` is mapped to a plain `Error` carrying the server's
   * business message so `cngxToastOn` (with `toastErrorDetail`) surfaces it.
   */
  protected readonly commit: CngxTabsCommitAction = (_from, to) => {
    const target = this.sections()[to];
    return this.http.put(`/api/sections/${target.id}/activate`, {}).pipe(
      map(() => true),
      catchError((err: HttpErrorResponse) =>
        throwError(() => new Error(err.error?.message ?? 'The server refused the switch.')),
      ),
    );
  };
}
export { HttpCommitMaterialExample as AppComponent };

Data-driven tabs via HTTP (default theme)

import { HttpClient, type HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, signal } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

import { CngxTab, CngxTabContent, type CngxTabsCommitAction } from '@cngx/common/tabs';
import { CngxTabGroup } from '@cngx/ui/tabs';
import { CngxToastOn, CngxToastOutlet } from '@cngx/ui/feedback';

import { type Section } from './backend';

export { appConfig } from './app.config';

/**
 * Data-driven tabs over a real `HttpClient`, on the cngx default theme (no
 * Material bridge).
 *
 * Identical wiring to the Material variant - tabs loaded from `GET
 * /api/sections`, `[commitAction]` PUTs per switch, **Billing** is refused by
 * the server (409) - but rendered on cngx's own default styling, so it shows the
 * components are fully themed without Material. Needs `provideHttpClient` + an
 * interceptor, so it only works as a runnable playground.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [CngxTabGroup, CngxTab, CngxTabContent, CngxToastOn, CngxToastOutlet],
  styleUrl: './http-commit-plain.component.scss',
  template: `
    @if (sections().length) {
      <cngx-tab-group
        [(activeIndex)]="active"
        [commitAction]="commit"
        commitMode="pessimistic"
        aria-label="Workspace sections"
        cngxToastOn
        toastError="Could not switch section"
        [toastErrorDetail]="true"
      >
        @for (section of sections(); track section.id) {
          <div cngxTab [label]="section.label">
            <ng-template cngxTabContent><p>{{ section.body }}</p></ng-template>
          </div>
        }
      </cngx-tab-group>
    } @else {
      <p class="loading">Loading sections from /api/sections…</p>
    }

    <cngx-toast-outlet />
  `,
})
export class HttpCommitPlainExample {
  private readonly http = inject(HttpClient);
  protected readonly sections = signal<Section[]>([]);
  protected readonly active = signal(0);

  constructor() {
    this.http.get<Section[]>('/api/sections').subscribe((sections) => this.sections.set(sections));
  }

  protected readonly commit: CngxTabsCommitAction = (_from, to) => {
    const target = this.sections()[to];
    return this.http.put(`/api/sections/${target.id}/activate`, {}).pipe(
      map(() => true),
      catchError((err: HttpErrorResponse) =>
        throwError(() => new Error(err.error?.message ?? 'The server refused the switch.')),
      ),
    );
  };
}
export { HttpCommitPlainExample as AppComponent };

Form error aggregation

import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';
import { startWith } from 'rxjs/operators';

import { injectErrorAggregator } from '@cngx/common/interactive';
import { CngxTab, CngxTabContent } from '@cngx/common/tabs';
import { CngxTabGroup } from '@cngx/ui/tabs';

/**
 * Per-tab form error aggregation.
 *
 * Each `[cngxTab]` binds `[errorAggregator]` to a `CngxErrorAggregator`
 * built with `injectErrorAggregator`. The aggregator's source signal reads
 * the form group's `statusChanges` (via `toSignal` + `startWith`) and the
 * matching tab header gains a `cngx-tabs__badge` plus an SR descriptor
 * span — fully signal-derived, no manual toggles. Both tabs start with
 * valid pre-filled values; clear a field to trigger its badge. The two
 * aggregators are independent — fixing one tab does not affect the other.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule, CngxTabGroup, CngxTab, CngxTabContent],
  template: `
    <p style="margin: 0 0 12px; opacity: 0.8; font-size: 0.875rem">
      Two tabs, each bound to a reactive <code>FormGroup</code>. Clear a
      field — the per-tab error aggregator reads
      <code>statusChanges</code> and a badge lights up on the tab header.
    </p>
    <cngx-tab-group aria-label="Form error aggregation demo">
      <div cngxTab label="Profile" [errorAggregator]="profileErrors">
        <ng-template cngxTabContent>
          <form
            [formGroup]="profileForm"
            style="display: flex; flex-direction: column; gap: 8px; padding: 12px"
          >
            <label style="display: flex; flex-direction: column; gap: 4px">
              <span>Name</span>
              <input type="text" formControlName="name" />
            </label>
            <small style="opacity: 0.7">Required, min 2 characters</small>
          </form>
        </ng-template>
      </div>
      <div cngxTab label="Account" [errorAggregator]="accountErrors">
        <ng-template cngxTabContent>
          <form
            [formGroup]="accountForm"
            style="display: flex; flex-direction: column; gap: 8px; padding: 12px"
          >
            <label style="display: flex; flex-direction: column; gap: 4px">
              <span>Email</span>
              <input type="email" formControlName="email" />
            </label>
            <small style="opacity: 0.7">Required, valid email format</small>
          </form>
        </ng-template>
      </div>
      <div cngxTab label="Notifications">
        <ng-template cngxTabContent>
          <p style="padding: 12px">No aggregator bound — no badge on this tab.</p>
        </ng-template>
      </div>
    </cngx-tab-group>
  `,
})
export class FormErrorsExample {
  protected readonly profileForm = new FormGroup({
    name: new FormControl('Jane Doe', {
      nonNullable: true,
      validators: [Validators.required, Validators.minLength(2)],
    }),
  });
  protected readonly accountForm = new FormGroup({
    email: new FormControl('jane@example.com', {
      nonNullable: true,
      validators: [Validators.required, Validators.email],
    }),
  });

  private readonly profileStatus = toSignal(
    this.profileForm.statusChanges.pipe(startWith(this.profileForm.status)),
    { initialValue: this.profileForm.status },
  );
  private readonly accountStatus = toSignal(
    this.accountForm.statusChanges.pipe(startWith(this.accountForm.status)),
    { initialValue: this.accountForm.status },
  );

  protected readonly profileErrors = injectErrorAggregator(
    undefined,
    { profile: computed(() => this.profileStatus() === 'INVALID') },
    undefined,
    { profile: 'Profile name is required (min 2 chars)' },
  );
  protected readonly accountErrors = injectErrorAggregator(
    undefined,
    { account: computed(() => this.accountStatus() === 'INVALID') },
    undefined,
    { account: 'Account email must be valid' },
  );
}
export { FormErrorsExample as AppComponent };

Routed tabs with a CanDeactivate guard

import { ChangeDetectionStrategy, Component, inject, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationCancel, Router, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs/operators';

import { CngxTab, type CngxTabGroupPresenter, CngxTabsRouteSync } from '@cngx/common/tabs';
import { CngxTabGroup } from '@cngx/ui/tabs';

import { DemoFormState } from './demo-routes';

// Re-export forces compodocx to ship app.config.ts (and, transitively,
// demo-routes.ts) in the StackBlitz manifest - the router lives there.
export { appConfig } from './app.config';

/**
 * Routed tab group - tabs gate a `<router-outlet>` through the router.
 *
 * `[cngxTabsRouteSync]` on `<cngx-tab-group>` turns every tab switch into
 * an Angular navigation: clicking a tab runs `router.navigate([id])`, the
 * active tab follows the *resolved* route, and the presenter's existing
 * commit lifecycle is the gate - no new state machine.
 *
 * - **CanDeactivate gating.** The `profile` route guards its own exit.
 *   Check "I have unsaved changes", then click another tab: the guard
 *   refuses, the navigation is cancelled, and the active tab stays on
 *   Profile.
 * - **The fault shows on the source, not the target.** A blocked *leave*
 *   is the Profile tab's problem, so the Profile tab binds `[error]` to the
 *   shared unsaved signal and shows the badge proactively. cngx also pins
 *   its generic rejection marker on the tab you *tried* to reach (the
 *   cancelled-navigation target) - the wrong tab for a leave guard - so
 *   this demo clears it on `NavigationCancel`. The clicked tab stays clean;
 *   only Profile signals.
 * - **External navigation.** Browser back/forward and direct URL edits
 *   reflect into the active tab without re-navigating.
 *
 * The tab strip drives the outlet; the group's own panel region is
 * hidden because the content is the route.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CngxTabGroup, CngxTab, CngxTabsRouteSync, RouterOutlet],
  styles: [
    `
      :host {
        display: block;
        max-width: 640px;
      }
      /* Routed content lives in the outlet, not the internal panels. */
      .routed-demo cngx-tab-group .cngx-tabs__panels {
        display: none;
      }
      .routed-demo__outlet {
        padding: 16px;
        border: 1px solid var(--cngx-color-border, #d4d4d8);
        border-top: none;
        border-radius: 0 0 8px 8px;
      }
    `,
  ],
  template: `
    <div class="routed-demo">
      <p style="opacity: 0.8; font-size: 0.875rem">
        A <code>&lt;cngx-tab-group cngxTabsRouteSync&gt;</code> over a
        <code>&lt;router-outlet&gt;</code>. The Profile route guards its
        exit while "unsaved changes" is on - only the Profile tab signals.
      </p>

      <cngx-tab-group #tg="cngxTabGroup" cngxTabsRouteSync aria-label="Routed account tabs">
        <div cngxTab id="overview" label="Overview"></div>
        <div
          cngxTab
          id="profile"
          label="Profile"
          [error]="state.unsaved() ? 'Unsaved changes - resolve before leaving' : false"
        ></div>
        <div cngxTab id="settings" label="Settings"></div>
      </cngx-tab-group>

      <div class="routed-demo__outlet">
        <router-outlet></router-outlet>
      </div>
    </div>
  `,
})
export class RoutedOutletExample {
  protected readonly state = inject(DemoFormState);
  private readonly router = inject(Router);
  private readonly group = viewChild<CngxTabGroupPresenter>('tg');

  constructor() {
    // A refused *leave* belongs to the Profile tab (its `[error]`), not to
    // the tab the user clicked. cngx pins its generic rejection marker on
    // the cancelled-navigation target; clear it after the presenter sets it
    // (queueMicrotask defers past the presenter's own NavigationCancel
    // handler) so the clicked tab never lights up red.
    this.router.events
      .pipe(
        filter((event): event is NavigationCancel => event instanceof NavigationCancel),
        takeUntilDestroyed(),
      )
      .subscribe(() => queueMicrotask(() => this.group()?.clearLastFailed()));
  }
}
export { RoutedOutletExample as AppComponent };