diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 626c7f6cc..93dffbec8 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -53,6 +53,7 @@ import IgcSelectComponent from '../../select/select.js'; import IgcSelectGroupComponent from '../../select/select-group.js'; import IgcSelectHeaderComponent from '../../select/select-header.js'; import IgcSelectItemComponent from '../../select/select-item.js'; +import IgcSkeletonComponent from '../../skeleton/skeleton.js'; import IgcRangeSliderComponent from '../../slider/range-slider.js'; import IgcSliderComponent from '../../slider/slider.js'; import IgcSliderLabelComponent from '../../slider/slider-label.js'; @@ -146,6 +147,7 @@ const allComponents: IgniteComponent[] = [ IgcTileComponent, IgcTileManagerComponent, IgcTooltipComponent, + IgcSkeletonComponent, ]; export function defineAllComponents() { diff --git a/src/components/skeleton/skeleton.spec.ts b/src/components/skeleton/skeleton.spec.ts new file mode 100644 index 000000000..bcd4c0d08 --- /dev/null +++ b/src/components/skeleton/skeleton.spec.ts @@ -0,0 +1,303 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { type SinonFakeTimers, useFakeTimers } from 'sinon'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { first } from '../common/util.js'; +import IgcSkeletonComponent from './skeleton.js'; + +describe('Skeleton', () => { + before(() => defineComponents(IgcSkeletonComponent)); + + //#region Helpers + + function getShapes(el: IgcSkeletonComponent): Element[] { + return Array.from(el.renderRoot.querySelectorAll('[part~="shape"]')); + } + + function getOverlay(el: IgcSkeletonComponent): Element | null { + return el.renderRoot.querySelector('[part="overlay"]'); + } + + /** Creates a loading skeleton with two leaf nodes guaranteed to have dimensions. */ + async function createLoadingSkeleton( + animation: IgcSkeletonComponent['animation'] = 'breathe' + ): Promise { + const el = await fixture(html` + +
+ Name + Role +
+
+ `); + await nextFrame(); + await elementUpdated(el); + return el; + } + + //#endregion + + //#region Accessibility + + describe('Accessibility', () => { + it('passes the a11y audit in idle state', async () => { + const el = await fixture(html` + Content + `); + + await expect(el).dom.to.be.accessible(); + await expect(el).shadowDom.to.be.accessible(); + }); + + it('passes the a11y audit in loading state', async () => { + const el = await createLoadingSkeleton(); + + await expect(el).dom.to.be.accessible(); + await expect(el).shadowDom.to.be.accessible(); + }); + }); + + //#endregion + + //#region Default values + + describe('Defaults', () => { + it('initializes with correct default property values', async () => { + const el = await fixture( + html`Content` + ); + + expect(el.loading).to.be.false; + expect(el.animation).to.equal('breathe'); + }); + + it('does not reflect `loading` attribute when false', async () => { + const el = await fixture( + html`Content` + ); + + expect(el).dom.not.to.have.attribute('loading'); + }); + + it('overlay is always present in the shadow DOM', async () => { + const el = await fixture( + html`Content` + ); + + expect(getOverlay(el)).to.exist; + }); + + it('renders no shape placeholders when not loading', async () => { + const el = await fixture( + html`Content` + ); + + expect(getShapes(el)).to.be.empty; + }); + }); + + //#endregion + + //#region `loading` property + + describe('`loading` property', () => { + it('reflects to attribute', async () => { + const el = await fixture( + html`Content` + ); + + el.loading = true; + await elementUpdated(el); + expect(el).dom.to.have.attribute('loading'); + + el.loading = false; + await elementUpdated(el); + expect(el).dom.not.to.have.attribute('loading'); + }); + + it('renders shape placeholders when set to true', async () => { + const el = await createLoadingSkeleton(); + expect(getShapes(el)).not.to.be.empty; + }); + + it('removes all shape placeholders when set to false', async () => { + const el = await createLoadingSkeleton(); + expect(getShapes(el)).not.to.be.empty; + + el.loading = false; + await elementUpdated(el); + + expect(getShapes(el)).to.be.empty; + }); + + it('sets `revealing` custom state on loading → false transition', async () => { + const el = await createLoadingSkeleton(); + + el.loading = false; + await elementUpdated(el); + + expect(el.matches(':state(revealing)')).to.be.true; + }); + + it('removes `revealing` custom state after 600ms', async () => { + const clock: SinonFakeTimers = useFakeTimers({ toFake: ['setTimeout'] }); + + try { + const el = await createLoadingSkeleton(); + + el.loading = false; + await elementUpdated(el); + expect(el.matches(':state(revealing)')).to.be.true; + + clock.tick(600); + expect(el.matches(':state(revealing)')).to.be.false; + } finally { + clock.restore(); + } + }); + + it('does not set `revealing` custom state when loading was never true', async () => { + const el = await fixture( + html`Content` + ); + + // loading was already false — no transition occurred + el.loading = false; + await elementUpdated(el); + + expect(el.matches(':state(revealing)')).to.be.false; + }); + + it('cancels pending `revealing` custom state when loading is set back to true', async () => { + const clock: SinonFakeTimers = useFakeTimers({ toFake: ['setTimeout'] }); + + try { + const el = await createLoadingSkeleton(); + + el.loading = false; + await elementUpdated(el); + expect(el.matches(':state(revealing)')).to.be.true; + + el.loading = true; + await elementUpdated(el); + clock.tick(600); + + // The reveal timer was cancelled — revealing state should not be present + expect(el.matches(':state(revealing)')).to.be.false; + } finally { + clock.restore(); + } + }); + }); + + //#endregion + + //#region `animation` property + + describe('`animation` property', () => { + const animations = ['pulse', 'breathe', 'shimmer', 'wave', 'glow'] as const; + + for (const animation of animations) { + it(`applies \`${animation}\` part to all shapes`, async () => { + const el = await createLoadingSkeleton(animation); + const shapes = getShapes(el); + + expect(shapes).not.to.be.empty; + expect(shapes.every((shape) => shape.part.contains(animation))).to.be + .true; + }); + } + + it('updates shape parts when `animation` changes', async () => { + const el = await createLoadingSkeleton('breathe'); + expect(first(getShapes(el)).part.contains('breathe')).to.be.true; + + el.animation = 'shimmer'; + await elementUpdated(el); + + expect(first(getShapes(el)).part.contains('shimmer')).to.be.true; + expect(first(getShapes(el)).part.contains('breathe')).to.be.false; + }); + + it('sets `--_wave-delay` on each shape for `wave` animation', async () => { + const el = await createLoadingSkeleton('wave'); + const shapes = getShapes(el) as HTMLElement[]; + const [firstShape, secondShape, _] = shapes; + + expect(shapes.length).to.be.at.least(2); + + expect(firstShape.style.getPropertyValue('--_wave-delay')).to.equal('0s'); + expect(secondShape.style.getPropertyValue('--_wave-delay')).to.equal( + '0.1s' + ); + }); + + it('does not set `--_wave-delay` for non-wave animations', async () => { + const el = await createLoadingSkeleton('shimmer'); + const shapes = getShapes(el) as HTMLElement[]; + expect( + shapes.every( + (shape) => shape.style.getPropertyValue('--_wave-delay') === '' + ) + ).to.be.true; + }); + }); + + //#endregion + + //#region Content projection + + describe('Content projection', () => { + it('renders slotted content', async () => { + const el = await fixture(html` + + Hello + + `); + + expect(el.querySelector('#projected')).to.exist; + }); + + it('measures the correct number of leaf nodes as shapes', async () => { + const el = await createLoadingSkeleton(); + + // The template in createLoadingSkeleton contains exactly 2 leaf elements + expect(getShapes(el)).to.have.lengthOf(2); + }); + }); + + //#endregion + + //#region Lifecycle + + describe('Lifecycle', () => { + it('clears the reveal timer and does not throw on disconnect', async () => { + const clock: SinonFakeTimers = useFakeTimers({ toFake: ['setTimeout'] }); + + try { + const el = await createLoadingSkeleton(); + + el.loading = false; + await elementUpdated(el); + expect(el.matches(':state(revealing)')).to.be.true; + + // Disconnect before timer fires + el.remove(); + + // Ticking the clock should not throw even though the element is detached + expect(() => clock.tick(600)).not.to.throw(); + } finally { + clock.restore(); + } + }); + }); + + //#endregion +}); diff --git a/src/components/skeleton/skeleton.ts b/src/components/skeleton/skeleton.ts new file mode 100644 index 000000000..69eae3c3b --- /dev/null +++ b/src/components/skeleton/skeleton.ts @@ -0,0 +1,264 @@ +import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { addInternalsController } from '../common/controllers/internals.js'; +import { createMutationController } from '../common/controllers/mutation-observer.js'; +import { createResizeObserverController } from '../common/controllers/resize-observer.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { partMap } from '../common/part-map.js'; +import { iterNodes } from '../common/util.js'; +import { styles } from './themes/skeleton.base.css.js'; + +type MeasuredNode = { + x: number; + y: number; + width: number; + height: number; + borderRadius: string; +}; + +function isLeafNode(node: HTMLElement): boolean { + return ( + node.children.length === 0 && node.checkVisibility({ checkOpacity: false }) + ); +} + +function createSkeletonRect( + node: HTMLElement, + parentRect: DOMRect +): MeasuredNode { + const { borderRadius } = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return { + x: rect.left - parentRect.left, + y: rect.top - parentRect.top, + borderRadius: + borderRadius === '0px' + ? 'var(--border-radius, var(--_fallback-radius, 0))' + : borderRadius, + width: rect.width, + height: rect.height, + }; +} + +/** + * A skeleton component that overlays placeholder shapes on top of projected + * content while it is in a loading state, then smoothly reveals the content + * once loading is complete. + * + * @element igc-skeleton + * + * @slot - The default slot for the skeleton content. + * + * @csspart content - The wrapper around the slotted content. + * @csspart overlay - The translucent layer rendered over the content during loading. + * @csspart shape - An individual placeholder shape rendered over a leaf element. + * + * @cssproperty --ig-skeleton-overlay-color - Background color of the overlay layer. + * @cssproperty --ig-skeleton-shape-color - Background color of the placeholder shapes. + * @cssproperty --border-radius - Border radius applied to the overlay and shapes when the element has no explicit border-radius. + * + * @example + * ```html + * + * + *
+ * + *
+ * John Smith + * Software Engineer + *
+ *
+ *
+ * + * + * + *

