gridgroupsscrollbar
File Browser
Finder-like file browser using vlist/builder with
withGrid plugin. Browse files from vlist and vlist.dev
projects with switchable grid/list views. Click to select,
double-click folders to navigate.
Source
// Builder Grid — File Browser
// Uses vlist/builder with withGrid plugin for grid view and standard list for list view
// Demonstrates a virtualized file browser similar to macOS Finder
import { vlist, withGrid, withScrollbar, withGroups } from "vlist";
// =============================================================================
// File Type Icons
// =============================================================================
const FILE_ICONS = {
folder: "📁",
js: "📄",
ts: "📘",
json: "📋",
html: "🌐",
css: "🎨",
scss: "🎨",
md: "📝",
png: "🖼️",
jpg: "🖼️",
jpeg: "🖼️",
gif: "🖼️",
svg: "🖼️",
txt: "📄",
pdf: "📕",
zip: "📦",
default: "📄",
};
function getFileIcon(item) {
if (item.type === "directory") return FILE_ICONS.folder;
const ext = item.extension;
return FILE_ICONS[ext] || FILE_ICONS.default;
}
function getFileKind(item) {
if (item.type === "directory") return "Folder";
const ext = item.extension;
const kindMap = {
js: "JavaScript",
ts: "TypeScript",
json: "JSON",
html: "HTML",
css: "CSS",
scss: "SCSS",
md: "Markdown",
txt: "Text",
png: "PNG Image",
jpg: "JPEG Image",
jpeg: "JPEG Image",
gif: "GIF Image",
svg: "SVG Image",
pdf: "PDF",
zip: "Archive",
gz: "Archive",
tar: "Archive",
};
return kindMap[ext] || (ext ? ext.toUpperCase() : "Document");
}
// =============================================================================
// State
// =============================================================================
let currentPath = "";
let items = [];
let currentView = "list";
let currentColumns = 6;
let currentGap = 8;
let list = null;
let navigationHistory = [""];
let historyIndex = 0;
let selectedIndex = -1;
let currentArrangeBy = "name";
// =============================================================================
// Templates
// =============================================================================
const gridItemTemplate = (item) => {
const icon = getFileIcon(item);
return `
<div class="file-card">
<div class="file-card__icon">
${icon}
</div>
<div class="file-card__name" title="${item.name}">
${item.name}
</div>
</div>
`;
};
const listItemTemplate = (item) => {
const icon = getFileIcon(item);
const sizeText =
item.type === "file" && item.size != null ? formatFileSize(item.size) : "—";
const kind = getFileKind(item);
// Format date
const now = new Date();
const modified = new Date(item.modified);
const isToday =
now.getFullYear() === modified.getFullYear() &&
now.getMonth() === modified.getMonth() &&
now.getDate() === modified.getDate();
let dateText = "";
if (isToday) {
const timeStr = modified.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
dateText = `Today at ${timeStr}`;
} else {
const month = modified.toLocaleDateString("en-US", { month: "short" });
const day = modified.getDate();
const year = modified.getFullYear();
dateText = `${month} ${day}, ${year}`;
}
return `
<div class="file-row">
<div class="file-row__icon">
${icon}
</div>
<div class="file-row__name">${item.name}</div>
<div class="file-row__size">${sizeText}</div>
<div class="file-row__date">${dateText}</div>
<div class="file-row__kind">${kind}</div>
</div>
`;
};
// =============================================================================
// Date Grouping
// =============================================================================
function getDateGroup(item) {
const now = new Date();
const modified = new Date(item.modified);
// Check if today
if (
now.getFullYear() === modified.getFullYear() &&
now.getMonth() === modified.getMonth() &&
now.getDate() === modified.getDate()
) {
return "Today";
}
const diffTime = Math.abs(now - modified);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) return "Previous 7 Days";
if (diffDays <= 30) return "Previous 30 Days";
return "Older";
}
// Get arrangement configuration
function getArrangementConfig(arrangeBy) {
switch (arrangeBy) {
case "name":
return {
groupBy: "none",
sortFn: (a, b) => {
// Folders first
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
// Then alphabetically
return a.name.localeCompare(b.name, undefined, { numeric: true });
},
};
case "kind":
return {
groupBy: "kind",
sortFn: (a, b) => {
const kindA = getFileKind(a);
const kindB = getFileKind(b);
if (kindA !== kindB) {
return kindA.localeCompare(kindB);
}
// Within same kind, sort by name
return a.name.localeCompare(b.name, undefined, { numeric: true });
},
};
case "date-modified":
return {
groupBy: "date",
sortFn: (a, b) => {
const groupA = getDateGroup(a);
const groupB = getDateGroup(b);
const groupOrder = [
"Today",
"Previous 7 Days",
"Previous 30 Days",
"Older",
];
const orderA = groupOrder.indexOf(groupA);
const orderB = groupOrder.indexOf(groupB);
if (orderA !== orderB) {
return orderA - orderB;
}
// Within same group, sort by date (newest first)
const dateA = new Date(a.modified).getTime();
const dateB = new Date(b.modified).getTime();
if (isNaN(dateA)) return 1;
if (isNaN(dateB)) return -1;
return dateB - dateA;
},
};
case "size":
return {
groupBy: "none",
sortFn: (a, b) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
return (b.size || 0) - (a.size || 0); // Largest first
},
};
default:
return {
groupBy: "none",
sortFn: (a, b) => a.name.localeCompare(b.name),
};
}
}
// =============================================================================
// Utility Functions
// =============================================================================
function formatFileSize(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
function formatPath(path) {
return path || "/";
}
// =============================================================================
// API
// =============================================================================
async function fetchDirectory(path) {
try {
const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch directory:", error);
return { path, items: [] };
}
}
// =============================================================================
// View Creation
// =============================================================================
async function createBrowser(view = "grid") {
// Destroy previous
if (list) {
list.destroy();
list = null;
}
// Clear container
const container = document.getElementById("browser-container");
container.innerHTML = "";
currentView = view;
if (view === "grid") {
createGridView();
} else {
createListView();
}
updateNavigationState();
}
function createGridView() {
const container = document.getElementById("browser-container");
const innerWidth = container.clientWidth - 2;
const colWidth =
(innerWidth - (currentColumns - 1) * currentGap) / currentColumns;
const height = colWidth * 0.8; // Icon + text
// Get arrangement config (grouping + sorting)
const config = getArrangementConfig(currentArrangeBy);
const sorted = [...items].sort(config.sortFn);
// Create group map if grouping is enabled
let groupMap = null;
if (config.groupBy !== "none") {
groupMap = new Map();
const groupCounts = {};
sorted.forEach((item, index) => {
const groupKey =
config.groupBy === "date" ? getDateGroup(item) : getFileKind(item);
groupMap.set(index, groupKey);
groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
});
}
// Hide list header in grid view
const listHeader = document.getElementById("list-header");
if (listHeader) listHeader.style.display = "none";
// Create list with builder pattern
let builder = vlist({
container: "#browser-container",
ariaLabel: "File browser",
item: {
height,
template: gridItemTemplate,
},
items: sorted,
})
.use(withGrid({ columns: currentColumns, gap: currentGap }))
.use(withScrollbar({ autoHide: true }));
// Add groups plugin if grouping is enabled
if (config.groupBy !== "none" && groupMap) {
builder = builder.use(
withGroups({
getGroupForIndex: (index) => groupMap.get(index) || "",
headerHeight: 40,
headerTemplate: (groupKey) => {
// Count items in this group
let count = 0;
groupMap.forEach((key) => {
if (key === groupKey) count++;
});
return `
<div class="group-header">
<span class="group-header__label">${groupKey}</span>
<span class="group-header__count">${count} items</span>
</div>
`;
},
sticky: true,
}),
);
}
list = builder.build();
// Bind events
list.on("item:click", ({ item, index }) => {
handleItemClick(item, index);
});
list.on("item:dblclick", ({ item, index }) => {
if (!item.__groupHeader) {
handleItemDoubleClick(item);
}
});
}
function createListView() {
const height = 28; // Fixed row height for list view
// Get arrangement config (grouping + sorting)
const config = getArrangementConfig(currentArrangeBy);
const sorted = [...items].sort(config.sortFn);
// Show list header
const listHeader = document.getElementById("list-header");
if (listHeader) listHeader.style.display = "grid";
// Create group map if grouping is enabled
let groupMap = null;
if (config.groupBy !== "none") {
groupMap = new Map();
const groupCounts = {};
sorted.forEach((item, index) => {
const groupKey =
config.groupBy === "date" ? getDateGroup(item) : getFileKind(item);
groupMap.set(index, groupKey);
groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
});
console.log("🔍 List View Grouping Debug:", {
arrangeBy: currentArrangeBy,
groupBy: config.groupBy,
totalItems: sorted.length,
groupCounts,
firstTenGroups: Array.from(
{ length: Math.min(10, sorted.length) },
(_, i) => ({
index: i,
name: sorted[i].name,
type: sorted[i].type,
kind: getFileKind(sorted[i]),
dateGroup: getDateGroup(sorted[i]),
assignedGroup: groupMap.get(i),
}),
),
});
}
// Create list with builder pattern
let builder = vlist({
container: "#browser-container",
ariaLabel: "File browser",
item: {
height,
template: listItemTemplate,
},
items: sorted,
}).use(withScrollbar({ autoHide: true }));
// Add groups plugin if grouping is enabled
if (config.groupBy !== "none" && groupMap) {
builder = builder.use(
withGroups({
getGroupForIndex: (index) => groupMap.get(index) || "",
headerHeight: 40,
headerTemplate: (groupKey) => {
// Count items in this group
let count = 0;
groupMap.forEach((key) => {
if (key === groupKey) count++;
});
return `
<div class="group-header">
<span class="group-header__label">${groupKey}</span>
<span class="group-header__count">${count} items</span>
</div>
`;
},
sticky: true,
}),
);
}
list = builder.build();
// Bind events
list.on("item:click", ({ item, index }) => {
handleItemClick(item, index);
});
list.on("item:dblclick", ({ item, index }) => {
console.log("🖱️🖱️ List dblclick event fired:", {
item,
index,
type: item.type,
name: item.name,
});
if (!item.__groupHeader) {
console.log("📁 Calling handleItemDoubleClick for:", item.name);
handleItemDoubleClick(item);
} else {
console.log("⚠️ Skipping group header");
}
});
}
// =============================================================================
// Navigation
// =============================================================================
async function navigateTo(path, addToHistory = true) {
const data = await fetchDirectory(path);
currentPath = data.path;
items = data.items;
// Clear selection when navigating
selectedIndex = -1;
// Update history
if (addToHistory) {
// Remove any forward history
navigationHistory = navigationHistory.slice(0, historyIndex + 1);
navigationHistory.push(path);
historyIndex = navigationHistory.length - 1;
}
await createBrowser(currentView);
updateBreadcrumb();
updateNavigationState();
}
function handleItemClick(item, index) {
// Update selection state
if (selectedIndex >= 0) {
// Deselect previous
const prevEl = document.querySelector(`[data-index="${selectedIndex}"]`);
if (prevEl) prevEl.setAttribute("aria-selected", "false");
}
selectedIndex = index;
// Select current
const currentEl = document.querySelector(`[data-index="${index}"]`);
if (currentEl) currentEl.setAttribute("aria-selected", "true");
}
function handleItemDoubleClick(item) {
if (item.type === "directory") {
const newPath = currentPath ? `${currentPath}/${item.name}` : item.name;
selectedIndex = -1; // Clear selection when navigating
navigateTo(newPath);
}
}
async function navigateBack() {
if (historyIndex > 0) {
historyIndex--;
await navigateTo(navigationHistory[historyIndex], false);
}
}
async function navigateForward() {
if (historyIndex < navigationHistory.length - 1) {
historyIndex++;
await navigateTo(navigationHistory[historyIndex], false);
}
}
// =============================================================================
// UI Updates
// =============================================================================
const breadcrumbEl = document.getElementById("breadcrumb");
function updateBreadcrumb() {
const parts = currentPath ? currentPath.split("/") : [];
let html = `<button class="breadcrumb__item" data-path="">home</button>`;
let pathSoFar = "";
parts.forEach((part, index) => {
pathSoFar += (index > 0 ? "/" : "") + part;
html += `<span class="breadcrumb__sep">›</span>`;
html += `<button class="breadcrumb__item" data-path="${pathSoFar}">${part}</button>`;
});
breadcrumbEl.innerHTML = html;
}
function updateNavigationState() {
const backBtn = document.getElementById("btn-back");
const forwardBtn = document.getElementById("btn-forward");
backBtn.disabled = historyIndex <= 0;
forwardBtn.disabled = historyIndex >= navigationHistory.length - 1;
}
// =============================================================================
// Initialization
// =============================================================================
(async () => {
// Set up view switcher
document.getElementById("btn-view-grid").addEventListener("click", () => {
if (currentView === "grid") return;
document.getElementById("btn-view-grid").classList.add("view-btn--active");
document
.getElementById("btn-view-list")
.classList.remove("view-btn--active");
createBrowser("grid");
});
document.getElementById("btn-view-list").addEventListener("click", () => {
if (currentView === "list") return;
document.getElementById("btn-view-list").classList.add("view-btn--active");
document
.getElementById("btn-view-grid")
.classList.remove("view-btn--active");
createBrowser("list");
});
// Set up arrange by
document
.getElementById("arrange-by-select")
.addEventListener("change", (e) => {
currentArrangeBy = e.target.value;
createBrowser(currentView);
});
// Set up navigation
document.getElementById("btn-back").addEventListener("click", () => {
navigateBack();
});
document.getElementById("btn-forward").addEventListener("click", () => {
navigateForward();
});
// Breadcrumb click handler
breadcrumbEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-path]");
if (!btn) return;
navigateTo(btn.dataset.path);
});
// Initial load - start in vlist folder with list view
await navigateTo("vlist");
})();
/* Builder Grid — File Browser example-specific styles
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. */
/* Browser container */
#browser-container {
height: 500px;
margin: 0 auto;
background: var(--bg);
}
/* Override vlist default styles - remove borders and border-radius */
#browser-container .vlist {
border: none !important;
border-radius: 0 !important;
}
#browser-container .vlist-viewport {
border-radius: 0 !important;
}
/* ============================================================================
Breadcrumb Navigation
============================================================================ */
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 0;
margin: 0;
border-radius: 0;
border: none;
background: transparent;
font-size: 13px;
overflow-x: auto;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.breadcrumb__item {
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.breadcrumb__item:hover {
background: var(--bg-hover);
color: var(--text);
}
.breadcrumb__item:last-child {
color: var(--text);
font-weight: 600;
}
.breadcrumb__sep {
color: var(--text-muted);
font-size: 12px;
}
/* ============================================================================
Toolbar
============================================================================ */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 12px;
margin-bottom: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-card);
}
.toolbar__left {
display: flex;
gap: 8px;
align-items: center;
flex: 1;
min-width: 0;
}
.toolbar__right {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.toolbar__btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.toolbar__btn--icon {
padding: 6px;
min-width: 32px;
min-height: 32px;
justify-content: center;
}
.toolbar__btn--icon svg {
display: block;
opacity: 0.7;
transition: opacity 0.15s ease;
}
.toolbar__btn--icon:hover:not(:disabled) svg {
opacity: 1;
}
.toolbar__btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent-text);
}
.toolbar__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolbar__btn .icon {
font-size: 14px;
}
.toolbar__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.toolbar__select {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.toolbar__select:hover {
border-color: var(--accent);
}
.toolbar__select:focus {
outline: none;
border-color: var(--accent);
}
/* ============================================================================
View Switcher
============================================================================ */
.view-switcher {
display: flex;
gap: 4px;
padding: 4px;
border-radius: 8px;
background: var(--bg);
border: 1px solid var(--border);
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
transition: all 0.15s ease;
}
.view-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
.view-btn--active {
background: var(--accent);
color: white;
}
/* ============================================================================
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);
}
/* ============================================================================
File Card (Grid View)
============================================================================ */
.file-card {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 6px;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.file-card:hover {
background: transparent;
}
.file-card[data-type="directory"]:hover {
background: transparent;
}
/* Finder-style selection - no card background */
.vlist-item[aria-selected="true"] .file-card {
background: transparent;
}
.vlist-item[aria-selected="true"] .file-card__name {
background: rgba(0, 122, 255, 0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.file-card__icon {
font-size: 52px;
line-height: 1;
margin-bottom: 6px;
}
.file-card__name {
font-weight: 500;
color: var(--text);
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
max-height: 2.6em;
word-break: break-word;
}
/* ============================================================================
List View Header
============================================================================ */
.list-header {
display: grid;
grid-template-columns: 24px minmax(200px, 2fr) 100px 200px 140px;
align-items: center;
gap: 12px;
padding: 0 12px;
height: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0;
}
.list-header__icon {
width: 24px;
}
.list-header__name,
.list-header__size,
.list-header__date,
.list-header__kind {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.list-header__size {
text-align: right;
}
/* ============================================================================
Manual Grid with Groups
============================================================================ */
.manual-grid-groups {
width: 100%;
}
.manual-grid {
display: grid;
padding: 12px 0;
margin-bottom: 24px;
}
.manual-grid-item {
cursor: pointer;
}
.manual-grid-item[aria-selected="true"] .file-card {
background: transparent;
}
.manual-grid-item[aria-selected="true"] .file-card__name {
background: rgba(0, 122, 255, 0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/* ============================================================================
Group Headers
============================================================================ */
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px 8px 48px;
background: transparent;
margin-top: 12px;
transition: all 0.2s ease;
}
/* Group headers in grid layout - background and spacing */
.vlist--grid .group-header {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
margin-top: 0;
margin-bottom: 8px;
}
/* First group header has no margin-top */
.vlist-item:first-child .group-header {
margin-top: 0;
}
/* Sticky header enhancement */
.vlist-item[data-sticky="true"] .group-header {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10;
}
.group-header__label {
font-size: 13px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.group-header__count {
font-size: 11px;
color: var(--text-muted);
opacity: 0.6;
}
/* Enhanced count visibility when sticky */
.vlist-item[data-sticky="true"] .group-header__count {
opacity: 0.8;
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 10px;
}
/* ============================================================================
File Row (List View)
============================================================================ */
.file-row {
font-size: 14px;
display: grid;
grid-template-columns: 24px minmax(200px, 2fr) 100px 200px 140px;
align-items: center;
gap: 12px;
width: 100%;
height: 100%;
padding: 0 12px;
background: transparent;
border-radius: 0 !important;
cursor: pointer;
transition: background 0.1s ease;
}
/* Zebra striping (even rows) */
.vlist-item:nth-child(even) .file-row {
background: rgba(255, 255, 255, 0.02);
}
/* Finder-style selection for list view */
.vlist-item[aria-selected="true"] .file-row {
background: rgba(0, 122, 255, 0.5);
}
.vlist-item[aria-selected="true"] .file-row:hover {
background: rgba(0, 122, 255, 0.6);
}
.file-row__icon {
font-size: 18px;
line-height: 1;
text-align: center;
}
.file-row__name {
font-weight: 400;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.vlist-item[aria-selected="true"] .file-row__name,
.vlist-item[aria-selected="true"] .file-row__size,
.vlist-item[aria-selected="true"] .file-row__date,
.vlist-item[aria-selected="true"] .file-row__kind {
color: white;
}
.file-row__size {
color: var(--text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.file-row__date {
color: var(--text-muted);
}
.file-row__kind {
color: var(--text-muted);
}
/* ============================================================================
File Detail (panel)
============================================================================ */
.detail__meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.detail__meta strong {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.detail__meta span {
font-size: 12px;
color: var(--text-muted);
}
/* ============================================================================
Panel value styling
============================================================================ */
.panel-value {
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
}
/* ============================================================================
vlist overrides — remove item borders/padding for cards
============================================================================ */
#browser-container .vlist-item {
padding: 0;
border: none;
background: transparent;
}
#browser-container .vlist-item:hover {
background: transparent;
}
/* List view specific - no padding, no border, no border-radius */
#browser-container .vlist-item:has(.file-row) {
padding: 0;
border: none !important;
border-radius: 0 !important;
}
/* Group headers in list view - full width, no grid layout */
#browser-container .vlist-item:has(.group-header) {
padding: 0;
border: none !important;
border-radius: 0 !important;
}
#browser-container .vlist-item:has(.group-header) .group-header {
width: 100%;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 820px) {
#browser-container {
height: 400px;
}
.breadcrumb {
font-size: 12px;
}
.toolbar {
flex-direction: column;
gap: 8px;
}
.toolbar__left,
.toolbar__right {
width: 100%;
justify-content: space-between;
}
.list-header {
grid-template-columns: 24px 1fr 80px;
gap: 8px;
}
.list-header__date,
.list-header__kind {
display: none;
}
.file-row {
grid-template-columns: 24px 1fr 80px;
gap: 8px;
}
.file-row__date,
.file-row__kind {
display: none;
}
.file-card__icon {
font-size: 44px;
}
}
<div class="container container">
<header>
<h1>File Browser</h1>
<p class="description">
Finder-like file browser using <code>vlist/builder</code> with
<code>withGrid</code> plugin. Browse files from vlist and vlist.dev
projects with switchable grid/list views. Click to select,
double-click folders to navigate.
</p>
</header>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar__left">
<button
id="btn-back"
class="toolbar__btn toolbar__btn--icon"
disabled
title="Back"
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="20px"
viewBox="0 0 24 24"
width="20px"
fill="currentColor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
</button>
<button
id="btn-forward"
class="toolbar__btn toolbar__btn--icon"
disabled
title="Forward"
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="20px"
viewBox="0 0 24 24"
width="20px"
fill="currentColor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
<!-- Breadcrumb navigation -->
<div class="breadcrumb" id="breadcrumb">
<button class="breadcrumb__item" data-path="">Home</button>
</div>
</div>
<div class="toolbar__right">
<select id="arrange-by-select" class="toolbar__select">
<option value="name" selected>Name</option>
<option value="kind">Kind</option>
<option value="date-modified">Date Modified</option>
<option value="size">Size</option>
</select>
<div class="view-switcher">
<button id="btn-view-grid" class="view-btn" title="Grid View">
<span class="icon">⊞</span>
</button>
<button
id="btn-view-list"
class="view-btn view-btn--active"
title="List View"
>
<span class="icon">☰</span>
</button>
</div>
</div>
</div>
<!-- List view header (hidden in grid view) -->
<div class="list-header" id="list-header" style="display: none">
<div class="list-header__icon"></div>
<div class="list-header__name">Name</div>
<div class="list-header__size">Size</div>
<div class="list-header__date">Date Modified</div>
<div class="list-header__kind">Kind</div>
</div>
<!-- File browser container -->
<div id="browser-container"></div>
<footer>
<p>
File browser demo using <code>withGrid</code> plugin with switchable
views. Backend API serves real filesystem data. Click to select,
double-click folders to navigate. 📁
</p>
</footer>
</div>