diff --git a/projects/angular-showcase/src/app/app.module.ts b/projects/angular-showcase/src/app/app.module.ts index 498b1cbeb4..9c44147307 100644 --- a/projects/angular-showcase/src/app/app.module.ts +++ b/projects/angular-showcase/src/app/app.module.ts @@ -1,11 +1,12 @@ +import { MonacoEditorModule } from 'ngx-monaco-editor'; +import { PerfectScrollbarModule } from 'ngx-perfect-scrollbar'; + import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ICON_COMPONENT_LIST, IconCollectionModule } from '@sbb-esta/angular-icons'; -import { MonacoEditorModule } from 'ngx-monaco-editor'; -import { PerfectScrollbarModule } from 'ngx-perfect-scrollbar'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; diff --git a/projects/angular-showcase/src/app/business/examples/examples.module.ts b/projects/angular-showcase/src/app/business/examples/examples.module.ts index f45b00523e..2c5b89d60b 100644 --- a/projects/angular-showcase/src/app/business/examples/examples.module.ts +++ b/projects/angular-showcase/src/app/business/examples/examples.module.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { HeaderModule } from '@sbb-esta/angular-business'; + @NgModule({ declarations: [], - imports: [CommonModule] + imports: [CommonModule, HeaderModule] }) export class ExamplesModule {} diff --git a/projects/angular-showcase/src/app/public/examples/examples.module.ts b/projects/angular-showcase/src/app/public/examples/examples.module.ts index 25f548f3fd..e5889b6b7a 100644 --- a/projects/angular-showcase/src/app/public/examples/examples.module.ts +++ b/projects/angular-showcase/src/app/public/examples/examples.module.ts @@ -1,12 +1,10 @@ +import { MonacoEditorModule } from 'ngx-monaco-editor'; + import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; - import { RouterModule } from '@angular/router'; - -import { MonacoEditorModule } from 'ngx-monaco-editor'; - import { IconCollectionModule } from '@sbb-esta/angular-icons'; import { AccordionModule, diff --git a/projects/sbb-esta/angular-business/src/lib/header/header.md b/projects/sbb-esta/angular-business/src/lib/header/header.md new file mode 100644 index 0000000000..8659b00bbb --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header.md @@ -0,0 +1,32 @@ +# Header Overview + +Import header module in your application + +```ts +import { HeaderModule } from '@sbb-esta/angular-business'; +``` + +The header will appear at the top of the screen in a fixed position, and provide a container for navigation, usermenu, and eventually a logo. +It supports <a> and <button> tags for navigation. Optionally an <sbb-usermenu> can be provided, as well as any element with a [brand] property, or .brand class, for replacing the standard logo. + +```html + + A tag + + + + Option 1 + Option 2 + + + + + + + + +``` diff --git a/projects/sbb-esta/angular-business/src/lib/header/header.module.ts b/projects/sbb-esta/angular-business/src/lib/header/header.module.ts new file mode 100644 index 0000000000..b569a58b66 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header.module.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { + IconChevronSmallDownModule, + IconChevronSmallUpModule, + IconHamburgerMenuModule +} from '@sbb-esta/angular-icons'; +import { DropdownModule } from '@sbb-esta/angular-public'; + +import { HeaderComponent } from './header/header.component'; +import { NavbuttonComponent } from './navbutton/navbutton.component'; + +@NgModule({ + imports: [ + CommonModule, + IconHamburgerMenuModule, + IconChevronSmallDownModule, + IconChevronSmallUpModule, + DropdownModule + ], + declarations: [HeaderComponent, NavbuttonComponent], + exports: [HeaderComponent, NavbuttonComponent] +}) +export class HeaderModule {} diff --git a/projects/sbb-esta/angular-business/src/lib/header/header.ts b/projects/sbb-esta/angular-business/src/lib/header/header.ts new file mode 100644 index 0000000000..1fd6faa579 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header.ts @@ -0,0 +1,3 @@ +export * from './header.module'; + +export * from './header/header.component'; diff --git a/projects/sbb-esta/angular-business/src/lib/header/header/header.component.html b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.html new file mode 100644 index 0000000000..d2c537a6b4 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.html @@ -0,0 +1,41 @@ +
+ {{ environment }} +
+ +
+ + + + + +
+ +
+
{{ label }}
+
{{ subtitle }}
+
+ +
+
+ +
+ +
+
+ +
+ + + + + diff --git a/projects/sbb-esta/angular-business/src/lib/header/header/header.component.scss b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.scss new file mode 100644 index 0000000000..2188cea508 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.scss @@ -0,0 +1,218 @@ +@import 'common'; + +$border: solid 2px $sbbColorGranite; +$buttonMarginTop: calc(54px / 2 - 15px); + +@mixin headerBlockComponent { + position: absolute; + top: 0px; + height: 54px; +} + +@mixin smallIconDropdown() { + position: relative; + top: 3px; + + svg { + width: 20px; + height: 20px; + } +} + +@mixin navButtonDefaultButton() { + @include buttonResetFrameless(); + + position: relative; + white-space: nowrap; + font-family: $fontSbbLight; + font-size: pxToRem(15); + text-decoration: none; + text-align: left; + background: $sbbColorWhite; //TODO: this is required for overlaps + + &:hover, + &:focus { + cursor: pointer; + outline: none; + } + + &::after { + content: ''; + display: block; + position: absolute; + bottom: 0; + width: 0; + left: 50%; + height: 1px; + border-bottom: 1px solid currentColor; + transition: width 0.3s, left 0.3s; + } + + &.sbb-active::after, + &:not(.sbb-active):focus::after, + &:not(.sbb-active):hover::after { + left: 0; + width: 100%; + } + + &:not(.sbb-active):focus &:not([aria-expanded='true']), + &:not(.sbb-active):hover { + color: $sbbColorRed125; + + &::after { + border-bottom-color: $sbbColorRed125; + } + } + + &[disabled] { + color: $sbbColorStorm; + + &:hover, + &:focus { + color: $sbbColorStorm; + cursor: default; + + &::after { + width: 0; + } + } + } + + &.sbb-active:hover, + &.sbb-active:focus { + color: currentColor; + cursor: default; + + &::after { + border-bottom-color: currentColor; + } + } +} + +.sbb-header { + width: 100%; + + position: fixed; + top: 0px; + left: 0px; + z-index: 1000; + height: 54px; + + background-color: $sbbColorWhite; + border-bottom: solid 1px $sbbColorSilver; + + &-ribbon { + width: 80px; + position: absolute; + top: 12px; + left: -20px; + text-align: center; + line-height: 12px; + letter-spacing: 0px; + font-size: 10px; + color: $sbbColorSilver; + transform: rotate(-45deg); + } + + &-appchooser { + @include headerBlockComponent(); + + left: 46px; + width: 54px; + padding: 17px; + } + + &-titlebox { + @include headerBlockComponent(); + + left: 100px; + width: 200px; + font-family: $fontSbbLight; + &-label { + height: 23px; + width: 200px; + color: $sbbColorBlack; + font-size: 15px; + line-height: 23px; + + margin-top: 8px; + } + &-subtitle { + height: 16px; + width: 200px; + color: $sbbColorAnthracite; + font-size: 13px; + font-weight: 300; + line-height: 16px; + + margin-bottom: 7px; + } + } + + &-mainnavigation { + @include headerBlockComponent(); + left: 350px; + + a { + @include navButtonDefaultButton(); + white-space: nowrap; + position: absolute; + height: 30px; + margin-top: $buttonMarginTop; + display: flex; + } + + .sbb-navbutton { + @include navButtonDefaultButton(); + white-space: nowrap; + position: absolute; + height: 30px; + // top: -1px; + // margin-top: $buttonMarginTop; + margin-top: -1px; + top: $buttonMarginTop; + display: flex; + + .sbb-navbutton-icon-small { + @include smallIconDropdown(); + } + .sbb-navbutton-icon-small-expanded { + @include smallIconDropdown(); + position: absolute; + right: 5px; + } + } + + .sbb-navbutton.sbb-navbutton-dropdown-expanded { + border: $border; + border-bottom: none; + + z-index: 5; + + padding-left: calc(15px - 2px); + margin-left: -15px; + margin-top: calc($buttonMarginTop - 7px); + height: 35px; + + &::after { + border-bottom: none; + } + } + } + + &-usermenu { + @include headerBlockComponent(); + + right: 159px; + width: 200px; + z-index: 1; + } + + &-logo { + @include headerBlockComponent(); + + top: 17px; + right: 35px; + width: 80px; + } +} diff --git a/projects/sbb-esta/angular-business/src/lib/header/header/header.component.spec.ts b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.spec.ts new file mode 100644 index 0000000000..a411241443 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.spec.ts @@ -0,0 +1,221 @@ +import { configureTestSuite } from 'ng-bullet'; + +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IconCollectionModule } from '@sbb-esta/angular-icons'; +import { DropdownModule, UserMenuModule } from '@sbb-esta/angular-public'; + +import { HeaderComponent } from '../../../public-api'; +import { NavbuttonComponent } from '../navbutton/navbutton.component'; + +@Component({ + selector: 'sbb-header-all-test', + template: ` + + link 1 + + + + + + + + + + + `, + entryComponents: [NavbuttonComponent] +}) +class HeaderTemplateAllSetTestComponent {} + +describe('HeaderComponent with everything set', () => { + let component: HeaderTemplateAllSetTestComponent; + let fixture: ComponentFixture; + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [IconCollectionModule, DropdownModule, UserMenuModule], + declarations: [HeaderComponent, HeaderTemplateAllSetTestComponent, NavbuttonComponent] + }); + }); + + beforeEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100000; + fixture = TestBed.createComponent(HeaderTemplateAllSetTestComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have ribbon', () => { + const ribbon = fixture.debugElement.queryAll(By.css('.sbb-header-ribbon'))[0]; + expect(ribbon).toBeTruthy(); + }); + + it('should have ribbon text and color match properties', () => { + const ribbon = fixture.debugElement.queryAll(By.css('.sbb-header-ribbon'))[0]; + expect(ribbon.nativeElement.style.backgroundColor).toBe('red'); + expect(ribbon.nativeElement.innerHTML.trim()).toBe('dev'); + }); + + it('should have titlebox', () => { + const titlebox = fixture.debugElement.queryAll(By.css('.sbb-header-titlebox'))[0]; + expect(titlebox).toBeTruthy(); + }); + + it('should have titlebox label and subtitle match properties', () => { + const label = fixture.debugElement.queryAll(By.css('.sbb-header-titlebox-label'))[0]; + const subtitle = fixture.debugElement.queryAll(By.css('.sbb-header-titlebox-subtitle'))[0]; + expect(label).toBeTruthy(); + expect(label.nativeElement.innerHTML.trim()).toBe('test'); + + expect(subtitle).toBeTruthy(); + expect(subtitle.nativeElement.innerHTML.trim()).toBe('test subtitle'); + }); + + it('should have mainnavigation', () => { + const mainnavigation = fixture.debugElement.queryAll(By.css('.sbb-header-mainnavigation'))[0]; + expect(mainnavigation).toBeTruthy(); + }); + + it('should have 1 navbutton and a link', () => { + const navbuttons = fixture.debugElement.queryAll(By.css('.sbb-navbutton')); + expect(navbuttons).toBeTruthy(); + expect(navbuttons.length).toBe(1); + + const links = fixture.debugElement.queryAll(By.css('a')); + expect(links).toBeTruthy(); + expect(links.length).toBe(1); + }); + + it('should have button with working dropdown', () => { + const dropdownButton = fixture.debugElement.queryAll(By.directive(NavbuttonComponent))[0]; + expect(dropdownButton.componentInstance.isDropdown).toBeTruthy(); + }); + + it('should have usermenu', () => { + const usermenu = fixture.debugElement.queryAll(By.css('.sbb-header-usermenu'))[0]; + expect(usermenu).toBeTruthy(); + expect(usermenu.children.length).toBe(1); + }); + + it('should have logo', () => { + const logo = fixture.debugElement.queryAll(By.css('.sbb-header-logo'))[0]; + expect(logo).toBeTruthy(); + }); +}); + +@Component({ + selector: 'sbb-header-minimal-test', + template: ` + + `, + entryComponents: [NavbuttonComponent] +}) +class HeaderTemplateMinimalTestComponent {} + +describe('HeaderComponent minimal', () => { + let component: HeaderTemplateMinimalTestComponent; + let fixture: ComponentFixture; + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [IconCollectionModule], + declarations: [HeaderComponent, HeaderTemplateMinimalTestComponent, NavbuttonComponent] + }); + }); + + beforeEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100000; + fixture = TestBed.createComponent(HeaderTemplateMinimalTestComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have no ribbon', () => { + const ribbon = fixture.debugElement.queryAll(By.css('.sbb-header-ribbon'))[0]; + expect(ribbon).toBeFalsy(); + }); + + it('should have titlebox', () => { + const titlebox = fixture.debugElement.queryAll(By.css('.sbb-header-titlebox'))[0]; + expect(titlebox).toBeTruthy(); + }); + + it('should have titlebox label and subtitle match properties', () => { + const label = fixture.debugElement.queryAll(By.css('.sbb-header-titlebox-label'))[0]; + const subtitle = fixture.debugElement.queryAll(By.css('.sbb-header-titlebox-subtitle'))[0]; + expect(label).toBeTruthy(); + expect(label.nativeElement.innerHTML.trim()).toBe('test'); + + expect(subtitle).toBeFalsy(); + }); + + // Mainnavigation is present regardless + it('should have mainnavigation', () => { + const mainnavigation = fixture.debugElement.queryAll(By.css('.sbb-header-mainnavigation')); + expect(mainnavigation).toBeTruthy(); + expect(mainnavigation.length).toBe(1); + }); + + it('should have no navbuttons', () => { + const navbuttons = fixture.debugElement.queryAll(By.css('.sbb-navbutton')); + expect(navbuttons).toBeTruthy(); + expect(navbuttons.length).toBe(0); + }); + + it('should have no usermenu', () => { + const usermenu = fixture.debugElement.queryAll(By.css('.sbb-header-usermenu'))[0]; + expect(usermenu).toBeTruthy(); + expect(usermenu.children.length).toBe(0); + }); + + it('should have logo', () => { + const logo = fixture.debugElement.queryAll(By.css('.sbb-header-logo'))[0]; + expect(logo).toBeTruthy(); + }); +}); + +@Component({ + selector: 'sbb-header-no-label-test', + template: ` + + `, + entryComponents: [NavbuttonComponent] +}) +class HeaderTemplateNoLabelTestComponent {} + +describe('HeaderComponent without label', () => { + let fixture: ComponentFixture; + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [IconCollectionModule], + declarations: [HeaderComponent, HeaderTemplateNoLabelTestComponent, NavbuttonComponent] + }); + }); + + beforeEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100000; + fixture = TestBed.createComponent(HeaderTemplateNoLabelTestComponent); + }); + + it('should not create', () => { + expect(() => fixture.detectChanges()).toThrow(); + }); +}); diff --git a/projects/sbb-esta/angular-business/src/lib/header/header/header.component.ts b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.ts new file mode 100644 index 0000000000..7f98cb1f09 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/header/header.component.ts @@ -0,0 +1,99 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + HostBinding, + Input, + OnInit, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; + +@Component({ + selector: 'sbb-header', + templateUrl: './header.component.html', + styleUrls: ['./header.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class HeaderComponent implements OnInit, AfterViewInit { + /** @docs-private */ + @HostBinding('class.sbb-header') cssClass = true; + + /** + * Main title shown in the header. + */ + @Input() + label: String; + + /** + * Subtitle shown below the main title, if present. + */ + @Input() + subtitle?: String; + + /** + * String representing the kind of environment the application is running in. + * Will be shown in a ribbon, top-left corner of the header. + */ + @Input() + environment?: String; + + /** + * Background color for the ribbon, if present. + */ + @Input() + environmentColor?: String; + + /** + * Reference to children elements projected through ng-content + */ + @ViewChild('content', { static: true }) + ngContent: ElementRef; + + /** + * Reference to the template which will hold navbuttons wrapping projected elements + */ + @ViewChild('navigationButtons', { static: true, read: ViewContainerRef }) + navigationButtons: ViewContainerRef; + + /** + * Reference to icon, if given through projection. + */ + @ViewChild('iconContent', { static: true }) + iconContent: ElementRef; + + /** + * Distance between navigation buttons, handled through code. + */ + buttonSpacing = 70; + + /** @docs-private */ + private _left = 0; + + ngOnInit() { + this._checkLabel(); + } + + ngAfterViewInit() { + // Absolute positioning of buttons so that they're all 70px apart and won't + // move when one is expanded due to dropdowns, is decided here + const element = this.ngContent.nativeElement; + for (let k = 0; k < element.children.length; k++) { + const child = element.children[k]; + child.style.left = this._left + 'px'; + this._left += child.clientWidth + this.buttonSpacing; + } + } + + /** + * Validates required inputs. + */ + private _checkLabel() { + if (!this.label) { + throw new Error('You must set [label] for sbb-header.'); + } + } +} diff --git a/projects/sbb-esta/angular-business/src/lib/header/navbutton/navbutton.component.html b/projects/sbb-esta/angular-business/src/lib/header/navbutton/navbutton.component.html new file mode 100644 index 0000000000..4b862b3d42 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/navbutton/navbutton.component.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/projects/sbb-esta/angular-business/src/lib/header/navbutton/navbutton.component.spec.ts b/projects/sbb-esta/angular-business/src/lib/header/navbutton/navbutton.component.spec.ts new file mode 100644 index 0000000000..db852cc6a9 --- /dev/null +++ b/projects/sbb-esta/angular-business/src/lib/header/navbutton/navbutton.component.spec.ts @@ -0,0 +1,158 @@ +import { configureTestSuite } from 'ng-bullet'; + +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IconCollectionModule } from '@sbb-esta/angular-icons'; +import { DropdownModule } from '@sbb-esta/angular-public'; + +import { NavbuttonComponent } from '../navbutton/navbutton.component'; + +@Component({ + selector: 'sbb-navbutton-a-test', + template: ` + + `, + entryComponents: [NavbuttonComponent] +}) +export class NavbuttonTemplateATagTestComponent {} + +describe('Navbutton with child', () => { + let component: NavbuttonTemplateATagTestComponent; + let fixture: ComponentFixture; + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [IconCollectionModule, DropdownModule], + declarations: [NavbuttonTemplateATagTestComponent, NavbuttonComponent] + }); + }); + + beforeEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100000; + fixture = TestBed.createComponent(NavbuttonTemplateATagTestComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should recognize it has no dropdown', () => { + const navbutton = fixture.debugElement.queryAll(By.directive(NavbuttonComponent))[0]; + expect(navbutton.componentInstance.isDropdown).toBeFalsy(); + expect(navbutton.componentInstance.isDropdownExpanded).toBeFalsy(); + }); + + it('should have no icons', () => { + const icons = fixture.debugElement.queryAll(By.css('.sbb-svgsprite-icon')); + expect(icons.length).toBe(0); + }); +}); + +@Component({ + selector: 'sbb-navbutton-button-test', + template: ` + + + `, + entryComponents: [NavbuttonComponent] +}) +class NavbuttonTemplateButtonTagTestComponent {} + +describe('Navbutton with + + + + `, + entryComponents: [NavbuttonComponent] +}) +class NavbuttonTemplateButtonDropdownTagTestComponent {} + +describe('Navbutton with