/ Examples
carousel

Plugin Wizard

Discover vlist's composable plugins — scroll or use the arrows to browse each one. Uses the carousel() plugin for infinite-loop scrolling with snap-to-card.

Plugins

0% 0.00 / 0.00 px/ms 0 / 0 items
1 / 16
Source
// Plugin Explorer — Discover vlist's composable plugins
// Demonstrates scroll.wheel: false, wrap, button-only navigation

import { createVList, carousel } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";

import { PLUGINS } from "../../src/data/plugins.js";

const TOTAL = PLUGINS.length;

const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
  ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);

// =============================================================================
// Syntax highlighting (reused from code-explorer)
// =============================================================================

const KW = new Set([
  "export", "function", "const", "let", "var", "interface", "type", "class",
  "enum", "extends", "implements", "readonly", "async", "abstract", "static",
  "declare", "import", "from", "new", "return", "default", "of", "in", "await",
]);
const TYPES = new Set([
  "string", "number", "boolean", "void", "null", "undefined", "any",
  "unknown", "never", "true", "false", "Promise", "Array", "Record",
  "Set", "Map", "Partial", "Required", "Omit", "Pick",
]);
const TOKEN_RE = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b[A-Za-z_$][\w$]*\b)|(\d+(?:\.\d+)?)|([<>()[\]{};:,=|&?.]|=>|\.\.\.)/g;

function syntaxHighlight(raw) {
  let out = "";
  let last = 0;
  let m;
  TOKEN_RE.lastIndex = 0;
  while ((m = TOKEN_RE.exec(raw)) !== null) {
    if (m.index > last) out += esc(raw.slice(last, m.index));
    const [tok, str, ident, num, punct] = m;
    const e = esc(tok);
    if (str) out += `<span class="syn-str">${e}</span>`;
    else if (num) out += `<span class="syn-num">${e}</span>`;
    else if (punct) out += `<span class="syn-punct">${e}</span>`;
    else if (ident && KW.has(ident)) out += `<span class="syn-kw">${e}</span>`;
    else if (ident && TYPES.has(ident)) out += `<span class="syn-type">${e}</span>`;
    else out += e;
    last = m.index + tok.length;
  }
  if (last < raw.length) out += esc(raw.slice(last));
  return out;
}

// =============================================================================
// State
// =============================================================================

let currentIndex = 0;
let currentOrientation = "vertical";
let list = null;

// =============================================================================
// Template
// =============================================================================

const CATEGORY_COLORS = {
  Core: "#667eea",
  Layout: "#38bdf8",
  Interaction: "#a78bfa",
  Organization: "#34d399",
  Data: "#fb923c",
  Performance: "#f87171",
  UI: "#f472b6",
  State: "#facc15",
};

const itemTemplate = (item) => {
  const color = CATEGORY_COLORS[item.category] || "#94a3b8";
  const features = item.features.map((f) => `<li>${esc(f)}</li>`).join("");
  return `
    <div class="plugin-card">
      <div class="plugin-card__header">
        <div class="plugin-card__title-row">
          <h2 class="plugin-card__name">${esc(item.name)}</h2>
          <span class="plugin-card__size">${esc(item.size)}</span>
        </div>
        <span class="plugin-card__category" style="color:${color};border-color:${color}33;background:${color}14">${esc(item.category)}</span>
      </div>
      <p class="plugin-card__tagline">${esc(item.tagline)}</p>
      <p class="plugin-card__desc">${esc(item.description)}</p>
      <ul class="plugin-card__features">${features}</ul>
      <div class="plugin-card__code-wrap">
        <button class="plugin-card__copy" title="Copy code" data-code="${esc(item.code)}">Copy</button>
        <pre class="plugin-card__code"><code>${syntaxHighlight(item.code)}</code></pre>
      </div>
    </div>
  `;
};

// =============================================================================
// Stats
// =============================================================================

const ITEM_HEIGHT = 480;

const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => TOTAL,
  getItemSize: () => ITEM_HEIGHT,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create list
// =============================================================================

