diff --git a/src/baklava.ts b/src/baklava.ts index a3813b839..4226bbd49 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -25,6 +25,7 @@ export { default as BlRadioGroup } from "./components/radio-group/bl-radio-group export { default as BlRadio } from "./components/radio-group/radio/bl-radio"; export { default as BlSelect } from "./components/select/bl-select"; export { default as BlSelectOption } from "./components/select/option/bl-select-option"; +export { default as BlSkeleton } from "./components/skeleton/bl-skeleton"; export { default as BlSpinner } from "./components/spinner/bl-spinner"; export { default as BlSplitButton } from "./components/split-button/bl-split-button"; export { default as BlStepper } from "./components/stepper/bl-stepper"; diff --git a/src/components/skeleton/bl-skeleton.css b/src/components/skeleton/bl-skeleton.css new file mode 100644 index 000000000..a91ddb9e5 --- /dev/null +++ b/src/components/skeleton/bl-skeleton.css @@ -0,0 +1,58 @@ +:host { + display: block; +} + +.skeleton { + --bg-color: var(--bl-skeleton-bg-color, var(--bl-color-neutral-lightest)); + --highlight-color: var(--bl-skeleton-highlight-color, var(--bl-color-neutral-full)); + --radius: var(--bl-skeleton-radius, var(--bl-border-radius-s)); + + background-color: var(--bg-color); + border-radius: var(--radius); + width: 100%; + height: var(--bl-size-m); + overflow: hidden; + position: relative; +} + +:host([variant="circle"]) .skeleton { + --radius: var(--bl-border-radius-circle); + + width: var(--bl-size-3xl); + height: var(--bl-size-3xl); +} + +:host([variant="text"]) .skeleton { + height: var(--bl-size-xs); + border-radius: var(--bl-border-radius-xs); +} + +:host([effect="pulse"]) .skeleton { + animation: pulse 1.5s ease-in-out infinite; +} + +:host([effect="wave"]) .skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, var(--highlight-color), transparent); + animation: wave 1.6s linear infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +@keyframes wave { + 100% { + transform: translateX(100%); + } +} diff --git a/src/components/skeleton/bl-skeleton.stories.mdx b/src/components/skeleton/bl-skeleton.stories.mdx new file mode 100644 index 000000000..e1291937f --- /dev/null +++ b/src/components/skeleton/bl-skeleton.stories.mdx @@ -0,0 +1,172 @@ +import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; + + + +export const SkeletonTemplate = (args) => html``; + +export const VariantTemplate = () => html` +
+
+

Rect (Default)

+ +
+
+

Circle

+ +
+
+

Text

+ + + +
+
+`; + +export const EffectTemplate = () => html` +
+
+

Pulse (Default)

+ +
+
+

Wave

+ +
+
+

None

