diff --git a/.github/workflows/ci_test_core.yml b/.github/workflows/ci_test_core.yml index f8479ea4..d6686adf 100644 --- a/.github/workflows/ci_test_core.yml +++ b/.github/workflows/ci_test_core.yml @@ -35,3 +35,7 @@ jobs: ci_test_snackbar: needs: ci_test_core uses: ./.github/workflows/ci_test_snackbar.yml + + ci_test_splitter: + needs: ci_test_core + uses: ./.github/workflows/ci_test_splitter.yml diff --git a/.github/workflows/ci_test_splitter.yml b/.github/workflows/ci_test_splitter.yml new file mode 100644 index 00000000..ae465a5e --- /dev/null +++ b/.github/workflows/ci_test_splitter.yml @@ -0,0 +1,26 @@ +name: Test splitter + +on: + workflow_dispatch: + workflow_call: + push: + branches: + - '**' + tags-ignore: + - '**' + paths: + - '.github/workflows/ci_test_splitter.yml' + - 'projects/splitter/**' + +concurrency: + group: ci-test-splitter-group-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci_test_splitter: + if: "${{ !contains(github.event.head_commit.message, 'chore(release): publish') }}" + uses: dsi-hug/action/.github/workflows/action.yml@v1 + with: + working-directory: projects/splitter + runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' + node-versions: '[18, 20]' diff --git a/projects/splitter/ng-package.json b/projects/splitter/ng-package.json index d6858b36..1e532b63 100644 --- a/projects/splitter/ng-package.json +++ b/projects/splitter/ng-package.json @@ -1,7 +1,15 @@ { "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", "dest": "../../dist/splitter", + "assets": [ + "CHANGELOG.md", + { + "input": "src/", + "glob": "_splitter-theme.scss", + "output": "." + } + ], "lib": { "entryFile": "src/index.ts" } -} \ No newline at end of file +} diff --git a/projects/splitter/package.json b/projects/splitter/package.json index 48216728..dcab7c2d 100644 --- a/projects/splitter/package.json +++ b/projects/splitter/package.json @@ -11,7 +11,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/dsi-hug/ngx-components.git" + "url": "git+https://github.com/dsi-hug/ngx-components.git" }, "keywords": [ "angular", @@ -20,15 +20,26 @@ "components" ], "sideEffects": false, + "exports": { + ".": { + "sass": "./_splitter-theme.scss" + } + }, "scripts": { "lint": "eslint . --fix", "test": "ng test splitter", "test:ci": "ng test splitter --watch=false --browsers=ChromeHeadless", - "build": "ng build splitter -c production" + "build:ng": "ng build splitter -c=production", + "build": "nx build:ng @hug/ngx-splitter --verbose", + "release": "nx release -p=@hug/ngx-splitter --yes --verbose", + "release:dry-run": "nx release -p=@hug/ngx-splitter --verbose --dry-run" }, "peerDependencies": { - "@angular/common": "^14.3.0", - "@angular/core": "^14.3.0" + "@angular/common": ">= 14", + "@angular/core": ">= 14", + "@angular/cdk": ">= 14", + "rxjs": ">= 7.0.0", + "@hug/ngx-core": "1.1.4" }, "dependencies": { "tslib": "^2.6.3" diff --git a/projects/splitter/src/_splitter-theme.scss b/projects/splitter/src/_splitter-theme.scss new file mode 100644 index 00000000..2ae3fde2 --- /dev/null +++ b/projects/splitter/src/_splitter-theme.scss @@ -0,0 +1,11 @@ +@use "@angular/material" as mat; + +@mixin theme($theme) { + $background: map-get($theme, background); + + splitter { + .split-gutter { + background-color: mat.get-color-from-palette($background, app-bar); + } + } +} diff --git a/projects/splitter/src/example/example.component.spec.ts b/projects/splitter/src/example/example.component.spec.ts deleted file mode 100644 index 61022488..00000000 --- a/projects/splitter/src/example/example.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ExampleComponent } from './example.component'; - -describe('ExampleComponent', () => { - let component: ExampleComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ExampleComponent] - }).compileComponents(); - - fixture = TestBed.createComponent(ExampleComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/splitter/src/example/example.component.ts b/projects/splitter/src/example/example.component.ts deleted file mode 100644 index d6dc2085..00000000 --- a/projects/splitter/src/example/example.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; - -@Component({ - selector: 'deja-example', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], - template: '