function createList() {
  if (list) {
    list.destroy();
    list = null;
  }

  const container = document.getElementById("list-container");
  container.innerHTML = "";

  const isH = currentOrientation === "horizontal";
  const wizardEl = document.querySelector(".wizard");
  wizardEl.classList.toggle("wizard--horizontal", isH);

  const containerWidth = isH ? container.clientWidth : undefined;

  list = createVList({
    container: "#list-container",
    orientation: currentOrientation,
    scroll: { scrollbar: "none" },
    ariaLabel: "Plugin explorer",
    item: {
      height: ITEM_HEIGHT,
      width: isH ? containerWidth : undefined,
      template: itemTemplate,
    },
    items: PLUGINS,
  }, [
    carousel({ variant: "static", snap: true, snapDuration: 400, initialIndex: currentIndex }),
  ]);

  list.on("scroll", updateInfo);
  list.on("range:change", updateInfo);
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });

  list.on("item:click", ({ index }) => goTo(index % TOTAL));

  // Update dots and panel when scrolling changes the active card
  list.on("carousel:change", ({ index }) => {
    currentIndex = index;
    updateCurrentInfo();
    updateDots();
    updateStep();
  });

  updateInfo();
}

// =============================================================================
// Navigation
// =============================================================================

function goTo(index, instant = false) {
  currentIndex = ((index % TOTAL) + TOTAL) % TOTAL;

  if (list) {
    list.goTo(currentIndex, {
      behavior: instant ? "auto" : "smooth",
      duration: instant ? 0 : 400,
    });
  }

  updateCurrentInfo();
  updateDots();
  updateInfo();
  updateStep();
}

// =============================================================================
// Step indicator dots
// =============================================================================

const indicatorEl = document.getElementById("step-indicator");

function updateDots() {
  indicatorEl.innerHTML = PLUGINS
    .map((_, i) =>
      `<span class="dot ${i === currentIndex ? "dot-active" : ""}" data-index="${i}"></span>`,
    )
    .join("");
}

indicatorEl.addEventListener("click", (e) => {
  const dot = e.target.closest(".dot");
  if (dot) goTo(Number(dot.dataset.index));
});

// =============================================================================
// Current plugin info (panel)
// =============================================================================

const currentNameEl = document.getElementById("current-name");
const currentSizeEl = document.getElementById("current-size");
const currentCategoryEl = document.getElementById("current-category");
const infoStepEl = document.getElementById("info-step");

function updateCurrentInfo() {
  const p = PLUGINS[currentIndex];
  currentNameEl.textContent = p.name;
  currentSizeEl.textContent = p.size;
  currentCategoryEl.textContent = p.category;
}

function updateStep() {
  infoStepEl.textContent = `${currentIndex + 1} / ${TOTAL}`;
}

// =============================================================================
// Controls — prev/next/first/last/random
// =============================================================================

document.getElementById("btn-prev").addEventListener("click", () => goTo(currentIndex - 1));
document.getElementById("btn-next").addEventListener("click", () => goTo(currentIndex + 1));
document.getElementById("btn-first").addEventListener("click", () => goTo(0));
document.getElementById("btn-last").addEventListener("click", () => goTo(TOTAL - 1));
document.getElementById("btn-random").addEventListener("click", () =>
  goTo(Math.floor(Math.random() * TOTAL)),
);

// =============================================================================
// Copy button
// =============================================================================

document.getElementById("list-container").addEventListener("click", (e) => {
  const btn = e.target.closest(".plugin-card__copy");
  if (!btn) return;
  const code = btn.getAttribute("data-code");
  navigator.clipboard.writeText(code).then(() => {
    btn.textContent = "Copied!";
    setTimeout(() => { btn.textContent = "Copy"; }, 1500);
  });
});

// =============================================================================
// Orientation toggle
// =============================================================================

document.getElementById("orientation-mode").addEventListener("click", (e) => {
  const btn = e.target.closest("[data-orientation]");
  if (!btn) return;
  const orientation = btn.dataset.orientation;
  if (orientation === currentOrientation) return;

  currentOrientation = orientation;
  document.querySelectorAll("#orientation-mode .ui-segmented__btn").forEach((b) => {
    b.classList.toggle("ui-segmented__btn--active", b.dataset.orientation === orientation);
  });

  createList();
});

