tablegridselectiongroupsscrollbar
File Browser
Finder-like file browser using withTable for the list
view with resizable, sortable columns and withGrid for
the grid view. Click headers to sort, drag borders to resize.
Double-click folders to navigate. [Experimental]
0
items
/
Source
// File Browser — Finder-like file browser with grid/list views
// Grid view uses withGrid, list view uses withTable for resizable/sortable columns
// Demonstrates switching between two layout modes with shared navigation
import { vlist, withGrid, withGroups, withTable, withSelection } 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 sortedItems = [];
let currentView = "list";
let currentColumns = 6;
let currentGap = 8;
let list = null;
let navigationHistory = [""];
let historyIndex = 0;
let selectedIndex = -1;
let currentArrangeBy = "none";
// Table sort state
let sortKey = null;
let sortDirection = "asc";
// =============================================================================
// 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 formatDate(dateStr) {
const now = new Date();
const modified = new Date(dateStr);
const isToday =
now.getFullYear() === modified.getFullYear() &&
now.getMonth() === modified.getMonth() &&
now.getDate() === modified.getDate();
if (isToday) {
const timeStr = modified.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
return `Today at ${timeStr}`;
}
const month = modified.toLocaleDateString("en-US", { month: "short" });
const day = modified.getDate();
const year = modified.getFullYear();
return `${month} ${day}, ${year}`;
}
function formatPath(path) {
return path || "/";
}
// =============================================================================
// Date Grouping
// =============================================================================
function getDateGroup(item) {
const now = new Date();
const modified = new Date(item.modified);
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";
}
function getNameGroup(item) {
const first = item.name.charAt(0).toUpperCase();
if (/[A-Z]/.test(first)) return first;
if (/[0-9]/.test(first)) return "#";
return "•";
}
function getSizeGroup(item) {
if (item.type === "directory") return "—";
const size = item.size || 0;
if (size === 0) return "Zero Bytes";
if (size < 1024) return "Up to 1 KB";
if (size < 100 * 1024) return "1 KB to 100 KB";
if (size < 1024 * 1024) return "100 KB to 1 MB";
if (size < 100 * 1024 * 1024) return "1 MB to 100 MB";
return "100 MB or Greater";
}
// =============================================================================
// Arrangement / Sorting
// =============================================================================
function getArrangementConfig(arrangeBy) {
switch (arrangeBy) {
case "none":
return {
groupBy: "none",
getGroupKey: null,
sortFn: (a, b) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
return a.name.localeCompare(b.name, undefined, { numeric: true });
},
};
case "name":
return {
groupBy: "none",
getGroupKey: null,
sortFn: (a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true }),
};
case "kind":
return {
groupBy: "kind",
getGroupKey: getFileKind,
sortFn: (a, b) => {
const kindA = getFileKind(a);
const kindB = getFileKind(b);
if (kindA !== kindB) return kindA.localeCompare(kindB);
return a.name.localeCompare(b.name, undefined, { numeric: true });
},
};
case "date-modified":
return {
groupBy: "date",
getGroupKey: getDateGroup,
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;
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": {
const sizeGroupOrder = [
"—",
"Zero Bytes",
"Up to 1 KB",
"1 KB to 100 KB",
"100 KB to 1 MB",
"1 MB to 100 MB",
"100 MB or Greater",
];
return {
groupBy: "size",
getGroupKey: getSizeGroup,
sortFn: (a, b) => {
const groupA = getSizeGroup(a);
const groupB = getSizeGroup(b);
const orderA = sizeGroupOrder.indexOf(groupA);
const orderB = sizeGroupOrder.indexOf(groupB);
if (orderA !== orderB) return orderA - orderB;
return (b.size || 0) - (a.size || 0);
},
};
}
default:
return {
groupBy: "none",
getGroupKey: null,
sortFn: (a, b) => a.name.localeCompare(b.name),
};
}
}
// =============================================================================
// Table Column Sorting (for list view)
// =============================================================================
/**
* Sort items by a column key and direction.
* Files and folders are interleaved (like macOS Finder column sort).
*/
function sortByColumn(data, key, direction) {
const dir = direction === "desc" ? -1 : 1;
return [...data].sort((a, b) => {
let aVal, bVal;
switch (key) {
case "name":
return a.name.localeCompare(b.name, undefined, { numeric: true }) * dir;
case "size":
aVal = a.size || 0;
bVal = b.size || 0;
return (aVal - bVal) * dir;
case "modified":
aVal = new Date(a.modified).getTime();
bVal = new Date(b.modified).getTime();
if (isNaN(aVal)) return 1;
if (isNaN(bVal)) return -1;
return (aVal - bVal) * dir;
case "kind":
aVal = getFileKind(a);
bVal = getFileKind(b);
return aVal.localeCompare(bVal) * dir;
default:
return 0;
}
});
}
function applyColumnSort(key, direction) {
sortKey = key;
sortDirection = direction || "asc";
if (key === null) {
// Reset to default name sort
sortedItems = [...items].sort((a, b) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
return a.name.localeCompare(b.name, undefined, { numeric: true });
});
} else {
sortedItems = sortByColumn(items, key, direction);
}
if (list && currentView === "list") {
list.setItems(sortedItems);
}
updateSortDetail();
}
// =============================================================================
// Templates
// =============================================================================
// Grid view template — icon + name card
const gridItemTemplate = (item) => {
const icon = getFileIcon(item);
return `
<div class="file-card" data-type="${item.type}">
<div class="file-card__icon">${icon}</div>
<div class="file-card__name" title="${item.name}">${item.name}</div>
</div>
`;
};
// Fallback template for table (withTable uses cell renderers instead)
const tableRowTemplate = () => "";
// =============================================================================
// Table Column Definitions
// =============================================================================
/** Icon + name cell renderer */
const nameCell = (item) => {
const icon = getFileIcon(item);
return `
<div class="file-name">
<span class="file-name__icon">${icon}</span>
<span class="file-name__text">${item.name}</span>
</div>
`;
};
/** File size cell renderer */
const sizeCell = (item) => {
if (item.type === "directory") return "—";
return item.size != null ? formatFileSize(item.size) : "—";
};
/** Date modified cell renderer */
const dateCell = (item) => {
return formatDate(item.modified);
};
/** File kind cell renderer */
const kindCell = (item) => {
return getFileKind(item);
};
const FILE_COLUMNS = [
{
key: "name",
label: "Name",
width: 350,
minWidth: 140,
sortable: true,
cell: nameCell,
},
{
key: "size",
label: "Size",
width: 100,
minWidth: 70,
sortable: true,
align: "right",
cell: sizeCell,
},
{
key: "modified",
label: "Date Modified",
width: 200,
minWidth: 120,
sortable: true,
cell: dateCell,
},
{
key: "kind",
label: "Kind",
width: 140,
minWidth: 80,
sortable: true,
cell: kindCell,
},
];
// =============================================================================
// 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 = "list") {
if (list) {
list.destroy();
list = null;
}
const container = document.getElementById("browser-container");
container.innerHTML = "";
currentView = view;
if (view === "grid") {
createGridView();
} else {
createTableList();
}
updateNavigationState();
}
// =============================================================================
// Grid View (withGrid + withGroups)
// =============================================================================
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;
const config = getArrangementConfig(currentArrangeBy);
const sorted = [...items].sort(config.sortFn);
// Create group map if grouping is enabled
let groupMap = null;
if (config.groupBy !== "none" && config.getGroupKey) {
groupMap = new Map();
const groupCounts = {};
sorted.forEach((item, index) => {
const groupKey = config.getGroupKey(item);
groupMap.set(index, groupKey);
groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
});
}
let builder = vlist({
container: "#browser-container",
ariaLabel: "File browser",
item: {
height,
template: gridItemTemplate,
},
items: sorted,
}).use(withGrid({ columns: currentColumns, gap: currentGap }));
if (groupMap) {
builder = builder.use(
withGroups({
getGroupForIndex: (index) => groupMap.get(index) || "",
header: {
height: 40,
template: (groupKey) => {
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();
list.on("item:click", ({ item, index }) => {
handleItemClick(item, index);
});
list.on("item:dblclick", ({ item }) => {
if (!item.__groupHeader) {
handleItemDoubleClick(item);
}
});
}
// =============================================================================
// List View (withTable + withSelection)
// =============================================================================
function createTableList() {
const rowHeight = 28;
const headerHeight = 28;
// Check if current arrangement has grouping
const config = getArrangementConfig(currentArrangeBy);
const hasGroups = config.groupBy !== "none";
// When grouping is active, use the arrangement sort (groups must be contiguous).
// Otherwise use column sort or default name sort.
if (hasGroups) {
sortedItems = [...items].sort(config.sortFn);
} else if (sortKey) {
sortedItems = sortByColumn(items, sortKey, sortDirection);
} else {
sortedItems = [...items].sort((a, b) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
return a.name.localeCompare(b.name, undefined, { numeric: true });
});
}
// Build group map if grouping is enabled
let groupMap = null;
if (hasGroups && config.getGroupKey) {
groupMap = new Map();
const groupCounts = {};
sortedItems.forEach((item, index) => {
const groupKey = config.getGroupKey(item);
groupMap.set(index, groupKey);
groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
});
}
let builder = vlist({
container: "#browser-container",
ariaLabel: "File browser",
padding: [2, 6],
item: {
height: rowHeight,
striped: "odd",
template: tableRowTemplate,
},
items: sortedItems,
});
builder = builder.use(
withTable({
columns: FILE_COLUMNS,
rowHeight,
headerHeight,
resizable: true,
columnBorders: false,
rowBorders: false,
minColumnWidth: 50,
sort: sortKey ? { key: sortKey, direction: sortDirection } : undefined,
}),
);
if (groupMap) {
builder = builder.use(
withGroups({
getGroupForIndex: (index) => groupMap.get(index) || "",
header: {
height: 32,
template: (groupKey) => {
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: false,
}),
);
}
builder = builder.use(withSelection({ mode: "single" }));
list = builder.build();
// Column sort — user clicks a sortable header
list.on("column:sort", ({ key, direction }) => {
applyColumnSort(direction === null ? null : key, direction);
if (list.setSort) {
list.setSort(sortKey, sortDirection);
}
});
// Selection — show detail in panel
list.on("selection:change", ({ items: selectedItems }) => {
if (selectedItems.length > 0) {
showFileDetail(selectedItems[0]);
} else {
clearFileDetail();
}
});
// Double-click row to navigate into folder
list.on("item:dblclick", ({ item }) => {
if (item && !item.__groupHeader && item.type === "directory") {
handleItemDoubleClick(item);
}
});
updateSortDetail();
}
// =============================================================================
// Navigation
// =============================================================================
async function navigateTo(path, addToHistory = true) {
const data = await fetchDirectory(path);
currentPath = data.path;
items = data.items.map((item) => ({ ...item, id: item.name }));
selectedIndex = -1;
if (addToHistory) {
navigationHistory = navigationHistory.slice(0, historyIndex + 1);
navigationHistory.push(path);
historyIndex = navigationHistory.length - 1;
}
await createBrowser(currentView);
updateBreadcrumb();
updateNavigationState();
updateInfo();
}
function handleItemClick(item, index) {
if (selectedIndex >= 0) {
const prevEl = document.querySelector(`[data-index="${selectedIndex}"]`);
if (prevEl) prevEl.setAttribute("aria-selected", "false");
}
selectedIndex = index;
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;
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="">root</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;
}
// =============================================================================
// Info Bar Stats
// =============================================================================
const infoItems = document.getElementById("info-items");
const infoPath = document.getElementById("info-path");
function updateInfo() {
if (infoItems) infoItems.textContent = String(items.length);
if (infoPath) infoPath.textContent = currentPath ? `/${currentPath}` : "/";
}
// =============================================================================
// Detail Panel — selected file info
// =============================================================================
const detailEl = document.getElementById("file-detail");
function showFileDetail(item) {
if (!detailEl) return;
const icon = getFileIcon(item);
const kind = getFileKind(item);
const sizeText =
item.type === "file" && item.size != null ? formatFileSize(item.size) : "—";
const dateText = formatDate(item.modified);
detailEl.innerHTML = `
<div class="file-detail__header">
<span class="file-detail__icon">${icon}</span>
<div>
<div class="file-detail__name">${item.name}</div>
<div class="file-detail__kind">${kind}</div>
</div>
</div>
<div class="file-detail__meta">
<span>${sizeText}</span>
<span>${dateText}</span>
</div>
`;
}
function clearFileDetail() {
if (!detailEl) return;
detailEl.innerHTML = `
<span class="ui-detail__empty">Click a row to see details</span>
`;
}
// =============================================================================
// Sort Detail Panel
// =============================================================================
const sortDetailEl = document.getElementById("sort-detail");
function updateSortDetail() {
if (!sortDetailEl) return;
let html = "";
// Show arrangement info if active
if (currentArrangeBy !== "none") {
const arrangeLabels = {
name: "Name",
kind: "Kind",
"date-modified": "Date Modified",
size: "Size",
};
const arrangeLabel = arrangeLabels[currentArrangeBy] || currentArrangeBy;
html += `
<div class="sort-info">
<span class="sort-info__label">Arranged by</span>
<span class="sort-info__key">${arrangeLabel}</span>
</div>
`;
}
// Show column sort info if active
if (sortKey !== null) {
const arrow = sortDirection === "asc" ? "▲" : "▼";
const label = sortDirection === "asc" ? "Ascending" : "Descending";
html += `
<div class="sort-info">
<span class="sort-info__label">Sorted by</span>
<span class="sort-info__key">${sortKey}</span>
<span class="sort-info__dir">${arrow} ${label}</span>
</div>
`;
}
if (!html) {
html = `<span class="ui-detail__empty">Click a column header to sort</span>`;
}
sortDetailEl.innerHTML = html;
}
// =============================================================================
// Initialization
// =============================================================================
(async () => {
// View switcher
document.getElementById("btn-view-grid").addEventListener("click", () => {
if (currentView === "grid") return;
document
.getElementById("btn-view-grid")
.classList.add("ui-segmented__btn--active");
document
.getElementById("btn-view-list")
.classList.remove("ui-segmented__btn--active");
createBrowser("grid");
});
document.getElementById("btn-view-list").addEventListener("click", () => {
if (currentView === "list") return;
document
.getElementById("btn-view-list")
.classList.add("ui-segmented__btn--active");
document
.getElementById("btn-view-grid")
.classList.remove("ui-segmented__btn--active");
createBrowser("list");
});
// Arrange by (still useful for grid view grouping)
document
.getElementById("arrange-by-select")
.addEventListener("change", (e) => {
currentArrangeBy = e.target.value;
// Arrange-by controls grouping, not column sort.
// Reset column sort when changing arrangement, then rebuild.
sortKey = null;
sortDirection = "asc";
createBrowser(currentView);
});
// Toolbar navigation
document.getElementById("btn-back").addEventListener("click", () => {
navigateBack();
});
document.getElementById("btn-forward").addEventListener("click", () => {
navigateForward();
});
// Side panel navigation buttons
const btnNavBack = document.getElementById("btn-nav-back");
const btnNavForward = document.getElementById("btn-nav-forward");
if (btnNavBack) {
btnNavBack.addEventListener("click", () => {
navigateBack();
});
}
if (btnNavForward) {
btnNavForward.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");
})();
/* File Browser — example-specific styles
Common styles (.container, h1, .description, .split-layout, .split-panel, .ui-*)
are provided by example/example.css using shell.css design tokens. */
/* ============================================================================
Browser Container
============================================================================ */
#browser-container {
height: 500px;
margin: 0 auto;
background: var(--bg);
}
/* Override vlist default styles for grid view — no borders/radius */
#browser-container .vlist {
border-radius: 0px 0px var(--vlist-border-radius, 0.5rem)
var(--vlist-border-radius, 0.5rem);
}
/* ============================================================================
Table Header Overrides (List View — withTable)
============================================================================ */
#browser-container .vlist-table-header {
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.04em;
background: var(--bg-card) !important;
}
#browser-container .vlist-table-header-cell {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
#browser-container .vlist-table-header-sort {
font-size: 1.1em;
}
/* ============================================================================
Table Row & Cell Overrides (List View — withTable)
============================================================================ */
#browser-container .vlist-table-row {
font-size: 0.8125rem;
}
#browser-container .vlist-table-cell {
padding-left: 0.75rem;
padding-right: 0.75rem;
line-height: 1.4;
}
/* Finder-style selection for table rows — compound selectors because
.vlist-item and .vlist-table-row live on the same element in table mode. */
#browser-container .vlist-table-row.vlist-item--selected {
background: rgba(0, 122, 255, 0.5);
}
#browser-container .vlist-table-row.vlist-item--selected:hover {
background: rgba(0, 122, 255, 0.6);
}
#browser-container .vlist-table-row.vlist-item--selected .vlist-table-cell {
color: white;
}
#browser-container .vlist-table-row.vlist-item--selected .file-name__text {
color: white;
}
#browser-container
.vlist-table-row.vlist-item:not(.vlist-item--odd):not(
.vlist-item--selected
) {
background-color: initial;
}
/* ============================================================================
Name Cell — icon + name inline (used by withTable cell renderer)
============================================================================ */
.file-name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.file-name__icon {
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.file-name__text {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
color: var(--vlist-text, #111827);
}
/* ============================================================================
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: 14px;
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: 4px 8px;
border: 1px solid var(--vlist-border);
border-bottom: none;
border-radius: var(--vlist-border-radius, 0.5rem)
var(--vlist-border-radius, 0.5rem) 0px 0px;
}
.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__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);
}
.toolbar #btn-back i {
-webkit-mask-image: url(/examples/icons/navigate/before.svg);
mask-image: url(/examples/icons/navigate/before.svg);
}
.toolbar #btn-forward i {
-webkit-mask-image: url(/examples/icons/navigate/next.svg);
mask-image: url(/examples/icons/navigate/next.svg);
}
.toolbar #btn-view-grid i {
-webkit-mask-image: url(/examples/icons/navigate/grid.svg);
mask-image: url(/examples/icons/grid.svg);
}
.toolbar #btn-view-list i {
-webkit-mask-image: url(/examples/icons/list.svg);
mask-image: url(/examples/icons/list.svg);
}
/* ============================================================================
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 — highlight name label only */
.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;
}
/* ============================================================================
Group Headers (Grid View)
============================================================================ */
.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 table/list layout — smaller, tighter */
.vlist--table .group-header {
padding: 8px 12px 8px 24px;
}
.vlist--table .group-header__label {
font-size: 11px;
text-transform: initial;
}
.vlist--table .group-header__count {
font-size: 10px;
}
/* Group headers in grid layout */
.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 — 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 Detail Panel — selected file info
============================================================================ */
.file-detail__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.file-detail__icon {
font-size: 28px;
line-height: 1;
flex-shrink: 0;
}
.file-detail__name {
font-size: 13px;
font-weight: 600;
color: var(--text);
word-break: break-all;
}
.file-detail__kind {
font-size: 12px;
color: var(--text-muted);
}
.file-detail__meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
/* ============================================================================
Sort Info Panel
============================================================================ */
.sort-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.sort-info__key {
font-weight: 600;
color: var(--vlist-text, #111827);
text-transform: capitalize;
}
.sort-info__dir {
font-size: 12px;
color: var(--text-muted);
}
/* ============================================================================
vlist overrides — remove item borders/padding for grid cards
============================================================================ */
#browser-container .vlist-item {
padding: 0;
border: none;
}
#browser-container .vlist--grid .vlist-item:hover {
background: transparent;
}
/* ============================================================================
Split layout — browser takes more room
============================================================================ */
.split-main--full #browser-container {
height: 600px;
}
/* ============================================================================
Responsive
============================================================================ */
@media (min-width: 1200px) {
.split-main--full #browser-container {
height: 600px;
}
}
@media (max-width: 820px) {
.split-main--full #browser-container {
height: 480px;
}
.breadcrumb {
font-size: 12px;
}
.toolbar {
flex-direction: column;
gap: 8px;
}
.toolbar__left,
.toolbar__right {
width: 100%;
justify-content: space-between;
}
.file-card__icon {
font-size: 44px;
}
}
<div class="container">
<header>
<h1>File Browser</h1>
<p class="description">
Finder-like file browser using <code>withTable</code> for the list
view with resizable, sortable columns and <code>withGrid</code> for
the grid view. Click headers to sort, drag borders to resize.
Double-click folders to navigate. [Experimental]
</p>
</header>
<div class="split-layout">
<div class="split-main split-main--full">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar__left">
<button
id="btn-back"
class="ui-btn ui-btn--icon"
disabled
title="Back"
>
<i class="icon icon--prev"></i>
</button>
<button
id="btn-forward"
class="ui-btn ui-btn--icon"
disabled
title="Forward"
>
<i class="icon icon--next"></i>
</button>
<!-- Breadcrumb navigation -->
<div class="breadcrumb" id="breadcrumb">
<button class="breadcrumb__item" data-path="">
root
</button>
</div>
</div>
<div class="toolbar__right">
<select id="arrange-by-select" class="ui-select">
<option value="none" selected>None</option>
<option value="name">Name</option>
<option value="kind">Kind</option>
<option value="date-modified">Date Modified</option>
<option value="size">Size</option>
</select>
<div class="ui-segmented">
<button
id="btn-view-grid"
class="ui-segmented__btn"
title="Grid View"
>
<i class="icon icon--grid"></i>
</button>
<button
id="btn-view-list"
class="ui-segmented__btn ui-segmented__btn--active"
title="List View"
>
<i class="icon icon--list"></i>
</button>
</div>
</div>
</div>
<!-- File browser container -->
<div id="browser-container"></div>
</div>
<aside class="split-panel">
<!-- Sort State -->
<section class="ui-section">
<h3 class="ui-title">Sort</h3>
<div class="ui-detail" id="sort-detail">
<span class="ui-detail__empty"
>Click a column header to sort</span
>
</div>
</section>
<!-- Selected File -->
<section class="ui-section">
<h3 class="ui-title">Selected File</h3>
<div class="ui-detail" id="file-detail">
<span class="ui-detail__empty"
>Click a row to see details</span
>
</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-nav-back"
class="ui-btn ui-btn--icon"
title="Back"
>
<i class="icon icon--up"></i>
</button>
<button
id="btn-nav-forward"
class="ui-btn ui-btn--icon"
title="Forward"
>
<i class="icon icon--down"></i>
</button>
</div>
</div>
</section>
</aside>
</div>
<div class="example-info" id="example-info">
<div class="example-info__left">
<span class="example-info__stat">
<strong id="info-items">0</strong>
<span class="example-info__unit">items</span>
</span>
</div>
<div class="example-info__right">
<span class="example-info__stat">
<strong id="info-path">/</strong>
</span>
</div>
</div>
</div>