+ +
+
+`; + +export const CustomSizeTemplate = () => html` +
+ + + + +
+`; + +export const CardTemplate = () => html` +
+
+ +
+ +
+ + +
+
+ + + +
+
+ +
+ +
+ + +
+
+ + + +
+
+`; + +# Skeleton + +Skeleton component provides a placeholder preview of content before data is loaded. It reduces the perceived loading time and provides a better user experience. + +## Variants + +Skeleton has 3 variants: `rect` (default), `circle`, and `text`. + +- **rect** — General-purpose rectangular placeholder. Use for images, cards, and content blocks. +- **circle** — Circular placeholder. Use for avatars and profile pictures. +- **text** — Thin line placeholder. Use for text content and paragraphs. + + + + {VariantTemplate.bind({})} + + + +## Animation Effects + +Skeleton supports 3 animation effects: `pulse` (default), `wave`, and `none`. + + + + {EffectTemplate.bind({})} + + + +## Custom Sizes + +You can set custom `width` and `height` properties to control the skeleton dimensions. + + + + {CustomSizeTemplate.bind({})} + + + +## Card Loading Example + +Combine multiple skeleton elements to create realistic loading placeholders for complex layouts. + + + + {CardTemplate.bind({})} + + + +## Customizing Colors + +You can customize the skeleton appearance using CSS custom properties: + +```css +bl-skeleton { + --bl-skeleton-bg-color: #e0e0e0; + --bl-skeleton-highlight-color: #f5f5f5; +} +``` + +## Reference + + diff --git a/src/components/skeleton/bl-skeleton.test.ts b/src/components/skeleton/bl-skeleton.test.ts new file mode 100644 index 000000000..3f22e5307 --- /dev/null +++ b/src/components/skeleton/bl-skeleton.test.ts @@ -0,0 +1,116 @@ +import { assert, expect, fixture, html } from "@open-wc/testing"; +import BlSkeleton from "./bl-skeleton"; + +import type typeOfBlSkeleton from "./bl-skeleton"; + +describe("bl-skeleton", () => { + it("is defined", () => { + const el = document.createElement("bl-skeleton"); + + assert.instanceOf(el, BlSkeleton); + }); + + it("renders with default values", async () => { + const el = await fixture(html``); + + assert.shadowDom.equal( + el, + "
" + ); + }); + + it("has correct default property values", async () => { + const el = await fixture(html``); + + expect(el.variant).to.equal("rect"); + expect(el.effect).to.equal("pulse"); + expect(el.width).to.be.undefined; + expect(el.height).to.be.undefined; + }); + + it("reflects variant attribute", async () => { + const el = await fixture( + html`` + ); + + expect(el.variant).to.equal("circle"); + expect(el.getAttribute("variant")).to.equal("circle"); + }); + + it("reflects effect attribute", async () => { + const el = await fixture( + html`` + ); + + expect(el.effect).to.equal("wave"); + expect(el.getAttribute("effect")).to.equal("wave"); + }); + + it("applies custom width via inline style", async () => { + const el = await fixture( + html`` + ); + const skeleton = el.shadowRoot!.querySelector(".skeleton")!; + + expect(skeleton.style.width).to.equal("200px"); + }); + + it("applies custom height via inline style", async () => { + const el = await fixture( + html`` + ); + const skeleton = el.shadowRoot!.querySelector(".skeleton")!; + + expect(skeleton.style.height).to.equal("50px"); + }); + + it("applies both width and height", async () => { + const el = await fixture( + html`` + ); + const skeleton = el.shadowRoot!.querySelector(".skeleton")!; + + expect(skeleton.style.width).to.equal("300px"); + expect(skeleton.style.height).to.equal("100px"); + }); + + it("does not set inline width/height when not provided", async () => { + const el = await fixture(html``); + const skeleton = el.shadowRoot!.querySelector(".skeleton")!; + + expect(skeleton.style.width).to.equal(""); + expect(skeleton.style.height).to.equal(""); + }); + + it("sets role=presentation and aria-hidden=true for accessibility", async () => { + const el = await fixture(html``); + const skeleton = el.shadowRoot!.querySelector(".skeleton")!; + + expect(skeleton.getAttribute("role")).to.equal("presentation"); + expect(skeleton.getAttribute("aria-hidden")).to.equal("true"); + }); + + it("supports text variant", async () => { + const el = await fixture( + html`` + ); + + expect(el.variant).to.equal("text"); + expect(el.getAttribute("variant")).to.equal("text"); + }); + + it("supports none effect", async () => { + const el = await fixture( + html`` + ); + + expect(el.effect).to.equal("none"); + expect(el.getAttribute("effect")).to.equal("none"); + }); + + it("renders as block-level element by default", async () => { + const el = await fixture(html``); + + expect(getComputedStyle(el).display).to.equal("block"); + }); +}); diff --git a/src/components/skeleton/bl-skeleton.ts b/src/components/skeleton/bl-skeleton.ts new file mode 100644 index 000000000..2f8731316 --- /dev/null +++ b/src/components/skeleton/bl-skeleton.ts @@ -0,0 +1,68 @@ +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { styleMap } from "lit/directives/style-map.js"; +import style from "./bl-skeleton.css"; + +export type SkeletonVariant = "rect" | "circle" | "text"; +export type SkeletonEffect = "pulse" | "wave" | "none"; + +export const blSkeletonTag = "bl-skeleton"; + +/** + * @tag bl-skeleton + * @summary Baklava Skeleton component + * + * @cssproperty [--bl-skeleton-bg-color=--bl-color-neutral-lightest] Sets the background color of skeleton + * @cssproperty [--bl-skeleton-highlight-color=--bl-color-neutral-full] Sets the highlight color for wave animation + * @cssproperty [--bl-skeleton-radius=--bl-border-radius-s] Overrides the border radius of skeleton + */ +@customElement(blSkeletonTag) +export default class BlSkeleton extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Sets the skeleton variant + */ + @property({ type: String, reflect: true }) + variant: SkeletonVariant = "rect"; + + /** + * Sets the animation effect + */ + @property({ type: String, reflect: true }) + effect: SkeletonEffect = "pulse"; + + /** + * Sets a custom width (any CSS value) + */ + @property({ type: String }) + width?: string; + + /** + * Sets a custom height (any CSS value) + */ + @property({ type: String }) + height?: string; + + render(): TemplateResult { + const inlineStyles: Record = {}; + + if (this.width) inlineStyles.width = this.width; + if (this.height) inlineStyles.height = this.height; + + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blSkeletonTag]: BlSkeleton; + } +} diff --git a/src/components/skeleton/doc/ADR.md b/src/components/skeleton/doc/ADR.md new file mode 100644 index 000000000..c4135479f --- /dev/null +++ b/src/components/skeleton/doc/ADR.md @@ -0,0 +1,92 @@ +## Figma Design Document + +_TBD_ + +## Implementation + +Skeleton component provides placeholder loading states for content. It helps reduce perceived loading time by showing the structure of the page before the actual content is loaded. + +General usage example: + +```html + +``` + +### Rules + +* Default variant is `rect` and default effect is `pulse`. +* The `circle` variant uses equal width and height by default (`40px`). Override with `width` and `height` for custom sizes. +* The `text` variant renders a thin line suitable for paragraph placeholders. +* Custom `width` and `height` accept any valid CSS value (e.g. `200px`, `50%`, `10rem`). +* Skeleton elements have `role="presentation"` and `aria-hidden="true"` to be hidden from assistive technologies. + +### Usage Examples + +Basic rectangular skeleton: + +```html + +``` + +Circle skeleton for avatar placeholders: + +```html + +``` + +Text skeleton for paragraph placeholders: + +```html + + + +``` + +Wave animation effect: + +```html + +``` + +Card loading placeholder: + +```html +
+ + + + +
+``` + +Custom colors: + +```css +.custom-skeleton { + --bl-skeleton-bg-color: #e0e0e0; + --bl-skeleton-highlight-color: #f5f5f5; +} +``` + +```html + +``` + +## API Reference + +### Attributes + +| Attribute | Type | Description | Default Value | +| --------- | ---- | ----------- | ------------- | +| `variant` | `"rect"` \| `"circle"` \| `"text"` | Shape variant of the skeleton | `"rect"` | +| `effect` | `"pulse"` \| `"wave"` \| `"none"` | Animation effect | `"pulse"` | +| `width` | `string` | Custom CSS width | - | +| `height` | `string` | Custom CSS height | - | + +### CSS Custom Properties + +| Property | Description | Default Value | +| -------- | ----------- | ------------- | +| `--bl-skeleton-bg-color` | Background color | `--bl-color-neutral-lightest` | +| `--bl-skeleton-highlight-color` | Highlight color for wave animation | `--bl-color-neutral-full` | +| `--bl-skeleton-radius` | Border radius override | `--bl-border-radius-s` |