+
+ `);
+
+ expectContents(fixture, ['1-true', '2-false'], 0);
+
+ getLinks(fixture)[1].click();
+ fixture.detectChanges();
+ expectContents(fixture, ['1-false', '2-true'], 1);
+ });
+});
+
+@Component({selector: 'test-cmp', template: ''})
+class TestComponent {
+ activeId;
+ disabled = true;
+ items = [1, 2];
+ orientation = 'horizontal';
+ roles: 'tablist' | false = 'tablist';
+ onActiveIdChange = () => {};
+ onNavChange = () => {};
+ onNavChangePrevent = event => {
+ if (isDefined(event.activeId)) {
+ event.preventDefault();
+ }
+ }
+}
diff --git a/src/nav/nav.ts b/src/nav/nav.ts
new file mode 100644
index 0000000000..67285b1925
--- /dev/null
+++ b/src/nav/nav.ts
@@ -0,0 +1,269 @@
+import {
+ AfterContentChecked,
+ AfterContentInit,
+ Attribute,
+ ChangeDetectorRef,
+ ContentChildren,
+ Directive,
+ ElementRef,
+ EventEmitter,
+ forwardRef,
+ Inject,
+ Input,
+ OnInit,
+ Output,
+ QueryList,
+ TemplateRef
+} from '@angular/core';
+import {isDefined} from '../util/util';
+import {NgbNavConfig} from './nav-config';
+
+let navCounter = 0;
+
+/**
+ * Context passed to the nav content template.
+ *
+ * See [this demo](#/components/nav/examples#keep-content) as the example.
+ */
+export interface NgbNavContentContext {
+ /**
+ * If `true`, current nav content is visible and active
+ */
+ $implicit: boolean;
+}
+
+
+/**
+ * This directive must be used to wrap content to be displayed in the nav.
+ */
+@Directive({selector: 'ng-template[ngbNavContent]'})
+export class NgbNavContent {
+ constructor(public templateRef: TemplateRef) {}
+}
+
+
+/**
+ * The directive used to group nav link and related nav content. As well as set nav identifier and some options.
+ */
+@Directive({selector: '[ngbNavItem]', exportAs: 'ngbNavItem', host: {'[class.nav-item]': 'true'}})
+export class NgbNavItem implements AfterContentChecked, OnInit {
+ private _nav: NgbNav;
+
+ /**
+ * If `true`, non-active current nav item content will be removed from DOM
+ * Otherwise it will just be hidden
+ */
+ @Input() destroyOnHide;
+
+ /**
+ * If `true`, the current nav item is disabled and can't be toggled by user.
+ *
+ * Nevertheless disabled nav can be selected programmatically via the `.select()` method and the `[activeId]` binding.
+ */
+ @Input() disabled = false;
+
+ /**
+ * The id used for the DOM elements.
+ * Must be unique inside the document in case you have multiple `ngbNav`s on the page.
+ *
+ * Autogenerated as `ngb-nav-XXX` if not provided.
+ */
+ @Input() domId: string;
+
+ /**
+ * The id used as a model for active nav.
+ * It can be anything, but must be unique inside one `ngbNav`.
+ */
+ @Input('ngbNavItem') _id: any;
+
+ contentTpl: NgbNavContent | null;
+
+ @ContentChildren(NgbNavContent, {descendants: false}) contentTpls: QueryList;
+
+ constructor(@Inject(forwardRef(() => NgbNav)) nav, public elementRef: ElementRef) {
+ // TODO: cf https://github.com/angular/angular/issues/30106
+ this._nav = nav;
+ }
+
+ ngAfterContentChecked() {
+ // We are using @ContentChildren instead of @ContentChild as in the Angular version being used
+ // only @ContentChildren allows us to specify the {descendants: false} option.
+ // Without {descendants: false} we are hitting bugs described in:
+ // https://github.com/ng-bootstrap/ng-bootstrap/issues/2240
+ this.contentTpl = this.contentTpls.first;
+ }
+
+ ngOnInit() {
+ if (!isDefined(this.domId)) {
+ this.domId = `ngb-nav-${navCounter++}`;
+ }
+ }
+
+ get active() { return this._nav.activeId === this.id; }
+
+ get id() { return this._id || this.domId; }
+
+ get panelDomId() { return `${this.domId}-panel`; }
+
+ isPanelInDom() {
+ return (isDefined(this.destroyOnHide) ? !this.destroyOnHide : !this._nav.destroyOnHide) || this.active;
+ }
+}
+
+
+/**
+ * A nav directive that helps with implementing tabbed navigation components.
+ */
+@Directive({
+ selector: '[ngbNav]',
+ exportAs: 'ngbNav',
+ host: {
+ '[class.nav]': 'true',
+ '[class.flex-column]': `orientation === 'vertical'`,
+ '[attr.aria-orientation]': `orientation === 'vertical' && roles === 'tablist' ? 'vertical' : undefined`,
+ '[attr.role]': `role ? role : roles ? 'tablist' : undefined`,
+ }
+})
+export class NgbNav implements AfterContentInit {
+ /**
+ * The id of the nav that should be active
+ *
+ * You could also use the `.select()` method and the `(navChange)` event
+ */
+ @Input() activeId: any;
+
+ /**
+ * The event emitted after the active nav changes
+ * The payload of the event is the newly active nav id
+ *
+ * If you want to prevent nav change, you should use `(navChange)` event
+ */
+ @Output() activeIdChange = new EventEmitter();
+
+ /**
+ * If `true`, non-active nav content will be removed from DOM
+ * Otherwise it will just be hidden
+ */
+ @Input() destroyOnHide;
+
+ /**
+ * The orientation of navs.
+ *
+ * Using `vertical` will also add the `aria-orientation` attribute
+ */
+ @Input() orientation: 'horizontal' | 'vertical';
+
+ /**
+ * Role attribute generating strategy:
+ * - `false` - no role attributes will be generated
+ * - `'tablist'` - 'tablist', 'tab' and 'tabpanel' will be generated (default)
+ */
+ @Input() roles: 'tablist' | false;
+
+ @ContentChildren(NgbNavItem) items: QueryList;
+
+ constructor(@Attribute('role') public role: string, config: NgbNavConfig, private _cd: ChangeDetectorRef) {
+ this.destroyOnHide = config.destroyOnHide;
+ this.orientation = config.orientation;
+ this.roles = config.roles;
+ }
+
+ /**
+ * The nav change event emitted right before the nav change happens on user click.
+ *
+ * This event won't be emitted if nav is changed programmatically via `[activeId]` or `.select()`.
+ *
+ * See [`NgbNavChangeEvent`](#/components/nav/api#NgbNavChangeEvent) for payload details.
+ */
+ @Output() navChange = new EventEmitter();
+
+ click(item: NgbNavItem) {
+ if (!item.disabled) {
+ this._updateActiveId(item.id);
+ }
+ }
+
+ /**
+ * Selects the nav with the given id and shows its associated pane.
+ * Any other nav that was previously selected becomes unselected and its associated pane is hidden.
+ */
+ select(id: any) { this._updateActiveId(id, false); }
+
+ ngAfterContentInit() {
+ if (!isDefined(this.activeId)) {
+ const nextId = this.items.first ? this.items.first.id : null;
+ if (nextId) {
+ this._updateActiveId(nextId);
+ this._cd.detectChanges();
+ }
+ }
+ }
+
+ private _updateActiveId(nextId: any, emitNavChange = true) {
+ if (this.activeId !== nextId) {
+ let defaultPrevented = false;
+
+ if (emitNavChange) {
+ this.navChange.emit({activeId: this.activeId, nextId, preventDefault: () => { defaultPrevented = true; }});
+ }
+
+ if (!defaultPrevented) {
+ this.activeId = nextId;
+ this.activeIdChange.emit(nextId);
+ }
+ }
+ }
+}
+
+
+/**
+ * A directive to put on the nav link.
+ */
+@Directive({
+ selector: 'a[ngbNavLink]',
+ host: {
+ '[id]': 'navItem.domId',
+ '[class.nav-link]': 'true',
+ '[class.nav-item]': 'hasNavItemClass()',
+ '[attr.role]': `role ? role : nav.roles ? 'tab' : undefined`,
+ 'href': '',
+ '[class.active]': 'navItem.active',
+ '[class.disabled]': 'navItem.disabled',
+ '[attr.tabindex]': 'navItem.disabled ? -1 : undefined',
+ '[attr.aria-controls]': 'navItem.isPanelInDom() ? navItem.panelDomId : null',
+ '[attr.aria-selected]': 'navItem.active',
+ '[attr.aria-disabled]': 'navItem.disabled',
+ '(click)': 'nav.click(navItem); $event.preventDefault()'
+ }
+})
+export class NgbNavLink {
+ constructor(@Attribute('role') public role: string, public navItem: NgbNavItem, public nav: NgbNav) {}
+
+ hasNavItemClass() {
+ // with alternative markup we have to add `.nav-item` class, because `ngbNavItem` is on the ng-container
+ return this.navItem.elementRef.nativeElement.nodeType === Node.COMMENT_NODE;
+ }
+}
+
+
+/**
+ * The payload of the change event emitted right before the nav change happens on user click.
+ *
+ * This event won't be emitted if nav is changed programmatically via `[activeId]` or `.select()`.
+ */
+export interface NgbNavChangeEvent {
+ /**
+ * Id of the currently active nav.
+ */
+ activeId: any;
+
+ /**
+ * Id of the newly selected nav.
+ */
+ nextId: any;
+
+ /**
+ * Function that will prevent nav change if called.
+ */
+ preventDefault: () => void;
+}
diff --git a/src/tabset/tabset-directives.ts b/src/tabset/tabset-directives.ts
deleted file mode 100644
index ceec360baf..0000000000
--- a/src/tabset/tabset-directives.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-import {
- Directive,
- TemplateRef,
- Component,
- Input,
- ContentChildren,
- QueryList,
- Inject,
- forwardRef,
- Output,
- EventEmitter,
- AfterContentChecked,
- Self,
- ChangeDetectorRef
-} from '@angular/core';
-import {NgbTabsetConfig} from './tabset-config';
-
-let nextId = 0;
-
-/**
- * This directive must be used to wrap content to be displayed in a tab.
- */
-@Directive({selector: 'ng-template[ngbTabContent]'})
-export class NgbTabContent {
- constructor(public templateRef: TemplateRef) {}
-}
-
-@Directive({selector: '[ngbTab]', exportAs: 'ngbTab', host: {'class': 'nav-item'}})
-export class NgbTabDirective implements AfterContentChecked {
- defaultDomId = `ngb-tab-${nextId++}`;
- /**
- * Base id to be used in the DOM (globally unique).
- */
- @Input() domId: string;
- /**
- * Id used in the model (unique in the current tabset)
- */
- @Input() ngbTab: string;
-
- @Input() disabled = false;
-
- contentTpl: NgbTabContent | null;
-
- @ContentChildren(NgbTabContent, {descendants: false}) contentTpls: QueryList;
-
- tabset: NgbTabsetDirective;
-
- constructor(@Inject(forwardRef(() => NgbTabsetDirective)) tabset) {
- // TODO: cf https://github.com/angular/angular/issues/30106
- this.tabset = tabset;
- }
-
- ngAfterContentChecked() {
- // We are using @ContentChildren instead of @ContentChild as in the Angular version being used
- // only @ContentChildren allows us to specify the {descendants: false} option.
- // Without {descendants: false} we are hitting bugs described in:
- // https://github.com/ng-bootstrap/ng-bootstrap/issues/2240
- this.contentTpl = this.contentTpls.first;
- }
-
- getDomId() { return this.domId || this.defaultDomId; }
-
- getModelId() { return this.ngbTab || this.getDomId(); }
-
- isPanelInDom() { return !this.tabset.destroyOnHide || this.isActive(); }
-
- getPanelDomId() { return `${this.getDomId()}-panel`; }
-
- isActive() { return this.tabset.activeId === this.getModelId(); }
-
- click() {
- if (!this.disabled) {
- this.tabset.tabClick(this.getModelId());
- }
- }
-}
-
-@Directive({
- selector: '[ngbTabLink]',
- host: {
- '[id]': 'tab.getDomId()',
- 'class': 'nav-link',
- 'role': 'tab',
- 'href': '',
- '[class.active]': 'tab.isActive()',
- '[class.disabled]': 'tab.disabled',
- '[attr.tabindex]': 'tab.disabled ? -1 : undefined',
- '[attr.aria-controls]': 'tab.isPanelInDom() ? tab.getPanelDomId() : null',
- '[attr.aria-selected]': 'tab.isActive()',
- '[attr.aria-disabled]': 'tab.disabled',
- '(click)': 'tab.click(); $event.preventDefault();'
- }
-})
-export class NgbTabLinkDirective {
- constructor(public tab: NgbTabDirective) {}
-}
-
-@Directive({
- selector: '[ngbTabset]',
- exportAs: 'ngbTabset',
- host: {
- 'role': 'tablist',
- '[class]':
- 'className + \' nav nav-\' + type + (orientation == \'horizontal\'? \' \' + justifyClass : \' flex-column\')'
- }
-})
-export class NgbTabsetDirective {
- justifyClass: string;
-
- @Input('class') className = '';
-
- @Input() destroyOnHide = true;
-
- /**
- * The horizontal alignment of the nav with flexbox utilities. Can be one of 'start', 'center', 'end', 'fill' or
- * 'justified'
- * The default value is 'start'.
- */
- @Input()
- set justify(className: 'start' | 'center' | 'end' | 'fill' | 'justified') {
- if (className === 'fill' || className === 'justified') {
- this.justifyClass = `nav-${className}`;
- } else {
- this.justifyClass = `justify-content-${className}`;
- }
- }
-
- /**
- * The orientation of the nav (horizontal or vertical).
- * The default value is 'horizontal'.
- */
- @Input() orientation: 'horizontal' | 'vertical';
-
- /**
- * Type of navigation to be used for tabs. Can be one of Bootstrap defined types ('tabs' or 'pills') or
- * an arbitrary string (for custom themes).
- */
- @Input() type: 'tabs' | 'pills' | string;
-
- @Input() activeId: string;
-
- @Output() activeIdChange = new EventEmitter();
-
- @ContentChildren(NgbTabDirective) tabs: QueryList;
-
- constructor(config: NgbTabsetConfig) {
- this.type = config.type;
- this.justify = config.justify;
- this.orientation = config.orientation;
- }
-
- tabClick(tabId: string) { this.activeIdChange.next(tabId); }
-}
-
-@Component({
- selector: '[ngbTabsetOutlet]',
- host: {class: 'tab-content'},
- template: `
-
-
-
-
-
-`
-})
-export class NgbTabsetOutlet {
- @Input() ngbTabsetOutlet: NgbTabsetDirective;
-}
-
-/**
- * The payload of the change event fired right before the tab change
- */
-export interface NgbTabChangeEvent {
- /**
- * Id of the currently active tab
- */
- activeId: string;
-
- /**
- * Id of the newly selected tab
- */
- nextId: string;
-
- /**
- * Function that will prevent tab switch if called
- */
- preventDefault: () => void;
-}
-
-@Directive({selector: '[ngbTabset][selfControlled]'})
-export class NgbSelfControlledTabset implements AfterContentChecked {
- get activeId() { return this.tabset.activeId; }
-
- @Input()
- set activeId(value: string) {
- this.tabset.activeId = value;
- }
-
- @ContentChildren(NgbTabDirective) tabs: QueryList;
-
- constructor(@Self() public tabset: NgbTabsetDirective, public changeDetector: ChangeDetectorRef) {
- tabset.activeIdChange.subscribe(this.select.bind(this));
- }
-
- /**
- * A tab change event fired right before the tab selection happens. See NgbTabChangeEvent for payload details
- */
- @Output() tabChange = new EventEmitter();
-
- /**
- * Selects the tab with the given id and shows its associated pane.
- * Any other tab that was previously selected becomes unselected and its associated pane is hidden.
- */
- select(tabId: string) {
- const selectedTab = this._getTabById(tabId);
- if (selectedTab && !selectedTab.disabled && !selectedTab.isActive()) {
- let defaultPrevented = false;
- const selectedTabId = selectedTab.getModelId();
-
- this.tabChange.emit(
- {activeId: this.activeId, nextId: selectedTabId, preventDefault: () => { defaultPrevented = true; }});
-
- if (!defaultPrevented) {
- this.activeId = selectedTabId;
- }
- }
- }
-
- ngAfterContentChecked() {
- // auto-correct activeId that might have been set incorrectly as input
- const activeTab = this._getTabById(this.activeId);
- const newActiveId = this.tabs.length ? this.tabs.first.getModelId() : null;
- if (!activeTab && this.activeId !== newActiveId) {
- this.activeId = newActiveId;
- this.changeDetector.detectChanges();
- }
- }
-
- private _getTabById(id: string): NgbTabDirective {
- const tabsWithId: NgbTabDirective[] = this.tabs.filter(tab => tab.getModelId() === id);
- return tabsWithId.length ? tabsWithId[0] : null;
- }
-}
diff --git a/src/test/common.ts b/src/test/common.ts
index fb47ce01bc..55a28eb8ea 100644
--- a/src/test/common.ts
+++ b/src/test/common.ts
@@ -4,10 +4,13 @@ import {Key} from '../util/key';
-export function createGenericTestComponent(html: string, type: {new (...args: any[]): T}): ComponentFixture {
+export function createGenericTestComponent(
+ html: string, type: {new (...args: any[]): T}, detectChanges = true): ComponentFixture {
TestBed.overrideComponent(type, {set: {template: html}});
const fixture = TestBed.createComponent(type);
- fixture.detectChanges();
+ if (detectChanges) {
+ fixture.detectChanges();
+ }
return fixture as ComponentFixture;
}