/** * @license * Copyright Akveo. All Rights Reserved. * Licensed under the MIT License. See License.txt in the project root for license information. */ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Output, } from '@angular/core'; import { combineLatest, Subject } from 'rxjs'; import { takeUntil, filter, map, startWith } from 'rxjs/operators'; import { convertToBoolProperty, NbBooleanInput } from '../helpers'; import { NbThemeService } from '../../services/theme.service'; import { NbMediaBreakpoint } from '../../services/breakpoints.service'; import { NbSidebarService, getSidebarState$, getSidebarResponsiveState$ } from './sidebar.service'; export type NbSidebarState = 'expanded' | 'collapsed' | 'compacted'; export type NbSidebarResponsiveState = 'mobile' | 'tablet' | 'pc'; /** * Sidebar header container. * * Placeholder which contains a sidebar header content, * placed at the very top of the sidebar outside of the scroll area. */ @Component({ selector: 'nb-sidebar-header', template: ` `, }) export class NbSidebarHeaderComponent { } /** * Sidebar footer container. * * Placeholder which contains a sidebar footer content, * placed at the very bottom of the sidebar outside of the scroll area. */ @Component({ selector: 'nb-sidebar-footer', template: ` `, }) export class NbSidebarFooterComponent { } /** * Layout sidebar component. * * @stacked-example(Showcase, sidebar/sidebar-showcase.component) * * ### Installation * * Import `NbSidebarModule.forRoot()` to your app module. * ```ts * @NgModule({ * imports: [ * // ... * NbSidebarModule.forRoot(), * ], * }) * export class AppModule { } * ``` * and `NbSidebarModule` to your feature module where the component should be shown: * ```ts * @NgModule({ * imports: [ * // ... * NbSidebarModule, * ], * }) * export class PageModule { } * ``` * ### Usage * * Sidebar can be placed on the left or the right side of the layout, * or on start/end position of layout (depends on document direction, left to right or right to left) * It can be fixed (shown above the content) or can push the layout when opened. * * There are three states - `expanded`, `collapsed`, `compacted`. * By default sidebar content is fixed and saves its position while the page is being scrolled. * * Compacted sidebar example: * @stacked-example(Compacted Sidebar, sidebar/sidebar-compacted.component) * * Sidebar also supports a `responsive` behavior, listening to window size change and changing its size respectably. * * In a pair with header it is possible to setup a configuration when header is placed on a side of the sidebar * and not on top of it. To achieve this simply put a `subheader` property to the header like this: * ```html * * ``` * @stacked-example(Subheader, layout/layout-sidebar-subheader.component) * Note that in such configuration sidebar shadow is removed and header cannot be make `fixed`. * * @additional-example(Right Sidebar, sidebar/sidebar-right.component) * @additional-example(Fixed Sidebar, sidebar/sidebar-fixed.component) * * @styles * * sidebar-background-color: * sidebar-text-color: * sidebar-text-font-family: * sidebar-text-font-size: * sidebar-text-font-weight: * sidebar-text-line-height: * sidebar-height: * sidebar-width: * sidebar-width-compact: * sidebar-padding: * sidebar-header-height: * sidebar-footer-height: * sidebar-shadow: * sidebar-menu-item-highlight-color: * sidebar-scrollbar-background-color: * sidebar-scrollbar-color: * sidebar-scrollbar-width: */ @Component({ selector: 'nb-sidebar', styleUrls: ['./sidebar.component.scss'], template: `
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class NbSidebarComponent implements OnInit, OnDestroy { protected readonly responsiveValueChange$: Subject = new Subject(); protected responsiveState: NbSidebarResponsiveState = 'pc'; protected destroy$ = new Subject(); containerFixedValue: boolean = true; @HostBinding('class.fixed') fixedValue: boolean = false; @HostBinding('class.right') rightValue: boolean = false; @HostBinding('class.left') leftValue: boolean = true; @HostBinding('class.start') startValue: boolean = false; @HostBinding('class.end') endValue: boolean = false; @HostBinding('class.expanded') get expanded() { return this.state === 'expanded'; } @HostBinding('class.collapsed') get collapsed() { return this.state === 'collapsed'; } @HostBinding('class.compacted') get compacted() { return this.state === 'compacted'; } /** * Places sidebar on the right side * @type {boolean} */ @Input() set right(val: boolean) { this.rightValue = convertToBoolProperty(val); this.leftValue = !this.rightValue; this.startValue = false; this.endValue = false; } static ngAcceptInputType_right: NbBooleanInput; /** * Places sidebar on the left side * @type {boolean} */ @Input() set left(val: boolean) { this.leftValue = convertToBoolProperty(val); this.rightValue = !this.leftValue; this.startValue = false; this.endValue = false; } static ngAcceptInputType_left: NbBooleanInput; /** * Places sidebar on the start edge of layout * @type {boolean} */ @Input() set start(val: boolean) { this.startValue = convertToBoolProperty(val); this.endValue = !this.startValue; this.leftValue = false; this.rightValue = false; } static ngAcceptInputType_start: NbBooleanInput; /** * Places sidebar on the end edge of layout * @type {boolean} */ @Input() set end(val: boolean) { this.endValue = convertToBoolProperty(val); this.startValue = !this.endValue; this.leftValue = false; this.rightValue = false; } static ngAcceptInputType_end: NbBooleanInput; /** * Makes sidebar fixed (shown above the layout content) * @type {boolean} */ @Input() set fixed(val: boolean) { this.fixedValue = convertToBoolProperty(val); } static ngAcceptInputType_fixed: NbBooleanInput; /** * Makes sidebar container fixed * @type {boolean} */ @Input() set containerFixed(val: boolean) { this.containerFixedValue = convertToBoolProperty(val); } static ngAcceptInputType_containerFixed: NbBooleanInput; /** * Initial sidebar state, `expanded`|`collapsed`|`compacted` * @type {string} */ @Input() get state(): NbSidebarState { return this._state; } set state(value: NbSidebarState) { this._state = value; } protected _state: NbSidebarState = 'expanded'; /** * Makes sidebar listen to media query events and change its behaviour * @type {boolean} */ @Input() get responsive(): boolean { return this._responsive; } set responsive(value: boolean) { if (this.responsive !== convertToBoolProperty(value)) { this._responsive = !this.responsive; this.responsiveValueChange$.next(this.responsive); } } protected _responsive: boolean = false; static ngAcceptInputType_responsive: NbBooleanInput; /** * Tags a sidebar with some ID, can be later used in the sidebar service * to determine which sidebar triggered the action, if multiple sidebars exist on the page. * * @type {string} */ @Input() tag: string; // TODO: get width by the key and define only max width for the tablets and mobiles /** * Controls on which screen sizes sidebar should be switched to compacted state. * Works only when responsive mode is on. * Default values are `['xs', 'is', 'sm', 'md', 'lg']`. * * @type string[] */ @Input() compactedBreakpoints: string[] = ['xs', 'is', 'sm', 'md', 'lg']; /** * Controls on which screen sizes sidebar should be switched to collapsed state. * Works only when responsive mode is on. * Default values are `['xs', 'is']`. * * @type string[] */ @Input() collapsedBreakpoints: string[] = ['xs', 'is']; /** * Emits whenever sidebar state change. */ @Output() readonly stateChange = new EventEmitter(); /** * Emits whenever sidebar responsive state change. */ @Output() readonly responsiveStateChange = new EventEmitter(); constructor( private sidebarService: NbSidebarService, private themeService: NbThemeService, private element: ElementRef, private cd: ChangeDetectorRef, ) {} ngOnInit() { this.sidebarService.onToggle() .pipe( filter(({ tag }) => !this.tag || this.tag === tag), takeUntil(this.destroy$), ) .subscribe(({ compact }) => this.toggle(compact)); this.sidebarService.onExpand() .pipe( filter(({ tag }) => !this.tag || this.tag === tag), takeUntil(this.destroy$), ) .subscribe(() => this.expand()); this.sidebarService.onCollapse() .pipe( filter(({ tag }) => !this.tag || this.tag === tag), takeUntil(this.destroy$), ) .subscribe(() => this.collapse()); this.sidebarService.onCompact() .pipe( filter(({ tag }) => !this.tag || this.tag === tag), takeUntil(this.destroy$), ) .subscribe(() => this.compact()); getSidebarState$ .pipe( filter(({ tag }) => !this.tag || this.tag === tag), takeUntil(this.destroy$), ) .subscribe(({ observer }) => observer.next(this.state)); getSidebarResponsiveState$ .pipe( filter(({ tag }) => !this.tag || this.tag === tag), takeUntil(this.destroy$), ) .subscribe(({ observer }) => observer.next(this.responsiveState)); this.responsiveValueChange$ .pipe( filter((responsive: boolean) => !responsive), takeUntil(this.destroy$), ) .subscribe(() => this.expand()); this.subscribeToMediaQueryChange(); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } // TODO: this is more of a workaround, should be a better way to make components communicate to each other onClick(event): void { const menu = this.element.nativeElement.querySelector('nb-menu'); if (menu && menu.contains(event.target)) { const link = this.getMenuLink(event.target); if (link && link.nextElementSibling && link.nextElementSibling.classList.contains('menu-items')) { this.sidebarService.expand(this.tag); } } } /** * Collapses the sidebar */ collapse() { this.updateState('collapsed'); } /** * Expands the sidebar */ expand() { this.updateState('expanded'); } /** * Compacts the sidebar (minimizes) */ compact() { this.updateState('compacted'); } /** * Toggles sidebar state (expanded|collapsed|compacted) * @param {boolean} compact If true, then sidebar state will be changed between expanded & compacted, * otherwise - between expanded & collapsed. False by default. * * Toggle sidebar state * * ```ts * this.sidebar.toggle(true); * ``` */ toggle(compact: boolean = false) { if (this.responsive) { if (this.responsiveState === 'mobile') { compact = false; } } if (this.state === 'compacted' || this.state === 'collapsed') { this.updateState('expanded'); } else { this.updateState(compact ? 'compacted' : 'collapsed'); } } protected subscribeToMediaQueryChange() { combineLatest([ this.responsiveValueChange$.pipe(startWith(this.responsive)), this.themeService.onMediaQueryChange(), ]) .pipe( filter(([responsive]) => responsive), map(([, breakpoints]) => breakpoints), takeUntil(this.destroy$), ) .subscribe(([prev, current]: [NbMediaBreakpoint, NbMediaBreakpoint]) => { const isCollapsed = this.collapsedBreakpoints.includes(current.name); const isCompacted = this.compactedBreakpoints.includes(current.name); let newResponsiveState; if (isCompacted) { this.fixed = this.containerFixedValue; this.compact(); newResponsiveState = 'tablet'; } if (isCollapsed) { this.fixed = true; this.collapse(); newResponsiveState = 'mobile'; } if (!isCollapsed && !isCompacted && (!prev.width || prev.width < current.width)) { this.expand(); this.fixed = false; newResponsiveState = 'pc'; } if (newResponsiveState && newResponsiveState !== this.responsiveState) { this.responsiveState = newResponsiveState; this.responsiveStateChange.emit(this.responsiveState); this.cd.markForCheck(); } }); } protected getMenuLink(element: HTMLElement): HTMLElement | undefined { if (!element || element.tagName.toLowerCase() === 'nb-menu') { return; } if (element.tagName.toLowerCase() === 'a') { return element; } return this.getMenuLink(element.parentElement); } protected updateState(state: NbSidebarState): void { if (this.state !== state) { this.state = state; this.stateChange.emit(this.state); this.cd.markForCheck(); } } /** * @deprecated Use `responsive` property instead * @breaking-change Remove @8.0.0 */ toggleResponsive(enabled: boolean) { this.responsive = enabled; } /** * @deprecated Use NbSidebarState type instead * @breaking-change Remove @8.0.0 */ static readonly STATE_EXPANDED: string = 'expanded'; /** * @deprecated Use NbSidebarState type instead * @breaking-change Remove @8.0.0 */ static readonly STATE_COLLAPSED: string = 'collapsed'; /** * @deprecated Use NbSidebarState type instead * @breaking-change Remove @8.0.0 */ static readonly STATE_COMPACTED: string = 'compacted'; /** * @deprecated Use NbSidebarResponsiveState type instead * @breaking-change Remove @8.0.0 */ static readonly RESPONSIVE_STATE_MOBILE: string = 'mobile'; /** * @deprecated Use NbSidebarResponsiveState type instead * @breaking-change Remove @8.0.0 */ static readonly RESPONSIVE_STATE_TABLET: string = 'tablet'; /** * @deprecated Use NbSidebarResponsiveState type instead * @breaking-change Remove @8.0.0 */ static readonly RESPONSIVE_STATE_PC: string = 'pc'; }