example works!

', - styles: [] -}) -export class ExampleComponent { } diff --git a/projects/splitter/src/example/example.service.spec.ts b/projects/splitter/src/example/example.service.spec.ts deleted file mode 100644 index 33a65090..00000000 --- a/projects/splitter/src/example/example.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { ExampleService } from './example.service'; - -describe('ExampleService', () => { - let service: ExampleService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(ExampleService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/splitter/src/example/example.service.ts b/projects/splitter/src/example/example.service.ts deleted file mode 100644 index 278d5bcf..00000000 --- a/projects/splitter/src/example/example.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class ExampleService { } diff --git a/projects/splitter/src/index.ts b/projects/splitter/src/index.ts index 020b9f72..65b15efe 100644 --- a/projects/splitter/src/index.ts +++ b/projects/splitter/src/index.ts @@ -1,6 +1,3 @@ -/* - * Public API Surface of lib - */ - -export * from './example/example.service'; -export * from './example/example.component'; +export * from './splitter-direction-type'; +export * from './split-area.directive'; +export * from './splitter.component'; diff --git a/projects/splitter/src/split-area.directive.ts b/projects/splitter/src/split-area.directive.ts new file mode 100644 index 00000000..5d398d35 --- /dev/null +++ b/projects/splitter/src/split-area.directive.ts @@ -0,0 +1,69 @@ +/** + * Created by rtr on 22.12.2016. + */ +import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; +import { Directive, ElementRef, HostBinding, Input } from '@angular/core'; + +import { SplitterDirection } from './splitter-direction-type'; + +/** + * Directive representing a panel in a Splitter Component + */ +@Directive({ + selector: 'split-area', + standalone: true +}) +export class SplitAreaDirective { + + @HostBinding('style.order') + public order?: number; + + @HostBinding('style.flex-basis.%') + protected _size: number | null = null; + + public direction = 'horizontal' as SplitterDirection; + + /** + * Size in percent of the current area + */ + @Input() + public set size(value: NumberInput) { + this._size = coerceNumberProperty(value); + } + + public get size(): number | undefined { + const parentElement = this.elementRef.nativeElement.parentElement; + const parentSizeInPixels = parentElement && (this.direction === 'horizontal' ? parentElement.offsetWidth : parentElement.offsetHeight) || undefined; + return parentSizeInPixels && (100 * this.sizeinPixels / parentSizeInPixels); + } + + public get sizeinPixels(): number { + return this.direction === 'horizontal' ? this.elementRef.nativeElement.offsetWidth : this.elementRef.nativeElement.offsetHeight; + } + + /** + * Min size in percent of the current area + */ + @Input() + public set minSizePixel(value: NumberInput) { + this._minSizePixel = coerceNumberProperty(value); + } + + public get minSizePixel(): number { + return this._minSizePixel; + } + + @HostBinding('style.min-width.px') + protected get minWidth(): number | undefined { + return this.direction === 'vertical' ? undefined : this._minSizePixel; + } + + @HostBinding('style.min-height.px') + protected get minHeight(): number | undefined { + return this.direction === 'horizontal' ? undefined : this._minSizePixel; + } + + private _minSizePixel = 0; + + public constructor(private elementRef: ElementRef) { } +} diff --git a/projects/splitter/src/splitter-direction-type.ts b/projects/splitter/src/splitter-direction-type.ts new file mode 100644 index 00000000..cf902a0f --- /dev/null +++ b/projects/splitter/src/splitter-direction-type.ts @@ -0,0 +1 @@ +export type SplitterDirection = 'horizontal' | 'vertical'; diff --git a/projects/splitter/src/splitter.component.html b/projects/splitter/src/splitter.component.html new file mode 100644 index 00000000..83741c4d --- /dev/null +++ b/projects/splitter/src/splitter.component.html @@ -0,0 +1,5 @@ + + +
+
+
diff --git a/projects/splitter/src/splitter.component.scss b/projects/splitter/src/splitter.component.scss new file mode 100644 index 00000000..c8e36f6e --- /dev/null +++ b/projects/splitter/src/splitter.component.scss @@ -0,0 +1,52 @@ +splitter { + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + overflow: hidden; + + split-area { + flex: 1 1 auto; + overflow: hidden; + + &[splitting] * { + user-select: none; + } + } + + .split-gutter { + flex-grow: 0; + flex-shrink: 0; + background-position: 50%; + background-repeat: no-repeat; + content: none; + cursor: default; + background-image: none; + } + + &[direction="horizontal"] { + split-area { + width: 100%; + } + + &:not([disabled]) { + .split-gutter { + cursor: col-resize; + content: " "; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=="); + } + } + } + + &[direction="vertical"] { + split-area { + height: 100%; + } + + &:not([disabled]) { + .split-gutter { + cursor: row-resize; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFCAMAAABl/6zIAAAABlBMVEUAAADMzMzIT8AyAAAAAXRSTlMAQObYZgAAABRJREFUeAFjYGRkwIMJSeMHlBkOABP7AEGzSuPKAAAAAElFTkSuQmCC"); + } + } + } +} diff --git a/projects/splitter/src/splitter.component.ts b/projects/splitter/src/splitter.component.ts new file mode 100644 index 00000000..fa8ea5b7 --- /dev/null +++ b/projects/splitter/src/splitter.component.ts @@ -0,0 +1,178 @@ +import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; +import { NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, ContentChildren, ElementRef, EventEmitter, HostBinding, Input, Output, QueryList, ViewEncapsulation } from '@angular/core'; +import { Destroy } from '@hug/ngx-core'; +import { filter, fromEvent, map, mergeWith, of, shareReplay, Subject, switchMap, take, takeUntil, tap } from 'rxjs'; + +import { SplitAreaDirective } from './split-area.directive'; +import { SplitterDirection } from './splitter-direction-type'; + +interface DraggingEvent { + event: MouseEvent | TouchEvent; + index: number; +} + +/** + * Splitter Component for Angular + * + * The splitter component allows to split horizontally or vertically, a container in N resizable part. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + selector: 'splitter', + styleUrls: ['./splitter.component.scss'], + templateUrl: './splitter.component.html', + standalone: true, + imports: [ + NgIf + ] +}) +export class SplitterComponent extends Destroy { + /** + * Event triggered when the user start to drag the cursor + */ + @Output() public readonly dragStart = new EventEmitter(false); + /** + * Event triggered during the cursor's drag + */ + @Output() public readonly dragProgress = new EventEmitter(false); + /** + * Event triggered when the user stop to drag the cursor + */ + @Output() public readonly dragEnd = new EventEmitter(false); + + /** + * Direction of the split + * Can be `horizontal` or `vertical` + */ + @Input() + public set direction(direction: SplitterDirection) { + this._direction = direction; + this.ensureDirections(); + } + + public get direction(): SplitterDirection { + return this._direction; + } + + /** + * Size of the gutter in pixels + * By default `10px` + */ + @Input() + public set gutterSize(gutterSize: NumberInput) { + this._gutterSize = coerceNumberProperty(gutterSize); + } + + public get gutterSize(): NumberInput { + return this._gutterSize; + } + + @HostBinding('style.flex-direction') + protected get styleFlexDirection(): string { + return this.direction === 'horizontal' ? 'row' : 'column'; + } + + @ContentChildren(SplitAreaDirective) + protected set spliterAreas(spliterAreas: QueryList) { + this.areas = spliterAreas.toArray(); + this.areas.forEach((area, index) => area.order = 2 * index); + this.ensureDirections(); + } + + @HostBinding('attr.direction') + private _direction = 'horizontal' as SplitterDirection; + + @HostBinding('attr.disabled') + private _disabled: boolean | null = null; + + protected startDragging$ = new Subject(); + + protected areas = new Array() as readonly SplitAreaDirective[]; + + private _gutterSize = 10; + + /** Retourne ou definit si le selecteur est desactivé. */ + @Input() + public set disabled(value: BooleanInput) { + this._disabled = coerceBooleanProperty(value) || null; + } + + public get disabled(): BooleanInput { + return this._disabled; + } + + /** + * Constructor + */ + public constructor( + elementRef: ElementRef + ) { + super(); + + this.startDragging$.pipe( + filter(() => !this.disabled), + switchMap(draggingEvent => { + const areaA = this.areas.find(a => a.order === draggingEvent.index - 1); + const areaB = this.areas.find(a => a.order === draggingEvent.index + 1); + const mouseEvent = draggingEvent.event as MouseEvent; + if (!areaA || !areaB) { + return of(mouseEvent); + } + + const startPos = this.direction === 'horizontal' ? mouseEvent.pageX || mouseEvent.screenX : mouseEvent.pageY || mouseEvent.screenY; + const containerSizeInPixels = this.direction === 'horizontal' ? elementRef.nativeElement.offsetWidth : elementRef.nativeElement.offsetHeight; + const startSizeInPixelsA = areaA.sizeinPixels; + const startSizeInPixelsB = areaB.sizeinPixels; + + this.dragStart.emit(); + + const mouseMove$ = fromEvent(document, 'mousemove').pipe( + shareReplay({ bufferSize: 1, refCount: true }) + ); + + const stopDragging$ = mouseMove$.pipe( + filter(event => event.buttons !== 1), + mergeWith(fromEvent(document, 'mouseup'), fromEvent(document, 'touchend'), fromEvent(document, 'touchcancel')), + map(event => { + this.dragEnd.emit(); + return event as MouseEvent; + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + const drag$ = mouseMove$.pipe( + filter(event => event.buttons === 1), + mergeWith(fromEvent(document, 'touchmove')), + tap(event => { + const pos = this.direction === 'horizontal' ? event.pageX || event.screenX : event.pageY || event.screenY; + const diffInPixels = startPos - pos; + areaA.size = Math.min(100, Math.max(0, 100 * (startSizeInPixelsA - diffInPixels) / containerSizeInPixels)); + areaB.size = Math.min(100, Math.max(0, 100 * (startSizeInPixelsB + diffInPixels) / containerSizeInPixels)); + this.dragProgress.emit(); + }), + takeUntil(stopDragging$) + ); + + return stopDragging$.pipe( + take(1), + mergeWith(drag$) + ); + }), + takeUntil(this.destroyed$) + ).subscribe(event => { + if (event.type !== 'mouseup' && event.type !== 'touchend' && event.type !== 'touchcancel') { + elementRef.nativeElement.setAttribute('splitting', 'true'); + } else { + elementRef.nativeElement.removeAttribute('splitting'); + } + event.preventDefault(); + return false; + }); + } + + private ensureDirections(): void { + this.areas.forEach(area => area.direction = this.direction); + } +} diff --git a/projects/splitter/tsconfig.lib.json b/projects/splitter/tsconfig.lib.json index 8f6c5298..599bb9f4 100644 --- a/projects/splitter/tsconfig.lib.json +++ b/projects/splitter/tsconfig.lib.json @@ -1,6 +1,6 @@ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "../../out-tsc/lib", "declaration": true,