diff --git a/commitlint.config.js b/commitlint.config.js index 3fab66757..dfa513b48 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,14 +1,14 @@ module.exports = { extends: ['@ptsecurity/commitlint-config'], rules: { - 'scope-enum': [ + 'scope-enum': [ 2, 'always', [ 'build', 'docs', 'chore', - 'cdk', + 'cdk', 'common', 'typography', 'button', @@ -28,7 +28,8 @@ module.exports = { 'progress-bar', 'datepicker', 'timepicker', - 'visual' + 'visual', + 'navbar' ] ] } diff --git a/src/lib-dev/navbar/module.ts b/src/lib-dev/navbar/module.ts index c09846de4..b0043f1df 100644 --- a/src/lib-dev/navbar/module.ts +++ b/src/lib-dev/navbar/module.ts @@ -1,10 +1,11 @@ -import { FormsModule } from '@angular/forms'; import { Component, NgModule, ViewChild, ViewEncapsulation } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { McNavbarModule, McNavbar } from '../../lib/navbar/'; +import { McButtonModule } from '../../lib/button'; import { McIconModule } from '../../lib/icon'; +import { McNavbarModule, McNavbar, IMcNavbarDropdownItem } from '../../lib/navbar/'; @Component({ @@ -20,6 +21,23 @@ export class NavbarDemoComponent { readonly minNavbarWidth: number = 940; + dropdownItems: IMcNavbarDropdownItem[] = [ + { link: '#1', text: 'Очень длинный список для проверки ширины' }, + { link: '#2', text: 'Общие сведения' }, + { link: '#3', text: 'Еще один пункт' } + ]; + + buttonDropdownItems: IMcNavbarDropdownItem[] = [ + { text: 'Пример кастомного компонента 1' }, + { text: 'Пример кастомного компонента 2' }, + { text: 'Пример кастомного компонента 3' } + ]; + + rightDropdownItems: IMcNavbarDropdownItem[] = [ + { link: '#4', text: 'Пункт в правой части navbar 1' }, + { link: '#5', text: 'Пункт в правой части navbar 2' } + ]; + private _collapsedNavbarWidth: number = 1280; get collapsedNavbarWidth(): number { @@ -50,6 +68,7 @@ export class NavbarDemoComponent { imports: [ BrowserModule, McNavbarModule, + McButtonModule, McIconModule, FormsModule ], diff --git a/src/lib-dev/navbar/template.html b/src/lib-dev/navbar/template.html index e0ccba0d0..408eb96c6 100644 --- a/src/lib-dev/navbar/template.html +++ b/src/lib-dev/navbar/template.html @@ -28,13 +28,6 @@

Common example

Right Icon - -
- mc-dropdown - -
-
- @@ -59,6 +52,33 @@

Common example

+

Dropdowns exmaple

+
+ + + + Dropdown items + + + Custom dropdown items + + + + + + + + + Right dropdown items + + + +
+ +

Collapse example

diff --git a/src/lib/navbar/README.md b/src/lib/navbar/README.md index 5faaa7a9a..0825b4f0f 100644 --- a/src/lib/navbar/README.md +++ b/src/lib/navbar/README.md @@ -19,8 +19,9 @@ We strongly recommend adhere to use our child components until it is possible bu - menu item container **mc-navbar-item** - icon **[mc-icon]** (another PT Mosaic component, out of the scope of this) - title **mc-navbar-title** - - dropdown **[mc-dropdown]** (another PT Mosaic component, out of the scope of this) - - any markup + - menu item dropdown container **mc-navbar-item** with property **[dropdownItems]** + - any markup for title dropdown container + - **ng-temaplate** with custom component for dropdown item - any markup - any markup @@ -38,4 +39,4 @@ Disable state also could be combined with other states but it is **disabled** at In the case that absence of space the following elements is collapsed: - mc-navbar-item is collapsed to [mc-icon] if exists (if not then the item is not collapsed). The title attribute is added for [mc-icon] from mc-navbar-item's [collapsedTitle] input value; -- mc-navbar-item is collapsed to [mc-icon] if exists (if not then the item is not collapsed). +- mc-navbar-item is collapsed to [mc-icon] if exists (if not then the item is not collapsed). \ No newline at end of file diff --git a/src/lib/navbar/_navbar-base.scss b/src/lib/navbar/_navbar-base.scss index 87676ed5d..9a3cba2a6 100644 --- a/src/lib/navbar/_navbar-base.scss +++ b/src/lib/navbar/_navbar-base.scss @@ -2,6 +2,9 @@ $mc-navbar-h-padding: 0px; $mc-navbar-height: 48px; $mc-navbar-item-h-padding: 16px; +$mc-navbar-dropdown-v-padding: 4px; +$mc-navbar-dropdown-link-v-padding: 6px; +$mc-navbar-dropdown-link-h-padding: 16px; $mc-navbar-brand-h-padding: 12px; $mc-navbar-brand-margin-right: 24px; $mc-navbar-title-icon-space: 8px; @@ -44,6 +47,8 @@ $mc-navbar-icon-min-width: 15px; align-items: center; padding-left: $mc-navbar-item-h-padding; padding-right: $mc-navbar-item-h-padding; + background-color: transparent; + border: none; } %mc-navbar-brand-base { diff --git a/src/lib/navbar/_navbar-theme.scss b/src/lib/navbar/_navbar-theme.scss index a2c754fb9..c111c077a 100644 --- a/src/lib/navbar/_navbar-theme.scss +++ b/src/lib/navbar/_navbar-theme.scss @@ -63,6 +63,34 @@ opacity: 0.5; } } + + .mc-navbar-dropdown { + background-color: #fff; + border-color: mc-color($mc-grey, 300); + box-shadow: 0 3px 3px 0 rgba(0, 0, 0, 0.2); + + &-link { + color: mc-color($mc-grey, 700); + outline: none; + + &:hover { + background-color: $black-6-opacity; + } + + &.cdk-focused { + border-color: mc-color($palette, 500); + } + + &:active, + &.is-active { + background-color: mc-color($palette, 100); + + &:hover::before { + background-color: $black-6-opacity; + } + } + } + } } @mixin mc-navbar-theme($theme) { @@ -72,7 +100,8 @@ } @mixin mc-navbar-typography($config) { - .mc-navbar-title { + .mc-navbar-title, + .mc-navbar-dropdown { @include mc-typography-level-to-styles($config, body); } diff --git a/src/lib/navbar/navbar.component.spec.html b/src/lib/navbar/navbar.component.spec.html index 1be9045f2..701999b19 100644 --- a/src/lib/navbar/navbar.component.spec.html +++ b/src/lib/navbar/navbar.component.spec.html @@ -48,6 +48,10 @@ Left icon + + Dropdown item + + diff --git a/src/lib/navbar/navbar.component.spec.ts b/src/lib/navbar/navbar.component.spec.ts index e1c4ceab1..ca6af78b5 100644 --- a/src/lib/navbar/navbar.component.spec.ts +++ b/src/lib/navbar/navbar.component.spec.ts @@ -2,8 +2,11 @@ import { Component, ViewChild } from '@angular/core'; import { fakeAsync, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { McNavbarModule, McNavbar } from './index'; +import { SPACE } from '@ptsecurity/cdk/keycodes'; +import { createKeyboardEvent, createFakeEvent } from '@ptsecurity/cdk/testing'; + import { McIconModule } from './../icon/icon.module'; +import { McNavbarModule, McNavbar, IMcNavbarDropdownItem } from './index'; const FONT_RENDER_TIMEOUT_MS = 10; @@ -105,6 +108,81 @@ describe('McNavbar', () => { expect(testComponent.counter).toBe(0); }); + + it('dropdown item by default should render list links', () => { + const fixture = TestBed.createComponent(TestApp); + + fixture.detectChanges(); + + const dropdowns = fixture.debugElement.queryAll(By.css('[ng-reflect-dropdown-items]')); + + dropdowns.forEach((dropdown) => { + const links = dropdown.queryAll(By.css('.mc-navbar-dropdown-link')); + expect(links.length).toBeGreaterThan(0); + }); + }); + + it('dropdown content should open by click on navbar-item', () => { + const fixture = TestBed.createComponent(TestApp); + + fixture.detectChanges(); + + const dropdown = fixture.debugElement.query(By.css('[ng-reflect-dropdown-items]')); + const dropdownToggler = dropdown.query(By.css('.mc-navbar-item')); + const dropdownContent = dropdown.query(By.css('.mc-navbar-dropdown')).nativeElement as HTMLElement; + + dropdownToggler.nativeElement.click(); + + fixture.detectChanges(); + + const isOpened = !dropdownContent.classList.contains('is-collapsed'); + + expect(isOpened).toBeTruthy(); + }); + + it('dropdown content should open by keydown SPACE on navbar-item', () => { + const fixture = TestBed.createComponent(TestApp); + + fixture.detectChanges(); + + const dropdown = fixture.debugElement.query(By.css('[ng-reflect-dropdown-items]')); + const dropdownToggler = dropdown.query(By.css('.mc-navbar-item')).nativeElement as HTMLElement; + const dropdownContent = dropdown.query(By.css('.mc-navbar-dropdown')).nativeElement as HTMLElement; + + const keydownEvent = createKeyboardEvent('keydown', SPACE, dropdownToggler); + + dropdownToggler.dispatchEvent(keydownEvent); + + fixture.detectChanges(); + + const isOpened = !dropdownContent.classList.contains('is-collapsed'); + + expect(isOpened).toBeTruthy(); + }); + + it('dropdown content should close by blur event from ', () => { + const fixture = TestBed.createComponent(TestApp); + + fixture.detectChanges(); + + const dropdown = fixture.debugElement.query(By.css('[ng-reflect-dropdown-items]')); + const dropdownToggler = dropdown.query(By.css('.mc-navbar-item')); + const dropdownContent = dropdown.query(By.css('.mc-navbar-dropdown')).nativeElement as HTMLElement; + + const keydownEvent = createFakeEvent('blur'); + + dropdownToggler.nativeElement.click(); + + fixture.detectChanges(); + + dropdown.nativeElement.dispatchEvent(keydownEvent); + + fixture.detectChanges(); + + const isClosed = dropdownContent.classList.contains('is-collapsed'); + + expect(isClosed).toBeTruthy(); + }); }); @Component({ @@ -118,6 +196,12 @@ class TestApp { counter: number = 0; navbarContainerWidth: number = 915; + dropdownItems: IMcNavbarDropdownItem[] = [ + { link: '#', text: 'Очень длинный список для проверки ширины' }, + { link: '#', text: 'Общие сведения' }, + { link: '#', text: 'Еще один пункт' } + ]; + onItemClick() { this.counter++; } diff --git a/src/lib/navbar/navbar.component.ts b/src/lib/navbar/navbar.component.ts index 1a81a4ac8..15689b0ec 100644 --- a/src/lib/navbar/navbar.component.ts +++ b/src/lib/navbar/navbar.component.ts @@ -1,5 +1,4 @@ -import { fromEvent } from 'rxjs'; -import { Subscription } from 'rxjs/internal/Subscription'; +import { fromEvent, Observable, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { @@ -10,10 +9,16 @@ import { Input, OnDestroy, OnInit, - ViewEncapsulation + ViewEncapsulation, + ContentChild, + TemplateRef, + ChangeDetectorRef, + ChangeDetectionStrategy, + ViewChild } from '@angular/core'; -import { FocusMonitor } from '@ptsecurity/cdk/a11y'; - +import { FocusMonitor, FocusOrigin } from '@ptsecurity/cdk/a11y'; +import { SPACE } from '@ptsecurity/cdk/keycodes'; +import { Platform } from '@ptsecurity/cdk/platform'; import { CanDisable, mixinDisabled } from '@ptsecurity/mosaic/core'; @@ -28,6 +33,11 @@ const MC_NAVBAR_LOGO = 'mc-navbar-logo'; export type McNavbarContainerPositionType = 'left' | 'right'; +export interface IMcNavbarDropdownItem { + link?: string; + text: string; +} + @Directive({ selector: MC_NAVBAR_LOGO, host: { @@ -61,30 +71,84 @@ export const _McNavbarMixinBase = mixinDisabled(McNavbarItemBase); @Component({ selector: MC_NAVBAR_ITEM, template: ` - - - + + + + `, encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, inputs: ['disabled'], host: { - '[attr.disabled]': 'disabled || null' + '[attr.disabled]': 'disabled || null', + '[attr.tabindex]': '-1' } }) -export class McNavbarItem extends _McNavbarMixinBase implements OnInit, OnDestroy, CanDisable { +export class McNavbarItem extends _McNavbarMixinBase implements OnInit, AfterViewInit, OnDestroy, CanDisable { @Input() tabIndex: number = 0; + @Input() + dropdownItems: IMcNavbarDropdownItem[] = []; + @Input() set collapsedTitle(value: string) { this.elementRef.nativeElement.setAttribute('computedTitle', encodeURI(value)); } + @ContentChild('dropdownItemTmpl', { read: TemplateRef }) + dropdownItemTmpl: TemplateRef; + + @ViewChild('dropdownContent', { read: ElementRef }) + dropdownContent: ElementRef; + + get hasDropdownContent() { + return this.dropdownItems.length > 0; + } + + isCollapsed: boolean = true; + + private _subscription: Subscription = new Subscription(); + private _focusMonitor$: Observable; + private _lastFocusedElement: HTMLElement; + + private get _dropdownElements(): HTMLElement[] { + return this.dropdownContent ? this.dropdownContent.nativeElement.querySelectorAll('li > *') : []; + } + constructor( public elementRef: ElementRef, - private _focusMonitor: FocusMonitor + private _focusMonitor: FocusMonitor, + private _platform: Platform, + private _cdRef: ChangeDetectorRef ) { super(elementRef); } @@ -92,11 +156,80 @@ export class McNavbarItem extends _McNavbarMixinBase implements OnInit, OnDestro ngOnInit() { this.denyClickIfDisabled(); - this._focusMonitor.monitor(this.elementRef.nativeElement, true); + this._focusMonitor$ = this._focusMonitor.monitor(this.elementRef.nativeElement, true); + + if (this.hasDropdownContent) { + this.listenClickOutside(); + } + } + + ngAfterViewInit() { + if (!this.hasDropdownContent) { + return; + } + + this.startListenFocusDropdownItems(); } ngOnDestroy() { + this._subscription.unsubscribe(); this._focusMonitor.stopMonitoring(this.elementRef.nativeElement); + this.stopListenFocusDropdownItems(); + } + + isActiveDropdownLink(link: string): boolean { + if (!this._platform.isBrowser) { + return false; + } + + return window.location.href.indexOf(link) >= 0; + } + + handleClickByItem() { + this.toggleDropdown(); + } + + handleKeydown($event: KeyboardEvent) { + const isNavbarItem = ($event.target as HTMLElement).classList.contains(MC_NAVBAR_ITEM); + + if (this.hasDropdownContent && $event.keyCode === SPACE && isNavbarItem) { + this.toggleDropdown(); + } + } + + handleClickByDropdownItem() { + this.forceCloseDropdown(); + } + + private listenClickOutside() { + this._subscription.add( + this._focusMonitor$.subscribe((origin) => { + if (origin === null) { + this.forceCloseDropdown(); + } + }) + ); + } + + private toggleDropdown() { + this.isCollapsed = !this.isCollapsed; + } + + private forceCloseDropdown() { + this.isCollapsed = true; + this._cdRef.detectChanges(); + } + + private startListenFocusDropdownItems() { + this._dropdownElements.forEach((el) => { + this._focusMonitor.monitor(el, true); + }); + } + + private stopListenFocusDropdownItems() { + this._dropdownElements.forEach((el) => { + this._focusMonitor.stopMonitoring(el); + }); } // This method is required due to angular 2 issue https://github.com/angular/angular/issues/11200 @@ -209,6 +342,7 @@ class CachedItemWidth { @Component({ selector: MC_NAVBAR, + changeDetection: ChangeDetectionStrategy.OnPush, template: `