// =============================================================================
// Initialise
// =============================================================================

createList();
/* Plugin Explorer — example styles */

/* ============================================================================
   Wizard layout — prev | cards | next
   ============================================================================ */

.wizard {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 24px 16px;
    height: 100%;
}

.wizard__center {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

#list-container {
    height: 480px;
    border-radius: 16px;
    width: auto;
    margin: 0;
}

#list-container .vlist {
    border: none;
    border-radius: 16px;
    background: transparent;
}

.wizard--horizontal #list-container {
    height: 480px;
}

#list-container .vlist-item {
    padding: 0;
    border: none;
    cursor: default;
}

/* ============================================================================
   Navigation buttons (prev / next)
   ============================================================================ */

.nav-btn {
    flex-shrink: 0;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--bg-card);
    color: var(--text);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s ease;
}

.nav-btn .icon {
    width: 18px;
    height: 18px;
}

.nav-btn:hover {
    background: var(--bg-card-hover);
    border-color: var(--border-hover);
}

.nav-btn:active {
    background: var(--border);
    transform: scale(0.95);
}

/* ============================================================================
   Step indicator dots
   ============================================================================ */

.step-indicator {
    display: flex;
    justify-content: center;
    gap: 6px;
    padding: 4px 0;
}

.dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--border);
    cursor: pointer;
    transition: all 0.25s ease;
}

.dot:hover {
    background: var(--text-dim);
    transform: scale(1.3);
}

.dot-active {
    background: var(--accent);
    transform: scale(1.3);
}

.dot-active:hover {
    background: var(--accent);
}

/* ============================================================================
   Plugin card
   ============================================================================ */

.plugin-card {
    height: 100%;
    max-width: 600px;
    margin: 0 auto;
    padding: 24px 28px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    box-sizing: border-box;
    overflow-y: auto;
}

.plugin-card__header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
}

.plugin-card__title-row {
    display: flex;
    align-items: baseline;
    gap: 10px;
}

.plugin-card__name {
    font-size: 22px;
    font-weight: var(--fw-bold);
    color: var(--text);
    letter-spacing: -0.3px;
    font-family: var(--font-mono);
}

.plugin-card__size {
    font-size: 13px;
    font-weight: 600;
    color: var(--text-dim);
    white-space: nowrap;
}

.plugin-card__category {
    flex-shrink: 0;
    padding: 2px 10px;
    border-radius: 20px;
    font-size: 11px;
    font-weight: 600;
    border: 1px solid;
    white-space: nowrap;
}

.plugin-card__tagline {
    font-size: 15px;
    font-weight: 600;
    color: var(--accent);
    line-height: 1.3;
}

.plugin-card__desc {
    font-size: 13px;
    color: var(--text-muted);
    line-height: 1.6;
}

.plugin-card__features {
    list-style: none;
    padding: 0;
    margin: 4px 0 0;
    display: flex;
    flex-direction: column;
    gap: 3px;
}

.plugin-card__features li {
    font-size: 12px;
    color: var(--text-dim);
    line-height: 1.4;
    padding-left: 16px;
    position: relative;
}

.plugin-card__features li::before {
    content: "✓";
    position: absolute;
    left: 0;
    color: var(--accent);
    font-weight: 600;
    font-size: 11px;
}

.plugin-card__code-wrap {
    position: relative;
    margin: auto 0 0;
}

.plugin-card__copy {
    position: absolute;
    top: 6px;
    right: 6px;
    padding: 3px 10px;
    border-radius: 5px;
    border: 1px solid var(--border);
    background: var(--bg-card, rgba(255, 255, 255, 0.06));
    color: var(--text-dim);
    font-size: 11px;
    font-weight: 600;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.15s ease, background 0.15s ease;
    z-index: 1;
}

.plugin-card__code-wrap:hover .plugin-card__copy {
    opacity: 1;
}

