diff --git a/libs/design/image/src/image/image.component.spec.ts b/libs/design/image/src/image/image.component.spec.ts index e407bbdbdc..cb956b5ece 100644 --- a/libs/design/image/src/image/image.component.spec.ts +++ b/libs/design/image/src/image/image.component.spec.ts @@ -82,8 +82,11 @@ describe('@daffodil/design/image | DaffImageComponent', () => { expect(component.height).toEqual(100); }); - it('should be able to take `skeleton` as an input', () => { - expect(component.skeleton).toEqual(wrapper.skeleton); + it('should take skeleton as an input', () => { + wrapper.skeleton = true; + fixture.detectChanges(); + + expect(de.nativeElement.classList.contains('daff-skeleton')).toEqual(true); }); it('should throw an error when src is invalid', () => { diff --git a/libs/design/image/src/image/image.component.ts b/libs/design/image/src/image/image.component.ts index b0e5bd7e10..accaef5f23 100644 --- a/libs/design/image/src/image/image.component.ts +++ b/libs/design/image/src/image/image.component.ts @@ -4,17 +4,12 @@ import { Input, EventEmitter, OnInit, - ElementRef, - Renderer2, Output, HostBinding, } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { - daffSkeletonableMixin, - DaffSkeletonable, -} from '@daffodil/design'; +import { DaffSkeletonableDirective } from '@daffodil/design'; import { daffThumbnailCompatToken } from '@daffodil/design/media-gallery'; const validateProperty = (object: Record, prop: string) => { @@ -38,15 +33,6 @@ const validateProperties = (object: Record, props: string[]) => { } }; -/** - * An _elementRef is needed for the GolfGhostable mixin - */ -class DaffImageBase { - constructor(public _elementRef: ElementRef, public _renderer: Renderer2) { } -} - -const _daffImageBase = daffSkeletonableMixin(DaffImageBase); - @Component({ selector: 'daff-image', templateUrl: './image.component.html', @@ -58,11 +44,12 @@ const _daffImageBase = daffSkeletonableMixin(DaffImageBase); provide: daffThumbnailCompatToken, useExisting: DaffImageComponent, }, ], - // todo(damienwebdev): remove once decorators hit stage 3 - https://github.com/microsoft/TypeScript/issues/7342 - // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['skeleton'], + hostDirectives: [{ + directive: DaffSkeletonableDirective, + inputs: ['skeleton'], + }], }) -export class DaffImageComponent extends _daffImageBase implements OnInit, DaffSkeletonable { +export class DaffImageComponent implements OnInit { private _src: string; @@ -119,13 +106,7 @@ export class DaffImageComponent extends _daffImageBase implements OnInit, DaffSk validateProperties(this, ['src', 'alt', 'width', 'height']); } - constructor( - private sanitizer: DomSanitizer, - private elementRef: ElementRef, - private renderer: Renderer2, - ) { - super(elementRef, renderer); - } + constructor(private sanitizer: DomSanitizer) {} /** * @docs-private diff --git a/libs/design/media-gallery/src/media-gallery/media-gallery.component.spec.ts b/libs/design/media-gallery/src/media-gallery/media-gallery.component.spec.ts index dd4c74c714..f288123b0c 100644 --- a/libs/design/media-gallery/src/media-gallery/media-gallery.component.spec.ts +++ b/libs/design/media-gallery/src/media-gallery/media-gallery.component.spec.ts @@ -78,8 +78,11 @@ describe('@daffodil/design/media-gallery | DaffMediaGalleryComponent', () => { expect(component.name).toEqual(stubName); }); - it('should take a skeleton as input', () => { - expect(component.skeleton).toEqual(wrapper.skeleton); + it('should take skeleton as an input', () => { + wrapper.skeleton = true; + fixture.detectChanges(); + + expect(de.nativeElement.classList.contains('daff-skeleton')).toEqual(true); }); it('should remove the gallery from the registry when the gallery is destroyed', () => { diff --git a/libs/design/media-gallery/src/media-gallery/media-gallery.component.ts b/libs/design/media-gallery/src/media-gallery/media-gallery.component.ts index d91257725f..4dd9579ba4 100644 --- a/libs/design/media-gallery/src/media-gallery/media-gallery.component.ts +++ b/libs/design/media-gallery/src/media-gallery/media-gallery.component.ts @@ -5,14 +5,11 @@ import { Input, OnInit, OnDestroy, - ElementRef, - Renderer2, } from '@angular/core'; import { - daffSkeletonableMixin, - DaffSkeletonable, DaffArticleEncapsulatedDirective, + DaffSkeletonableDirective, } from '@daffodil/design'; import { DaffMediaGalleryRegistration } from '../helpers/media-gallery-registration.interface'; @@ -21,15 +18,6 @@ import { DaffMediaGalleryRegistry } from '../registry/media-gallery.registry'; let uniqueGalleryId = 0; -/** - * An _elementRef and an instance of renderer2 are needed for the link set mixins - */ -class DaffMediaGalleryBase { - constructor(public _elementRef: ElementRef, public _renderer: Renderer2) {} -} - -const _daffMediaGalleryBase = daffSkeletonableMixin((DaffMediaGalleryBase)); - @Component({ selector: 'daff-media-gallery', templateUrl: './media-gallery.component.html', @@ -42,11 +30,15 @@ const _daffMediaGalleryBase = daffSkeletonableMixin((DaffMediaGalleryBase)); // todo(damienwebdev): remove once decorators hit stage 3 - https://github.com/microsoft/TypeScript/issues/7342 // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property inputs: ['skeleton'], - hostDirectives: [{ - directive: DaffArticleEncapsulatedDirective, - }], + hostDirectives: [ + { directive: DaffArticleEncapsulatedDirective }, + { + directive: DaffSkeletonableDirective, + inputs: ['skeleton'], + }, + ], }) -export class DaffMediaGalleryComponent extends _daffMediaGalleryBase implements DaffMediaGalleryRegistration, DaffSkeletonable, OnInit, OnDestroy { +export class DaffMediaGalleryComponent implements DaffMediaGalleryRegistration, OnInit, OnDestroy { /** * Adds a class for styling the media gallery */ @@ -57,13 +49,8 @@ export class DaffMediaGalleryComponent extends _daffMediaGalleryBase implements */ @Input() name = `${uniqueGalleryId}`; - constructor( - private elementRef: ElementRef, - private renderer: Renderer2, - private registry: DaffMediaGalleryRegistry, - ) { - super(elementRef, renderer); - uniqueGalleryId++; + constructor(private registry: DaffMediaGalleryRegistry) { + uniqueGalleryId++; } ngOnInit() { diff --git a/libs/design/scss/state/skeleton/_mixins.scss b/libs/design/scss/state/skeleton/_mixins.scss index 59ba1c9f63..96dbb831aa 100644 --- a/libs/design/scss/state/skeleton/_mixins.scss +++ b/libs/design/scss/state/skeleton/_mixins.scss @@ -28,7 +28,7 @@ &::before { animation-name: loading; - animation-duration: 1500ms; + animation-duration: 1000ms; animation-timing-function: linear; animation-iteration-count: infinite; animation-direction: alternate; diff --git a/libs/design/src/core/skeletonable/public_api.ts b/libs/design/src/core/skeletonable/public_api.ts index f49b0eb94c..88f246b2fa 100644 --- a/libs/design/src/core/skeletonable/public_api.ts +++ b/libs/design/src/core/skeletonable/public_api.ts @@ -1,2 +1,2 @@ export { DaffSkeletonable } from './skeletonable'; -export { daffSkeletonableMixin } from './skeletonable-mixin'; +export { DaffSkeletonableDirective } from './skeletonable.directive'; diff --git a/libs/design/src/core/skeletonable/skeletonable-mixin.ts b/libs/design/src/core/skeletonable/skeletonable-mixin.ts deleted file mode 100644 index 1d73f2617a..0000000000 --- a/libs/design/src/core/skeletonable/skeletonable-mixin.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - ElementRef, - Renderer2, -} from '@angular/core'; - -import { Constructor } from '../constructor/constructor'; - -interface HasElementRef { - _elementRef: ElementRef; - _renderer: Renderer2; -} - -export function -daffSkeletonableMixin>(Base: T, defaultSkeleton: boolean = false) { - return class extends Base { - // TODO move this back to private in Typescript 3.1 - _skeleton: boolean; - - get skeleton(): boolean { - return this._skeleton; - } - - set skeleton(value: boolean) { - // Handles the default skeleton - const incomingSkeleton = value || defaultSkeleton; - - if (incomingSkeleton === this._skeleton) { // Only run the dom-render if a change occurs - return; - } - - if (incomingSkeleton) { - this._renderer.addClass(this._elementRef.nativeElement, `daff-skeleton`); - } else { - this._renderer.removeClass(this._elementRef.nativeElement, `daff-skeleton`); - } - - this._skeleton = incomingSkeleton; - } - - constructor(...args: any[]) { - super(...args); - this.skeleton = defaultSkeleton; - } - }; -} diff --git a/libs/design/src/core/skeletonable/skeletonable.directive.spec.ts b/libs/design/src/core/skeletonable/skeletonable.directive.spec.ts new file mode 100644 index 0000000000..7137718786 --- /dev/null +++ b/libs/design/src/core/skeletonable/skeletonable.directive.spec.ts @@ -0,0 +1,72 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffSkeletonableDirective } from './skeletonable.directive'; + +@Component({ + template: ` +
+
`, +}) + +class WrapperComponent { + skeleton: boolean; +} + +describe('@daffodil/design | DaffSkeletonableDirective', () => { + let wrapper: WrapperComponent; + let de: DebugElement; + let fixture: ComponentFixture; + let directive: DaffSkeletonableDirective; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + WrapperComponent, + ], + imports: [ + DaffSkeletonableDirective, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + de = fixture.debugElement.query(By.css('[daffSkeletonable]')); + directive = de.injector.get(DaffSkeletonableDirective); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + expect(directive).toBeTruthy(); + }); + + it('should take skeleton as an input', () => { + expect(directive.skeleton).toEqual(wrapper.skeleton); + }); + + it('should add a class of "daff-skeleton" to the host element when skeleton is true', () => { + wrapper.skeleton = true; + fixture.detectChanges(); + + expect(de.classes).toEqual(jasmine.objectContaining({ + 'daff-skeleton': true, + })); + }); + + it('should not add a class of "daff-skeleton" to the host element when skeleton is false', () => { + expect(de.classes['daff-skeleton']).toBeUndefined(); + }); +}); diff --git a/libs/design/src/core/skeletonable/skeletonable.directive.ts b/libs/design/src/core/skeletonable/skeletonable.directive.ts new file mode 100644 index 0000000000..81f04fbed1 --- /dev/null +++ b/libs/design/src/core/skeletonable/skeletonable.directive.ts @@ -0,0 +1,24 @@ +import { + Directive, + HostBinding, + Input, +} from '@angular/core'; + +/** + * The `DaffSkeletonableDirective` allows a component to display a skeleton loading + * state by conditionally applying a CSS class. This is useful for indicating to + * users that content is loading or being processed. This directive can be used to + * apply a skeleton loading state to any component by toggling the `skeleton` + * input property. When `skeleton` is `true`, the `daff-skeleton` CSS class + * is applied, which should style the component to look like a loading placeholder. + * + * The styles for the`daff-skeleton` class should be defined component's + * stylesheets to display the loading state as desired. + */ +@Directive({ + selector: '[daffSkeletonable]', + standalone: true, +}) +export class DaffSkeletonableDirective { + @Input() @HostBinding('class.daff-skeleton') skeleton = false; +} diff --git a/libs/design/src/core/skeletonable/skeletonable.spec.ts b/libs/design/src/core/skeletonable/skeletonable.spec.ts deleted file mode 100644 index 7b3e424605..0000000000 --- a/libs/design/src/core/skeletonable/skeletonable.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ElementRef } from '@angular/core'; - -import { daffSkeletonableMixin } from './skeletonable-mixin'; - -class TestingClass { - element: HTMLElement = document.createElement('div'); - - _elementRef = new ElementRef(this.element); - _renderer: any = { - addClass: (el: HTMLElement, className: string) => { - el.classList.add(className); - }, - removeClass: (el: HTMLElement, className: string) => { - el.classList.remove(className); - }, - }; -} - -describe('@daffodil/design | daffSkeletonableMixin', () => { - let instance; - let classWithSkeleton; - - beforeEach(() => { - classWithSkeleton = daffSkeletonableMixin(TestingClass); - instance = new classWithSkeleton(); - }); - - it('should add a skeleton property to an existing class', () => { - expect('skeleton' in instance).toBeTruthy(); - }); - - it('should set skeleton to false by default', () => { - instance.skeleton = false; - expect(instance.element.classList.length).toEqual(0); - }); - - describe('when skeleton is set to true', () => { - it('should set a namespaced skeleton class', () => { - instance.skeleton = true; - - expect(instance.element.classList).toContain('daff-skeleton'); - }); - }); - - describe('when skeleton is set to false or not specified', () => { - it('should default to no skeleton class', () => { - instance.skeleton = false; - expect(instance.element.classList.length).toEqual(0); - - instance.skeleton = undefined; - expect(instance.element.classList.length).toEqual(0); - }); - }); -}); diff --git a/libs/design/src/core/skeletonable/skeletonable.ts b/libs/design/src/core/skeletonable/skeletonable.ts index 3259613aa9..0175dc5c9e 100644 --- a/libs/design/src/core/skeletonable/skeletonable.ts +++ b/libs/design/src/core/skeletonable/skeletonable.ts @@ -1,6 +1,5 @@ /** * An interface for giving a component the ability to display a skeleton/loading UI. - * In order to be skeletonable, our class must implement this property. */ export interface DaffSkeletonable { skeleton: boolean;