/ Examples
gridmasonryscrollbar
Source
// Photo Album — Vue variant
// Uses useVList composable from vlist-vue with declarative layout config
// Layout mode toggle: Grid ↔ Masonry

import { createApp, ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useVList, useVListEvent } from "vlist-vue";
import { ITEM_COUNT, ASPECT_RATIO, items, itemTemplate } from "../shared.js";
import { createStats } from "../../stats.js";

// =============================================================================
// Item config helpers
// =============================================================================

function getItemConfig(mode, orientation) {
  if (mode === "masonry") {
    return {
      height: (_index, ctx) =>
        ctx ? Math.round(ctx.columnWidth * items[_index].aspectRatio) : 200,
      width:
        orientation === "horizontal"
          ? (_index, ctx) =>
              ctx
                ? Math.round(ctx.columnWidth * items[_index].aspectRatio)
                : 200
          : undefined,
      template: itemTemplate,
    };
  }

  // Grid — horizontal needs fixed cross-axis height
  if (orientation === "horizontal") {
    return {
      height: 200,
      width: (_index, ctx) =>
        ctx ? Math.round(ctx.columnWidth * (4 / 3)) : 200,
      template: itemTemplate,
    };
  }

  return {
    height: (_index, ctx) =>
      ctx ? Math.round(ctx.columnWidth * ASPECT_RATIO) : 200,
    template: itemTemplate,
  };
}

// =============================================================================
// Stats (module-level)
// =============================================================================

let statsInstance = null;

// =============================================================================
// App
// =============================================================================

