@blossom-carousel/svelte
Version:
A native-scroll-first carousel component for Svelte.
98 lines (97 loc) • 3.85 kB
JavaScript
import { pageScroll } from "./scroll.js";
import { getMarkerTargets } from "./markers.js";
import { resolveInlineAlign } from "./snap.js";
import { getSnapCache } from "./cache.js";
const COMMAND_NAMESPACE = "--blossom-";
export const COMMANDS = {
prev: `${COMMAND_NAMESPACE}prev`,
next: `${COMMAND_NAMESPACE}next`,
gotoPrefix: `${COMMAND_NAMESPACE}goto-`,
};
const REGISTRY = Symbol.for("blossom-carousel.navigation.registry");
function runCommand(scroller, command) {
if (command === COMMANDS.prev) {
pageScroll(scroller, "prev");
}
else if (command === COMMANDS.next) {
pageScroll(scroller, "next");
}
else if (command.startsWith(COMMANDS.gotoPrefix)) {
const index = Number.parseInt(command.slice(COMMANDS.gotoPrefix.length), 10);
if (Number.isNaN(index) || index < 0)
return;
// Prefer the cache, but ignore a stale entry whose element has been detached
// (a slide removed before the cache refresh runs) and read live instead.
const cached = getSnapCache(scroller)?.markers[index];
const fresh = cached?.el.isConnected ? cached : undefined;
const target = fresh?.el ?? getMarkerTargets(scroller)[index];
if (!target)
return;
const inline = fresh?.align ?? resolveInlineAlign(getComputedStyle(target).scrollSnapAlign);
target.scrollIntoView({ block: "nearest", inline, behavior: "smooth" });
}
}
/**
* Attaches a single `command` listener to a scroller that handles the Blossom
* navigation commands using native scroll APIs only (no Blossom instance).
*
* The native `command` event does not bubble, so the listener must live on the
* target element itself. Registration is idempotent and ref-counted so multiple
* controls pointing at the same scroller share one handler; the returned cleanup
* detaches the handler once the last control releases it.
*/
export function registerCommands(scroller) {
installFallback();
const el = scroller;
const existing = el[REGISTRY];
if (existing) {
existing.count++;
return () => release(el);
}
const handler = (event) => {
const command = event.command ??
event.detail?.command;
if (typeof command === "string" && command.startsWith(COMMAND_NAMESPACE)) {
runCommand(scroller, command);
}
};
scroller.addEventListener("command", handler);
el[REGISTRY] = { count: 1, handler };
return () => release(el);
}
function release(el) {
const registration = el[REGISTRY];
if (!registration)
return;
registration.count--;
if (registration.count <= 0) {
el.removeEventListener("command", registration.handler);
delete el[REGISTRY];
}
}
/**
* Manual fallback for engines without the Invoker Commands API: delegate clicks
* on `button[commandfor]` and dispatch a synthetic `command` event on the
* target, matching the native event shape. Installed once, only when needed.
*/
let fallbackInstalled = false;
function installFallback() {
if (fallbackInstalled || typeof document === "undefined")
return;
fallbackInstalled = true;
const supportsInvokers = typeof HTMLButtonElement !== "undefined" &&
"commandForElement" in HTMLButtonElement.prototype;
if (supportsInvokers)
return;
document.addEventListener("click", (event) => {
const button = event.target?.closest?.("button[commandfor]");
if (!button)
return;
const id = button.getAttribute("commandfor");
const command = button.getAttribute("command");
if (!id || !command)
return;
const target = document.getElementById(id);
target?.dispatchEvent(new CustomEvent("command", { bubbles: false, detail: { command } }));
});
}