Loading content…

+ *
+ * ``` + */ +export default class IgcSkeletonComponent extends LitElement { + public static readonly tagName = 'igc-skeleton'; + public static override styles = styles; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcSkeletonComponent); + } + + //#region Internal state + + private readonly _internals = addInternalsController(this); + private readonly _resizeObserver = createResizeObserverController(this, { + callback: this._scheduleMeasure, + }); + private readonly _mutationObserver = createMutationController(this, { + callback: this._scheduleMeasure, + config: { childList: true, subtree: true }, + }); + + private _hasScheduledMeasure = false; + private _revealTimeoutId?: ReturnType; + + @state() + private _nodes: MeasuredNode[] = []; + + //#endregion + + //#region Public properties + + /** + * Indicates whether the skeleton is in a loading state. + * + * When `true`, the skeleton will display its content with a loading animation. + * When `false`, the skeleton will display its content without the animation. + * + * @attr loading + * @default false + */ + @property({ type: Boolean, reflect: true }) + public loading = false; + + /** + * Defines the animation style for the skeleton when in a loading state. + * + * - `pulse`: A pulsing animation that fades the skeleton in and out. + * - `breathe`: A subtle breathing animation that smoothly transitions the skeleton's opacity. + * - `shimmer`: A horizontal highlight sweep across each shape. + * - `wave`: A staggered vertical bounce across shapes. + * - `glow`: A pulsing box-shadow glow on each shape. + * + * @attr animation + * @default 'breathe' + */ + @property() + public animation: 'pulse' | 'breathe' | 'shimmer' | 'wave' | 'glow' = + 'breathe'; + + //#endregion + + //#region Lifecycle + + constructor() { + super(); + + addSlotController(this, { + slots: setSlots(), + onChange: this._scheduleMeasure, + }); + } + + protected override update(properties: PropertyValues): void { + if (properties.has('loading')) { + this._internals.setARIA({ ariaBusy: this.loading.toString() }); + + if (this.loading) { + this._setupObservers(); + } else { + this._cleanupObservers(); + + if (properties.get('loading') === true) { + clearTimeout(this._revealTimeoutId); + this._internals.setState('revealing', true); + this._revealTimeoutId = setTimeout( + () => this._internals.setState('revealing', false), + 600 + ); + } + } + } + + super.update(properties); + } + + /** @internal */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + clearTimeout(this._revealTimeoutId); + } + + //#endregion + + //#region Private methods + + private _setupObservers(): void { + clearTimeout(this._revealTimeoutId); + this._mutationObserver.observe(); + this._resizeObserver.observe(this); + this._scheduleMeasure(); + } + + private _cleanupObservers(): void { + this._hasScheduledMeasure = false; + this._mutationObserver.disconnect(); + this._resizeObserver.unobserve(this); + this._nodes = []; + } + + private _scheduleMeasure(): void { + if (this._hasScheduledMeasure) return; + + this._hasScheduledMeasure = true; + + requestAnimationFrame(() => { + this._hasScheduledMeasure = false; + this._nodes = this._measure(); + }); + } + + private _measure(): MeasuredNode[] { + if (!this.loading) return []; + + const parentRect = this.getBoundingClientRect(); + + return iterNodes(this, { + show: 'SHOW_ELEMENT', + filter: isLeafNode, + }) + .map((node) => createSkeletonRect(node, parentRect)) + .toArray(); + } + + //#endregion + + private _renderPlaceholder(node: MeasuredNode, index: number) { + const parts = { shape: true, [this.animation]: true }; + const shapeStyles = styleMap({ + left: `${node.x}px`, + top: `${node.y}px`, + width: `${node.width}px`, + height: `${node.height}px`, + borderRadius: node.borderRadius, + '--_wave-delay': this.animation === 'wave' ? `${index * 0.1}s` : null, + }); + + return html` + + `; + } + + private _renderLoadingState() { + return this.loading + ? html`${this._nodes.map((node, i) => this._renderPlaceholder(node, i))}` + : nothing; + } + + protected override render() { + return html` +
+ + ${this._renderLoadingState()} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-skeleton': IgcSkeletonComponent; + } +} diff --git a/src/components/skeleton/themes/skeleton.base.scss b/src/components/skeleton/themes/skeleton.base.scss new file mode 100644 index 000000000..4a1edb336 --- /dev/null +++ b/src/components/skeleton/themes/skeleton.base.scss @@ -0,0 +1,157 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +@keyframes breathe { + 0%, + 100% { + opacity: 0.6; + transform: scale(1); + } + + 50% { + opacity: 1; + transform: scale(1.02); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } +} + +@keyframes wave { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-6px); + } +} + +@keyframes glow { + 0%, + 100% { + box-shadow: none; + } + + 50% { + box-shadow: 0 0 10px 4px rgb(180 180 180 / 60%); + } +} + +@keyframes reveal-content { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +:host { + --_fallback-radius: clamp( + 0rem, + var(--ig-radius-factor, 0.25) * 1.5rem, + 1.5rem + ); + + display: block; + position: relative; + width: fit-content; + height: fit-content; +} + +:host([loading]) [part='content'] { + opacity: 0; + pointer-events: none; + user-select: none; +} + +:host(:state(revealing)) [part='content'] { + animation: reveal-content 0.6s ease-out both; + will-change: opacity, transform; +} + +[part='overlay'] { + position: absolute; + inset: 0; + background-color: var(--ig-skeleton-overlay-color, #eee); + pointer-events: none; + user-select: none; + border-radius: var(--border-radius, var(--_fallback-radius, 0)); + transition: opacity 0.3s ease-out; +} + +[part~='shape'] { + position: absolute; + contain: strict; + background-color: var(--ig-skeleton-shape-color, #cdcdcd); + pointer-events: none; + user-select: none; + will-change: opacity, transform; +} + +[part~='pulse'] { + animation: pulse 1.5s ease-in-out infinite; +} + +[part~='breathe'] { + animation: breathe 1.5s ease-in-out infinite; +} + +[part~='shimmer'] { + background: linear-gradient( + 90deg, + var(--ig-skeleton-shape-color, #cdcdcd) 25%, + color-mix(in srgb, var(--ig-skeleton-shape-color, #cdcdcd) 60%, white) + 50%, + var(--ig-skeleton-shape-color, #cdcdcd) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s linear infinite; + will-change: background-position; +} + +[part~='wave'] { + animation: wave 1s ease-in-out var(--_wave-delay, 0s) infinite; +} + +[part~='glow'] { + animation: glow 1.5s ease-in-out infinite; + will-change: box-shadow; +} + +@media (prefers-reduced-motion: reduce) { + [part~='pulse'], + [part~='breathe'], + [part~='shimmer'], + [part~='wave'], + [part~='glow'] { + animation: none; + } + + :host(:state(revealing)) [part='content'] { + animation: none; + } +} diff --git a/src/index.ts b/src/index.ts index c1f626e04..286b190f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcHighlightComponent } from './components/highlight/highlight.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; export { default as IgcThemeProviderComponent } from './components/theme-provider/theme-provider.js'; +export { default as IgcSkeletonComponent } from './components/skeleton/skeleton.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; diff --git a/stories/skeleton.stories.ts b/stories/skeleton.stories.ts new file mode 100644 index 000000000..19eb63a14 --- /dev/null +++ b/stories/skeleton.stories.ts @@ -0,0 +1,501 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { + IgcAvatarComponent, + IgcButtonComponent, + IgcCardActionsComponent, + IgcCardComponent, + IgcCardContentComponent, + IgcCardHeaderComponent, + IgcCardMediaComponent, + IgcChipComponent, + IgcSkeletonComponent, + defineComponents, +} from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +defineComponents( + IgcSkeletonComponent, + IgcAvatarComponent, + IgcButtonComponent, + IgcCardComponent, + IgcCardActionsComponent, + IgcCardContentComponent, + IgcCardHeaderComponent, + IgcCardMediaComponent, + IgcChipComponent +); + +// region default +const metadata: Meta = { + title: 'Skeleton', + component: 'igc-skeleton', + parameters: { + docs: { + description: { + component: + 'A skeleton component that overlays placeholder shapes on top of projected\ncontent while it is in a loading state, then smoothly reveals the content\nonce loading is complete.', + }, + }, + }, + argTypes: { + loading: { + type: 'boolean', + description: + 'Indicates whether the skeleton is in a loading state.\n\nWhen `true`, the skeleton will display its content with a loading animation.\nWhen `false`, the skeleton will display its content without the animation.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + animation: { + type: '"pulse" | "breathe" | "shimmer" | "wave" | "glow"', + description: + "Defines the animation style for the skeleton when in a loading state.\n\n- `pulse`: A pulsing animation that fades the skeleton in and out.\n- `breathe`: A subtle breathing animation that smoothly transitions the skeleton's opacity.\n- `shimmer`: A horizontal highlight sweep across each shape.\n- `wave`: A staggered vertical bounce across shapes.\n- `glow`: A pulsing box-shadow glow on each shape.", + options: ['pulse', 'breathe', 'shimmer', 'wave', 'glow'], + control: { type: 'select' }, + table: { defaultValue: { summary: 'breathe' } }, + }, + }, + args: { loading: false, animation: 'breathe' }, +}; + +export default metadata; + +interface IgcSkeletonArgs { + /** + * Indicates whether the skeleton is in a loading state. + * + * When `true`, the skeleton will display its content with a loading animation. + * When `false`, the skeleton will display its content without the animation. + */ + loading: boolean; + /** + * Defines the animation style for the skeleton when in a loading state. + * + * - `pulse`: A pulsing animation that fades the skeleton in and out. + * - `breathe`: A subtle breathing animation that smoothly transitions the skeleton's opacity. + * - `shimmer`: A horizontal highlight sweep across each shape. + * - `wave`: A staggered vertical bounce across shapes. + * - `glow`: A pulsing box-shadow glow on each shape. + */ + animation: 'pulse' | 'breathe' | 'shimmer' | 'wave' | 'glow'; +} +type Story = StoryObj; + +// endregion + +export const Default: Story = { + args: { + loading: true, + }, + render: ({ loading, animation }) => html` + +
+ +
+ John Smith + Software Engineer +
+
+
+ `, +}; + +// A simple card-like layout using native elements and igc-avatar + +export const ProfileCard: Story = { + args: { loading: true, animation: 'breathe' }, + parameters: { + docs: { + description: { + story: + 'A typical profile card layout composed of native elements and `igc-avatar`. Toggle `loading` to reveal the actual content.', + }, + }, + }, + render: ({ loading, animation }) => html` + +
+
+ +
+ John Smith + + Senior Engineer + +
+
+