const App = {
  setup() {
    const mode = ref("grid");
    const orientation = ref("vertical");
    const columns = ref(4);
    const gap = ref(8);
    const selectedPhoto = ref(null);

    // Computed layout config for useVList
    const vlistConfig = computed(() => {
      const m = mode.value;
      const o = orientation.value;
      const c = columns.value;
      const g = gap.value;

      const layoutConfig =
        m === "masonry"
          ? { layout: "masonry", masonry: { columns: c, gap: g } }
          : { layout: "grid", grid: { columns: c, gap: g } };

      return {
        ariaLabel: "Photo gallery",
        orientation: o,
        ...layoutConfig,
        item: getItemConfig(m, o),
        items,
        scroll: {
          scrollbar: { autoHide: true },
        },
      };
    });

    const containerRef = ref(null);
    const instance = ref(null);

    // Manual lifecycle — useVList doesn't support config changes (needs remount)
    let cleanup = null;

    function mount() {
      if (!containerRef.value) return;

      const config = vlistConfig.value;
      const { useVList: _, ...rest } = config; // just use config directly

      // Import and build manually since useVList is designed for single mount
      import("vlist").then(
        ({ vlist, withGrid, withMasonry, withScrollbar }) => {
          let builder = vlist({
            ...config,
            container: containerRef.value,
          });

          if (config.layout === "grid" && config.grid) {
            builder = builder.use(withGrid(config.grid));
          }
          if (config.layout === "masonry" && config.masonry) {
            builder = builder.use(withMasonry(config.masonry));
          }
          builder = builder.use(withScrollbar({ autoHide: true }));

          const inst = builder.build();
          instance.value = inst;

          // Stats
          if (!statsInstance) {
            statsInstance = createStats({
              getList: () => instance.value,
              getTotal: () => ITEM_COUNT,
              getItemHeight: () => {
                const el = containerRef.value;
                if (!el) return 200;
                const innerWidth = el.clientWidth - 2;
                const colW =
                  (innerWidth - (columns.value - 1) * gap.value) /
                  columns.value;
                return mode.value === "masonry"
                  ? Math.round(colW * 1.05)
                  : Math.round(colW * ASPECT_RATIO);
              },
              container: "#grid-container",
            });
          }

          // Events
          inst.on("scroll", () => statsInstance?.scheduleUpdate());
          inst.on("range:change", () => statsInstance?.scheduleUpdate());
          inst.on("velocity:change", ({ velocity }) =>
            statsInstance?.onVelocity(velocity),
          );
          inst.on("item:click", ({ item }) => {
            selectedPhoto.value = item;
          });

          statsInstance.update();
          updateFooterContext();
        },
      );
    }

    function unmount() {
      if (instance.value) {
        instance.value.destroy();
        instance.value = null;
      }
      if (containerRef.value) {
        containerRef.value.innerHTML = "";
      }
    }

    function updateFooterContext() {
      const ftMode = document.getElementById("ft-mode");
      const ftOrientation = document.getElementById("ft-orientation");
      if (ftMode) ftMode.textContent = mode.value;
      if (ftOrientation) ftOrientation.textContent = orientation.value;
    }

    // Recreate on config change
    watch([mode, orientation, columns, gap], () => {
      unmount();
      mount();
    });

    onMounted(() => mount());
    onUnmounted(() => unmount());

    // Navigation
    const scrollToFirst = () => instance.value?.scrollToIndex(0, "start");
    const scrollToMiddle = () =>
      instance.value?.scrollToIndex(Math.floor(ITEM_COUNT / 2), "center");
    const scrollToLast = () =>
      instance.value?.scrollToIndex(ITEM_COUNT - 1, "end");

    return {
      mode,
      orientation,
      columns,
      gap,
      selectedPhoto,
      containerRef,
      scrollToFirst,
      scrollToMiddle,
      scrollToLast,
      ITEM_COUNT,
    };
  },

  template: `
    <div class="container">
      <header>
        <h1>Photo Album</h1>
        <p class="description">
          Virtualized 2D photo gallery with real images from Lorem Picsum.
          Toggle between grid and masonry layouts, adjust columns and gap —
          only visible rows are rendered.
        </p>
      </header>

      <div class="split-layout">
        <div class="split-main split-main--full">
          <div ref="containerRef" id="grid-container"></div>
        </div>

        <aside class="split-panel">
          <!-- Layout -->
          <section class="panel-section">
            <h3 class="panel-title">Layout</h3>

            <div class="panel-row">
              <label class="panel-label">Mode</label>
              <div class="panel-segmented">
                <button
                  v-for="m in ['grid', 'masonry']"
                  :key="m"
                  :class="['panel-segmented__btn', { 'panel-segmented__btn--active': m === mode }]"
                  @click="mode = m"
                >
                  {{ m === 'grid' ? 'Grid' : 'Masonry' }}
                </button>
              </div>
            </div>

            <div class="panel-row">
              <label class="panel-label">Orientation</label>
              <div class="panel-segmented">
                <button
                  v-for="o in ['vertical', 'horizontal']"
                  :key="o"
                  :class="['panel-segmented__btn', { 'panel-segmented__btn--active': o === orientation }]"
                  @click="orientation = o"
                >
                  {{ o === 'vertical' ? 'Vertical' : 'Horizontal' }}
                </button>
              </div>
            </div>

            <div class="panel-row">
              <label class="panel-label">{{ orientation === 'horizontal' ? 'Rows' : 'Columns' }}</label>
              <div class="panel-btn-group">
                <button
                  v-for="c in [3, 4, 5, 6, 10]"
                  :key="c"
                  :class="['ctrl-btn', { 'ctrl-btn--active': c === columns }]"
                  @click="columns = c"
                >
                  {{ c }}
                </button>
              </div>
            </div>

            <div class="panel-row">
              <label class="panel-label">Gap</label>
              <div class="panel-btn-group">
                <button
                  v-for="g in [0, 4, 8, 12, 16]"
                  :key="g"
                  :class="['ctrl-btn', { 'ctrl-btn--active': g === gap }]"
                  @click="gap = g"
                >
                  {{ g }}
                </button>
              </div>
            </div>
          </section>

          <!-- Navigation -->
          <section class="panel-section">
            <h3 class="panel-title">Navigation</h3>
            <div class="panel-row">
              <div class="panel-btn-group">
                <button class="panel-btn panel-btn--icon" title="First" @click="scrollToFirst">
                  <i class="icon icon--up"></i>
                </button>
                <button class="panel-btn panel-btn--icon" title="Middle" @click="scrollToMiddle">
                  <i class="icon icon--center"></i>
                </button>
                <button class="panel-btn panel-btn--icon" title="Last" @click="scrollToLast">
                  <i class="icon icon--down"></i>
                </button>
              </div>
            </div>
          </section>

          <!-- Photo Detail -->
          <section class="panel-section">
            <h3 class="panel-title">Last clicked</h3>
            <div class="panel-detail">
              <template v-if="selectedPhoto">
                <img
                  class="detail__img"
                  :src="'https://picsum.photos/id/' + selectedPhoto.picId + '/400/300'"
                  :alt="selectedPhoto.title"
                />
                <div class="detail__meta">
                  <strong>{{ selectedPhoto.title }}</strong>
                  <span>{{ selectedPhoto.category }} · ♥ {{ selectedPhoto.likes }}</span>
                </div>
              </template>
              <span v-else class="panel-detail__empty">Click a photo to see details</span>
            </div>
          </section>
        </aside>
      </div>

      <footer class="example-footer" id="example-footer">
        <div class="example-footer__left">
          <span class="example-footer__stat">
            <strong id="ft-progress">0%</strong>
          </span>
          <span class="example-footer__stat">
            <span id="ft-velocity">0.00</span> /
            <strong id="ft-velocity-avg">0.00</strong>
            <span class="example-footer__unit">px/ms</span>
          </span>
          <span class="example-footer__stat">
            <span id="ft-dom">0</span> /
            <strong id="ft-total">0</strong>
            <span class="example-footer__unit">items</span>
          </span>
        </div>
        <div class="example-footer__right">
          <span class="example-footer__stat">
            <strong id="ft-mode">grid</strong>
          </span>
          <span class="example-footer__stat">
            <strong id="ft-orientation">vertical</strong>
          </span>
        </div>
      </footer>
    </div>
  `,
};

