From 783d983980472e30cd754dc5feecd97ea56854f6 Mon Sep 17 00:00:00 2001 From: Max Okorokov Date: Thu, 12 Dec 2019 11:45:17 +0100 Subject: [PATCH] feat(nav): initial nav implementation --- src/index.ts | 15 +- src/nav/nav-config.spec.ts | 11 + src/nav/nav-config.ts | 14 + src/nav/nav-outlet.ts | 34 ++ src/nav/nav.module.ts | 15 + src/nav/nav.spec.ts | 831 ++++++++++++++++++++++++++++++++ src/nav/nav.ts | 269 +++++++++++ src/tabset/tabset-directives.ts | 247 ---------- src/test/common.ts | 7 +- 9 files changed, 1192 insertions(+), 251 deletions(-) create mode 100644 src/nav/nav-config.spec.ts create mode 100644 src/nav/nav-config.ts create mode 100644 src/nav/nav-outlet.ts create mode 100644 src/nav/nav.module.ts create mode 100644 src/nav/nav.spec.ts create mode 100644 src/nav/nav.ts delete mode 100644 src/tabset/tabset-directives.ts diff --git a/src/index.ts b/src/index.ts index af0d0c98af..964c25dc9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import {NgbCollapseModule} from './collapse/collapse.module'; import {NgbDatepickerModule} from './datepicker/datepicker.module'; import {NgbDropdownModule} from './dropdown/dropdown.module'; import {NgbModalModule} from './modal/modal.module'; +import {NgbNavModule} from './nav/nav.module'; import {NgbPaginationModule} from './pagination/pagination.module'; import {NgbPopoverModule} from './popover/popover.module'; import {NgbProgressbarModule} from './progressbar/progressbar.module'; @@ -88,6 +89,16 @@ export { NgbModalOptions, NgbModalRef } from './modal/modal.module'; +export { + NgbNavChangeEvent, + NgbNavConfig, + NgbNav, + NgbNavContent, + NgbNavContentContext, + NgbNavItem, + NgbNavLink, + NgbNavOutlet +} from './nav/nav.module'; export { NgbPagination, NgbPaginationConfig, @@ -133,8 +144,8 @@ export {Placement} from './util/positioning'; const NGB_MODULES = [ NgbAccordionModule, NgbAlertModule, NgbButtonsModule, NgbCarouselModule, NgbCollapseModule, NgbDatepickerModule, - NgbDropdownModule, NgbModalModule, NgbPaginationModule, NgbPopoverModule, NgbProgressbarModule, NgbRatingModule, - NgbTabsetModule, NgbTimepickerModule, NgbToastModule, NgbTooltipModule, NgbTypeaheadModule + NgbDropdownModule, NgbModalModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbProgressbarModule, + NgbRatingModule, NgbTabsetModule, NgbTimepickerModule, NgbToastModule, NgbTooltipModule, NgbTypeaheadModule ]; @NgModule({imports: NGB_MODULES, exports: NGB_MODULES}) diff --git a/src/nav/nav-config.spec.ts b/src/nav/nav-config.spec.ts new file mode 100644 index 0000000000..a73520e3fa --- /dev/null +++ b/src/nav/nav-config.spec.ts @@ -0,0 +1,11 @@ +import {NgbNavConfig} from './nav-config'; + +describe('ngb-nav-config', () => { + it('should have sensible default values', () => { + const config = new NgbNavConfig(); + + expect(config.destroyOnHide).toBe(true); + expect(config.orientation).toBe('horizontal'); + expect(config.roles).toBe('tablist'); + }); +}); diff --git a/src/nav/nav-config.ts b/src/nav/nav-config.ts new file mode 100644 index 0000000000..b5ea8a25b2 --- /dev/null +++ b/src/nav/nav-config.ts @@ -0,0 +1,14 @@ +import {Injectable} from '@angular/core'; + +/** + * A configuration service for the [`NgbNav`](#/components/nav/api#NgbNav) component. + * + * You can inject this service, typically in your root component, and customize the values of its properties in + * order to provide default values for all the navs used in the application. + */ +@Injectable({providedIn: 'root'}) +export class NgbNavConfig { + destroyOnHide = true; + orientation: 'horizontal' | 'vertical' = 'horizontal'; + roles: 'tablist' | false = 'tablist'; +} diff --git a/src/nav/nav-outlet.ts b/src/nav/nav-outlet.ts new file mode 100644 index 0000000000..0d41ae7cce --- /dev/null +++ b/src/nav/nav-outlet.ts @@ -0,0 +1,34 @@ +import {Component, Input, ViewEncapsulation} from '@angular/core'; +import {NgbNav} from './nav'; + +/** + * The outlet where currently active nav content will be displayed. + */ +@Component({ + selector: '[ngbNavOutlet]', + host: {'[class.tab-content]': 'true'}, + encapsulation: ViewEncapsulation.None, + template: ` + +
+ +
+
+ ` +}) +export class NgbNavOutlet { + /** + * A role to set on the nav pane + */ + @Input() paneRole; + + /** + * Reference to the `NgbNav` + */ + @Input('ngbNavOutlet') nav: NgbNav; +} diff --git a/src/nav/nav.module.ts b/src/nav/nav.module.ts new file mode 100644 index 0000000000..692ee5095e --- /dev/null +++ b/src/nav/nav.module.ts @@ -0,0 +1,15 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLink} from './nav'; +import {NgbNavOutlet} from './nav-outlet'; + +export {NgbNav, NgbNavContent, NgbNavContentContext, NgbNavItem, NgbNavLink, NgbNavChangeEvent} from './nav'; +export {NgbNavOutlet} from './nav-outlet'; +export {NgbNavConfig} from './nav-config'; + +const NGB_NAV_DIRECTIVES = [NgbNavContent, NgbNav, NgbNavItem, NgbNavLink, NgbNavOutlet]; + +@NgModule({declarations: NGB_NAV_DIRECTIVES, exports: NGB_NAV_DIRECTIVES, imports: [CommonModule]}) +export class NgbNavModule { +} diff --git a/src/nav/nav.spec.ts b/src/nav/nav.spec.ts new file mode 100644 index 0000000000..db39d37640 --- /dev/null +++ b/src/nav/nav.spec.ts @@ -0,0 +1,831 @@ +import {Component} from '@angular/core'; +import {ComponentFixture, inject, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NgbNav, NgbNavConfig, NgbNavItem, NgbNavLink, NgbNavModule, NgbNavOutlet} from './nav.module'; +import {createGenericTestComponent} from '../test/common'; +import {isDefined} from '../util/util'; + +function createTestComponent(html: string, detectChanges = true) { + return createGenericTestComponent(html, TestComponent, detectChanges) as ComponentFixture; +} + +function getNavDirective(fixture: ComponentFixture): NgbNav { + return fixture.debugElement.query(By.directive(NgbNav)).injector.get(NgbNav); +} + +function getNav(fixture: ComponentFixture): HTMLElement { + return fixture.debugElement.query(By.directive(NgbNav)).nativeElement; +} + +function getOutlet(fixture: ComponentFixture): HTMLElement { + return fixture.debugElement.query(By.directive(NgbNavOutlet)).nativeElement; +} + +function getContents(fixture: ComponentFixture): HTMLElement[] { + return Array.from(getOutlet(fixture).children) as HTMLElement[]; +} + +function getContent(fixture: ComponentFixture): HTMLElement { + return Array.from(getOutlet(fixture).children)[0] as HTMLElement; +} + +function getItems(fixture: ComponentFixture): HTMLElement[] { + return fixture.debugElement.queryAll(By.directive(NgbNavItem)).map(debugElement => debugElement.nativeElement); +} + +function getLinks(fixture: ComponentFixture): HTMLElement[] { + return fixture.debugElement.queryAll(By.directive(NgbNavLink)).map(debugElement => debugElement.nativeElement); +} + +function expectLinks(fixture: ComponentFixture, expected: boolean[], shouldHaveNavItemClass = false) { + const links = getLinks(fixture); + expect(links.length).toBe(expected.length, `expected to find ${expected.length} links, but found ${links.length}`); + + links.forEach(({classList}, i) => { + expect(classList.contains('nav-link')).toBe(true, `link should have 'nav-link' class`); + expect(classList.contains('active')) + .toBe(expected[i], `link should ${expected[i] ? '' : 'not'} have 'active' class`); + expect(classList.contains('nav-item')) + .toBe(shouldHaveNavItemClass, `link should ${shouldHaveNavItemClass ? '' : 'not'} have 'nav-item' class`); + }); +} + +function expectContents(fixture: ComponentFixture, expected: string[], activeIndex = 0) { + const contents = getContents(fixture); + expect(contents.length) + .toBe(expected.length, `expected to find ${expected.length} contents, but found ${contents.length}`); + + for (let i = 0; i < expected.length; ++i) { + const text = contents[i].innerText; + expect(text).toBe(expected[i], `expected to find '${expected[i]}' in content ${i + 1}, but found '${text}'`); + expect(contents[i].classList.contains('active')) + .toBe(i === activeIndex, `content should ${i === activeIndex ? '' : 'not'} have 'active' class`); + } +} + +describe('nav', () => { + + beforeEach(() => { TestBed.configureTestingModule({declarations: [TestComponent], imports: [NgbNavModule]}); }); + + it('should initialize inputs with default values', inject([NgbNavConfig], config => { + const nav = new NgbNav('tablist', config, null); + expect(nav.destroyOnHide).toBe(config.destroyOnHide); + expect(nav.roles).toBe(config.roles); + })); + + it(`should set and allow overriding CSS classes`, () => { + const fixture = createTestComponent(` +
    +
  • + + +
  • +
+
+ `); + + expect(getNav(fixture)).toHaveCssClass('nav'); + expect(getNav(fixture)).toHaveCssClass('my-nav'); + expect(getItems(fixture)[0]).toHaveCssClass('nav-item'); + expect(getItems(fixture)[0]).toHaveCssClass('my-nav-item'); + expect(getLinks(fixture)[0]).toHaveCssClass('nav-link'); + expect(getLinks(fixture)[0]).toHaveCssClass('my-nav-link'); + expect(getOutlet(fixture)).toHaveCssClass('tab-content'); + expect(getOutlet(fixture)).toHaveCssClass('my-tab-content'); + expect(getContent(fixture)).toHaveCssClass('tab-pane'); + }); + + it(`should set correct A11Y roles`, () => { + const fixture = createTestComponent(` + +
+ `); + + expect(getNav(fixture).getAttribute('role')).toBe('tablist'); + expect(getLinks(fixture)[0].getAttribute('role')).toBe('tab'); + expect(getContent(fixture).getAttribute('role')).toBe('tabpanel'); + }); + + it(`should not set any A11Y roles if [roles]='false'`, () => { + const fixture = createTestComponent(` + +
+ `); + + expect(getNav(fixture).getAttribute('role')).toBeNull(); + expect(getLinks(fixture)[0].getAttribute('role')).toBeNull(); + expect(getContent(fixture).getAttribute('role')).toBeNull(); + }); + + it(`should allow overriding any A11Y roles`, () => { + const fixture = createTestComponent(` + +
+ `); + + expect(getNav(fixture).getAttribute('role')).toBe('list'); + expect(getLinks(fixture)[0].getAttribute('role')).toBe('alert'); + expect(getContent(fixture).getAttribute('role')).toBe('myRole'); + }); + + it(`should set orientation CSS and 'aria-orientation' correctly`, () => { + const fixture = createTestComponent(` +
    +
  • + + +
  • +
+ `); + + // horizontal + 'tablist' + expect(getNav(fixture)).not.toHaveCssClass('flex-column'); + expect(getNav(fixture).getAttribute('aria-orientation')).toBeNull(); + + // vertical + 'tablist' + fixture.componentInstance.orientation = 'vertical'; + fixture.detectChanges(); + expect(getNav(fixture)).toHaveCssClass('flex-column'); + expect(getNav(fixture).getAttribute('aria-orientation')).toBe('vertical'); + + // vertical + no 'tablist' + fixture.componentInstance.roles = false; + fixture.detectChanges(); + expect(getNav(fixture)).toHaveCssClass('flex-column'); + expect(getNav(fixture).getAttribute('aria-orientation')).toBeNull(); + + // horizontal + no 'tablist' + fixture.componentInstance.orientation = 'horizontal'; + fixture.detectChanges(); + expect(getNav(fixture)).not.toHaveCssClass('flex-column'); + expect(getNav(fixture).getAttribute('aria-orientation')).toBeNull(); + }); + + it(`should initially select first tab, if no [activeId] provided`, () => { + const fixture = createTestComponent( + ` + +
+ `, + false); + + const navChangeSpy = spyOn(fixture.componentInstance, 'onNavChange'); + const activeIdChangeSpy = spyOn(fixture.componentInstance, 'onActiveIdChange'); + fixture.detectChanges(); + + expectLinks(fixture, [true, false]); + expectContents(fixture, ['content 1']); + expect(activeIdChangeSpy).toHaveBeenCalledWith(1); + expect(navChangeSpy).toHaveBeenCalledWith({activeId: undefined, nextId: 1, preventDefault: jasmine.any(Function)}); + }); + + it(`should initially select nothing, if [activeId] provided is incorrect`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectLinks(fixture, [false]); + expect(getContent(fixture)).toBeUndefined(); + }); + + it(`should work without any items provided`, () => { + const fixture = createTestComponent(` + +
+ `); + + expect(getLinks(fixture).length).toBe(0); + expect(getContent(fixture)).toBeUndefined(); + }); + + it(`should work without nav content provided`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectLinks(fixture, [true, false]); + expectContents(fixture, ['']); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectLinks(fixture, [false, true]); + expectContents(fixture, ['']); + }); + + it(`should work without 'ngbNavOutlet'`, () => { + const fixture = createTestComponent(` + + `); + + expectLinks(fixture, [true, false]); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectLinks(fixture, [false, true]); + }); + + it(`should work with dynamically generated navs`, () => { + const fixture = createTestComponent(` + +
+ `); + + // 2 navs + expect(fixture.componentInstance.activeId).toBe(1); + expectLinks(fixture, [true, false]); + expectContents(fixture, ['content 1']); + + // 1 nav + fixture.componentInstance.items.shift(); + fixture.detectChanges(); + + expect(fixture.componentInstance.activeId).toBe(1); + expectLinks(fixture, [false]); + expect(getContent(fixture)).toBeUndefined(); + + // adjust activeId + fixture.componentInstance.activeId = 2; + fixture.detectChanges(); + expectLinks(fixture, [true]); + expectContents(fixture, ['content 2']); + + // no navs + fixture.componentInstance.items.shift(); + fixture.detectChanges(); + + expect(fixture.componentInstance.activeId).toBe(2); + expect(getLinks(fixture).length).toBe(0); + expect(getContent(fixture)).toBeUndefined(); + }); + + it(`should change navs with [activeId] binding`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectLinks(fixture, [true, false]); + expectContents(fixture, ['content 1']); + + fixture.componentInstance.activeId = 2; + fixture.detectChanges(); + expectLinks(fixture, [false, true]); + expectContents(fixture, ['content 2']); + }); + + it(`should allow overriding ids used in DOM with [domId]`, () => { + const fixture = createTestComponent(` + +
+ `); + + const links = getLinks(fixture); + + expect(links[0].id).toBe('one'); + expect(links[1].id).toBe('two'); + expect(getContent(fixture).id).toBe('one-panel'); + }); + + it(`should fallback to [domId] if [ngbNavItem] is not set`, () => { + const fixture = createTestComponent(` + +
+ `); + + const nav = getNavDirective(fixture); + + expectLinks(fixture, [true, false]); + expectContents(fixture, ['content 1']); + + nav.select('two'); + fixture.detectChanges(); + expectLinks(fixture, [false, true]); + expectContents(fixture, ['content 2']); + }); + + describe(`change with`, () => { + + let fixture: ComponentFixture; + let links: HTMLElement[]; + let nav: NgbNav; + let navChangeSpy: jasmine.Spy; + let activeIdChangeSpy: jasmine.Spy; + + beforeEach(() => { + fixture = createTestComponent(` + +
+ `); + + navChangeSpy = spyOn(fixture.componentInstance, 'onNavChange'); + activeIdChangeSpy = spyOn(fixture.componentInstance, 'onActiveIdChange'); + links = getLinks(fixture); + nav = getNavDirective(fixture); + + expectLinks(fixture, [true, false, false]); + expectContents(fixture, ['content 1']); + }); + + it(`(click) should change navs`, () => { + links[1].click(); + fixture.detectChanges(); + + expectLinks(fixture, [false, true, false]); + expectContents(fixture, ['content 2']); + expect(fixture.componentInstance.activeId).toBe(2); + expect(activeIdChangeSpy).toHaveBeenCalledWith(2); + expect(navChangeSpy).toHaveBeenCalledWith({activeId: 1, nextId: 2, preventDefault: jasmine.any(Function)}); + }); + + it(`(click) on the same nav should do nothing`, () => { + links[0].click(); + fixture.detectChanges(); + + expectLinks(fixture, [true, false, false]); + expectContents(fixture, ['content 1']); + expect(fixture.componentInstance.activeId).toBe(1); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`(click) should not change to a disabled nav`, () => { + links[2].click(); + fixture.detectChanges(); + + expectLinks(fixture, [true, false, false]); + expectContents(fixture, ['content 1']); + expect(fixture.componentInstance.activeId).toBe(1); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`[activeId] should change navs`, () => { + fixture.componentInstance.activeId = 2; + fixture.detectChanges(); + + expectLinks(fixture, [false, true, false]); + expectContents(fixture, ['content 2']); + expect(fixture.componentInstance.activeId).toBe(2); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`[activeId] on the same nav should do nothing`, () => { + fixture.componentInstance.activeId = 1; + fixture.detectChanges(); + + expectLinks(fixture, [true, false, false]); + expectContents(fixture, ['content 1']); + expect(fixture.componentInstance.activeId).toBe(1); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`[activeId] should change to a disabled nav`, () => { + fixture.componentInstance.activeId = 3; + fixture.detectChanges(); + + expectLinks(fixture, [false, false, true]); + expectContents(fixture, ['content 3']); + expect(fixture.componentInstance.activeId).toBe(3); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`[activeId] should change to an invalid nav`, () => { + fixture.componentInstance.activeId = 1000; + fixture.detectChanges(); + + expectLinks(fixture, [false, false, false]); + expect(getContent(fixture)).toBeUndefined(); + expect(fixture.componentInstance.activeId).toBe(1000); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`'.select()' should change navs`, () => { + nav.select(2); + fixture.detectChanges(); + + expectLinks(fixture, [false, true, false]); + expectContents(fixture, ['content 2']); + expect(fixture.componentInstance.activeId).toBe(2); + expect(activeIdChangeSpy).toHaveBeenCalledWith(2); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`'.select()' on the same nav should do nothing`, () => { + nav.select(1); + fixture.detectChanges(); + + expectLinks(fixture, [true, false, false]); + expectContents(fixture, ['content 1']); + expect(fixture.componentInstance.activeId).toBe(1); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`'.select()' should change to a disabled nav`, () => { + nav.select(3); + fixture.detectChanges(); + + expectLinks(fixture, [false, false, true]); + expectContents(fixture, ['content 3']); + expect(fixture.componentInstance.activeId).toBe(3); + expect(activeIdChangeSpy).toHaveBeenCalledWith(3); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + + it(`'.select()' should change to an invalid nav`, () => { + nav.select(1000); + fixture.detectChanges(); + + expectLinks(fixture, [false, false, false]); + expect(getContent(fixture)).toBeUndefined(); + expect(fixture.componentInstance.activeId).toBe(1000); + expect(activeIdChangeSpy).toHaveBeenCalledWith(1000); + expect(navChangeSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe(`(navChange) preventDefault()`, () => { + + let fixture: ComponentFixture; + let links: HTMLElement[]; + let navChangeSpy: jasmine.Spy; + let activeIdChangeSpy: jasmine.Spy; + + beforeEach(() => { + fixture = createTestComponent(` + +
+ `); + + navChangeSpy = spyOn(fixture.componentInstance, 'onNavChangePrevent').and.callThrough(); + activeIdChangeSpy = spyOn(fixture.componentInstance, 'onActiveIdChange'); + links = getLinks(fixture); + + expectLinks(fixture, [true, false]); + expectContents(fixture, ['content 1']); + }); + + it(`on (click) should not change navs`, () => { + links[1].click(); + fixture.detectChanges(); + + expectLinks(fixture, [true, false]); + expectContents(fixture, ['content 1']); + expect(fixture.componentInstance.activeId).toBe(1); + expect(activeIdChangeSpy).toHaveBeenCalledTimes(0); + expect(navChangeSpy).toHaveBeenCalledWith({activeId: 1, nextId: 2, preventDefault: jasmine.any(Function)}); + }); + }); + + it(`should work with two-way [(activeId)] binding`, () => { + const fixture = createTestComponent(` + +
+ `); + + const activeIdChangeSpy = spyOn(fixture.componentInstance, 'onActiveIdChange'); + + expect(fixture.componentInstance.activeId).toBe(1); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expect(activeIdChangeSpy).toHaveBeenCalledWith(2); + expect(fixture.componentInstance.activeId).toBe(2); + }); + + it(`should render only one nav content by default`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectContents(fixture, ['content 1']); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectContents(fixture, ['content 2']); + }); + + it(`should render all nav contents with [destroyOnHide]='false'`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectContents(fixture, ['content 1', 'content 2'], 0); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectContents(fixture, ['content 1', 'content 2'], 1); + }); + + it(`should allow overriding [destroyOnHide] per nav item (destroyOnHide === true)`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectContents(fixture, ['content 1', 'content 2'], 0); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectContents(fixture, ['content 2']); + }); + + it(`should allow overriding [destroyOnHide] per nav item (destroyOnHide === false)`, () => { + const fixture = createTestComponent(` + +
+ `); + + expectContents(fixture, ['content 1']); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectContents(fixture, ['content 1', 'content 2'], 1); + }); + + it(`should work with alternative markup without
    and
  • `, () => { + const fixture = createTestComponent(` + +
    + `); + + expectLinks(fixture, [true, false], true); + expectContents(fixture, ['content 1']); + + getLinks(fixture)[1].click(); + fixture.detectChanges(); + expectLinks(fixture, [false, true], true); + expectContents(fixture, ['content 2']); + }); + + it(`should add correct CSS classes for disabled tabs`, () => { + const fixture = createTestComponent(` + +
    + `); + + const links = getLinks(fixture); + expectLinks(fixture, [true]); + expect(links[0]).toHaveCssClass('disabled'); + expectContents(fixture, ['content 1']); + + fixture.componentInstance.disabled = false; + fixture.detectChanges(); + expectLinks(fixture, [true]); + expect(links[0]).not.toHaveCssClass('disabled'); + expectContents(fixture, ['content 1']); + }); + + it(`should set 'aria-selected', 'aria-controls' and 'aria-labelledby' attributes correctly`, () => { + const fixture = createTestComponent(` + +
    + `); + + const links = getLinks(fixture); + expect(links[0].getAttribute('aria-controls')).toBe('one-panel'); + expect(links[0].getAttribute('aria-selected')).toBe('true'); + expect(links[1].getAttribute('aria-controls')).toBeNull(); + expect(links[1].getAttribute('aria-selected')).toBe('false'); + expect(getContent(fixture).getAttribute('aria-labelledby')).toBe('one'); + + links[1].click(); + fixture.detectChanges(); + expect(links[0].getAttribute('aria-controls')).toBeNull(); + expect(links[0].getAttribute('aria-selected')).toBe('false'); + expect(links[1].getAttribute('aria-controls')).toBe('two-panel'); + expect(links[1].getAttribute('aria-selected')).toBe('true'); + expect(getContent(fixture).getAttribute('aria-labelledby')).toBe('two'); + }); + + it(`should set 'aria-disabled' attribute correctly`, () => { + const fixture = createTestComponent(` + +
    + `); + + const links = getLinks(fixture); + expect(links[0].getAttribute('aria-disabled')).toBe('true'); + + fixture.componentInstance.disabled = false; + fixture.detectChanges(); + expect(links[0].getAttribute('aria-disabled')).toBe('false'); + }); + + it(`should pass the 'active' value to content template`, () => { + const fixture = createTestComponent(` + +
    + `); + + 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; }