+ Passionate about building scalable systems and developer tooling. + Based in Berlin. +

+
+ TypeScript + Lit + Web Components +
+
+
+ `, +}; + +// A media card using igc-card components + +export const MediaCard: Story = { + args: { loading: true, animation: 'breathe' }, + parameters: { + docs: { + description: { + story: + 'An `igc-card` with media, header and actions wrapped by the skeleton. Use the **Controls** panel to toggle `loading` and switch `animation` variants.', + }, + }, + }, + render: ({ loading, animation }) => html` + + + + New York City + + + +

New York

+
City that never sleeps
+
+ +

+ New York City comprises 5 boroughs sitting where the Hudson River + meets the Atlantic Ocean. +

+
+ + Like + Learn More + +
+
+ `, +}; + +// All animations side by side + +export const Animations: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'All five animation variants rendered simultaneously so you can compare them at a glance.', + }, + }, + }, + render: () => { + const animations = ['breathe', 'pulse', 'shimmer', 'wave', 'glow'] as const; + + return html` +
+ ${animations.map( + (anim) => html` +
+ +
+
+ +
+ John Smith + Engineer +
+
+

+ Building scalable systems and developer tooling. +

+ TypeScript +
+
+ ${anim} +
+ ` + )} +
+ `; + }, +}; + +// Demonstrates ResizeObserver: an image loads after a delay causing the host to grow and remeasure + +export const ResizeObserverDemo: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: `Demonstrates the **ResizeObserver** integration. The skeleton starts loading with an \`\` that has no size +yet (no \`width\`/\`height\` attributes, no explicit CSS dimensions). After 2 seconds the image \`src\` is resolved +and the browser lays it out at its natural dimensions — the skeleton host grows and the \`ResizeObserver\` +fires, triggering a remeasure so the shape overlays move to the correct positions. +Click **Start** to reset and run the sequence again.`, + }, + }, + }, + play: async ({ canvasElement }) => { + const startBtn = canvasElement.querySelector('igc-button'); + startBtn?.click(); + }, + render: () => { + let skeleton: IgcSkeletonComponent | null = null; + let img: HTMLImageElement | null = null; + let timeoutId: ReturnType | undefined; + + const IMAGE_URL = + 'https://images.unsplash.com/photo-1518235506717-e1ed3306a89b?ixlib=rb-1.2.1&auto=format&fit=crop&w=640&q=80'; + + function start() { + clearTimeout(timeoutId); + + if (!skeleton || !img) return; + + // Reset state + img.src = ''; + img.style.width = ''; + img.style.height = ''; + skeleton.loading = true; + + // Simulate delayed image resolution — host grows, ResizeObserver fires + timeoutId = setTimeout(() => { + img!.src = IMAGE_URL; + img!.style.width = '300px'; + img!.style.height = '200px'; + }, 2000); + } + + function stop() { + clearTimeout(timeoutId); + if (skeleton) skeleton.loading = false; + } + + return html` +
+
+ { + const root = (e.target as HTMLElement).closest( + 'div' + )!.parentElement!; + skeleton = root.querySelector('igc-skeleton'); + img = root.querySelector('img'); + start(); + }} + > + Start + + { + const root = (e.target as HTMLElement).closest( + 'div' + )!.parentElement!; + skeleton = root.querySelector('igc-skeleton'); + stop(); + }} + > + Stop + +
+ +

