diff --git a/apps/docs/src/app/services/documentation-items.ts b/apps/docs/src/app/services/documentation-items.ts
index 0681582c1..5defa3ebe 100644
--- a/apps/docs/src/app/services/documentation-items.ts
+++ b/apps/docs/src/app/services/documentation-items.ts
@@ -549,6 +549,18 @@ const DOCS: { [key: string]: DocCategory[] } = {
apiId: 'select',
hasExamples: true
},
+ {
+ id: 'sidebar',
+ name: {
+ ru: 'Sidebar',
+ en: 'Sidebar'
+ },
+ svgPreview: 'sidebar',
+ hasApi: true,
+ apiId: 'sidebar',
+ hasExamples: true,
+ isNew: expiresAt('2025-06-16')
+ },
{
id: 'sidepanel',
name: {
diff --git a/apps/docs/src/sitemap.xml b/apps/docs/src/sitemap.xml
index 75cdb0a60..6b88086a1 100644
--- a/apps/docs/src/sitemap.xml
+++ b/apps/docs/src/sitemap.xml
@@ -480,6 +480,18 @@
https://koobiq.io/ru/components/select/api
+
+ https://koobiq.io/en/components/sidebar/overview
+
+
+ https://koobiq.io/ru/components/sidebar/overview
+
+
+ https://koobiq.io/en/components/sidebar/api
+
+
+ https://koobiq.io/ru/components/sidebar/api
+
https://koobiq.io/en/components/sidepanel/overview
diff --git a/packages/components-dev/sidebar/module.ts b/packages/components-dev/sidebar/module.ts
index 012116e12..a50451f1a 100644
--- a/packages/components-dev/sidebar/module.ts
+++ b/packages/components-dev/sidebar/module.ts
@@ -1,45 +1,29 @@
-import { JsonPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
-import { KbqButtonModule } from '@koobiq/components/button';
-import { KbqSidebarModule, SidebarPositions } from '@koobiq/components/sidebar';
-import { Direction, KbqSplitterModule } from '@koobiq/components/splitter';
+import { SidebarExamplesModule } from 'packages/docs-examples/components/sidebar';
+
+@Component({
+ standalone: true,
+ imports: [SidebarExamplesModule],
+ selector: 'dev-examples',
+ template: `
+
+
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DevExamples {}
@Component({
standalone: true,
imports: [
- KbqSplitterModule,
- KbqButtonModule,
- KbqSidebarModule,
- JsonPipe
+ DevExamples
],
selector: 'dev-app',
templateUrl: './template.html',
- styleUrls: ['./styles.scss'],
+ styleUrl: './styles.scss',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class DevApp {
- direction = Direction;
- sidebarPositions = SidebarPositions;
-
- leftSidebarSidebarState: boolean = false;
- leftSplitterState: boolean = false;
-
- rightSidebarSidebarState: boolean = false;
-
- onStateChanged($event): void {
- console.log('onStateChanged: ', $event);
- }
-
- toggleLeftSidebar() {
- this.leftSidebarSidebarState = !this.leftSidebarSidebarState;
- }
-
- toggleRightSidebar() {
- this.rightSidebarSidebarState = !this.rightSidebarSidebarState;
- }
-
- toggleLeftSplitterState() {
- this.leftSplitterState = !this.leftSplitterState;
- }
-}
+export class DevApp {}
diff --git a/packages/components-dev/sidebar/styles.scss b/packages/components-dev/sidebar/styles.scss
index ac592d8b8..b3c58707a 100644
--- a/packages/components-dev/sidebar/styles.scss
+++ b/packages/components-dev/sidebar/styles.scss
@@ -1,25 +1 @@
-dev-app {
- display: block;
-}
-
-.dev-container {
- display: flex;
- flex-direction: row;
- justify-content: flex-start;
-
- height: 200px;
-}
-
-.dev-container__body {
- flex: 1 1 30%;
-
- background-color: antiquewhite;
-}
-
-.kbq-sidebar-opened {
- background-color: #6fba53;
-}
-
-.kbq-sidebar-closed {
- background-color: darkgray;
-}
+/* stylelint-disable-next-line no-empty-source */
diff --git a/packages/components-dev/sidebar/template.html b/packages/components-dev/sidebar/template.html
index 366b01e75..c6a5b7b43 100644
--- a/packages/components-dev/sidebar/template.html
+++ b/packages/components-dev/sidebar/template.html
@@ -1,88 +1 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
main area
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- main body
-
-
-
-
-
-
-
-
-
-
-
-{{ leftSidebar.params | json }}
+
diff --git a/packages/components/sidebar/__snapshots__/sidebar.spec.ts.snap b/packages/components/sidebar/__snapshots__/sidebar.spec.ts.snap
new file mode 100644
index 000000000..105bbe469
--- /dev/null
+++ b/packages/components/sidebar/__snapshots__/sidebar.spec.ts.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KbqSidebar should match snapshot when closed 1`] = `
+DebugElement {
+ "nativeNode": ,
+}
+`;
+
+exports[`KbqSidebar should match snapshot when opened 1`] = `
+DebugElement {
+ "nativeNode": ,
+}
+`;
diff --git a/packages/components/sidebar/examples.sidebar.en.md b/packages/components/sidebar/examples.sidebar.en.md
index 2faef3848..2058d4fdf 100644
--- a/packages/components/sidebar/examples.sidebar.en.md
+++ b/packages/components/sidebar/examples.sidebar.en.md
@@ -1,5 +1,3 @@
-🚧 **Documentation in progress** 🚧
+### With splitter
-Unfortunately, the documentation for this section is not ready yet. We are actively working on its creation and plan to add it soon.
-
-If you would like to contribute to the documentation or have any questions, please feel free to [open an issue](https://github.com/koobiq/angular-components/issues) in our GitHub repository.
+
diff --git a/packages/components/sidebar/examples.sidebar.ru.md b/packages/components/sidebar/examples.sidebar.ru.md
index b7a203c6a..7818d5c08 100644
--- a/packages/components/sidebar/examples.sidebar.ru.md
+++ b/packages/components/sidebar/examples.sidebar.ru.md
@@ -1,5 +1,3 @@
-🚧 **Документация в процессе написания** 🚧
+### Со сплиттером
-К сожалению, документация для этого раздела еще не готова. Мы активно работаем над ее созданием и планируем добавить в ближайшее время.
-
-Если вы хотите помочь в написании документации или у вас есть вопросы, пожалуйста, [создайте issue](https://github.com/koobiq/angular-components/issues) в нашем репозитории на GitHub.
+
diff --git a/packages/components/sidebar/public-api.ts b/packages/components/sidebar/public-api.ts
index 27c9cbd2e..3edcf2b3f 100644
--- a/packages/components/sidebar/public-api.ts
+++ b/packages/components/sidebar/public-api.ts
@@ -1,2 +1,2 @@
-export * from './sidebar.component';
+export * from './sidebar';
export * from './sidebar.module';
diff --git a/packages/components/sidebar/sidebar-animations.ts b/packages/components/sidebar/sidebar-animations.ts
deleted file mode 100644
index 9de8eec33..000000000
--- a/packages/components/sidebar/sidebar-animations.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { animate, AnimationTriggerMetadata, state, style, transition, trigger } from '@angular/animations';
-
-export enum KbqSidebarAnimationState {
- Opened = 'opened',
- Closed = 'closed'
-}
-
-export const kbqSidebarAnimations: { readonly sidebarState: AnimationTriggerMetadata } = {
- sidebarState: trigger('state', [
- state(
- 'opened',
- style({
- minWidth: '{{ openedStateMinWidth }}',
- width: '{{ openedStateWidth }}',
- maxWidth: '{{ openedStateMaxWidth }}'
- }),
- { params: { openedStateMinWidth: '', openedStateWidth: '', openedStateMaxWidth: '' } }
- ),
- state(
- 'closed',
- style({
- minWidth: '{{ closedStateWidth }}',
- width: '{{ closedStateWidth }}',
- maxWidth: '{{ closedStateWidth }}'
- }),
- { params: { closedStateWidth: '' } }
- ),
- transition('opened => closed', [animate('0.1s')]),
- transition('closed => opened', [animate('0.2s')])
-
- ])
-};
diff --git a/packages/components/sidebar/sidebar.component.html b/packages/components/sidebar/sidebar.component.html
deleted file mode 100644
index 8cbcb0892..000000000
--- a/packages/components/sidebar/sidebar.component.html
+++ /dev/null
@@ -1,5 +0,0 @@
-@if (internalState) {
-
-} @else {
-
-}
diff --git a/packages/components/sidebar/sidebar.component.ts b/packages/components/sidebar/sidebar.component.ts
deleted file mode 100644
index 0ff491b20..000000000
--- a/packages/components/sidebar/sidebar.component.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { DOCUMENT } from '@angular/common';
-import {
- AfterContentInit,
- ChangeDetectionStrategy,
- Component,
- ContentChild,
- Directive,
- ElementRef,
- EventEmitter,
- inject,
- Input,
- NgZone,
- OnDestroy,
- OnInit,
- Output,
- ViewEncapsulation
-} from '@angular/core';
-import { isControl, isInput, isLeftBracket, isRightBracket } from '@koobiq/cdk/keycodes';
-import { kbqSidebarAnimations, KbqSidebarAnimationState } from './sidebar-animations';
-
-export enum SidebarPositions {
- Left = 'left',
- Right = 'right'
-}
-
-interface KbqSidebarParams {
- openedStateMinWidth: string;
- openedStateWidth: string;
- openedStateMaxWidth: string;
-
- closedStateWidth: string;
-}
-
-@Directive({
- selector: '[kbq-sidebar-opened]',
- exportAs: 'kbqSidebarOpened'
-})
-export class KbqSidebarOpened {
- @Input() minWidth: string;
- @Input() width: string;
- @Input() maxWidth: string;
-}
-
-@Directive({
- selector: '[kbq-sidebar-closed]',
- exportAs: 'kbqSidebarClosed'
-})
-export class KbqSidebarClosed {
- @Input() width: string;
-}
-
-@Component({
- selector: 'kbq-sidebar',
- exportAs: 'kbqSidebar',
- templateUrl: 'sidebar.component.html',
- styleUrls: ['./sidebar.scss'],
- host: {
- class: 'kbq-sidebar',
- '[@state]': `{
- value: animationState,
- params: params
- }`,
- '(@state.start)': 'onAnimationStart()',
- '(@state.done)': 'onAnimationDone()'
- },
- animations: [kbqSidebarAnimations.sidebarState],
- encapsulation: ViewEncapsulation.None,
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class KbqSidebar implements OnDestroy, OnInit, AfterContentInit {
- protected readonly document = inject(DOCUMENT);
-
- @Input()
- get opened(): boolean {
- return this._opened;
- }
-
- set opened(value: boolean) {
- if (this._opened) {
- this.saveWidth();
- }
-
- this._opened = value;
- }
- private _opened: boolean = true;
-
- @Input() position: SidebarPositions;
-
- params: KbqSidebarParams = {
- openedStateWidth: 'inherit',
- openedStateMinWidth: 'inherit',
- openedStateMaxWidth: 'inherit',
-
- closedStateWidth: '32px'
- };
-
- @Output() readonly stateChanged: EventEmitter = new EventEmitter();
-
- @ContentChild(KbqSidebarOpened, { static: false }) openedContent: KbqSidebarOpened;
-
- @ContentChild(KbqSidebarClosed, { static: false }) closedContent: KbqSidebarClosed;
-
- get animationState(): KbqSidebarAnimationState {
- return this._opened ? KbqSidebarAnimationState.Opened : KbqSidebarAnimationState.Closed;
- }
-
- internalState: boolean = true;
-
- private documentKeydownListener: (event: KeyboardEvent) => void;
-
- constructor(
- private ngZone: NgZone,
- private elementRef: ElementRef
- ) {}
-
- ngOnInit(): void {
- if (this.position === SidebarPositions.Left || this.position === SidebarPositions.Right) {
- this.registerKeydownListener();
- }
- }
-
- ngOnDestroy(): void {
- if (this.position === SidebarPositions.Left || this.position === SidebarPositions.Right) {
- this.unRegisterKeydownListener();
- }
- }
-
- toggle(): void {
- this.opened = !this.opened;
- }
-
- onAnimationStart() {
- if (this._opened) {
- this.internalState = this._opened;
- }
- }
-
- onAnimationDone() {
- this.internalState = this._opened;
-
- this.stateChanged.emit(this._opened);
- }
-
- ngAfterContentInit(): void {
- this.params = {
- openedStateWidth: this.openedContent.width || 'inherit',
- openedStateMinWidth: this.openedContent.minWidth || 'inherit',
- openedStateMaxWidth: this.openedContent.maxWidth || 'inherit',
-
- closedStateWidth: this.closedContent.width || '32px'
- };
- }
-
- private registerKeydownListener(): void {
- this.documentKeydownListener = (event) => {
- if (isControl(event) || isInput(event)) return;
-
- if (
- (this.position === SidebarPositions.Left && isLeftBracket(event)) ||
- (this.position === SidebarPositions.Right && isRightBracket(event))
- ) {
- this.ngZone.run(() => (this._opened = !this._opened));
- }
- };
-
- this.ngZone.runOutsideAngular(() => {
- this.document.addEventListener('keypress', this.documentKeydownListener, true);
- });
- }
-
- private unRegisterKeydownListener(): void {
- this.document.removeEventListener('keypress', this.documentKeydownListener, true);
- }
-
- private saveWidth() {
- this.params.openedStateWidth = `${this.elementRef.nativeElement.offsetWidth}px`;
- }
-}
diff --git a/packages/components/sidebar/sidebar.en.md b/packages/components/sidebar/sidebar.en.md
index 2faef3848..16f80466f 100644
--- a/packages/components/sidebar/sidebar.en.md
+++ b/packages/components/sidebar/sidebar.en.md
@@ -1,5 +1,10 @@
-🚧 **Documentation in progress** 🚧
+Component designed to add collapsible side content.
-Unfortunately, the documentation for this section is not ready yet. We are actively working on its creation and plan to add it soon.
+
-If you would like to contribute to the documentation or have any questions, please feel free to [open an issue](https://github.com/koobiq/angular-components/issues) in our GitHub repository.
+### Keyboard interaction
+
+| Key
| Action |
+| ---------------------------------------------- | ------------------------ |
+| [ | Open/close left Sidebar |
+| ] | Open/close right Sidebar |
diff --git a/packages/components/sidebar/sidebar.module.ts b/packages/components/sidebar/sidebar.module.ts
index a6d842a38..721c5cd23 100644
--- a/packages/components/sidebar/sidebar.module.ts
+++ b/packages/components/sidebar/sidebar.module.ts
@@ -1,18 +1,14 @@
-import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
-import { KbqSidebar, KbqSidebarClosed, KbqSidebarOpened } from './sidebar.component';
+import { KbqSidebar, KbqSidebarClosed, KbqSidebarOpened } from './sidebar';
+
+const COMPONENTS = [
+ KbqSidebarClosed,
+ KbqSidebarOpened,
+ KbqSidebar
+];
@NgModule({
- imports: [CommonModule],
- declarations: [
- KbqSidebarClosed,
- KbqSidebarOpened,
- KbqSidebar
- ],
- exports: [
- KbqSidebarClosed,
- KbqSidebarOpened,
- KbqSidebar
- ]
+ imports: COMPONENTS,
+ exports: COMPONENTS
})
export class KbqSidebarModule {}
diff --git a/packages/components/sidebar/sidebar.ru.md b/packages/components/sidebar/sidebar.ru.md
index e69de29bb..dbdaac562 100644
--- a/packages/components/sidebar/sidebar.ru.md
+++ b/packages/components/sidebar/sidebar.ru.md
@@ -0,0 +1,10 @@
+Компонент, предназначенный для добавления сворачиваемого бокового контента.
+
+
+
+### Работа с клавиатурой
+
+| Клавиша
| Действие |
+| ---------------------------------------------- | ------------------------------ |
+| [ | Открыть/закрыть левый Sidebar |
+| ] | Открыть/закрыть правый Sidebar |
diff --git a/packages/components/sidebar/sidebar.scss b/packages/components/sidebar/sidebar.scss
index 300a4909c..9d3a4542a 100644
--- a/packages/components/sidebar/sidebar.scss
+++ b/packages/components/sidebar/sidebar.scss
@@ -1,12 +1,10 @@
.kbq-sidebar {
display: inline-block;
-
height: 100%;
-
overflow: hidden;
-}
-.kbq-sidebar-opened,
-.kbq-sidebar-closed {
- height: 100%;
+ .kbq-sidebar-opened,
+ .kbq-sidebar-closed {
+ height: 100%;
+ }
}
diff --git a/packages/components/sidebar/sidebar.spec.ts b/packages/components/sidebar/sidebar.spec.ts
index 147daa029..bd09df1bd 100644
--- a/packages/components/sidebar/sidebar.spec.ts
+++ b/packages/components/sidebar/sidebar.spec.ts
@@ -1,137 +1,144 @@
-import { Component, ViewChild } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { ChangeDetectionStrategy, Component, DebugElement, Provider, Type, viewChild } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { KbqSidebar, KbqSidebarModule, SidebarPositions } from './index';
-
-describe('Sidebar', () => {
- beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [
- NoopAnimationsModule,
- FormsModule,
- ReactiveFormsModule,
- KbqSidebarModule
- ],
- declarations: [SimpleSidebar]
- }).compileComponents();
+import { KbqSidebar, KbqSidebarPositions } from './sidebar';
+import { KbqSidebarModule } from './sidebar.module';
+
+const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => {
+ TestBed.configureTestingModule({
+ imports: [component, NoopAnimationsModule],
+ providers
});
+ const fixture = TestBed.createComponent(component);
- describe('base', () => {
- let fixture: ComponentFixture;
- let testComponent: SimpleSidebar;
- let sidebarComponent: KbqSidebar;
+ fixture.autoDetectChanges();
- beforeEach(() => {
- fixture = TestBed.createComponent(SimpleSidebar);
- fixture.detectChanges();
+ return fixture;
+};
- testComponent = fixture.debugElement.componentInstance;
- sidebarComponent = fixture.debugElement.componentInstance.sidebar;
- });
+const getSidebarDebugElement = (debugElement: DebugElement): DebugElement => {
+ return debugElement.query(By.directive(KbqSidebar));
+};
- it('should render with default parameters', () => {
- expect(sidebarComponent.opened).toBeTruthy();
- expect(sidebarComponent.position).toBe(SidebarPositions.Left);
- expect(sidebarComponent.openedContent).toBeDefined();
- expect(sidebarComponent.closedContent).toBeDefined();
- });
+@Component({
+ standalone: true,
+ selector: 'test-sidebar',
+ imports: [KbqSidebarModule],
+ template: `
+
+ kbqSidebarOpened
+ kbqSidebarClosed
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+class TestSidebar {
+ readonly sidebar = viewChild.required(KbqSidebar);
- it('should change state by property', () => {
- expect(sidebarComponent.opened).toBeTruthy();
+ opened = true;
- testComponent.state = false;
- fixture.detectChanges();
+ position = KbqSidebarPositions.Left;
- expect(sidebarComponent.opened).toBeFalsy();
+ readonly onOpenedChange = jest.fn((_event: boolean) => {});
+}
- testComponent.state = true;
- fixture.detectChanges();
+describe(KbqSidebar.name, () => {
+ it('should match snapshot when opened', () => {
+ const { debugElement } = createComponent(TestSidebar);
- expect(sidebarComponent.opened).toBeTruthy();
- });
+ expect(getSidebarDebugElement(debugElement)).toMatchSnapshot();
+ });
- it('should change state by method', () => {
- expect(sidebarComponent.opened).toBeTruthy();
+ it('should match snapshot when closed', fakeAsync(() => {
+ const { debugElement, componentInstance } = createComponent(TestSidebar);
- sidebarComponent.toggle();
- fixture.detectChanges();
+ componentInstance.sidebar().toggle();
+ tick();
- expect(sidebarComponent.opened).toBeFalsy();
+ expect(getSidebarDebugElement(debugElement)).toMatchSnapshot();
+ }));
- sidebarComponent.toggle();
- fixture.detectChanges();
+ it('should emit two-way data binding by toggle method', fakeAsync(() => {
+ const { componentInstance } = createComponent(TestSidebar);
- expect(sidebarComponent.opened).toBeTruthy();
- });
+ expect(componentInstance.opened).toBe(true);
- it('should change position', () => {
- expect(sidebarComponent.position).toBe(SidebarPositions.Left);
+ componentInstance.sidebar().toggle();
+ tick();
- testComponent.position = SidebarPositions.Right;
- fixture.detectChanges();
+ expect(componentInstance.opened).toBe(false);
+ }));
- expect(sidebarComponent.position).toBe(SidebarPositions.Right);
- });
+ it('should emit openedChange event by model change', fakeAsync(() => {
+ const fixture = createComponent(TestSidebar);
+ const { componentInstance } = fixture;
+ const spy = jest.spyOn(componentInstance, 'onOpenedChange');
- xit('should fire change event', () => {
- const changeSpy = jest.fn();
+ expect(spy).toHaveBeenCalledTimes(0);
- sidebarComponent.stateChanged.subscribe(changeSpy);
+ componentInstance.opened = false;
+ fixture.detectChanges();
+ tick();
- expect(sidebarComponent.opened).toBeTruthy();
+ expect(spy).toHaveBeenCalledTimes(1);
+ }));
- // sidebarComponent.stateChanged.emit(true);
- sidebarComponent.toggle();
- fixture.detectChanges();
+ xit('should emit openedChange event by toggle method', fakeAsync(() => {
+ const { componentInstance } = createComponent(TestSidebar);
+ const spy = jest.spyOn(componentInstance, 'onOpenedChange');
- expect(sidebarComponent.opened).toBeFalsy();
+ expect(spy).toHaveBeenCalledTimes(0);
- expect(changeSpy).toHaveBeenCalled();
- });
+ componentInstance.sidebar().toggle();
+ tick();
- it('should add and remove event listeners from document', () => {
- const addEventListenerSpyFn = jest.spyOn(document, 'addEventListener');
- const removeEventListenerSpyFn = jest.spyOn(document, 'removeEventListener');
+ expect(spy).toHaveBeenCalledTimes(1);
+ }));
- testComponent.showContainer = false;
- fixture.detectChanges();
+ xit('should emit openedChange event with correct $event value by model change', fakeAsync(() => {
+ const fixture = createComponent(TestSidebar);
+ const { componentInstance } = fixture;
+ const spy = jest.spyOn(componentInstance, 'onOpenedChange');
- expect(removeEventListenerSpyFn).toHaveBeenCalledWith('keypress', expect.any(Function), true);
+ componentInstance.opened = false;
+ fixture.detectChanges();
+ tick();
- testComponent.showContainer = true;
- fixture.detectChanges();
+ expect(spy).toHaveBeenCalledWith(false);
+ }));
- expect(addEventListenerSpyFn).toHaveBeenCalledWith('keypress', expect.any(Function), true);
- });
- });
-});
+ it('should emit openedChange event with correct $event value by toggle method', fakeAsync(() => {
+ const { componentInstance } = createComponent(TestSidebar);
+ const spy = jest.spyOn(componentInstance, 'onOpenedChange');
-@Component({
- template: `
- @if (showContainer) {
-
-
- kbq-sidebar-opened
- kbq-sidebar-closed
-
-
- }
- `
-})
-class SimpleSidebar {
- showContainer: boolean = true;
+ componentInstance.sidebar().toggle();
+ tick();
- position: SidebarPositions = SidebarPositions.Left;
+ expect(spy).toHaveBeenCalledWith(false);
+ }));
- state: boolean = true;
+ it('should close on `BracketLeft` key click', fakeAsync(() => {
+ const { componentInstance } = createComponent(TestSidebar);
- @ViewChild(KbqSidebar, { static: false }) sidebar: KbqSidebar;
+ expect(componentInstance.opened).toBe(true);
+ expect(componentInstance.position).toBe(KbqSidebarPositions.Left);
- onStateChanged(): void {}
-}
+ document.dispatchEvent(new KeyboardEvent('keypress', { code: 'BracketLeft' }));
+ tick();
+
+ expect(componentInstance.opened).toBe(false);
+ }));
+
+ it('should ignore on `BracketRight` key click', fakeAsync(() => {
+ const { componentInstance } = createComponent(TestSidebar);
+
+ expect(componentInstance.opened).toBe(true);
+ expect(componentInstance.position).toBe(KbqSidebarPositions.Left);
+
+ document.dispatchEvent(new KeyboardEvent('keypress', { code: 'BracketRight' }));
+ tick();
+
+ expect(componentInstance.opened).toBe(true);
+ }));
+});
diff --git a/packages/components/sidebar/sidebar.ts b/packages/components/sidebar/sidebar.ts
new file mode 100644
index 000000000..26ecd2e1f
--- /dev/null
+++ b/packages/components/sidebar/sidebar.ts
@@ -0,0 +1,276 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+import { DOCUMENT } from '@angular/common';
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ contentChild,
+ Directive,
+ inject,
+ input,
+ NgZone,
+ OnDestroy,
+ OnInit,
+ output,
+ Renderer2,
+ signal,
+ ViewEncapsulation
+} from '@angular/core';
+import { outputFromObservable, outputToObservable, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
+import { isControl, isInput, isLeftBracket, isRightBracket } from '@koobiq/cdk/keycodes';
+import { KbqAnimationDurations } from '@koobiq/components/core';
+import { distinctUntilChanged } from 'rxjs/operators';
+
+enum KbqSidebarAnimationState {
+ Opened = 'opened',
+ Closed = 'closed'
+}
+
+type KbqSidebarAnimationParams = Partial<{
+ openedStateMinWidth: string;
+ openedStateWidth: string;
+ openedStateMaxWidth: string;
+ closedStateWidth: string;
+}>;
+
+const KBQ_SIDEBAR_ANIMATION_PARAMS_DEFAULT: KbqSidebarAnimationParams = {
+ openedStateMinWidth: 'inherit',
+ openedStateWidth: 'inherit',
+ openedStateMaxWidth: 'inherit',
+ closedStateWidth: '32px'
+};
+
+const KBQ_SIDEBAR_ANIMATION = trigger('state', [
+ state(
+ KbqSidebarAnimationState.Opened,
+ style({
+ minWidth: '{{ openedStateMinWidth }}',
+ width: '{{ openedStateWidth }}',
+ maxWidth: '{{ openedStateMaxWidth }}'
+ }),
+ {
+ params: {
+ openedStateMinWidth: '',
+ openedStateWidth: '',
+ openedStateMaxWidth: ''
+ } satisfies KbqSidebarAnimationParams
+ }
+ ),
+ state(
+ KbqSidebarAnimationState.Closed,
+ style({
+ minWidth: '{{ closedStateWidth }}',
+ width: '{{ closedStateWidth }}',
+ maxWidth: '{{ closedStateWidth }}'
+ }),
+ { params: { closedStateWidth: '' } satisfies KbqSidebarAnimationParams }
+ ),
+ transition(`${KbqSidebarAnimationState.Opened} => ${KbqSidebarAnimationState.Closed}`, [
+ animate(KbqAnimationDurations.Entering)]),
+ transition(`${KbqSidebarAnimationState.Closed} => ${KbqSidebarAnimationState.Opened}`, [
+ animate(KbqAnimationDurations.Complex)])
+
+]);
+
+/**
+ * Sidebar positions.
+ */
+export enum KbqSidebarPositions {
+ Left = 'left',
+ Right = 'right'
+}
+
+@Directive({
+ standalone: true,
+ selector: '[kbq-sidebar-opened],[kbqSidebarOpened]',
+ exportAs: 'kbqSidebarOpened',
+ host: {
+ class: 'kbq-sidebar-opened'
+ }
+})
+export class KbqSidebarOpened {
+ /**
+ * Min width of the sidebar when opened.
+ */
+ readonly minWidth = input();
+ /**
+ * Width of the sidebar when opened.
+ */
+ readonly width = input();
+ /**
+ * Max width of the sidebar when opened.
+ */
+ readonly maxWidth = input();
+}
+
+@Directive({
+ standalone: true,
+ selector: '[kbq-sidebar-closed],[kbqSidebarClosed]',
+ exportAs: 'kbqSidebarClosed',
+ host: {
+ class: 'kbq-sidebar-closed'
+ }
+})
+export class KbqSidebarClosed {
+ /**
+ * Width of the sidebar when closed.
+ */
+ readonly width = input();
+}
+
+@Component({
+ standalone: true,
+ selector: 'kbq-sidebar',
+ exportAs: 'kbqSidebar',
+ template: `
+ @if (state()) {
+
+ } @else {
+
+ }
+ `,
+ styleUrl: './sidebar.scss',
+ host: {
+ class: 'kbq-sidebar',
+ '[@state]': `{
+ value: animationStateValue(),
+ params: animationStateParams()
+ }`,
+ '(@state.start)': 'onAnimationStart()',
+ '(@state.done)': 'onAnimationDone()'
+ },
+ animations: [KBQ_SIDEBAR_ANIMATION],
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class KbqSidebar implements OnDestroy, OnInit {
+ private readonly document = inject(DOCUMENT);
+ private readonly ngZone = inject(NgZone);
+ private readonly renderer = inject(Renderer2);
+
+ private readonly openedContent = contentChild(KbqSidebarOpened);
+ private readonly closedContent = contentChild(KbqSidebarClosed);
+
+ /**
+ * Whether the sidebar is opened or closed.
+ */
+ readonly opened = input(true, { transform: booleanAttribute });
+
+ private readonly _opened = signal(this.opened());
+
+ /**
+ * Emits event when the sidebar opened state changes.
+ * Also used for two-way binding.
+ */
+ readonly openedChange = output();
+
+ /**
+ * Emits event when the sidebar opened state changes.
+ *
+ * @deprecated Will be removed in next major release, use `openedChange` instead.
+ */
+ readonly stateChanged = outputFromObservable(outputToObservable(this.openedChange).pipe(distinctUntilChanged()));
+
+ /**
+ * Sidebar position.
+ */
+ readonly position = input.required();
+
+ /** @docs-private */
+ protected readonly animationStateValue = computed(() => {
+ return this._opened() ? KbqSidebarAnimationState.Opened : KbqSidebarAnimationState.Closed;
+ });
+
+ /** @docs-private */
+ protected readonly animationStateParams = computed(() => {
+ const openedContent = this.openedContent();
+ const closedContent = this.closedContent();
+
+ return {
+ openedStateMinWidth: openedContent?.minWidth() || KBQ_SIDEBAR_ANIMATION_PARAMS_DEFAULT.openedStateMinWidth,
+ openedStateWidth: openedContent?.width() || KBQ_SIDEBAR_ANIMATION_PARAMS_DEFAULT.openedStateWidth,
+ openedStateMaxWidth: openedContent?.maxWidth() || KBQ_SIDEBAR_ANIMATION_PARAMS_DEFAULT.openedStateMaxWidth,
+ closedStateWidth: closedContent?.width() || KBQ_SIDEBAR_ANIMATION_PARAMS_DEFAULT.closedStateWidth
+ };
+ });
+
+ /**
+ * Internal opened state based on animations.
+ *
+ * @docs-private
+ */
+ protected readonly state = signal(this._opened());
+
+ private unbindKeydownListener: ReturnType | null = null;
+
+ constructor() {
+ // @TODO: should be removed after migration to `linkedSignal`
+ toObservable(this.opened)
+ .pipe(takeUntilDestroyed())
+ .subscribe((opened) => {
+ this._opened.set(opened);
+ });
+ }
+
+ ngOnInit(): void {
+ this.registerKeydownListener();
+ }
+
+ ngOnDestroy(): void {
+ this.unRegisterKeydownListener();
+ }
+
+ /**
+ * Toggles the sidebar opened state.
+ */
+ toggle(): void {
+ this._opened.update((opened) => !opened);
+ }
+
+ /** @docs-private */
+ protected onAnimationStart(): void {
+ const opened = this._opened();
+
+ if (opened) {
+ this.state.set(opened);
+ }
+ }
+
+ /** @docs-private */
+ protected onAnimationDone() {
+ const opened = this._opened();
+
+ this.state.set(opened);
+
+ this.openedChange.emit(opened);
+ }
+
+ private registerKeydownListener(): void {
+ this.ngZone.runOutsideAngular(() => {
+ this.unbindKeydownListener ||= this.renderer.listen(this.document, 'keypress', (event) =>
+ this.handleKeydown(event)
+ );
+ });
+ }
+
+ private unRegisterKeydownListener(): void {
+ if (this.unbindKeydownListener) {
+ this.unbindKeydownListener();
+ this.unbindKeydownListener = null;
+ }
+ }
+
+ private handleKeydown(event: KeyboardEvent): void {
+ if (isControl(event) || isInput(event)) return;
+
+ const position = this.position();
+
+ if (
+ (position === KbqSidebarPositions.Left && isLeftBracket(event)) ||
+ (position === KbqSidebarPositions.Right && isRightBracket(event))
+ ) {
+ this.toggle();
+ }
+ }
+}
diff --git a/packages/docs-examples/components/sidebar/index.ts b/packages/docs-examples/components/sidebar/index.ts
new file mode 100644
index 000000000..2089181ce
--- /dev/null
+++ b/packages/docs-examples/components/sidebar/index.ts
@@ -0,0 +1,16 @@
+import { NgModule } from '@angular/core';
+import { SidebarOverviewExample } from './sidebar-overview/sidebar-overview-example';
+import { SidebarWithSplitterExample } from './sidebar-with-splitter/sidebar-with-splitter-example';
+
+export { SidebarOverviewExample, SidebarWithSplitterExample };
+
+const EXAMPLES = [
+ SidebarOverviewExample,
+ SidebarWithSplitterExample
+];
+
+@NgModule({
+ imports: EXAMPLES,
+ exports: EXAMPLES
+})
+export class SidebarExamplesModule {}
diff --git a/packages/docs-examples/components/sidebar/ng-package.json b/packages/docs-examples/components/sidebar/ng-package.json
new file mode 100644
index 000000000..bebf62dcb
--- /dev/null
+++ b/packages/docs-examples/components/sidebar/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "index.ts"
+ }
+}
diff --git a/packages/docs-examples/components/sidebar/sidebar-overview/sidebar-overview-example.ts b/packages/docs-examples/components/sidebar/sidebar-overview/sidebar-overview-example.ts
new file mode 100644
index 000000000..5c25793ce
--- /dev/null
+++ b/packages/docs-examples/components/sidebar/sidebar-overview/sidebar-overview-example.ts
@@ -0,0 +1,89 @@
+import { ChangeDetectionStrategy, Component, model } from '@angular/core';
+import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
+import { KbqButtonModule } from '@koobiq/components/button';
+import { KbqSidebarModule, KbqSidebarPositions } from '@koobiq/components/sidebar';
+
+/**
+ * @title Sidebar overview
+ */
+@Component({
+ standalone: true,
+ imports: [KbqSidebarModule, KbqButtonModule],
+ selector: 'sidebar-overview-example',
+ template: `
+
+ Left opened content
+ Left closed content
+
+
+
+ Main content
+
+
+
+
+
+
+
+ Right opened content
+ Right closed content
+
+ `,
+ styles: `
+ :host {
+ display: flex;
+ height: 250px;
+ }
+
+ .kbq-sidebar {
+ background-color: var(--kbq-background-bg-secondary);
+ }
+
+ .kbq-sidebar-opened,
+ .kbq-sidebar-closed {
+ padding: var(--kbq-size-m);
+ }
+
+ .kbq-sidebar-closed {
+ box-sizing: border-box;
+ writing-mode: sideways-lr;
+ text-align: center;
+ }
+
+ main {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--kbq-size-m);
+ padding: var(--kbq-size-m);
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SidebarOverviewExample {
+ readonly position = KbqSidebarPositions;
+ readonly leftOpened = model(true);
+ readonly rightOpened = model(false);
+
+ constructor() {
+ toObservable(this.leftOpened)
+ .pipe(takeUntilDestroyed())
+ .subscribe((leftOpened) => {
+ console.log('Left sidebar opened: ', leftOpened);
+ });
+
+ toObservable(this.rightOpened)
+ .pipe(takeUntilDestroyed())
+ .subscribe((rightOpened) => {
+ console.log('Right sidebar opened: ', rightOpened);
+ });
+ }
+
+ toggleLeft(): void {
+ this.leftOpened.update((opened) => !opened);
+ }
+
+ toggleRight(): void {
+ this.rightOpened.update((opened) => !opened);
+ }
+}
diff --git a/packages/docs-examples/components/sidebar/sidebar-with-splitter/sidebar-with-splitter-example.ts b/packages/docs-examples/components/sidebar/sidebar-with-splitter/sidebar-with-splitter-example.ts
new file mode 100644
index 000000000..1af0b55d3
--- /dev/null
+++ b/packages/docs-examples/components/sidebar/sidebar-with-splitter/sidebar-with-splitter-example.ts
@@ -0,0 +1,80 @@
+import { ChangeDetectionStrategy, Component, model } from '@angular/core';
+import { KbqButtonModule } from '@koobiq/components/button';
+import { KbqSidebarModule, KbqSidebarPositions } from '@koobiq/components/sidebar';
+import { Direction, KbqSplitterModule } from '@koobiq/components/splitter';
+
+/**
+ * @title Sidebar with splitter
+ */
+@Component({
+ standalone: true,
+ imports: [KbqSidebarModule, KbqButtonModule, KbqSplitterModule],
+ selector: 'sidebar-with-splitter-example',
+ template: `
+
+
+ Opened content
+ Closed content
+
+
+
+ Main content
+
+
+
+
+ `,
+ styles: `
+ :host {
+ display: flex;
+ height: 250px;
+ }
+
+ .kbq-splitter {
+ flex-grow: 1;
+ }
+
+ .kbq-sidebar {
+ background-color: var(--kbq-background-bg-secondary);
+ }
+
+ .kbq-sidebar-opened,
+ .kbq-sidebar-closed {
+ padding: var(--kbq-size-m);
+ }
+
+ .kbq-sidebar-closed {
+ box-sizing: border-box;
+ writing-mode: sideways-lr;
+ text-align: center;
+ }
+
+ main {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--kbq-size-m);
+ padding: var(--kbq-size-m);
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SidebarWithSplitterExample {
+ readonly direction = Direction;
+ readonly position = KbqSidebarPositions;
+ readonly opened = model(true);
+
+ toggle(): void {
+ this.opened.update((opened) => !opened);
+ }
+
+ onOpenedChange(opened: boolean): void {
+ console.log('Sidebar opened: ', opened);
+ }
+}
diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts
index f2e50170e..a4887905a 100644
--- a/packages/docs-examples/example-module.ts
+++ b/packages/docs-examples/example-module.ts
@@ -2966,6 +2966,30 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = {
"primaryFile": "select-with-panel-width-attribute-example.ts",
"importPath": "components/select"
},
+ "sidebar-overview": {
+ "packagePath": "components/sidebar/sidebar-overview",
+ "title": "Sidebar overview",
+ "componentName": "SidebarOverviewExample",
+ "files": [
+ "sidebar-overview-example.ts"
+ ],
+ "selector": "sidebar-overview-example",
+ "additionalComponents": [],
+ "primaryFile": "sidebar-overview-example.ts",
+ "importPath": "components/sidebar"
+ },
+ "sidebar-with-splitter": {
+ "packagePath": "components/sidebar/sidebar-with-splitter",
+ "title": "Sidebar with splitter",
+ "componentName": "SidebarWithSplitterExample",
+ "files": [
+ "sidebar-with-splitter-example.ts"
+ ],
+ "selector": "sidebar-with-splitter-example",
+ "additionalComponents": [],
+ "primaryFile": "sidebar-with-splitter-example.ts",
+ "importPath": "components/sidebar"
+ },
"sidepanel-modal-mode": {
"packagePath": "components/sidepanel/sidepanel-modal-mode",
"title": "Sidepanel modal mode",
@@ -4639,6 +4663,10 @@ return import('@koobiq/docs-examples/components/select');
return import('@koobiq/docs-examples/components/select');
case 'select-with-panel-width-attribute':
return import('@koobiq/docs-examples/components/select');
+ case 'sidebar-overview':
+return import('@koobiq/docs-examples/components/sidebar');
+ case 'sidebar-with-splitter':
+return import('@koobiq/docs-examples/components/sidebar');
case 'sidepanel-modal-mode':
return import('@koobiq/docs-examples/components/sidepanel');
case 'sidepanel-normal-mode':
diff --git a/tools/cspell-locales/ru.json b/tools/cspell-locales/ru.json
index 792015197..c3167a46c 100644
--- a/tools/cspell-locales/ru.json
+++ b/tools/cspell-locales/ru.json
@@ -90,15 +90,15 @@
"сайдпанели",
"сайдпанель",
"селект",
- "селекты",
"селекта",
- "селекте",
- "селектом",
- "селекту",
"селектам",
+ "селектами",
"селектах",
+ "селекте",
"селектов",
- "селектами",
+ "селектом",
+ "селекту",
+ "селекты",
"скролл",
"скролла",
"скроллбар",
@@ -110,6 +110,7 @@
"спиннера",
"спиннером",
"спиннеры",
+ "сплиттером",
"табов",
"таймзон",
"таймзоны",
diff --git a/tools/generate-sitemap.ts b/tools/generate-sitemap.ts
index aa519e1e7..be6ea8da5 100644
--- a/tools/generate-sitemap.ts
+++ b/tools/generate-sitemap.ts
@@ -126,6 +126,9 @@ const paths = [
'components/select/overview',
'components/select/api',
+ 'components/sidebar/overview',
+ 'components/sidebar/api',
+
'components/sidepanel/overview',
'components/sidepanel/api',
diff --git a/tools/public_api_guard/components/sidebar.api.md b/tools/public_api_guard/components/sidebar.api.md
index 3ea8c84c8..8e90af9f0 100644
--- a/tools/public_api_guard/components/sidebar.api.md
+++ b/tools/public_api_guard/components/sidebar.api.md
@@ -4,65 +4,51 @@
```ts
-import { AfterContentInit } from '@angular/core';
-import { ElementRef } from '@angular/core';
-import { EventEmitter } from '@angular/core';
import * as i0 from '@angular/core';
-import * as i2 from '@angular/common';
-import { NgZone } from '@angular/core';
+import { InputSignal } from '@angular/core';
+import { InputSignalWithTransform } from '@angular/core';
import { OnDestroy } from '@angular/core';
import { OnInit } from '@angular/core';
+import { OutputEmitterRef } from '@angular/core';
+import { OutputRef } from '@angular/core';
+import { Signal } from '@angular/core';
+import { WritableSignal } from '@angular/core';
// @public (undocumented)
-export class KbqSidebar implements OnDestroy, OnInit, AfterContentInit {
- constructor(ngZone: NgZone, elementRef: ElementRef);
+export class KbqSidebar implements OnDestroy, OnInit {
+ constructor();
+ protected readonly animationStateParams: Signal>;
// Warning: (ae-forgotten-export) The symbol "KbqSidebarAnimationState" needs to be exported by the entry point index.d.ts
- //
- // (undocumented)
- get animationState(): KbqSidebarAnimationState;
- // (undocumented)
- closedContent: KbqSidebarClosed;
- // (undocumented)
- protected readonly document: Document;
- // (undocumented)
- internalState: boolean;
- // (undocumented)
- ngAfterContentInit(): void;
+ protected readonly animationStateValue: Signal;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
- // (undocumented)
- onAnimationDone(): void;
- // (undocumented)
- onAnimationStart(): void;
- // (undocumented)
- get opened(): boolean;
- set opened(value: boolean);
- // (undocumented)
- openedContent: KbqSidebarOpened;
- // Warning: (ae-forgotten-export) The symbol "KbqSidebarParams" needs to be exported by the entry point index.d.ts
- //
- // (undocumented)
- params: KbqSidebarParams;
- // (undocumented)
- position: SidebarPositions;
- // (undocumented)
- readonly stateChanged: EventEmitter;
- // (undocumented)
+ protected onAnimationDone(): void;
+ protected onAnimationStart(): void;
+ readonly opened: InputSignalWithTransform;
+ readonly openedChange: OutputEmitterRef;
+ readonly position: InputSignal;
+ protected readonly state: WritableSignal;
+ // @deprecated
+ readonly stateChanged: OutputRef;
toggle(): void;
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: i0.ɵɵComponentDeclaration;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration;
}
// @public (undocumented)
export class KbqSidebarClosed {
+ readonly width: InputSignal;
// (undocumented)
- width: string;
- // (undocumented)
- static ɵdir: i0.ɵɵDirectiveDeclaration;
+ static ɵdir: i0.ɵɵDirectiveDeclaration;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration;
}
@@ -76,25 +62,22 @@ export class KbqSidebarModule {
// Warning: (ae-forgotten-export) The symbol "i1" needs to be exported by the entry point index.d.ts
//
// (undocumented)
- static ɵmod: i0.ɵɵNgModuleDeclaration;
+ static ɵmod: i0.ɵɵNgModuleDeclaration;
}
// @public (undocumented)
export class KbqSidebarOpened {
+ readonly maxWidth: InputSignal;
+ readonly minWidth: InputSignal;
+ readonly width: InputSignal;
// (undocumented)
- maxWidth: string;
- // (undocumented)
- minWidth: string;
- // (undocumented)
- width: string;
- // (undocumented)
- static ɵdir: i0.ɵɵDirectiveDeclaration;
+ static ɵdir: i0.ɵɵDirectiveDeclaration;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration;
}
-// @public (undocumented)
-export enum SidebarPositions {
+// @public
+export enum KbqSidebarPositions {
// (undocumented)
Left = "left",
// (undocumented)