diff --git a/.circleci/config.yml b/.circleci/config.yml index 6191fc3..b344928 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -51,6 +51,8 @@ jobs: paths: - node_modules key: v1-dependencies-{{ checksum "yarn.lock" }} + + - run: yarn build:all - run: yarn test diff --git a/projects/common/package.json b/projects/common/package.json index 7c452fd..71629b8 100644 --- a/projects/common/package.json +++ b/projects/common/package.json @@ -3,6 +3,7 @@ "version": "{{ version }}", "peerDependencies": { "@angular/common": "^{{ angularCompatVersion }}", - "@angular/core": "^{{ angularCompatVersion }}" + "@angular/core": "^{{ angularCompatVersion }}", + "@angular-contrib/core": "{{ version }}" } } diff --git a/projects/common/src/ng-dynamic/README.md b/projects/common/src/ng-dynamic/README.md new file mode 100644 index 0000000..540373f --- /dev/null +++ b/projects/common/src/ng-dynamic/README.md @@ -0,0 +1,26 @@ +# NgDynamic + +Helper directive to render dynamic tag. + +## Type + +**Directive** + +## Provenance + +None. + +## NgModule + +`@angular-contrib/core#ContribNgDynamicModule` + +## Usage + +```typescript +@Component({ + template: ` + + ` +}) +class MyComponent {} +``` diff --git a/projects/common/src/ng-dynamic/index.ts b/projects/common/src/ng-dynamic/index.ts new file mode 100644 index 0000000..3168944 --- /dev/null +++ b/projects/common/src/ng-dynamic/index.ts @@ -0,0 +1,2 @@ +export * from './ng-dynamic'; +export * from './ng-dynamic.module'; diff --git a/projects/common/src/ng-dynamic/ng-dynamic.module.ts b/projects/common/src/ng-dynamic/ng-dynamic.module.ts new file mode 100644 index 0000000..617e971 --- /dev/null +++ b/projects/common/src/ng-dynamic/ng-dynamic.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { NgDynamic } from './ng-dynamic'; + +@NgModule({ + declarations: [NgDynamic], + exports: [NgDynamic], +}) +export class ContribNgDynamicModule {} diff --git a/projects/common/src/ng-dynamic/ng-dynamic.spec.ts b/projects/common/src/ng-dynamic/ng-dynamic.spec.ts new file mode 100644 index 0000000..83ca66b --- /dev/null +++ b/projects/common/src/ng-dynamic/ng-dynamic.spec.ts @@ -0,0 +1,65 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ContribNgDynamicModule } from './ng-dynamic.module'; +import { By } from '@angular/platform-browser'; + +describe('ng-dynamic', () => { + let fixture: ComponentFixture; + let component: TestComponent; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [CommonModule, ContribNgDynamicModule], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + }); + + it('should render dynamic element', () => { + fixture.detectChanges(); + + const elements = fixture.debugElement.queryAll(By.css('section')); + + expect(elements[0].nativeElement.outerHTML).toBe(`
Content
`); + expect(elements[1].nativeElement.outerHTML).toBe(`
Content
`); + expect(elements[2].nativeElement.outerHTML).toBe(`
Content
`); + expect(elements[3].nativeElement.outerHTML).toBe(`
Content
`); + }); + + it('should support changing tag name', () => { + fixture.detectChanges(); + + component.tag = 'article'; + component.props = { id: 'test-id2' }; + component.attrs = { title: 'test-title2' }; + component.styles = { height: '60px' }; + fixture.detectChanges(); + + const elements = fixture.debugElement.queryAll(By.css('article')); + + expect(elements[0].nativeElement.outerHTML).toBe(`
Content
`); + expect(elements[1].nativeElement.outerHTML).toBe(`
Content
`); + expect(elements[2].nativeElement.outerHTML).toBe(`
Content
`); + expect(elements[3].nativeElement.outerHTML).toBe(`
Content
`); + }); +}); + +@Component({ + template: ` + Content + Content + Content + Content + `, +}) +class TestComponent { + tag = 'section'; + props = { id: 'test-id' }; + attrs = { title: 'test-title' }; + styles = { height: '50px' }; +} diff --git a/projects/common/src/ng-dynamic/ng-dynamic.ts b/projects/common/src/ng-dynamic/ng-dynamic.ts new file mode 100644 index 0000000..2a83d8c --- /dev/null +++ b/projects/common/src/ng-dynamic/ng-dynamic.ts @@ -0,0 +1,158 @@ +import { Input, KeyValueDiffer, KeyValueDiffers, DoCheck, SimpleChanges, OnChanges, OnInit, ElementRef, Renderer2, Component, ViewChild, AfterContentInit, HostBinding, Directive } from '@angular/core'; +import { RendererExtension } from '@angular-contrib/core'; + +const EMPTY_OBJ = {}; + +@Directive({ + selector: 'ng-dynamic', +}) +export class NgDynamic implements AfterContentInit, DoCheck, OnChanges, OnInit { + @Input() tag: string = 'ng-unknown'; + @Input() props: Record = {}; + @Input() attrs: Record = {}; + @Input() styles: Record = {}; + + @HostBinding('style.display') display = 'none'; + + private hostParent!: Element; + private el: Element | null = null; + private propsDiffer: KeyValueDiffer | null = null; + private attrsDiffer: KeyValueDiffer | null = null; + private stylesDiffer: KeyValueDiffer | null = null; + + constructor( + private host: ElementRef, + private renderer: Renderer2, + private kvDiffers: KeyValueDiffers, + private rendererEx: RendererExtension, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (this.hostParent == null) { + this.hostParent = this.renderer.parentNode(this.host.nativeElement); + } + + if (changes['tag']) { + this.createNewElement(); + } + + if (this.propsDiffer == null && changes['props']) { + this.propsDiffer = this.kvDiffers.find(EMPTY_OBJ).create(); + } + + if (this.attrsDiffer == null && changes['attrs']) { + this.attrsDiffer = this.kvDiffers.find(EMPTY_OBJ).create(); + } + + if (this.stylesDiffer == null && changes['styles']) { + this.stylesDiffer = this.kvDiffers.find(EMPTY_OBJ).create(); + } + } + + ngOnInit(): void { + if (this.hostParent == null) { + this.hostParent = this.renderer.parentNode(this.host.nativeElement); + } + + if (this.el == null) { + this.createNewElement(); + } + } + + ngDoCheck(): void { + if (this.propsDiffer != null) { + const changes = this.propsDiffer.diff(this.props); + if (changes != null) { + changes.forEachRemovedItem((record) => this.setProp(record.key, null)); + changes.forEachAddedItem((record) => this.setProp(record.key, record.currentValue)); + changes.forEachChangedItem((record) => this.setProp(record.key, record.currentValue)); + } + } + + if (this.attrsDiffer != null) { + const changes = this.attrsDiffer.diff(this.attrs); + if (changes != null) { + changes.forEachRemovedItem((record) => this.setAttr(record.key, null)); + changes.forEachAddedItem((record) => this.setAttr(record.key, record.currentValue)); + changes.forEachChangedItem((record) => this.setAttr(record.key, record.currentValue)); + } + } + + if (this.stylesDiffer != null) { + const changes = this.stylesDiffer.diff(this.styles); + if (changes != null) { + changes.forEachRemovedItem((record) => this.setStyle(record.key, null)); + changes.forEachAddedItem((record) => this.setStyle(record.key, record.currentValue)); + changes.forEachChangedItem((record) => this.setStyle(record.key, record.currentValue)); + } + } + } + + ngAfterContentInit(): void { + this.moveChildNodes(this.host.nativeElement, this.el!); + } + + private createNewElement(): void { + const newElement = this.renderer.createElement(this.tag); + + if (this.el != null) { + this.renderer.insertBefore(this.hostParent, this.host.nativeElement, this.el); + this.renderer.removeChild(this.hostParent, this.el); + this.moveChildNodes(this.el, newElement); + } + + this.el = newElement; + + const propKeys = Object.keys(this.props); + const attrKeys = Object.keys(this.attrs); + const styleKeys = Object.keys(this.styles); + + for (let i = 0; i < propKeys.length; i++) { + const prop = propKeys[i]; + this.setProp(prop, this.props[prop]); + } + + for (let i = 0; i < attrKeys.length; i++) { + const attr = attrKeys[i]; + this.setProp(attr, this.attrs[attr]); + } + + for (let i = 0; i < styleKeys.length; i++) { + const style = styleKeys[i]; + this.setStyle(style, this.styles[style]); + } + + this.renderer.insertBefore(this.hostParent, this.el, this.host.nativeElement); + this.renderer.removeChild(this.hostParent, this.host.nativeElement); + } + + private moveChildNodes(from: Node, to: Node): void { + const nodes = this.rendererEx.getChildNodes(from); + for (let i = 0; i < nodes.length; i++) { + this.renderer.appendChild(to, nodes[i]); + } + } + + private setProp(name: string, value: unknown): void { + this.renderer.setProperty(this.el, name, value); + } + + private setAttr(name: string, value: string | null): void { + if (value != null) { + this.renderer.setAttribute(this.el, name, value); + } else { + this.renderer.removeAttribute(this.el, name); + } + } + + private setStyle(nameAndUnit: string, value: string | number | null): void { + const [name, unit] = nameAndUnit.split('.'); + value = value != null && unit ? `${value}${unit}` : value; + + if (value != null) { + this.renderer.setStyle(this.el, name, value as string); + } else { + this.renderer.removeStyle(this.el, name); + } + } +} diff --git a/projects/common/src/ng-let/ng-let.ts b/projects/common/src/ng-let/ng-let.ts index ff7fd17..8b21a01 100644 --- a/projects/common/src/ng-let/ng-let.ts +++ b/projects/common/src/ng-let/ng-let.ts @@ -1,9 +1,18 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, ElementRef, Renderer2, OnInit } from '@angular/core'; @Component({ selector: 'ng-let[data]', - template: ``, + template: ``, }) -export class NgLet { +export class NgLet implements OnInit { @Input() data!: T; + + constructor( + private renderer: Renderer2, + private host: ElementRef, + ) {} + + ngOnInit(): void { + this.renderer.removeChild(this.renderer.parentNode(this.host.nativeElement), this.host.nativeElement); + } } diff --git a/projects/common/src/public-api.ts b/projects/common/src/public-api.ts index 47bdb93..79b2a8f 100644 --- a/projects/common/src/public-api.ts +++ b/projects/common/src/public-api.ts @@ -1,3 +1,4 @@ +export * from './ng-dynamic/index'; export * from './ng-for-in/index'; export * from './ng-host/index'; export * from './ng-init/index'; diff --git a/projects/core/src/change-detection-scheduler/README.md b/projects/core/src/change-detection-scheduler/README.md index 7e8c98b..22c36b0 100644 --- a/projects/core/src/change-detection-scheduler/README.md +++ b/projects/core/src/change-detection-scheduler/README.md @@ -4,7 +4,7 @@ Automatically trigger change detection without Zone.js. ## Type -**Enhancement** +**Patch** ## Provenance diff --git a/projects/core/src/iterable-differs/README.md b/projects/core/src/iterable-differs/README.md index 28b3d5c..f3afa44 100644 --- a/projects/core/src/iterable-differs/README.md +++ b/projects/core/src/iterable-differs/README.md @@ -16,7 +16,7 @@ Supports extending `IterableDiffers` with custom differ implementations. ## Usage -Provding custom `IterableDifferFactory` (Non-exclusive): +Providing custom `IterableDifferFactory` (Non-exclusive): ```typescript import { ContribIterableDiffersModule, ITERABLE_DIFFER_FACTORIES } from '@angular-contrib/core'; diff --git a/projects/core/src/key-value-differs/README.md b/projects/core/src/key-value-differs/README.md index 71a36ae..3ea859a 100644 --- a/projects/core/src/key-value-differs/README.md +++ b/projects/core/src/key-value-differs/README.md @@ -16,7 +16,7 @@ Supports extending `KeyValueDiffers` with custom differ implementations. ## Usage -Provding custom `KeyValueDifferFactory` (Non-exclusive): +Providing custom `KeyValueDifferFactory` (Non-exclusive): ```typescript import { ContribKeyValueDiffersModule, KEY_VALUE_DIFFER_FACTORIES } from '@angular-contrib/core'; diff --git a/projects/core/src/public-api.ts b/projects/core/src/public-api.ts index 51143be..b695622 100644 --- a/projects/core/src/public-api.ts +++ b/projects/core/src/public-api.ts @@ -2,3 +2,4 @@ export * from './change-detection-scheduler/index'; export * from './fast-iterable-differ/index'; export * from './iterable-differs/index'; export * from './key-value-differs/index'; +export * from './renderer-extension/index'; diff --git a/projects/core/src/renderer-extension/README.md b/projects/core/src/renderer-extension/README.md new file mode 100644 index 0000000..50d6970 --- /dev/null +++ b/projects/core/src/renderer-extension/README.md @@ -0,0 +1,34 @@ +# Renderer Extension + +Addition renderer methods for low-level operations. + +## Type + +**Service** + +## Provenance + +None. + +## NgModule + +`@angular-contrib/core#ContribRendererExtensionModule` + +## Usage + +```typescript +import { RendererExtension } from '@angular-contrib/core'; + +@Component() +class MyComponent { + constructor(private rendererEx: RendererExtension) {} + + onAction(node: Node) { + const childLength = this.rendererEx.getChildNodes(node).length; + + if (childLength === 0) { + throw new Error(`No children available`); + } + } +} +``` diff --git a/projects/core/src/renderer-extension/index.ts b/projects/core/src/renderer-extension/index.ts new file mode 100644 index 0000000..63e0c12 --- /dev/null +++ b/projects/core/src/renderer-extension/index.ts @@ -0,0 +1,2 @@ +export * from './renderer-extension'; +export * from './renderer-extension.module'; diff --git a/projects/core/src/renderer-extension/renderer-extension.module.ts b/projects/core/src/renderer-extension/renderer-extension.module.ts new file mode 100644 index 0000000..d4e624b --- /dev/null +++ b/projects/core/src/renderer-extension/renderer-extension.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { RendererExtension } from './renderer-extension'; + +@NgModule({ + providers: [ RendererExtension ], +}) +export class ContribRendererExtensionModule {} diff --git a/projects/core/src/renderer-extension/renderer-extension.spec.ts b/projects/core/src/renderer-extension/renderer-extension.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/projects/core/src/renderer-extension/renderer-extension.ts b/projects/core/src/renderer-extension/renderer-extension.ts new file mode 100644 index 0000000..196aa68 --- /dev/null +++ b/projects/core/src/renderer-extension/renderer-extension.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class RendererExtension { + getChildNodes(node: Node): NodeList { + return node.childNodes; + } +} diff --git a/tslint.json b/tslint.json index 9987d7a..d7649d6 100644 --- a/tslint.json +++ b/tslint.json @@ -31,7 +31,12 @@ "max-classes-per-file": false, "max-line-length": [ true, - 140 + { + "limit": 160, + "ignore-pattern": "^import |^export {(.*?)}", + "check-strings": true, + "check-regex": true + } ], "member-access": false, "member-ordering": [ @@ -62,7 +67,7 @@ "ignore-params", "ignore-properties" ], - "no-input-rename": true, + "no-input-rename": false, "no-inputs-metadata-property": true, "no-non-null-assertion": false, "no-output-native": true,