CngxTabGroup
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].
Metadata#
Host#
Providers#
CNGX_TAB_PANEL_HOST- useExisting
CngxTabGroup
Dependencies#
CNGX_TAB_GROUP_HOSTinject()presenterRelationships
Index#
Properties
Methods
Inputs#
boolean | undefinedWhether 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.
boolean | undefinedWhether 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.
boolean | undefinedStretch tabs to fill the strip width (horizontal only); reflects [data-fitted]. Cascade input ?? config ?? false.
CngxTabIconLayout | undefinedIcon layout. Cascade input ?? config ?? 'start', reflected onto
[data-icon-layout]. Orthogonal to skin and orientation.
CngxTabsPanelMode | undefinedPanel 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.
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.
CngxTabAlign | undefinedTab-cluster alignment when not fitted (horizontal only); reflects [data-tab-align]. Cascade input ?? config ?? 'start'.
Instance Properties#
CngxTabGroupAnnouncementsAT-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,
})CngxTabDismissalsDismissable / 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,
})unknownDefault-glyph table for the template's fallback spans.
CNGX_TABS_GLYPHSCngxTabsHostAttrsResolved 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,
})CngxTabKeyboardNavAPG 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,
})CngxTabGroupTemplateBindingsResolved 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#
CngxTabBusySpinnerContextbusySpinnerContextFor(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)returningtruerequires it.
Clear the presenter's lastFailedIndex rejection flag - exposed
so template-ref consumers (#tg="cngxTabGroup") can dismiss the
decoration without injecting the host token.
CngxTabErrorBadgeContexterrorBadgeContextFor(tab: CngxTabHandle)WeakMap-cached context for *cngxTabErrorBadge. Stable ref per tab.
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.
KeyboardEventCngxTabIconContexticonContextFor(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.
"0" | nullpanelTabindex(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]).
CngxTabRejectionIconContextrejectionIconContextFor(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.
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]).
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.
"0" | unknowntabTabindex(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.
HostBindings#
| Binding | Expression |
|---|---|
[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. Carriesaria-selectedand points at its panel witharia-controls. - Tab panel (
role="tabpanel"): the content of the selected tab, labelled by its tab viaaria-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
Tabnever walks through every tab - it lands on the active one and the nextTabmoves 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-labeloraria-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-controlson the tab,aria-labelledbyon 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,CanDeactivateguards, deep links, and browser back/forward between tabs. The tabs stayrole="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 tocurrentColor(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:rootdelegation 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
Surface
State / Active
State / Disabled
State / Focus
State / Error
State / Rejected
State / Dismissable
State / Hover
Skin
Layout
*0.5remGap between adjacent tab buttons and between a tab's icon / label / badge slots.
*0.5rem 0Padding shorthand of the tab strip (outer container).
*1.4Line 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.
*0.8125remFont 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
*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.
*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).
<length>2pxThickness of the active-tab ink-bar indicator.
State / Active
<color>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]]
<length>2pxThickness of the active-tab underline indicator.
*currentColorColor of the ink-bar indicator under the active tab.
State / Disabled
State / Focus
State / Error
<color>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.
<color>oklch(1 0 0)Text color of the error-badge glyph.
State / Busy
*1remDiameter of the busy spinner shown while a tab's commit is pending.
State / Rejected
*1.25remDiameter of the persistent rejection icon (! glyph in a circle).
<color>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.
<color>oklch(1 0 0)Text color of the rejection icon glyph.
<color>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.
<length>0.375remCorner radius of the rejected-tab wash, so the tint reads as a soft highlight rather than a hard block.
<length>2pxThickness of the rejected tab's bottom accent bar - echoes the active indicator's language in the danger tone instead of a full outline ring.
*noneOutline shorthand of the rejected tab. none by default - the wash plus
the bottom accent carry the signal; set this to restore a full ring.
<length>1pxOutline offset of the rejected-tab outline.
<time>600msDuration of the rejected-tab pulse animation.
<color>oklch(0.45 0.15 25 / 0.18)Background tint used at the peak of the pulse animation.
State / Dismissable
*1.25remHit-target size of a tab's close button and the add-tab button.
*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
<time>0.15sTransition duration for color + border-color crossfade on hover / active changes.
State / Hover
Skin
*0.5remCorner 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.
<color>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.
<color>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.
<color>oklch(0.87 0 0)Border color of the contained skin's tab + panel box.
<length>1pxBorder 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.
*currentColorText color of the active pill (paints against --cngx-tab-active-bg).
<color>oklch(0.95 0 0)Resting-pill hover fill under the pill skin.
<color>oklch(0.96 0 0)Muted track the tabs sit in under the segmented skin.
<color>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.
*0 1px 2px oklch(0 0 0 / 0.06)Drop shadow that raises the active tab off the track under the
segmented skin.
<color>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.
<color>color-mix(in oklch, oklch(0.66 0.19 50) 12%, transparent)Tinted fill behind the active tab under the pill-outline skin.
<length>100pxCorner radius of the pill / pill-outline skins - a full pill
shape by default. Overridable for a softer rounded-rectangle.
<color>oklch(0.66 0.19 50)Color of the contained skin's top (inline-start for vertical)
accent bar under the active tab.
<length>3pxThickness 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><cngx-tab-group cngxTabsRouteSync></code> over a
<code><router-outlet></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 };