diff --git a/commitlint.config.js b/commitlint.config.js index 21774476e..d62795d19 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -27,6 +27,7 @@ module.exports = { 'progress-spinner', 'radio', 'select', + 'splitter', 'tabs', 'timepicker', 'tooltip', diff --git a/package.json b/package.json index a7bb58bc8..4d6054f11 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "server-dev:progress-spinner": "npm run server-dev -- --env.component progress-spinner", "server-dev:radio": "npm run server-dev -- --env.component radio", "server-dev:select": "npm run server-dev -- --env.component select", + "server-dev:splitter": "npm run server-dev -- --env.component splitter", "server-dev:tag": "npm run server-dev -- --env.component tag", "server-dev:theme-picker": "npm run server-dev -- --env.component theme-picker", "server-dev:tree": "npm run server-dev -- --env.component tree", diff --git a/src/lib-dev/splitter/module.ts b/src/lib-dev/splitter/module.ts new file mode 100644 index 000000000..1705360ed --- /dev/null +++ b/src/lib-dev/splitter/module.ts @@ -0,0 +1,34 @@ +import { Component, NgModule, ViewEncapsulation } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { McSplitterModule } from '../../lib/splitter'; + + +@Component({ + selector: 'app', + template: require('./template.html'), + styleUrls: ['./styles.scss'], + encapsulation: ViewEncapsulation.None +}) +export class DemoComponent {} + + +@NgModule({ + declarations: [ + DemoComponent + ], + imports: [ + BrowserModule, + McSplitterModule + ], + bootstrap: [ + DemoComponent + ] +}) +export class DemoModule {} + +platformBrowserDynamic() + .bootstrapModule(DemoModule) + .catch((error) => console.error(error)); + diff --git a/src/lib-dev/splitter/styles.scss b/src/lib-dev/splitter/styles.scss new file mode 100644 index 000000000..636e2132d --- /dev/null +++ b/src/lib-dev/splitter/styles.scss @@ -0,0 +1,43 @@ +@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; +@import '../../lib/core/theming/prebuilt/default-theme'; +@import '../../lib/core/visual/prebuilt/default-visual'; + +.container { + margin: 8px; +} + +.horizontal-block { + height: 100px; + border: 1px solid black; +} + +.vertical-block { + height: 300px; + border: 1px solid black; +} + +.min-width-enabled { + min-width: 200px; +} + +// custom color +mc-splitter.custom-color > mc-gutter { + background-color: #03A9F4; + color: white; +} + +mc-splitter.custom-color > mc-gutter:hover { + background-color: #8bc34a; + color: black; +} + +// custom gutter image +mc-splitter.custom-gutter > mc-gutter { + background-repeat: no-repeat; + background-position: center; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=="); +} + +mc-splitter.custom-gutter > mc-gutter > i { + display: none; +} diff --git a/src/lib-dev/splitter/template.html b/src/lib-dev/splitter/template.html new file mode 100644 index 000000000..9c167317d --- /dev/null +++ b/src/lib-dev/splitter/template.html @@ -0,0 +1,95 @@ +
+

Splitter examples

+ +

+ Import mosaic-icons to use default gutter icon. +

+ +
+ +

Horizontal

+ + + first + second + third + + +
+ +

Vertical

+ + + first + second + third + + +
+ +

Default direction

+ + + first + second + third + + +
+ +

Disabled

+ + + first + second + third + + +
+ +

min-width for the first area

+ + + first (with min-width) + second + third (with min-width) + + +
+ +

Nested

+ + + first + + + top + center + bottom + + + third + + +
+ +

With custom color

+ + + first + second + third + + +
+ +

With custom gutter image

+ + + first + second + third + + +
+
diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index 901069f08..f3c35dd07 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -18,6 +18,7 @@ @import '../option/option-theme'; @import '../../tag/tag-theme'; @import '../../tooltip/tooltip-theme'; +@import '../../splitter/splitter-theme'; @mixin mosaic-theme($theme) { @@ -42,4 +43,5 @@ @include mc-option-theme($theme); @include mc-tag-theme($theme); @include mc-tooltip-theme($theme); + @include mc-splitter-theme($theme); } diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts index c5414a443..4ca3c5e9b 100644 --- a/src/lib/public-api.ts +++ b/src/lib/public-api.ts @@ -19,5 +19,6 @@ export * from '@ptsecurity/mosaic/radio'; export * from '@ptsecurity/mosaic/tree'; export * from '@ptsecurity/mosaic/tag'; export * from '@ptsecurity/mosaic/select'; +export * from '@ptsecurity/mosaic/splitter'; export * from '@ptsecurity/mosaic/tooltip'; diff --git a/src/lib/splitter/_splitter-theme.scss b/src/lib/splitter/_splitter-theme.scss new file mode 100644 index 000000000..993a98d76 --- /dev/null +++ b/src/lib/splitter/_splitter-theme.scss @@ -0,0 +1,19 @@ +@mixin mc-splitter-theme($theme) { + $primary: map-get($theme, primary); + $second: map-get($theme, second); + + mc-gutter { + background-color: mc-color($second, 60); + color: mc-color($second, 600); + + &:hover { + background-color: mc-color($second, 100); + color: mc-color($second, 800); + } + + &[disabled] { + background-color: mc-color($second, 60); + color: mc-color($second, 600); + } + } +} diff --git a/src/lib/splitter/index.ts b/src/lib/splitter/index.ts new file mode 100644 index 000000000..7e1a213e3 --- /dev/null +++ b/src/lib/splitter/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/src/lib/splitter/public-api.ts b/src/lib/splitter/public-api.ts new file mode 100644 index 000000000..e6d784ad5 --- /dev/null +++ b/src/lib/splitter/public-api.ts @@ -0,0 +1,2 @@ +export * from './splitter.module'; +export * from './splitter.component'; diff --git a/src/lib/splitter/spliltter.spec.ts b/src/lib/splitter/spliltter.spec.ts new file mode 100644 index 000000000..d3560572a --- /dev/null +++ b/src/lib/splitter/spliltter.spec.ts @@ -0,0 +1,115 @@ +import { Component, Type } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { Direction, McGutterDirective, McSplitterAreaDirective, McSplitterComponent, McSplitterModule } from './index'; + + +function createTestComponent(component: Type) { + TestBed + .resetTestingModule() + .configureTestingModule({ + imports: [ McSplitterModule ], + declarations: [ component ], + providers: [] + }) + .compileComponents(); + + return TestBed.createComponent(component); +} + +function checkDirection(fixture: ComponentFixture, + direction: Direction, + guttersCount: number, + gutterSize: number) { + + const splitter = fixture.debugElement.query(By.directive(McSplitterComponent)); + const gutters = fixture.debugElement.queryAll(By.directive(McGutterDirective)); + + const expectedDirection = direction === Direction.Vertical + ? 'column' + : 'row'; + + const expectedWidth = (direction === Direction.Vertical) + ? '' + : `${gutterSize}px`; + + const expectedHeight = (direction === Direction.Vertical) + ? `${gutterSize}px` + : '100%'; + + expect(splitter.nativeElement.style.flexDirection).toBe(expectedDirection); + + expect(gutters.length).toBe(guttersCount); + expect(gutters.every((gutter) => gutter.nativeElement.style.width === expectedWidth)).toBe(true); + expect(gutters.every((gutter) => gutter.nativeElement.style.height === expectedHeight)).toBe(true); +} + + +@Component({ + selector: 'mc-demo-spllitter', + template: ` + + first + second + third + + ` +}) +class McSplitterDefaultDirection {} + +@Component({ + selector: 'mc-demo-spllitter', + template: ` + + first + second + third + + ` +}) +class McSplitterDirection { + direction: Direction = Direction.Vertical; +} + +describe('McSplitter', () => { + describe('direction', () => { + it('should be default', () => { + const fixture = createTestComponent(McSplitterDefaultDirection); + + fixture.detectChanges(); + + const areas = fixture.debugElement.queryAll(By.directive(McSplitterAreaDirective)); + const expectedAreasCount = 3; + const expectedGuttersCount = expectedAreasCount - 1; + const expectedGutterSize = 6; + + checkDirection(fixture, Direction.Horizontal, expectedGuttersCount, expectedGutterSize); + + expect(areas.length).toBe(expectedAreasCount); + }); + + + it('should be horizontal', () => { + const fixture = createTestComponent(McSplitterDirection); + const expectedGuttersCount = 2; + const expectedGutterSize = 6; + + fixture.componentInstance.direction = Direction.Horizontal; + fixture.detectChanges(); + + checkDirection(fixture, Direction.Horizontal, expectedGuttersCount, expectedGutterSize); + }); + + it('should be vertical', () => { + const fixture = createTestComponent(McSplitterDirection); + const expectedGuttersCount = 2; + const expectedGutterSize = 6; + + fixture.componentInstance.direction = Direction.Vertical; + fixture.detectChanges(); + + checkDirection(fixture, Direction.Vertical, expectedGuttersCount, expectedGutterSize); + }); + }); +}); diff --git a/src/lib/splitter/splitter.component.html b/src/lib/splitter/splitter.component.html new file mode 100644 index 000000000..217983f4a --- /dev/null +++ b/src/lib/splitter/splitter.component.html @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/lib/splitter/splitter.component.ts b/src/lib/splitter/splitter.component.ts new file mode 100644 index 000000000..8d8227f3a --- /dev/null +++ b/src/lib/splitter/splitter.component.ts @@ -0,0 +1,440 @@ +import { + ChangeDetectionStrategy, + Component, + Directive, + ElementRef, + Input, + NgZone, OnDestroy, + OnInit, + Renderer2, + ViewEncapsulation +} from '@angular/core'; + +import { coerceBooleanProperty, coerceCssPixelValue, coerceNumberProperty } from '@ptsecurity/cdk/coercion'; + + +interface IArea { + area: McSplitterAreaDirective; + index: number; + order: number; + initialSize: number; +} + +interface IPoint { + x: number; + y: number; +} + + +const enum AttributeProperty { + Disabled = 'disabled' +} + +const enum Cursor { + Default = 'default', + ResizeColumn = 'col-resize', + ResizeRow = 'row-resize' +} + +const enum StyleProperty { + Cursor = 'cursor', + Flex = 'flex', + FlexBasis = 'flex-basis', + FlexDirection = 'flex-direction', + Height = 'height', + MaxWidth = 'max-width', + MinHeight = 'min-height', + MinWidth = 'minWidth', + OffsetHeight = 'offsetHeight', + OffsetWidth = 'offsetWidth', + Order = 'order', + Width = 'width' +} + +const enum State { + Disabled = 'disabled', + Horizontal = 'horizontal', + Vertical = 'vertical' +} + +export const enum Direction { + Horizontal = 'horizontal', + Vertical = 'vertical' +} + +@Component({ + selector: 'mc-splitter', + preserveWhitespaces: false, + styleUrls: ['splitter.css'], + templateUrl: './splitter.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class McSplitterComponent implements OnInit { + readonly areas: IArea[] = []; + + private _direction: Direction; + private _disabled: boolean = false; + private _gutterSize: number = 6; + + private isDragging: boolean = false; + + private readonly areaPositionDivider: number = 2; + private readonly listeners: (() => void)[] = []; + + @Input() + set direction(direction: Direction) { + this._direction = direction; + } + + get direction(): Direction { + return this._direction; + } + + @Input() + set disabled(disabled: boolean) { + this._disabled = coerceBooleanProperty(disabled); + } + + get disabled(): boolean { + return this._disabled; + } + + @Input() + set gutterSize(gutterSize: number) { + const size = coerceNumberProperty(gutterSize); + this._gutterSize = size > 0 ? size : this.gutterSize; + } + + get gutterSize(): number { + return this._gutterSize; + } + + constructor(private elementRef: ElementRef, + private ngZone: NgZone, + private renderer: Renderer2) {} + + addArea(area: McSplitterAreaDirective): void { + const index: number = this.areas.length; + const order: number = index * this.areaPositionDivider; + const size: number = area.getSize(); + + area.setOrder(order); + + this.areas.push({ + area, + index, + order, + initialSize: size + }); + } + + ngOnInit(): void { + if (!this.direction) { + this.direction = Direction.Horizontal; + } + + this.setStyle(StyleProperty.FlexDirection, this.isVertical() ? 'column' : 'row'); + } + + onMouseDown(event: MouseEvent, leftAreaIndex: number, rightAreaIndex: number) { + if (this.disabled) { + return; + } + + const leftArea = this.areas[leftAreaIndex]; + const rightArea = this.areas[rightAreaIndex]; + + const startPoint: IPoint = { + x: event.screenX, + y: event.screenY + }; + + leftArea.initialSize = leftArea.area.getSize(); + rightArea.initialSize = rightArea.area.getSize(); + + this.areas.forEach((item) => { + const size = item.area.getSize(); + item.area.disableFlex(); + item.area.setSize(size); + }); + + this.ngZone.runOutsideAngular(() => { + this.listeners.push( + this.renderer.listen( + 'document', + 'mouseup', + () => this.onMouseUp() + ) + ); + }); + + this.ngZone.runOutsideAngular(() => { + this.listeners.push( + this.renderer.listen( + 'document', + 'mousemove', + (e: MouseEvent) => this.onMouseMove(e, startPoint, leftArea, rightArea) + ) + ); + }); + + this.isDragging = true; + } + + removeArea(area: McSplitterAreaDirective): void { + let indexToRemove: number = -1; + + this.areas.some((item, index) => { + if (item.area === area) { + indexToRemove = index; + + return true; + } + + return false; + }); + + if (indexToRemove === -1) { + return; + } + + this.areas.splice(indexToRemove, 1); + } + + private isVertical(): boolean { + return this.direction === Direction.Vertical; + } + + private onMouseMove(event: MouseEvent, startPoint: IPoint, leftArea: IArea, rightArea: IArea) { + if (!this.isDragging || this.disabled) { + return; + } + + const endPoint: IPoint = { + x: event.screenX, + y: event.screenY + }; + + const offset = this.isVertical() + ? startPoint.y - endPoint.y + : startPoint.x - endPoint.x; + + const newLeftAreaSize = leftArea.initialSize - offset; + const newRightAreaSize = rightArea.initialSize + offset; + + const minLeftAreaSize = leftArea.area.getMinSize(); + const minRightAreaSize = rightArea.area.getMinSize(); + + if (newLeftAreaSize <= minLeftAreaSize || newRightAreaSize <= minRightAreaSize) { + const rightAreaOffset = leftArea.initialSize - minLeftAreaSize; + + leftArea.area.setSize(minLeftAreaSize); + rightArea.area.setSize(rightArea.initialSize + rightAreaOffset); + } else if (newLeftAreaSize <= 0) { + leftArea.area.setSize(0); + rightArea.area.setSize(rightArea.initialSize + leftArea.initialSize); + } else if (newRightAreaSize <= 0) { + leftArea.area.setSize(rightArea.initialSize + leftArea.initialSize); + rightArea.area.setSize(0); + } else { + leftArea.area.setSize(newLeftAreaSize); + rightArea.area.setSize(newRightAreaSize); + } + } + + private onMouseUp() { + while (this.listeners.length > 0) { + const unsubscribe = this.listeners.pop(); + + if (unsubscribe) { + unsubscribe(); + } + } + + this.isDragging = false; + } + + private setStyle(property: StyleProperty, value: string | number) { + this.renderer.setStyle(this.elementRef.nativeElement, property, value); + } +} + +@Directive({ + selector: 'mc-gutter' +}) +export class McGutterDirective implements OnInit { + private _direction: Direction = Direction.Vertical; + private _disabled: boolean = false; + private _order: number = 0; + private _size: number = 6; + + @Input() + set direction(direction: Direction) { + this._direction = direction; + } + + get direction(): Direction { + return this._direction; + } + + @Input() + set disabled(disabled: boolean) { + this._disabled = coerceBooleanProperty(disabled); + } + + get disabled(): boolean { + return this._disabled; + } + + @Input() + set order(order: number) { + this._order = coerceNumberProperty(order); + } + + get order(): number { + return this._order; + } + + @Input() + set size(size: number) { + this._size = coerceNumberProperty(size); + } + + get size(): number { + return this._size; + } + + constructor(private renderer: Renderer2, + private elementRef: ElementRef) { + } + + ngOnInit(): void { + this.setStyle(StyleProperty.Cursor, this.getCursor(this.getState())); + this.setStyle(StyleProperty.FlexBasis, coerceCssPixelValue(this.size)); + this.setStyle(this.isVertical() ? StyleProperty.Height : StyleProperty.Width, coerceCssPixelValue(this.size)); + this.setStyle(StyleProperty.Order, this.order); + + if (!this.isVertical()) { + this.setStyle(StyleProperty.Height, '100%'); + } + + if (this.disabled) { + this.setAttr(AttributeProperty.Disabled, 'true'); + } + } + + private isVertical(): boolean { + return this.direction === Direction.Vertical; + } + + private getCursor(state: State): string { + switch (state) { + case State.Disabled: + return Cursor.Default; + case State.Vertical: + return Cursor.ResizeRow; + case State.Horizontal: + return Cursor.ResizeColumn; + default: + throw Error(`Unknown gutter state for cursor: ${state}`); + } + } + + private getState(): State { + return this.disabled + ? State.Disabled + : this.direction === Direction.Vertical + ? State.Vertical + : State.Horizontal; + } + + private setStyle(property: StyleProperty, value: string | number) { + this.renderer.setStyle(this.elementRef.nativeElement, property, value); + } + + private setAttr(attribute: AttributeProperty, value: string) { + this.renderer.setAttribute(this.elementRef.nativeElement, attribute, value); + } +} + +@Directive({ + selector: 'mc-splitter-area' +}) +export class McSplitterAreaDirective implements OnInit, OnDestroy { + constructor(private elementRef: ElementRef, + private renderer: Renderer2, + private splitter: McSplitterComponent) {} + + disableFlex(): void { + this.renderer.removeStyle(this.elementRef.nativeElement, 'flex'); + } + + ngOnInit(): void { + this.splitter.addArea(this); + + this.removeStyle(StyleProperty.MaxWidth); + this.setStyle(StyleProperty.Flex, '1'); + + if (this.splitter.direction === Direction.Vertical) { + this.setStyle(StyleProperty.Width, '100%'); + this.removeStyle(StyleProperty.Height); + } else { + this.setStyle(StyleProperty.Height, '100%'); + this.removeStyle(StyleProperty.Width); + } + } + + ngOnDestroy(): void { + this.splitter.removeArea(this); + } + + setOrder(order: number): void { + this.setStyle(StyleProperty.Order, order); + } + + setSize(size: number): void { + const sz = coerceNumberProperty(size); + this.setStyle(this.getSizeProperty(), coerceCssPixelValue(sz)); + } + + getSize(): number { + return this.elementRef.nativeElement[this.getOffsetSizeProperty()]; + } + + getMinSize(): number { + const styles = getComputedStyle(this.elementRef.nativeElement); + + return parseFloat(styles[this.getMinSizeProperty()]); + } + + private isVertical(): boolean { + return this.splitter.direction === Direction.Vertical; + } + + private getMinSizeProperty(): StyleProperty { + return this.isVertical() + ? StyleProperty.MinHeight + : StyleProperty.MinWidth; + } + + private getOffsetSizeProperty(): StyleProperty { + return this.isVertical() + ? StyleProperty.OffsetHeight + : StyleProperty.OffsetWidth; + } + + private getSizeProperty(): StyleProperty { + return this.isVertical() + ? StyleProperty.Height + : StyleProperty.Width; + } + + private setStyle(style: StyleProperty, value: string | number) { + this.renderer.setStyle(this.elementRef.nativeElement, style, value); + } + + private removeStyle(style: StyleProperty) { + this.renderer.removeStyle(this.elementRef.nativeElement, style); + } +} diff --git a/src/lib/splitter/splitter.module.ts b/src/lib/splitter/splitter.module.ts new file mode 100644 index 000000000..6dea5667b --- /dev/null +++ b/src/lib/splitter/splitter.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { McIconModule } from '@ptsecurity/mosaic/icon'; + +import { McGutterDirective, McSplitterAreaDirective, McSplitterComponent } from './splitter.component'; + + +@NgModule({ + imports: [ + CommonModule, + McIconModule + ], + exports: [ + McGutterDirective, + McSplitterAreaDirective, + McSplitterComponent + ], + declarations: [ + McGutterDirective, + McSplitterAreaDirective, + McSplitterComponent + ] +}) +export class McSplitterModule { +} diff --git a/src/lib/splitter/splitter.scss b/src/lib/splitter/splitter.scss new file mode 100644 index 000000000..d64506154 --- /dev/null +++ b/src/lib/splitter/splitter.scss @@ -0,0 +1,23 @@ +mc-splitter { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + overflow: hidden; +} + +mc-splitter-area { + overflow: hidden; +} + +mc-gutter { + display: flex; + flex-grow: 0; + flex-shrink: 0; + overflow: hidden; + justify-content: center; + align-items: center; +} + +.icon-vertical { + transform: rotate(90deg); +} diff --git a/src/lib/splitter/tsconfig.build.json b/src/lib/splitter/tsconfig.build.json new file mode 100644 index 000000000..9b6e4f0f3 --- /dev/null +++ b/src/lib/splitter/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.build", + "files": ["public-api.ts"], + "angularCompilerOptions": { + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ptsecurity/mosaic/splitter", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/tests/karma-system-config.js b/tests/karma-system-config.js index c92c5c04a..0ae80f12c 100644 --- a/tests/karma-system-config.js +++ b/tests/karma-system-config.js @@ -65,7 +65,8 @@ System.config({ '@ptsecurity/mosaic/modal': 'dist/packages/mosaic/modal/index.js', '@ptsecurity/mosaic/tag': 'dist/packages/mosaic/tag/index.js', '@ptsecurity/mosaic/select': 'dist/packages/mosaic/select/index.js', - '@ptsecurity/mosaic/tooltip': 'dist/packages/mosaic/tooltip/index.js' + '@ptsecurity/mosaic/tooltip': 'dist/packages/mosaic/tooltip/index.js', + '@ptsecurity/mosaic/splitter': 'dist/packages/mosaic/splitter/index.js' }, packages: { // Thirdparty barrels.