diff --git a/libs/core/skeleton/components/skeleton.component.html b/libs/core/skeleton/components/skeleton.component.html index 84e2308e7e3..b9bae963cc5 100644 --- a/libs/core/skeleton/components/skeleton.component.html +++ b/libs/core/skeleton/components/skeleton.component.html @@ -7,27 +7,20 @@ - - @if (type === 'rectangle') { + + @if (type() === 'rectangle') { } - @if (type === 'circle') { + @if (type() === 'circle') { } - @if (type === 'text') { - @for (line of [].constructor(textLines); track line; let i = $index) { - + @if (type() === 'text') { + @for (width of textLineWidths(); track $index; let i = $index) { + } } - + diff --git a/libs/core/skeleton/components/skeleton.component.spec.ts b/libs/core/skeleton/components/skeleton.component.spec.ts index c7570218f45..171a5f8e6d3 100644 --- a/libs/core/skeleton/components/skeleton.component.spec.ts +++ b/libs/core/skeleton/components/skeleton.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - import { SkeletonComponent } from './skeleton.component'; describe('SkeletonComponent', () => { @@ -21,4 +20,205 @@ describe('SkeletonComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('Default behavior', () => { + it('should be animated by default', () => { + expect(component.animated()).toBe(true); + expect(fixture.nativeElement.classList.contains('fd-skeleton--animated')).toBe(true); + }); + + it('should have null type by default', () => { + expect(component.type()).toBeNull(); + }); + + it('should have 3 text lines by default', () => { + expect(component.textLines()).toBe(3); + }); + + it('should have null width and height by default', () => { + expect(component.width()).toBeNull(); + expect(component.height()).toBeNull(); + }); + }); + + describe('Input changes', () => { + it('should disable animation when animated is false', () => { + fixture.componentRef.setInput('animated', false); + fixture.detectChanges(); + + expect(component.animated()).toBe(false); + expect(fixture.nativeElement.classList.contains('fd-skeleton--animated')).toBe(false); + }); + + it('should accept type changes', () => { + fixture.componentRef.setInput('type', 'rectangle'); + expect(component.type()).toBe('rectangle'); + + fixture.componentRef.setInput('type', 'circle'); + expect(component.type()).toBe('circle'); + + fixture.componentRef.setInput('type', 'text'); + expect(component.type()).toBe('text'); + }); + + it('should coerce textLines from string to number', () => { + fixture.componentRef.setInput('textLines', '5'); + expect(component.textLines()).toBe(5); + }); + }); + + describe('Text type dimensions', () => { + beforeEach(() => { + fixture.componentRef.setInput('type', 'text'); + fixture.detectChanges(); + }); + + it('should set default width to 100% for text type', () => { + expect(fixture.nativeElement.style.width).toBe('100%'); + }); + + it('should calculate height based on textLines for text type', () => { + fixture.componentRef.setInput('textLines', 3); + fixture.detectChanges(); + expect(fixture.nativeElement.style.height).toBe('60px'); // 3 * 20px + + fixture.componentRef.setInput('textLines', 1); + fixture.detectChanges(); + expect(fixture.nativeElement.style.height).toBe('8px'); // Single line + }); + + it('should use custom width when provided', () => { + fixture.componentRef.setInput('width', '200px'); + fixture.detectChanges(); + expect(fixture.nativeElement.style.width).toBe('200px'); + }); + + it('should use custom height when provided', () => { + fixture.componentRef.setInput('height', '150px'); + fixture.detectChanges(); + expect(fixture.nativeElement.style.height).toBe('150px'); + }); + }); + + describe('Circle type dimensions', () => { + beforeEach(() => { + fixture.componentRef.setInput('type', 'circle'); + fixture.detectChanges(); + }); + + it('should match width to height when only height is provided', () => { + fixture.componentRef.setInput('height', '100px'); + fixture.detectChanges(); + expect(fixture.nativeElement.style.width).toBe('100px'); + expect(fixture.nativeElement.style.height).toBe('100px'); + }); + + it('should match height to width when only width is provided', () => { + fixture.componentRef.setInput('width', '80px'); + fixture.detectChanges(); + expect(fixture.nativeElement.style.width).toBe('80px'); + expect(fixture.nativeElement.style.height).toBe('80px'); + }); + + it('should use both dimensions when both are provided', () => { + fixture.componentRef.setInput('width', '100px'); + fixture.componentRef.setInput('height', '150px'); + fixture.detectChanges(); + // When both provided, effect runs and checks each condition + // Since both exist, neither condition triggers, so values pass through + expect(fixture.nativeElement.style.width).toBe('100px'); + expect(fixture.nativeElement.style.height).toBe('150px'); + }); + }); + + describe('Rectangle type', () => { + it('should use custom dimensions for rectangle type', () => { + fixture.componentRef.setInput('type', 'rectangle'); + fixture.componentRef.setInput('width', '300px'); + fixture.componentRef.setInput('height', '200px'); + fixture.detectChanges(); + + expect(fixture.nativeElement.style.width).toBe('300px'); + expect(fixture.nativeElement.style.height).toBe('200px'); + }); + }); + + describe('Unique ID', () => { + it('should generate unique IDs for multiple instances', () => { + const fixture2 = TestBed.createComponent(SkeletonComponent); + fixture2.detectChanges(); + + const svg1 = fixture.nativeElement.querySelector('mask'); + const svg2 = fixture2.nativeElement.querySelector('mask'); + + expect(svg1.id).toBeTruthy(); + expect(svg2.id).toBeTruthy(); + expect(svg1.id).not.toBe(svg2.id); + }); + }); + + describe('Text line widths', () => { + beforeEach(() => { + fixture.componentRef.setInput('type', 'text'); + }); + + it('should render line with 100% width for single line', () => { + fixture.componentRef.setInput('textLines', 1); + fixture.detectChanges(); + + const rects = fixture.nativeElement.querySelectorAll('mask rect'); + expect(rects.length).toBe(1); + expect(rects[0].getAttribute('width')).toBe('100%'); + }); + + it('should render all lines as 100% except last line (60%) for multiple lines', () => { + fixture.componentRef.setInput('textLines', 3); + fixture.detectChanges(); + + const rects = fixture.nativeElement.querySelectorAll('mask rect'); + expect(rects.length).toBe(3); + expect(rects[0].getAttribute('width')).toBe('100%'); + expect(rects[1].getAttribute('width')).toBe('100%'); + expect(rects[2].getAttribute('width')).toBe('60%'); // Last line + }); + + it('should update rendered line widths when textLines changes', () => { + fixture.componentRef.setInput('textLines', 2); + fixture.detectChanges(); + + let rects = fixture.nativeElement.querySelectorAll('mask rect'); + expect(rects.length).toBe(2); + expect(rects[0].getAttribute('width')).toBe('100%'); + expect(rects[1].getAttribute('width')).toBe('60%'); + + fixture.componentRef.setInput('textLines', 5); + fixture.detectChanges(); + + rects = fixture.nativeElement.querySelectorAll('mask rect'); + expect(rects.length).toBe(5); + expect(rects[0].getAttribute('width')).toBe('100%'); + expect(rects[1].getAttribute('width')).toBe('100%'); + expect(rects[2].getAttribute('width')).toBe('100%'); + expect(rects[3].getAttribute('width')).toBe('100%'); + expect(rects[4].getAttribute('width')).toBe('60%'); // Last line + }); + + it('should render no lines when textLines is zero', () => { + fixture.componentRef.setInput('textLines', 0); + fixture.detectChanges(); + + const rects = fixture.nativeElement.querySelectorAll('mask rect'); + expect(rects.length).toBe(0); + }); + + it('should position lines correctly with 20px spacing', () => { + fixture.componentRef.setInput('textLines', 3); + fixture.detectChanges(); + + const rects = fixture.nativeElement.querySelectorAll('mask rect'); + expect(rects[0].getAttribute('y')).toBe('0'); + expect(rects[1].getAttribute('y')).toBe('20'); + expect(rects[2].getAttribute('y')).toBe('40'); + }); + }); }); diff --git a/libs/core/skeleton/components/skeleton.component.ts b/libs/core/skeleton/components/skeleton.component.ts index 6144db12a82..7f1a5558add 100644 --- a/libs/core/skeleton/components/skeleton.component.ts +++ b/libs/core/skeleton/components/skeleton.component.ts @@ -1,110 +1,160 @@ import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; -import { - ChangeDetectionStrategy, - Component, - HostBinding, - Input, - OnChanges, - SimpleChanges, - ViewEncapsulation -} from '@angular/core'; - -export type SkeletonType = 'rectangle' | 'circle' | 'text'; +/** + * Type of skeleton visualization. + * - `'rectangle'` - Rectangular skeleton with rounded corners + * - `'circle'` - Circular skeleton + * - `'text'` - Multi-line text skeleton + * - `null` - No predefined shape, allows custom SVG projection + */ +export type SkeletonType = 'rectangle' | 'circle' | 'text' | null; let skeletonUniqueId = 0; +/** + * Skeleton component for displaying loading placeholders with animated pulse effect. + */ @Component({ selector: 'fd-skeleton', templateUrl: './skeleton.component.html', styleUrl: './skeleton.component.scss', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'fd-skeleton', + '[class.fd-skeleton--animated]': 'animated()', + '[style.width]': 'computedWidth()', + '[style.height]': 'computedHeight()' + }, imports: [] }) -export class SkeletonComponent implements OnChanges { - /** Whether the skeleton is animated. True by default. */ - @Input() - @HostBinding('class.fd-skeleton--animated') - animated = true; - - /** Type of the skeleton. - * Can be one of the following: 'rectangle' | 'circle' | 'text'. - * Default is not set, allowing to project any SVG elements to define own complex loading template. +export class SkeletonComponent { + /** + * Whether the skeleton displays animated pulse effect. + * + * @type {boolean} + * @default true */ - @Input() - type: SkeletonType; - - /** Number of lines when type is set to `text`. Default is 3. Last one is 60% in width if more than 1 line. */ - @Input() - set textLines(value: NumberInput) { - this._textLines = coerceNumberProperty(value); - } - get textLines(): number { - return this._textLines; - } - - /** Width of skeleton */ - @Input() - set width(value: string) { - this._width = value; - } - get width(): string { - return this._width; - } - - /** Height of skeleton */ - @Input() - set height(value: string) { - this._height = value; - } - get height(): string { - return this._height; - } - - /** @hidden */ - @HostBinding('class.fd-skeleton') - readonly _skeletonClass = true; - - /** @hidden */ - @HostBinding('style.width') - _width: string; - - /** @hidden */ - @HostBinding('style.height') - _height: string; - - /** @hidden */ - _id = `fd-skeleton-${skeletonUniqueId++}`; - - /** @hidden */ - private _textLines = 3; - - /** @hidden */ - ngOnChanges(changes: SimpleChanges): void { - if (changes['type'] && this.type === 'text') { - if (!this.width) { - this.width = '100%'; - } + readonly animated = input(true); + + /** + * Type of the skeleton visualization. + * + * Available types: + * - `'rectangle'` - Rectangular skeleton with rounded corners + * - `'circle'` - Circular skeleton + * - `'text'` - Multi-line text skeleton + * - `null` - No predefined shape, allows custom SVG projection + * + * @default null + */ + readonly type = input(null); + + /** + * Number of lines when type is `'text'`. Last line is 60% width if more than 1 line. + * + * Accepts numbers or coercible number values (e.g., `'3'` will be coerced to `3`). + * + * @type {number} + * @default 3 + */ + readonly textLines = input(3, { + transform: coerceNumberProperty + }); + + /** + * Width of skeleton. + * + * Accepts any valid CSS width value (e.g., `'100px'`, `'50%'`, `'10rem'`). + * Auto-calculated for text and circle types if not provided. + * + * @type {string | null} + * @default null + */ + readonly width = input(null); + + /** + * Height of skeleton. + * + * Accepts any valid CSS height value (e.g., `'100px'`, `'50%'`, `'10rem'`). + * Auto-calculated for text and circle types if not provided. + * + * @type {string | null} + * @default null + */ + readonly height = input(null); - if (!this.height) { - const textLines = this.textLines || 1; - this.height = textLines > 1 ? 20 * textLines + 'px' : '8px'; - } + /** + * Vertical spacing between text lines in pixels. + * Used for positioning lines in SVG and calculating total height. + * @hidden + */ + protected readonly LINE_SPACING = 20; - return; + /** + * Computed dimensions (width and height) based on skeleton type. + * @hidden + */ + protected readonly dimensions = computed(() => { + const currentType = this.type(); + const currentWidth = this.width(); + const currentHeight = this.height(); + + if (currentType === 'text') { + const lines = Math.max(1, this.textLines()); + return { + width: currentWidth ?? '100%', + height: currentHeight ?? (lines > 1 ? `${this.LINE_SPACING * lines}px` : '8px') + }; } - if (changes['type'] && this.type === 'circle') { - if (!this.width && this.height) { - this.width = this.height; - return; + if (currentType === 'circle') { + // Make circle square by mirroring the provided dimension + if (!currentWidth && currentHeight) { + return { width: currentHeight, height: currentHeight }; } - - if (!this.height && this.width) { - this.height = this.width; - return; + if (!currentHeight && currentWidth) { + return { width: currentWidth, height: currentWidth }; } } - } + + // Default: pass through provided values + return { + width: currentWidth, + height: currentHeight + }; + }); + + /** + * Computed width value from dimensions. + * @hidden + */ + protected readonly computedWidth = computed(() => this.dimensions().width); + + /** + * Computed height value from dimensions. + * @hidden + */ + protected readonly computedHeight = computed(() => this.dimensions().height); + + /** + * Unique ID for SVG mask element. + * @hidden + */ + protected readonly svgMaskId = `fd-skeleton-${skeletonUniqueId++}`; + + /** + * Computed array of line widths for text type. + * Last line is 60% width when there are multiple lines. + * @hidden + */ + protected readonly textLineWidths = computed(() => { + const totalLines = this.textLines(); + return Array.from({ length: totalLines }, (_, i) => { + const isLastLine = i + 1 === totalLines; + const hasMultipleLines = i > 0; + return isLastLine && hasMultipleLines ? '60%' : '100%'; + }); + }); } diff --git a/libs/docs/core/card/examples/card-loading/card-loading-example.component.ts b/libs/docs/core/card/examples/card-loading/card-loading-example.component.ts index 3919a469af5..d8103345a13 100644 --- a/libs/docs/core/card/examples/card-loading/card-loading-example.component.ts +++ b/libs/docs/core/card/examples/card-loading/card-loading-example.component.ts @@ -2,13 +2,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RepeatDirective } from '@fundamental-ngx/cdk/utils'; import { CardModule } from '@fundamental-ngx/core/card'; import { ListModule } from '@fundamental-ngx/core/list'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; @Component({ selector: 'fd-card-loading-example', templateUrl: './card-loading-example.component.html', styleUrls: ['./card-loading-example.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CardModule, SkeletonModule, ListModule, RepeatDirective] + imports: [CardModule, SkeletonComponent, ListModule, RepeatDirective] }) export class CardLoadingExampleComponent {} diff --git a/libs/docs/core/list-byline/examples/list-byline-loading-example/list-byline-loading-example.component.ts b/libs/docs/core/list-byline/examples/list-byline-loading-example/list-byline-loading-example.component.ts index c99b9def377..3af9914be64 100644 --- a/libs/docs/core/list-byline/examples/list-byline-loading-example/list-byline-loading-example.component.ts +++ b/libs/docs/core/list-byline/examples/list-byline-loading-example/list-byline-loading-example.component.ts @@ -3,12 +3,12 @@ import { RepeatDirective } from '@fundamental-ngx/cdk/utils'; import { ButtonComponent } from '@fundamental-ngx/core/button'; import { IconComponent } from '@fundamental-ngx/core/icon'; import { ListModule } from '@fundamental-ngx/core/list'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; @Component({ selector: 'fd-list-byline-loading-example', templateUrl: './list-byline-loading-example.component.html', - imports: [ButtonComponent, ListModule, RepeatDirective, IconComponent, SkeletonModule] + imports: [ButtonComponent, ListModule, RepeatDirective, IconComponent, SkeletonComponent] }) export class ListBylineLoadingExampleComponent { loading = true; diff --git a/libs/docs/core/list/examples/list-loading-example/list-loading-example.component.ts b/libs/docs/core/list/examples/list-loading-example/list-loading-example.component.ts index 31fd5e4ea05..8288095870f 100644 --- a/libs/docs/core/list/examples/list-loading-example/list-loading-example.component.ts +++ b/libs/docs/core/list/examples/list-loading-example/list-loading-example.component.ts @@ -2,12 +2,12 @@ import { Component } from '@angular/core'; import { RepeatDirective } from '@fundamental-ngx/cdk/utils'; import { ButtonComponent } from '@fundamental-ngx/core/button'; import { ListModule } from '@fundamental-ngx/core/list'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; @Component({ selector: 'fd-list-loading-example', templateUrl: './list-loading-example.component.html', - imports: [ButtonComponent, ListModule, RepeatDirective, SkeletonModule] + imports: [ButtonComponent, ListModule, RepeatDirective, SkeletonComponent] }) export class ListLoadingExampleComponent { loading = true; diff --git a/libs/docs/core/skeleton/examples/complex/skeleton-complex-example.component.ts b/libs/docs/core/skeleton/examples/complex/skeleton-complex-example.component.ts index 999d3e4bdd9..ce26e46b14f 100644 --- a/libs/docs/core/skeleton/examples/complex/skeleton-complex-example.component.ts +++ b/libs/docs/core/skeleton/examples/complex/skeleton-complex-example.component.ts @@ -1,9 +1,9 @@ import { Component } from '@angular/core'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; @Component({ selector: 'fd-skeleton-complex-example', templateUrl: './skeleton-complex-example.component.html', - imports: [SkeletonModule] + imports: [SkeletonComponent] }) export class SkeletonComplexExampleComponent {} diff --git a/libs/docs/core/skeleton/examples/component/skeleton-component-example.component.ts b/libs/docs/core/skeleton/examples/component/skeleton-component-example.component.ts index 04ee16b3413..88bb178d33a 100644 --- a/libs/docs/core/skeleton/examples/component/skeleton-component-example.component.ts +++ b/libs/docs/core/skeleton/examples/component/skeleton-component-example.component.ts @@ -1,9 +1,9 @@ import { Component } from '@angular/core'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; @Component({ selector: 'fd-skeleton-component-example', templateUrl: './skeleton-component-example.component.html', - imports: [SkeletonModule] + imports: [SkeletonComponent] }) export class SkeletonComponentExampleComponent {} diff --git a/libs/docs/core/skeleton/skeleton-docs.component.html b/libs/docs/core/skeleton/skeleton-docs.component.html index b9987885e4c..42ccdba0ace 100644 --- a/libs/docs/core/skeleton/skeleton-docs.component.html +++ b/libs/docs/core/skeleton/skeleton-docs.component.html @@ -1,18 +1,27 @@ Component -

Input properties

+ Skeleton component displays loading placeholders with animated pulse effect while content loads. +

Inputs

    -
  • type - specifies the shape of a skeleton, 'rectangle' | 'circle' | 'text'
  • - -
  • textLines - specifies the number of lines, default is 3
  • - -
  • width - specifies the width of the skeleton, string
  • - -
  • height - specifies the height of the skeleton, string
  • - -
  • animation - specifies whether the skeleton is animated, boolean, default is true
  • +
  • + type - Shape of skeleton: 'rectangle' | 'circle' | 'text' | null. Set to + null to project custom SVG elements. Default: null +
  • +
  • + textLines - Number of lines for text type. Last line is 60% width if more than 1 line. Accepts + numbers or coercible values. Default: 3 +
  • +
  • + width - CSS width value (e.g., '100px', '50%'). Auto-calculated for + text/circle types. Default: null +
  • +
  • + height - CSS height value (e.g., '100px', '50%'). Auto-calculated for + text/circle types. Default: null +
  • +
  • animated - Enable pulse animation effect. Default: true
@@ -24,16 +33,10 @@

Input properties

-Complex templates +Custom Templates - It's possible to define complex loading templates by yourself. You can project any SVG elements in Skeleton - component, colors and animation will be applied automatically. - -

- - Besides this, we provide predefined loading templates for components that most likely would need it, please see - components docs. + Project custom SVG elements to create complex loading templates. Colors and animation apply automatically.
diff --git a/libs/docs/core/table/examples/loading/table-loading-example.component.ts b/libs/docs/core/table/examples/loading/table-loading-example.component.ts index 3e418cfccc3..2db8bfffcec 100644 --- a/libs/docs/core/table/examples/loading/table-loading-example.component.ts +++ b/libs/docs/core/table/examples/loading/table-loading-example.component.ts @@ -1,13 +1,13 @@ import { Component } from '@angular/core'; import { FocusableGridDirective, RepeatDirective } from '@fundamental-ngx/cdk/utils'; import { ButtonComponent } from '@fundamental-ngx/core/button'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; import { TableModule } from '@fundamental-ngx/core/table'; @Component({ selector: 'fd-table-loading-example', templateUrl: './table-loading-example.component.html', - imports: [ButtonComponent, FocusableGridDirective, TableModule, RepeatDirective, SkeletonModule] + imports: [ButtonComponent, FocusableGridDirective, TableModule, RepeatDirective, SkeletonComponent] }) export class TableLoadingExampleComponent { loading = true; diff --git a/libs/docs/core/wizard/examples/loading/wizard-loading-example.component.ts b/libs/docs/core/wizard/examples/loading/wizard-loading-example.component.ts index 578558d5226..8db20d74ffb 100644 --- a/libs/docs/core/wizard/examples/loading/wizard-loading-example.component.ts +++ b/libs/docs/core/wizard/examples/loading/wizard-loading-example.component.ts @@ -12,7 +12,7 @@ import { import { RepeatDirective } from '@fundamental-ngx/cdk/utils'; import { BarModule } from '@fundamental-ngx/core/bar'; import { ButtonComponent } from '@fundamental-ngx/core/button'; -import { SkeletonModule } from '@fundamental-ngx/core/skeleton'; +import { SkeletonComponent } from '@fundamental-ngx/core/skeleton'; import { WizardModule, WizardService, WizardStepComponent } from '@fundamental-ngx/core/wizard'; @Component({ @@ -24,7 +24,7 @@ import { WizardModule, WizardService, WizardStepComponent } from '@fundamental-n host: { class: 'fd-wizard-example' }, - imports: [ButtonComponent, A11yModule, WizardModule, RepeatDirective, SkeletonModule, BarModule] + imports: [ButtonComponent, A11yModule, WizardModule, RepeatDirective, SkeletonComponent, BarModule] }) export class WizardLoadingExampleComponent { /**