scroll.wheelselection
Wizard
Step-by-step recipe viewer with scroll.wheel: false
— navigation via buttons and keyboard only. Uses
withSelection and circular wrap mode.
Steps
0%
0.00 /
0.00
px/ms
0 /
0
items
vertical
wrap on
Source
// Wizard — Step-by-step recipe viewer
// Demonstrates scroll.wheel: false, wrap, button-only navigation
import { vlist } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import "./controls.js";
// =============================================================================
// Data — fetched from /api/recipes
// =============================================================================
export let recipes = [];
export let TOTAL = 0;
export const ITEM_HEIGHT = 320;
async function fetchRecipes() {
try {
const response = await fetch("/api/recipes");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
recipes = await response.json();
TOTAL = recipes.length;
} catch (err) {
console.error("Failed to fetch recipes:", err);
recipes = [];
TOTAL = 0;
}
}
// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================
export let currentOrientation = "vertical"; // "vertical" | "horizontal"
export let currentWrap = true;
export let currentIndex = 0;
export let list = null;
export function setCurrentOrientation(v) {
currentOrientation = v;
}
export function setCurrentWrap(v) {
currentWrap = v;
}
// =============================================================================
// Template
// =============================================================================
const itemTemplate = (item) => `
<div class="recipe-card">
<div class="recipe-header">
<span class="recipe-emoji">${item.emoji}</span>
<div class="recipe-meta">
<span class="ui-badge ui-badge--pill meta-time">⏱ ${item.time}</span>
<span class="ui-badge ui-badge--pill meta-difficulty">${item.difficulty}</span>
</div>
</div>
<h2 class="recipe-title">${item.title}</h2>
<p class="recipe-origin">${item.origin}</p>
<div class="recipe-section">
<h3>Ingredients</h3>
<p>${item.ingredients}</p>
</div>
<div class="recipe-tip">
<span class="tip-icon">💡</span>
<p>${item.tip}</p>
</div>
</div>
`;
// =============================================================================
// Stats — shared footer (progress, velocity, visible/total)
// =============================================================================
export const stats = createStats({
getScrollPosition: () => list?.getScrollPosition() ?? 0,
getTotal: () => TOTAL,
getItemSize: () => ITEM_HEIGHT,
getContainerSize: () =>
document.querySelector("#list-container")?.clientHeight ?? 0,
});
const updateInfo = createInfoUpdater(stats);
// =============================================================================
// Create / Recreate list
// =============================================================================
export function createList() {
if (list) {
list.destroy();
list = null;
}
const container = document.getElementById("list-container");
container.innerHTML = "";
// Toggle horizontal class on wizard wrapper
const wizardEl = document.querySelector(".wizard");
const isH = currentOrientation === "horizontal";
wizardEl.classList.toggle("wizard--horizontal", isH);
// In horizontal mode, item width = container width so one card fills the view
const containerWidth = isH ? container.clientWidth : undefined;
const builder = vlist({
container: "#list-container",
orientation: currentOrientation,
scroll: { wheel: false, scrollbar: "none", wrap: currentWrap },
ariaLabel: "Recipe wizard",
item: {
height: ITEM_HEIGHT,
width: isH ? containerWidth : undefined,
template: itemTemplate,
},
items: recipes,
});
list = builder.build();
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);
});
updateInfo();
updateContext();
// Restore current index (instant — no animation after rebuild)
goTo(currentIndex, true);
}
// =============================================================================
// Navigation — go to a specific recipe
// =============================================================================
export function goTo(index, instant = false) {
// scroll.wrap handles modulo internally — pass the raw index
list.scrollToIndex(index, {
align: "start",
behavior: instant ? "auto" : "smooth",
duration: instant ? 0 : 350,
});
// Resolve index for UI (dots, recipe info panel)
currentIndex =
currentWrap && TOTAL > 0
? ((index % TOTAL) + TOTAL) % TOTAL
: Math.max(0, Math.min(index, TOTAL - 1));
updateCurrentInfo();
updateDots();
updateInfo();
}
// =============================================================================
// Step indicator dots
// =============================================================================
const indicatorEl = document.getElementById("step-indicator");
function updateDots() {
indicatorEl.innerHTML = recipes
.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 recipe info (panel)
// =============================================================================
const currentNameEl = document.getElementById("current-name");
const currentDifficultyEl = document.getElementById("current-difficulty");
const currentTimeEl = document.getElementById("current-time");
function updateCurrentInfo() {
const r = recipes[currentIndex];
currentNameEl.textContent = `${r.emoji} ${r.title}`;
currentDifficultyEl.textContent = r.difficulty;
currentTimeEl.textContent = r.time;
}
// =============================================================================
// Keyboard navigation
// =============================================================================
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
goTo(currentIndex - 1);
} else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
goTo(currentIndex + 1);
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(TOTAL - 1);
}
});
// =============================================================================
// Footer — right side (contextual)
// =============================================================================
const infoOrientation = document.getElementById("info-orientation");
const infoWrap = document.getElementById("info-wrap");
export function updateContext() {
infoOrientation.textContent = currentOrientation;
infoWrap.textContent = currentWrap ? "on" : "off";
}
// =============================================================================
// Initialise
// =============================================================================
(async () => {
await fetchRecipes();
createList();
})();
/* Wizard — example styles */
/* ============================================================================
Wizard layout — prev | cards | next
============================================================================ */
.wizard {
display: flex;
align-items: center;
gap: 12px;
padding: 96px 16px;
height: 100%;
}
.wizard__center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
#list-container {
height: 320px;
border-radius: 16px;
width: auto;
margin: 0;
}
/* Horizontal orientation — same arrow layout, list scrolls horizontally */
.wizard--horizontal #list-container {
height: 320px;
}
/* Override vlist defaults */
#list-container .vlist {
border: none;
border-radius: 16px;
background: transparent;
}
#list-container .vlist-item {
padding: 0;
border: none;
cursor: default;
}
#list-container .vlist-item[aria-selected="true"] {
background: transparent;
}
/* ============================================================================
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);
}
/* ============================================================================
Recipe card
============================================================================ */
.recipe-card {
height: 100%;
max-width: 560px;
margin: 0 auto;
padding: 28px 32px;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
}
.recipe-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.recipe-emoji {
font-size: 44px;
line-height: 1;
}
.recipe-meta {
display: flex;
gap: 6px;
}
.meta-time {
background: var(--accent-dim);
color: var(--accent-text);
}
.meta-difficulty {
background: rgba(16, 163, 74, 0.1);
color: #16a34a;
}
[data-theme="dark"] .meta-difficulty {
background: rgba(74, 222, 128, 0.1);
color: #4ade80;
}
.recipe-title {
font-size: 22px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.3px;
}
.recipe-origin {
font-size: 14px;
color: var(--text-dim);
margin-top: -4px;
}
.recipe-section h3 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-dim);
margin-bottom: 4px;
}
.recipe-section p {
font-size: 14px;
color: var(--text-muted);
line-height: 1.5;
}
.recipe-tip {
display: flex;
align-items: flex-start;
gap: 8px;
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.2);
border-radius: 10px;
padding: 10px 14px;
margin-top: auto;
}
.tip-icon {
font-size: 16px;
line-height: 1.4;
flex-shrink: 0;
}
.recipe-tip p {
font-size: 13px;
color: #92400e;
line-height: 1.5;
}
[data-theme="dark"] .recipe-tip {
background: rgba(251, 191, 36, 0.06);
border-color: rgba(251, 191, 36, 0.12);
}
[data-theme="dark"] .recipe-tip p {
color: #fbbf24;
}
<div class="container">
<header>
<h1>Wizard</h1>
<p class="description">
Step-by-step recipe viewer with <code>scroll.wheel: false</code>
— navigation via buttons and keyboard only. Uses
<code>withSelection</code> and circular <code>wrap</code> mode.
</p>
</header>
<div class="split-layout">
<div class="split-main split-main--full">
<h2 class="sr-only">Steps</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">
<!-- Scroll -->
<section class="ui-section">
<h3 class="ui-title">Scroll</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"
>
Vertical
</button>
<button
class="ui-segmented__btn"
data-orientation="horizontal"
>
Horizontal
</button>
</div>
</div>
<div class="ui-row">
<label class="ui-label">Wrap</label>
<div class="ui-segmented" id="wrap-mode">
<button
class="ui-segmented__btn ui-segmented__btn--active"
data-wrap="true"
>
On
</button>
<button class="ui-segmented__btn" data-wrap="false">
Off
</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 Recipe -->
<section class="ui-section">
<h3 class="ui-title">Current</h3>
<div class="ui-row">
<span class="ui-label">Recipe</span>
<span class="ui-value" id="current-name">–</span>
</div>
<div class="ui-row">
<span class="ui-label">Difficulty</span>
<span class="ui-value" id="current-difficulty">–</span>
</div>
<div class="ui-row">
<span class="ui-label">Time</span>
<span class="ui-value" id="current-time">–</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-orientation">vertical</strong>
</span>
<span class="example-info__stat">
wrap <strong id="info-wrap">on</strong>
</span>
</div>
</div>
</div>