// =============================================================================
// Mount
// =============================================================================

createApp(App).mount("#vue-root");
<div id="vue-root"></div>
// Photo Album — Shared data, constants, template, and state
// Imported by all framework implementations to avoid duplication

// =============================================================================
// Constants
// =============================================================================

export const PHOTO_COUNT = 1084;
export const ITEM_COUNT = 600;
export const ASPECT_RATIO = 0.75; // 4:3 landscape

const CATEGORIES = [
  "Nature",
  "Urban",
  "Portrait",
  "Abstract",
  "Travel",
  "Food",
  "Animals",
  "Architecture",
  "Art",
  "Space",
];

// Variable aspect ratios for masonry mode (height/width)
const ASPECT_RATIOS = [0.75, 1.0, 1.33, 1.5, 0.66];

// =============================================================================
// Data
// =============================================================================

export const items = Array.from({ length: ITEM_COUNT }, (_, i) => {
  const picId = i % PHOTO_COUNT;
  const category = CATEGORIES[i % CATEGORIES.length];
  return {
    id: i + 1,
    title: `Photo ${i + 1}`,
    category,
    likes: Math.floor(Math.abs(Math.sin(i * 2.1)) * 500),
    picId,
    aspectRatio: ASPECT_RATIOS[i % ASPECT_RATIOS.length],
  };
});

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

export const itemTemplate = (item) => `
  <div class="card">
    <img
      class="card__img"
      src="https://picsum.photos/id/${item.picId}/300/225"
      alt="${item.title}"
      loading="lazy"
      decoding="async"
    />
    <div class="card__overlay">
      <span class="card__title">${item.title}</span>
      <span class="card__category">${item.category}</span>
    </div>
    <div class="card__likes">♥ ${item.likes}</div>
  </div>
`;

// =============================================================================
// State — mutable, shared across script.js and controls.js
// =============================================================================

export let currentMode = "grid";
export let currentOrientation = "vertical";
export let currentColumns = 4;
export let currentGap = 8;
export let list = null;