+ The image below has no dimensions until its + src is resolved. Click Start — the + skeleton activates immediately, then after 2 seconds the image loads + and the skeleton remeasures to fit the new dimensions. +

+ + + Delayed image + +
+ `; + }, +}; + +// Demonstrates MutationObserver: children are dynamically added while loading + +export const MutationObserverDemo: Story = { + argTypes: { + loading: {}, + animation: { + control: 'select', + options: ['pulse', 'breathe', 'shimmer', 'wave', 'glow'], + }, + }, + parameters: { + docs: { + description: { + story: `Demonstrates the **MutationObserver** integration. The skeleton starts loading with an empty list. +Clicking **Add item** appends a new list row to the DOM — the \`MutationObserver\` (configured with +\`childList: true, subtree: true\`) fires, the skeleton remeasures, and a new shape overlay appears +for the freshly added element. Click **Reset** to clear the list and start again.`, + }, + }, + }, + render: (args) => { + const items = [ + { + name: 'John Smith', + role: 'Software Engineer', + avatar: + 'https://www.infragistics.com/angular-demos/assets/images/men/1.jpg', + }, + { + name: 'Lisa Wagner', + role: 'Product Manager', + avatar: + 'https://www.infragistics.com/angular-demos/assets/images/women/1.jpg', + }, + { + name: 'Abraham Lee', + role: 'Team Lead', + avatar: + 'https://www.infragistics.com/angular-demos/assets/images/men/2.jpg', + }, + { + name: 'Kate Manson', + role: 'UX Designer', + avatar: + 'https://www.infragistics.com/angular-demos/assets/images/women/2.jpg', + }, + ]; + + let index = 0; + + function addItem(e: Event) { + const root = (e.target as HTMLElement).closest('div')!.parentElement!; + const list = root.querySelector('#mutation-list'); + const skeleton = root.querySelector('igc-skeleton'); + + if (!list || !skeleton) return; + + if (!skeleton.loading) skeleton.loading = true; + + const item = items[index++ % items.length]; + const li = document.createElement('li'); + li.style.cssText = + 'display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; list-style: none;'; + + li.innerHTML = ` + +
+ ${item.name} + ${item.role} +
+ `; + + list.appendChild(li); + } + + function reset(e: Event) { + const root = (e.target as HTMLElement).closest('div')!.parentElement!; + const list = root.querySelector('#mutation-list'); + const skeleton = root.querySelector('igc-skeleton'); + + if (list) list.innerHTML = ''; + if (skeleton) skeleton.loading = true; + index = 0; + } + + return html` +
+
+ Add item + { + const root = (e.target as HTMLElement).closest( + 'div' + )!.parentElement!; + const skeleton = + root.querySelector('igc-skeleton'); + if (skeleton) skeleton.loading = false; + }} + > + Reveal + + Reset +
+ + +
+ Team members +
    +
    +
    +
    + `; + }, +};