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 @@ -
- -
-
- -   -
- -
- -   -
-
- -
-
- -
- -
kbq-sidebar-opened
-
kbq-sidebar-closed
-
- -
main area
- - -
kbq-sidebar-opened
-
kbq-sidebar-closed
-
-
- -
-
-
- -
-
- -   - -   -
- -
- -   -
-
- -
-
- - - -
- kbq-sidebar-opened -
- -
kbq-sidebar-closed
-
- -
main body
- - -
kbq-sidebar-opened
-
kbq-sidebar-closed
-
-
- -
-
-
- -
{{ 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": + +
+ kbqSidebarClosed +
+ +
, +} +`; + +exports[`KbqSidebar should match snapshot when opened 1`] = ` +DebugElement { + "nativeNode": +
+ kbqSidebarOpened +
+ + +
, +} +`; 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)