.plugin-card__copy:hover {
    background: var(--bg-card-hover, rgba(255, 255, 255, 0.12));
}

.plugin-card__code {
    padding: 10px 14px;
    border-radius: 8px;
    background: var(--bg-code, rgba(0, 0, 0, 0.15));
    border: 1px solid var(--border);
    overflow-x: auto;
    font-size: 13px;
    line-height: 1.5;
    color: var(--text-dim);
    font-family: var(--font-mono);
    font-weight: 500;
    white-space: pre;
}

[data-theme-mode="light"] .plugin-card__code {
    background: rgba(0, 0, 0, 0.04);
}

/* Syntax highlighting (One Dark / One Light) */
.syn-kw {
    color: #c678dd;
}
.syn-type {
    color: #e6c07b;
}
.syn-str {
    color: #98c379;
}
.syn-num {
    color: #d19a66;
}
.syn-punct {
    color: #abb2bf;
}

[data-theme-mode="light"] .syn-kw {
    color: #a626a4;
}
[data-theme-mode="light"] .syn-type {
    color: #c18401;
}
[data-theme-mode="light"] .syn-str {
    color: #50a14f;
}
[data-theme-mode="light"] .syn-num {
    color: #986801;
}
[data-theme-mode="light"] .syn-punct {
    color: #383a42;
}

.split-main.split-main--full {
    height: 600px;
}
<div class="container">
    <header>
        <h1>Plugin Wizard</h1>
        <p class="description">
            Discover vlist's composable plugins — scroll or use the arrows
            to browse each one. Uses the <code>carousel()</code> plugin
            for infinite-loop scrolling with snap-to-card.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main split-main--full">
            <h2 class="sr-only">Plugins</h2>
            <div class="wizard">
                <button class="nav-btn nav-prev" id="btn-prev">
                    <i class="icon icon--back"></i>
                </button>

                <div class="wizard__center">
                    <div id="list-container"></div>
                    <div class="step-indicator" id="step-indicator"></div>
                </div>

                <button class="nav-btn nav-next" id="btn-next">
                    <i class="icon icon--forward"></i>
                </button>
            </div>
        </div>

        <aside class="split-panel">
            <!-- Layout -->
            <section class="ui-section">
                <h3 class="ui-title">Layout</h3>
                <div class="ui-row">
                    <label class="ui-label">Orientation</label>
                    <div class="ui-segmented" id="orientation-mode">
                        <button class="ui-segmented__btn ui-segmented__btn--active" data-orientation="vertical">Y</button>
                        <button class="ui-segmented__btn" data-orientation="horizontal">X</button>
                    </div>
                </div>
            </section>

            <!-- Navigation -->
            <section class="ui-section">
                <h3 class="ui-title">Navigation</h3>
                <div class="ui-row">
                    <div class="ui-btn-group">
                        <button id="btn-first" class="ui-btn ui-btn--icon" title="First">
                            <i class="icon icon--up"></i>
                        </button>
                        <button id="btn-last" class="ui-btn ui-btn--icon" title="Last">
                            <i class="icon icon--down"></i>
                        </button>
                        <button id="btn-random" class="ui-btn ui-btn--icon" title="Random">
                            <i class="icon icon--shuffle"></i>
                        </button>
                    </div>
                </div>
            </section>

            <!-- Current Plugin -->
            <section class="ui-section">
                <h3 class="ui-title">Current Plugin</h3>
                <div class="ui-row">
                    <span class="ui-label">Name</span>
                    <span class="ui-value" id="current-name">-</span>
                </div>
                <div class="ui-row">
                    <span class="ui-label">Size</span>
                    <span class="ui-value" id="current-size">-</span>
                </div>
                <div class="ui-row">
                    <span class="ui-label">Category</span>
                    <span class="ui-value" id="current-category">-</span>
                </div>
            </section>
        </aside>
    </div>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">items</span>
            </span>
        </div>
        <div class="example-info__right">
            <span class="example-info__stat">
                <strong id="info-step">1 / 16</strong>
            </span>
        </div>
    </div>
</div>