diff --git a/packages/blora-design/component-manifest.json b/packages/blora-design/component-manifest.json
index 7758b4b..249a7de 100644
--- a/packages/blora-design/component-manifest.json
+++ b/packages/blora-design/component-manifest.json
@@ -1,7 +1,7 @@
{
"name": "@bloret-crew/blora-design",
- "version": "2.0.1",
- "generatedAt": "2026-08-29T01:15:38.096Z",
+ "version": "2.0.8",
+ "generatedAt": "2026-09-29T13:20:35.152Z",
"components": [
{
"name": "accordion",
@@ -409,6 +409,17 @@
"cssExport": "./components/image.css",
"contract": "contracts/image.contract.json"
},
+ {
+ "name": "image-stack",
+ "status": "beta",
+ "kind": "custom-element",
+ "category": "data-display",
+ "tagName": "blora-image-stack",
+ "requiresJavaScript": true,
+ "formAssociated": false,
+ "cssExport": "./components/image-stack.css",
+ "contract": "contracts/image-stack.contract.json"
+ },
{
"name": "indicator",
"status": "stable",
diff --git a/packages/blora-design/contracts/image-stack.contract.json b/packages/blora-design/contracts/image-stack.contract.json
new file mode 100644
index 0000000..09aaae0
--- /dev/null
+++ b/packages/blora-design/contracts/image-stack.contract.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "../../schemas/component-contract.schema.json",
+ "name": "image-stack",
+ "category": "data-display",
+ "status": "beta",
+ "kind": "custom-element",
+ "tagName": "blora-image-stack",
+ "since": "2.0.9",
+ "requiresJavaScript": true,
+ "formAssociated": false,
+ "attributes": {
+ "current": { "type": "number", "default": 0 },
+ "label": { "type": "string", "default": "图片堆叠" }
+ },
+ "properties": { "current": { "type": "number" } },
+ "methods": {
+ "next": { "signature": "() => void" },
+ "prev": { "signature": "() => void" },
+ "goTo": { "signature": "(index: number) => void" }
+ },
+ "events": {
+ "blora-image-stack-change": { "detail": "{ index: number }", "bubbles": true, "composed": false }
+ },
+ "slots": {
+ "default": "Declarative
definitions; consumed into the official chat-compatible image stack tree."
+ },
+ "cssProperties": ["--blora-image-stack-layer", "--blora-image-stack-shift"],
+ "parts": [],
+ "accessibilityPattern": "group"
+}
diff --git a/packages/blora-design/custom-elements.json b/packages/blora-design/custom-elements.json
index 713625b..db36e96 100644
--- a/packages/blora-design/custom-elements.json
+++ b/packages/blora-design/custom-elements.json
@@ -1042,6 +1042,46 @@
}
]
},
+ {
+ "kind": "javascript-module",
+ "path": "dist/index.js",
+ "declarations": [
+ {
+ "kind": "class",
+ "name": "BloraImageStack",
+ "tagName": "blora-image-stack",
+ "customElement": true,
+ "description": "image-stack custom element",
+ "superclass": {
+ "name": "BloraElement",
+ "module": "dist/index.js"
+ }
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "BloraImageStack",
+ "declaration": {
+ "name": "BloraImageStack"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "defineBloraImageStack",
+ "declaration": {
+ "name": "defineBloraImageStack"
+ }
+ },
+ {
+ "kind": "custom-element-definition",
+ "name": "blora-image-stack",
+ "declaration": {
+ "name": "BloraImageStack"
+ }
+ }
+ ]
+ },
{
"kind": "javascript-module",
"path": "dist/index.js",
diff --git a/packages/blora-design/package.json b/packages/blora-design/package.json
index 1870266..e483079 100644
--- a/packages/blora-design/package.json
+++ b/packages/blora-design/package.json
@@ -114,6 +114,7 @@
"./components/copy.css": "./dist/components/copy/copy.css",
"./components/datepicker.css": "./dist/components/datepicker/datepicker.css",
"./components/deck.css": "./dist/components/deck/deck.css",
+ "./components/image-stack.css": "./dist/components/image-stack/image-stack.css",
"./components/dock.css": "./dist/components/dock/dock.css",
"./components/fab.css": "./dist/components/fab/fab.css",
"./components/masonry.css": "./dist/components/masonry/masonry.css",
diff --git a/packages/blora-design/src/auto.ts b/packages/blora-design/src/auto.ts
index 296e66e..5bc93ad 100644
--- a/packages/blora-design/src/auto.ts
+++ b/packages/blora-design/src/auto.ts
@@ -55,6 +55,7 @@ import { defineBloraTreeSelect } from "./components/tree-select/index.js";
import { defineBloraCalendar } from "./components/calendar/index.js";
import { defineBloraCarousel } from "./components/carousel/index.js";
import { defineBloraDeck } from "./components/deck/index.js";
+import { defineBloraImageStack } from "./components/image-stack/index.js";
import { defineBloraImage } from "./components/image/index.js";
import { defineBloraDock } from "./components/dock/index.js";
import { defineBloraMegamenu } from "./components/megamenu/index.js";
@@ -119,6 +120,7 @@ export function defineAllBloraElements(registry: CustomElementRegistry = customE
defineBloraCalendar(registry);
defineBloraCarousel(registry);
defineBloraDeck(registry);
+ defineBloraImageStack(registry);
defineBloraImage(registry);
defineBloraDock(registry);
defineBloraMegamenu(registry);
@@ -187,6 +189,7 @@ export {
defineBloraCalendar,
defineBloraCarousel,
defineBloraDeck,
+ defineBloraImageStack,
defineBloraImage,
defineBloraDock,
defineBloraMegamenu,
diff --git a/packages/blora-design/src/components/image-stack/image-stack.css b/packages/blora-design/src/components/image-stack/image-stack.css
new file mode 100644
index 0000000..704a40e
--- /dev/null
+++ b/packages/blora-design/src/components/image-stack/image-stack.css
@@ -0,0 +1,85 @@
+/* Blora Design 2.0 - chat-compatible stacked photo browsing. */
+
+blora-image-stack { display: block; }
+.blora-image-stack {
+ position: relative;
+ width: min(300px, calc(64vw - 48px));
+ height: min(320px, 68vw);
+ margin: var(--blora-space-8) var(--blora-space-8) 0;
+ isolation: isolate;
+ overscroll-behavior-x: contain;
+ touch-action: pan-y;
+ cursor: grab;
+ user-select: none;
+ outline: none;
+}
+.blora-image-stack:active { cursor: grabbing; }
+.blora-image-stack:focus-visible { box-shadow: 0 0 0 2px var(--blora-color-action-primary-default); border-radius: var(--blora-radius-lg); }
+.blora-image-stack__count {
+ display: flex;
+ align-items: center;
+ gap: var(--blora-space-2);
+ width: min(300px, calc(64vw - 48px));
+ margin: 0 var(--blora-space-3) var(--blora-space-3);
+ padding: 0 var(--blora-space-3);
+ border: 0;
+ color: var(--blora-color-action-primary-default);
+ background: transparent;
+ font: inherit;
+ font-size: var(--blora-text-sm);
+ font-weight: 600;
+ text-align: start;
+ cursor: pointer;
+}
+.blora-image-stack__count:focus-visible { outline: 2px solid var(--blora-color-action-primary-default); outline-offset: 3px; border-radius: var(--blora-radius-sm); }
+.blora-image-stack__index { margin-inline-start: auto; color: var(--blora-color-text-muted); font-size: var(--blora-text-sm); font-weight: 400; }
+.blora-image-stack__tile {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ border-radius: 22px;
+ background: var(--blora-color-surface-raised);
+ box-shadow: var(--blora-shadow-2);
+ transform: translate(calc(var(--blora-image-stack-layer, 0) * -10px), calc(var(--blora-image-stack-layer, 0) * -10px));
+ z-index: calc(4 - var(--blora-image-stack-layer, 0));
+ transition: transform var(--blora-duration-base) var(--blora-easing-standard), opacity var(--blora-duration-base) var(--blora-easing-standard);
+}
+.blora-image-stack__tile.is-hidden { opacity: 0; pointer-events: none; }
+.blora-image-stack__tile[data-passed] { transform: translate(calc(-1 * var(--blora-image-stack-shift, 0px)), var(--blora-space-3)) rotate(-5deg); opacity: 1; z-index: 0; pointer-events: auto; }
+.blora-image-stack__tile.is-departing { animation: blora-image-stack-pass var(--blora-duration-base) var(--blora-easing-standard) forwards; }
+.blora-image-stack__tile.is-returning { transform: translate(calc(-1 * var(--blora-image-stack-shift, 0px)), var(--blora-space-3)) rotate(-5deg); animation: blora-image-stack-return var(--blora-duration-base) var(--blora-easing-standard) forwards; z-index: 5; }
+.blora-image-stack__tile img { display: block; width: 100%; height: 100%; object-fit: cover; }
+@keyframes blora-image-stack-pass { to { transform: translate(calc(-1 * var(--blora-image-stack-shift, 0px)), var(--blora-space-3)) rotate(-5deg); opacity: 1; } }
+@keyframes blora-image-stack-return { to { transform: translate(0, 0) rotate(0); opacity: 1; } }
+.blora-image-stack__nav {
+ position: absolute;
+ top: 50%;
+ z-index: 6;
+ display: grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--blora-radius-full);
+ color: var(--blora-color-text-default);
+ background: color-mix(in srgb, var(--blora-color-surface-raised) 85%, transparent);
+ box-shadow: var(--blora-shadow-1);
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-50%);
+ transition: opacity var(--blora-duration-fast) var(--blora-easing-standard);
+}
+.blora-image-stack__nav--prev { left: var(--blora-space-3); }
+.blora-image-stack__nav--next { right: var(--blora-space-3); }
+.blora-image-stack:hover .blora-image-stack__nav,
+.blora-image-stack:focus-within .blora-image-stack__nav { opacity: 1; pointer-events: auto; }
+.blora-image-stack__nav[hidden] { display: none; }
+.blora-image-stack__nav:focus-visible { outline: 2px solid var(--blora-color-action-primary-default); outline-offset: 2px; }
+.blora-image-gallery[hidden] { display: none; }
+.blora-image-gallery { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--blora-space-2); width: calc(100% - var(--blora-space-6)); margin: 0 var(--blora-space-3) var(--blora-space-2); }
+.blora-image-gallery__item { display: block; aspect-ratio: 1; overflow: hidden; border-radius: var(--blora-radius-sm); }
+.blora-image-gallery__item img { display: block; width: 100%; height: 100%; object-fit: cover; }
+@media (hover: none) { .blora-image-stack__nav:not([hidden]) { opacity: 1; pointer-events: auto; } }
+@media (prefers-reduced-motion: reduce) { .blora-image-stack__tile, .blora-image-stack__nav { transition: none; } .blora-image-stack__tile.is-departing, .blora-image-stack__tile.is-returning { animation: none; } }
diff --git a/packages/blora-design/src/components/image-stack/image-stack.ts b/packages/blora-design/src/components/image-stack/image-stack.ts
new file mode 100644
index 0000000..e5b1538
--- /dev/null
+++ b/packages/blora-design/src/components/image-stack/image-stack.ts
@@ -0,0 +1,207 @@
+import { BloraElement } from "../../core/blora-element.js";
+import { t } from "../../core/i18n.js";
+import { createBloraIcon } from "../../core/icons.js";
+import { openImagePreview } from "../image/image.js";
+
+export const BLORA_IMAGE_STACK_TAG = "blora-image-stack";
+
+export interface ImageStackController {
+ destroy(): void;
+ next(): void;
+ prev(): void;
+ goTo(index: number): void;
+ getCurrent(): number;
+}
+
+const clamp = (value: number, count: number) => Math.max(0, Math.min(count - 1, value));
+
+export function createImageStackController(root: HTMLElement): ImageStackController {
+ const tiles = () => Array.from(root.querySelectorAll(".blora-image-stack__tile"));
+ const counter = () => root.parentElement?.querySelector(".blora-image-stack__index");
+ let current = clamp(Number(root.dataset.index ?? 0), tiles().length);
+ let wheelDistance = 0;
+ let wheelDirection = 0;
+ let wheelHandled = false;
+ let wheelResetTimer: ReturnType | undefined;
+ let startX = 0;
+ let startY = 0;
+ let pointerId = -1;
+ let pointerActive = false;
+ let swiped = false;
+
+ const positionPassed = () => {
+ tiles().forEach((tile, index) => {
+ const distance = index - current;
+ tile.style.setProperty("--blora-image-stack-layer", String(distance));
+ tile.style.setProperty("--blora-image-stack-shift", `${Math.max(0, current - index) * 22}px`);
+ tile.toggleAttribute("data-current", distance === 0);
+ tile.toggleAttribute("data-passed", distance < 0);
+ tile.classList.toggle("is-hidden", distance < 0 || distance > 2);
+ tile.setAttribute("aria-hidden", String(distance !== 0));
+ tile.tabIndex = distance < 0 || distance > 2 ? -1 : 0;
+ });
+ };
+
+ const paint = () => {
+ const items = tiles();
+ if (!items.length) return;
+ root.dataset.index = String(current);
+ if (counter()) counter()!.textContent = `${current + 1} / ${items.length}`;
+ root.querySelector(".blora-image-stack__nav--prev")!.hidden = current === 0;
+ root.querySelector(".blora-image-stack__nav--next")!.hidden = current === items.length - 1;
+ positionPassed();
+ };
+
+ const goTo = (index: number) => {
+ const count = tiles().length;
+ if (!count) return;
+ const target = clamp(index, count);
+ if (target === current || root.classList.contains("is-flipping")) return;
+ const previous = current;
+ const entering = target < previous ? tiles()[target] : null;
+ const front = tiles()[previous];
+ const reduced = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+ root.classList.add("is-flipping");
+ if (entering && !reduced) entering.classList.add("is-returning");
+ if (!entering && !reduced) front?.classList.add("is-departing");
+ const finish = () => {
+ current = target;
+ root.classList.remove("is-flipping");
+ front?.classList.remove("is-departing");
+ entering?.classList.remove("is-returning");
+ paint();
+ root.dispatchEvent(new CustomEvent("blora-image-stack-change", { bubbles: true, detail: { index: current } }));
+ };
+ if (reduced) finish(); else setTimeout(finish, 300);
+ };
+ const next = () => goTo(current + 1);
+ const prev = () => goTo(current - 1);
+
+ const onWheel = (event: WheelEvent) => {
+ if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) || Math.abs(event.deltaX) < 1) return;
+ event.preventDefault();
+ clearTimeout(wheelResetTimer);
+ wheelResetTimer = setTimeout(() => { wheelDistance = 0; wheelDirection = 0; wheelHandled = false; }, 600);
+ if (wheelHandled) return;
+ const direction = Math.sign(event.deltaX);
+ if (direction !== wheelDirection) { wheelDistance = 0; wheelDirection = direction; }
+ const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? root.clientWidth : 1;
+ wheelDistance += Math.abs(event.deltaX) * scale;
+ if (wheelDistance >= 140) { wheelHandled = true; direction > 0 ? next() : prev(); }
+ };
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+ event.preventDefault();
+ event.key === "ArrowRight" ? next() : prev();
+ tiles().find((tile) => !tile.classList.contains("is-hidden"))?.focus();
+ };
+ const onPointerDown = (event: PointerEvent) => {
+ if (event.button !== 0 || (event.target as HTMLElement).closest("button")) return;
+ startX = event.clientX; startY = event.clientY; pointerId = event.pointerId; pointerActive = true; swiped = false;
+ root.setPointerCapture?.(pointerId);
+ };
+ const onPointerUp = (event: PointerEvent) => {
+ if (!pointerActive || event.pointerId !== pointerId) return;
+ pointerActive = false;
+ const dx = event.clientX - startX; const dy = event.clientY - startY;
+ if (Math.abs(dx) < 35 || Math.abs(dx) < Math.abs(dy)) return;
+ swiped = true; dx < 0 ? next() : prev();
+ };
+ const onClick = (event: MouseEvent) => {
+ if (!swiped) return;
+ event.preventDefault(); event.stopPropagation(); swiped = false;
+ };
+ const previousButton = root.querySelector(".blora-image-stack__nav--prev");
+ const nextButton = root.querySelector(".blora-image-stack__nav--next");
+ root.addEventListener("wheel", onWheel, { passive: false });
+ root.addEventListener("keydown", onKey);
+ root.addEventListener("pointerdown", onPointerDown);
+ root.addEventListener("pointerup", onPointerUp);
+ root.addEventListener("pointercancel", () => { pointerActive = false; });
+ root.addEventListener("click", onClick, true);
+ previousButton?.addEventListener("click", prev);
+ nextButton?.addEventListener("click", next);
+ if (!root.hasAttribute("tabindex")) root.tabIndex = 0;
+ paint();
+
+ return {
+ destroy() {
+ clearTimeout(wheelResetTimer);
+ root.removeEventListener("wheel", onWheel);
+ root.removeEventListener("keydown", onKey);
+ root.removeEventListener("pointerdown", onPointerDown);
+ root.removeEventListener("pointerup", onPointerUp);
+ root.removeEventListener("click", onClick, true);
+ previousButton?.removeEventListener("click", prev);
+ nextButton?.removeEventListener("click", next);
+ },
+ next, prev, goTo, getCurrent: () => current,
+ };
+}
+
+interface ImageStackItem { src: string; alt: string; href: string; }
+
+export class BloraImageStack extends BloraElement {
+ private controller: ImageStackController | null = null;
+ private definitions: ImageStackItem[] | null = null;
+ private reflecting = false;
+ static get observedAttributes(): string[] { return ["current", "label"]; }
+ get current(): number { return this.controller?.getCurrent() ?? Number(this.getAttribute("current") ?? 0); }
+ set current(value: number) { this.setAttribute("current", String(value)); }
+ next(): void { this.controller?.next(); }
+ prev(): void { this.controller?.prev(); }
+ goTo(index: number): void { this.controller?.goTo(index); }
+ attributeChangedCallback(): void { if (this.isConnectedInternal && !this.reflecting) this.sync(); }
+
+ protected render(): void {
+ if (!this.definitions) this.definitions = Array.from(this.children)
+ .filter((item) => item.localName === "blora-image-stack-item")
+ .map((item) => {
+ const image = item.querySelector("img");
+ const src = item.getAttribute("src") ?? image?.getAttribute("src") ?? "";
+ return { src, alt: item.getAttribute("alt") ?? image?.getAttribute("alt") ?? "", href: item.getAttribute("href") ?? src };
+ });
+ const count = this.definitions.length;
+ const galleryId = `blora-image-stack-gallery-${Math.random().toString(36).slice(2)}`;
+ const countButton = this.ownerDocument.createElement("button");
+ countButton.type = "button"; countButton.className = "blora-image-stack__count"; countButton.setAttribute("aria-expanded", "false"); countButton.setAttribute("aria-controls", galleryId);
+ countButton.setAttribute("aria-label", `展开 ${count} 张照片`); countButton.appendChild(createBloraIcon("square.grid.2x2.fill", 14)); countButton.append(` ${count} 张照片 `);
+ const index = this.ownerDocument.createElement("span"); index.className = "blora-image-stack__index"; index.setAttribute("aria-live", "polite"); countButton.append(index);
+ const stack = this.ownerDocument.createElement("div");
+ stack.className = "blora-image-stack"; stack.dataset.bloraGenerated = ""; stack.setAttribute("role", "group"); stack.setAttribute("aria-label", `${count} 张图片,横向滑动浏览`); stack.dataset.index = "0";
+ this.definitions.forEach((item, itemIndex) => {
+ const link = this.ownerDocument.createElement("a"); link.className = `blora-image-stack__tile${itemIndex > 2 ? " is-hidden" : ""}`; link.href = item.href; link.target = "_blank"; link.rel = "noopener noreferrer"; link.setAttribute("aria-label", `打开第 ${itemIndex + 1} 张图片`); link.dataset.index = String(itemIndex); link.draggable = false;
+ const image = this.ownerDocument.createElement("img"); image.src = item.src; image.alt = item.alt; image.loading = "lazy"; image.draggable = false; link.append(image); stack.append(link);
+ });
+ stack.append(this.nav("prev"), this.nav("next"));
+ const gallery = this.ownerDocument.createElement("div"); gallery.className = "blora-image-gallery"; gallery.id = galleryId; gallery.hidden = true;
+ this.definitions.forEach((item, itemIndex) => {
+ const link = this.ownerDocument.createElement("a"); link.className = "blora-image-gallery__item"; link.href = item.href; link.target = "_blank"; link.rel = "noopener noreferrer"; link.setAttribute("aria-label", `打开第 ${itemIndex + 1} 张图片`);
+ const image = this.ownerDocument.createElement("img"); image.src = item.src; image.alt = item.alt; image.loading = "lazy"; link.append(image); gallery.append(link);
+ });
+ this.replaceChildren(countButton, stack, gallery);
+ }
+
+ private nav(direction: "prev" | "next"): HTMLButtonElement {
+ const button = this.ownerDocument.createElement("button"); button.type = "button"; button.className = `blora-image-stack__nav blora-image-stack__nav--${direction}`; button.setAttribute("aria-label", direction === "prev" ? t("preview.prev") : t("preview.next")); button.appendChild(createBloraIcon(direction === "prev" ? "chevron-left" : "chevron-right", 18, this.ownerDocument)); return button;
+ }
+ protected bindEvents(): void {
+ const stack = this.querySelector(".blora-image-stack"); if (!stack) return;
+ this.controller = createImageStackController(stack); const initial = Number(this.getAttribute("current") ?? 0); if (initial) this.controller.goTo(initial);
+ this.listen(stack, "blora-image-stack-change", (event) => { this.reflecting = true; this.setAttribute("current", String((event as CustomEvent<{ index: number }>).detail.index)); this.reflecting = false; });
+ const count = this.querySelector(".blora-image-stack__count"); const gallery = this.querySelector(".blora-image-gallery");
+ count?.addEventListener("click", () => { const expanded = count.getAttribute("aria-expanded") === "true"; count.setAttribute("aria-expanded", String(!expanded)); count.setAttribute("aria-label", `${expanded ? "展开" : "收起"} ${this.definitions?.length ?? 0} 张照片`); if (gallery) gallery.hidden = expanded; stack.hidden = !expanded; });
+ this.listen(this, "click", (event) => {
+ const link = (event.target as HTMLElement).closest(".blora-image-stack__tile, .blora-image-gallery__item");
+ if (!link || !this.contains(link)) return;
+ event.preventDefault();
+ const links = Array.from(this.querySelectorAll(".blora-image-stack__tile, .blora-image-gallery__item"));
+ const items = this.definitions?.map((item) => ({ src: item.src, alt: item.alt })) ?? [];
+ const index = Math.max(0, links.indexOf(link) % Math.max(1, items.length));
+ openImagePreview(items, index);
+ });
+ }
+ protected onDisconnect(): void { this.controller?.destroy(); this.controller = null; }
+}
+
+export function defineBloraImageStack(registry: CustomElementRegistry = customElements): void { if (!registry || registry.get(BLORA_IMAGE_STACK_TAG)) return; registry.define(BLORA_IMAGE_STACK_TAG, BloraImageStack); }
diff --git a/packages/blora-design/src/components/image-stack/index.ts b/packages/blora-design/src/components/image-stack/index.ts
new file mode 100644
index 0000000..a05daf4
--- /dev/null
+++ b/packages/blora-design/src/components/image-stack/index.ts
@@ -0,0 +1,5 @@
+export {
+ BLORA_IMAGE_STACK_TAG,
+ BloraImageStack,
+ defineBloraImageStack,
+} from "./image-stack.js";
diff --git a/packages/blora-design/src/index.ts b/packages/blora-design/src/index.ts
index 285af74..5ba9b46 100644
--- a/packages/blora-design/src/index.ts
+++ b/packages/blora-design/src/index.ts
@@ -150,6 +150,11 @@ export {
defineBloraCalendar,
} from "./components/calendar/index.js";
export { BLORA_DECK_TAG, BloraDeck, defineBloraDeck } from "./components/deck/index.js";
+export {
+ BLORA_IMAGE_STACK_TAG,
+ BloraImageStack,
+ defineBloraImageStack,
+} from "./components/image-stack/index.js";
export {
BLORA_TAGS_INPUT_TAG,
BloraTagsInput,
diff --git a/packages/blora-design/src/locales/en.ts b/packages/blora-design/src/locales/en.ts
index 6247aac..d5655a3 100644
--- a/packages/blora-design/src/locales/en.ts
+++ b/packages/blora-design/src/locales/en.ts
@@ -151,6 +151,7 @@ export const en: BloraLocalePack = {
"mockup.label": "{variant} mockup",
"navbar.title": "Blora Design",
"deck.label": "Card stack",
+ "imageStack.label": "Image stack",
"dock.label": "Dock",
"diff.position": "Compare position",
"countdown.days": "Days",
diff --git a/packages/blora-design/src/locales/zh-CN.ts b/packages/blora-design/src/locales/zh-CN.ts
index 2578ad7..20a6168 100644
--- a/packages/blora-design/src/locales/zh-CN.ts
+++ b/packages/blora-design/src/locales/zh-CN.ts
@@ -138,6 +138,7 @@ export const zhCN: BloraLocalePack = {
"mockup.label": "{variant} 样机",
"navbar.title": "Blora Design",
"deck.label": "卡片叠层",
+ "imageStack.label": "图片堆叠",
"dock.label": "底部导航",
"diff.position": "对比位置",
"countdown.days": "天",
diff --git a/packages/blora-design/taxonomy.json b/packages/blora-design/taxonomy.json
index 6b5e598..1b036e0 100644
--- a/packages/blora-design/taxonomy.json
+++ b/packages/blora-design/taxonomy.json
@@ -30,6 +30,7 @@
"effects": "data-display",
"empty": "data-display",
"image": "data-display",
+ "image-stack": "data-display",
"list": "data-display",
"markdown": "data-display",
"media": "data-display",
@@ -147,6 +148,7 @@
"hero": "Layout/Hero",
"hover-gallery": "Data display/Hover Gallery",
"image": "Data display/Image",
+ "image-stack": "Data display/Image Stack",
"image-diff": "Data display/Diff",
"indicator": "Layout/Indicator",
"input": "Data input/Input",
diff --git a/packages/blora-design/tests/browser/composite-elements.spec.ts b/packages/blora-design/tests/browser/composite-elements.spec.ts
index a4fd980..310c088 100644
--- a/packages/blora-design/tests/browser/composite-elements.spec.ts
+++ b/packages/blora-design/tests/browser/composite-elements.spec.ts
@@ -724,6 +724,10 @@ test("Calendar, carousel, deck and image CEs own structure and interactions", as
One
Two
+
+
+
+
.blora-image-preview")).toBeVisible();
for (const selector of [
diff --git a/packages/blora-design/tests/composite-elements.test.ts b/packages/blora-design/tests/composite-elements.test.ts
index 0de58e6..49659d1 100644
--- a/packages/blora-design/tests/composite-elements.test.ts
+++ b/packages/blora-design/tests/composite-elements.test.ts
@@ -42,6 +42,7 @@ import { defineBloraTreeSelect } from "../src/components/tree-select/index.js";
import { defineBloraCalendar } from "../src/components/calendar/index.js";
import { defineBloraCarousel } from "../src/components/carousel/index.js";
import { defineBloraDeck } from "../src/components/deck/index.js";
+import { defineBloraImageStack } from "../src/components/image-stack/index.js";
import { defineBloraImage } from "../src/components/image/index.js";
import { defineBloraDock } from "../src/components/dock/index.js";
import { defineBloraMegamenu } from "../src/components/megamenu/index.js";
@@ -103,6 +104,7 @@ for (const define of [
defineBloraCalendar,
defineBloraCarousel,
defineBloraDeck,
+ defineBloraImageStack,
defineBloraImage,
defineBloraDock,
defineBloraMegamenu,
@@ -167,6 +169,7 @@ const DEFAULT_TAGS = [
"blora-calendar",
"blora-carousel",
"blora-deck",
+ "blora-image-stack",
"blora-image",
"blora-dock",
"blora-megamenu",
@@ -261,6 +264,7 @@ describe("Composite Custom Elements", () => {
"defineBloraCalendar",
"defineBloraCarousel",
"defineBloraDeck",
+ "defineBloraImageStack",
"defineBloraImage",
"defineBloraDock",
"defineBloraMegamenu",
diff --git a/packages/blora-design/tests/image-stack.test.ts b/packages/blora-design/tests/image-stack.test.ts
new file mode 100644
index 0000000..0ca69e4
--- /dev/null
+++ b/packages/blora-design/tests/image-stack.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { createImageStackController } from "../src/components/image-stack/image-stack.js";
+
+describe("createImageStackController", () => {
+ let root: HTMLElement;
+
+ beforeEach(() => {
+ root = document.createElement("div");
+ root.innerHTML = `
+
+
+
+
+
+ `;
+ document.body.append(root);
+ });
+
+ afterEach(() => root.remove());
+
+ it("keeps the current tile and boundary controls in sync", async () => {
+ const controller = createImageStackController(root);
+ expect(root.dataset.index).toBe("0");
+ expect(root.querySelectorAll('[data-current]')).toHaveLength(1);
+ expect(root.querySelectorAll('.is-hidden')).toHaveLength(0);
+ expect((root.querySelector(".blora-image-stack__nav--prev") as HTMLElement).hidden).toBe(true);
+ controller.next();
+ await new Promise((resolve) => setTimeout(resolve, 310));
+ expect(controller.getCurrent()).toBe(1);
+ expect(root.querySelector(".blora-image-stack__index")!.textContent).toBe("2 / 3");
+ expect((root.querySelector(".blora-image-stack__nav--prev") as HTMLElement).hidden).toBe(false);
+ controller.goTo(99);
+ await new Promise((resolve) => setTimeout(resolve, 310));
+ expect(controller.getCurrent()).toBe(2);
+ expect((root.querySelector(".blora-image-stack__nav--next") as HTMLElement).hidden).toBe(true);
+ controller.destroy();
+ });
+
+ it("navigates with keyboard and horizontal wheel input", async () => {
+ const controller = createImageStackController(root);
+ root.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 310));
+ expect(controller.getCurrent()).toBe(1);
+ const event = new WheelEvent("wheel", { deltaX: 140, cancelable: true });
+ root.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ await new Promise((resolve) => setTimeout(resolve, 310));
+ expect(controller.getCurrent()).toBe(2);
+ controller.destroy();
+ });
+});