export function setCurrentMode(v) {
  currentMode = v;
}
export function setCurrentOrientation(v) {
  currentOrientation = v;
}
export function setCurrentColumns(v) {
  currentColumns = v;
}
export function setCurrentGap(v) {
  currentGap = v;
}
export function setList(v) {
  list = v;
}

// =============================================================================
// View lifecycle — set by each variant's script.js
// =============================================================================

let _createView = () => {};

export function createView() {
  _createView();
}

export function setCreateView(fn) {
  _createView = fn;
}
/* Photo Album — shared styles for all variants (javascript, react, vue, svelte)
   Common styles (.container, h1, .description, .stats, footer)
   are provided by example/example.css using shell.css design tokens.
   Panel system (.split-layout, .split-panel, .panel-*)
   is also provided by example/example.css. */

/* Grid container */
#grid-container {
    height: 600px;
    margin: 0 auto;
}

/* Horizontal orientation - swap dimensions */
#grid-container.vlist--horizontal {
    height: 300px;
    width: 100%;
}

#grid-container.vlist--horizontal .vlist-viewport {
    overflow-x: auto;
    overflow-y: hidden;
}

/* ============================================================================
   Grid Info Bar
   ============================================================================ */

.grid-info {
    padding: 8px 14px;
    margin-bottom: 16px;
    border-radius: 8px;
    border: 1px solid var(--border);
    background: var(--bg-card);
    font-size: 13px;
    color: var(--text-muted);
}

.grid-info strong {
    color: var(--text);
}

/* ============================================================================
   Control Buttons (columns / gap)
   ============================================================================ */

.ctrl-btn {
    padding: 6px 14px;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: var(--bg-card);
    color: var(--text-muted);
    font-size: 13px;
    font-weight: 600;
    font-family: inherit;
    cursor: pointer;
    transition: all 0.15s ease;
    min-width: 36px;
    text-align: center;
}

.ctrl-btn:hover {
    border-color: var(--accent);
    color: var(--accent-text);
}

.ctrl-btn--active {
    background: var(--accent);
    color: white;
    border-color: var(--accent);
}

/* ============================================================================
   Photo Card (inside grid items)
   ============================================================================ */

.card {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    border-radius: 8px;
    background: var(--bg-card);
    cursor: pointer;
}

.card__img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    transition: transform 0.25s ease;
}

.card:hover .card__img {
    transform: scale(1.05);
}

.card__overlay {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: 24px 8px 8px;
    background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
    display: flex;
    flex-direction: column;
    gap: 2px;
    opacity: 0;
    transition: opacity 0.2s ease;
}

.card:hover .card__overlay {
    opacity: 1;
}

.card__title {
    font-size: 12px;
    font-weight: 600;
    color: white;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.card__category {
    font-size: 10px;
    color: rgba(255, 255, 255, 0.7);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.card__likes {
    position: absolute;
    top: 6px;
    right: 6px;
    padding: 2px 8px;
    border-radius: 10px;
    background: rgba(0, 0, 0, 0.5);
    color: white;
    font-size: 11px;
    font-weight: 600;
    opacity: 0;
    transition: opacity 0.2s ease;
}

.card:hover .card__likes {
    opacity: 1;
}

/* ============================================================================
   Photo Detail (panel)
   ============================================================================ */

.detail__img {
    width: 100%;
    border-radius: 8px;
    display: block;
    margin-bottom: 8px;
}

.detail__meta {
    display: flex;
    flex-direction: column;
    gap: 2px;
    font-size: 13px;
}

.detail__meta strong {
    font-weight: 600;
}

.detail__meta span {
    font-size: 12px;
    color: var(--text-muted);
}

/* ============================================================================
   vlist grid overrides — remove item borders/padding for photo cards
   ============================================================================ */

#grid-container .vlist-item {
    padding: 0;
    border: none;
    background: transparent;
}

#grid-container .vlist-item:hover {
    background: transparent;
}

/* ============================================================================
   Responsive
   ============================================================================ */

@media (max-width: 820px) {
    #grid-container {
        height: 400px;
    }

    #grid-container.vlist--horizontal {
        height: 250px;
    }
}