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;
}
}