@k2b/ui
Files
Generic file trees, browser composition, and content-aware previews.
A fully interactive, path-first browser over an application-owned in-memory source. Create, upload, rename, move, delete, edit, save, and download without a backend. Tree icons sit in soft chips of their file-type color; folders use the app accent. The preview dialog shows a plain FileView whose Markdown heading becomes the dialog title.
Select a file
TSX
Copyconst source: FileSource = { list: async () => entries, read: async (path) => content[path], write: async (path, value) => update(path, value), rename: async (from, to) => move(from, to), remove: async (path) => remove(path), upload: async (dir, files) => upload(dir, files),}; <FileBrowserPanel source={source} initialPath="/README.md" /> // A dialog that shows the document's own heading as its title.<PanelDialog.Header title={title() ?? name} subtitle={name} close={close} /><PanelDialog.Body> <FileView variant="plain" file={file} load={load} onDocumentTitle={setTitle} /></PanelDialog.Body>FileBrowserPanel, FileTree, and FileView present application-owned file
data. They do not depend on Cloud storage, routes, permissions, or mutation
APIs.
Selected FileGrid items and FileTree rows take the row selection fill
without changing text weight or geometry; see Colors and selection.
Use file components
Use FileBrowserPanel for the ready tree-and-preview composition over a
FileSource. Use FileTree for a different layout or selection flow. Use
FileView when a page already owns the selected file.
Import
import {
FileBrowserPanel,
FileTree,
FileTypeIcon,
fileTypeOf,
type FileType,
type FileTypeIconProps,
FileView,
canPreviewFile,
formatFileViewSize,
getFileViewPreviewKind,
openFileBrowser,
type FileBrowserPanelProps,
type FileSource,
type FileTreeActions,
type FileTreeEntry,
type FileTreeProps,
type FileViewContent,
type FileViewFile,
type FileViewPreviewKind,
type FileViewProps,
type FileViewRenderer,
type FileViewRendererProps,
} from "@k2b/ui";File and folder icon tiles
FileTypeIcon shows a file or folder glyph in a soft chip of its type color.
FileTree rows use it at size="sm"; use size="md" in file-list rows and
size="lg" for FileGrid previews without a thumbnail. FileGrid leaves
preview content to its host. Folders take the app accent hook
--k2b-app-workspace-active.
<FileTypeIcon name="report.pdf" size="md" />
<FileTypeIcon name="Projects" folder open size="sm" />| Property | Type | Default | Purpose |
|---|---|---|---|
name |
string |
required | File or folder name; its extension selects the glyph and color family. |
mediaType |
string |
none | Media type used when resolving the file glyph and family. |
folder |
boolean |
false |
Uses the folder family and app accent. |
open |
boolean |
false |
Shows the open-folder glyph for a folder. |
icon |
string |
inferred | Overrides the Tabler glyph without changing its type color. Colour utility classes are ignored. |
size |
"sm" | "md" | "lg" |
"md" |
Fixed box of 1.25rem, 2rem, or 4.5rem. |
class |
string |
none | Adds classes to the icon wrapper. |
data-file-type records one FileType: folder, pdf, image, sheet,
document, slides, video, audio, archive, or other. Code, text, and
unknown files use the neutral other family. The glyph and chip keep their
size and fill on hover, focus, and selection, in light and dark themes.
fileTypeOf({ name, mediaType?, folder? }) returns this family without rendering.
The component renders on the server and needs no browser code. Its wrapper
is decorative (aria-hidden="true"); keep a visible file name beside it.
FileSource
FileSource is the asynchronous boundary:
type FileSource = {
list(): Promise<FileTreeEntry[]>;
read(path: string): Promise<FileViewContent>;
write?(path: string, content: string, encoding?: "utf8" | "base64"):
Promise<void>;
remove?(path: string): Promise<void>;
rename?(from: string, to: string): Promise<void>;
upload?(dirPath: string, files: File[]): Promise<void>;
downloadHref?(path: string): string | null;
isReadOnly?(path: string): boolean;
};Only supplied capabilities receive matching controls. readOnly on
FileBrowserPanel hides every mutation even when the source implements it.
isReadOnly can protect individual paths such as generated inputs.
Capabilities are independent: for example, a source may offer rename or
remove without offering write.
The host authenticates every operation and checks authorization again inside the source. Hiding a control is not an authorization boundary.
Path-first tree
Each FileTreeEntry has an absolute-style path such as /src/app.tsx.
Folders are derived from file paths; explicit { path, kind: "folder" }
entries represent empty folders. Optional displayName changes the visible and
accessible label; selection, reading, and renaming still use the original path.
selectedPath and onSelect own selection. expandedPaths and
onExpandedChange provide controlled expansion; otherwise folders start
expanded. FileTreeActions enables rename, remove, create, move, and download
affordances individually.
contextMenu adds application-specific menu items without replacing the
built-in actions.
File previews
FileView receives a FileViewFile, an asynchronous load function, and
optional preview and download URLs. Text, Markdown, JSON, delimited text,
images, PDF, audio, and video use built-in renderers. Supplying save enables
editing for compatible text renderers.
previewHref is an authenticated URL that answers inline. With it, images,
PDF, audio, and video render natively from that URL and load is not called.
Video plays in VideoPlayer: the browser fetches
it by range from previewHref, so serve that URL with 206 answers to Range
requests. A video the browser cannot decode shows the player's fallback with a
download link to downloadHref.
downloadHref only adds download actions; it is never a preview source,
because browsers do not show an attachment response inline. Without
previewHref, a PDF renders from the bytes load returns, through
PdfPreview: its "Open preview" action, which opens it
in the browser's own viewer, sits above the document in the preview frame.
There is no visible heading, because the host already names the file; the
document frame keeps the file name as its accessible title.
Pass revision to refetch a FileView whose path did not change. A
FileBrowserPanel forwards its refreshKey to both the file list and the
selected preview. A PDF preview reloads only when the refetched bytes differ,
so an unchanged file keeps its open page.
Markdown files edit through MarkdownEditor. Other UTF-8 source and text files
use a plain monospace textarea inside the same editor chrome, so Markdown
formatting controls and completions are not offered for code.
FileViewContent is either UTF-8 or base64:
type FileViewContent = {
encoding: "utf8" | "base64";
content: string;
mediaType: string;
};getFileViewPreviewKind returns the inferred FileViewPreviewKind.
Both helpers apply the built-in size limits when size is supplied: 2 MiB for text, Markdown, JSON and delimited text; 25 MiB for images; 50 MiB for PDF, audio, and video. Unsupported or oversized files return null from getFileViewPreviewKind and false from canPreviewFile.
Video means MP4, M4V, QuickTime (.mov), WebM, and Ogg video, by media type or extension. Whether a browser decodes one depends on its codec. A host that streams video by range can omit size to preview it at any size.
formatFileViewSize produces the compact size label used by the preview.
Pass renderers to FileView or FileBrowserPanel to add
application-specific renderers before the built-ins. Extensions are scoped to
that component instance, so concurrent SSR requests and independently mounted
applications cannot mutate each other's renderer set.
FileView reports local edits through onDirtyChange. FileBrowserPanel
uses that signal to guard selection changes; pass confirmDiscard to own the
confirmation copy and policy. Without it, the shared confirmation prompt is
used.
Dialog helper
openFileBrowser({ source, title, subtitle, icon }) opens the shared panel in
a dialog and resolves when it closes. The supplied source has the same
capability and authorization responsibilities as an inline panel.
API reference
type FileTreeEntry = {
path: string; displayName?: string; kind?: "file" | "folder"; size?: number; mediaType?: string;
updatedAt?: string; icon?: string; badge?: string;
};
type FileTreeActions = {
rename?: (path: string, nextName: string) => void | Promise<void>;
remove?: (path: string) => void | Promise<void>; createFile?: (dirPath: string) => void | Promise<void>;
createFolder?: (dirPath: string) => void | Promise<void>;
move?: (path: string, targetDir: string) => void | Promise<void>;
download?: (path: string, isFolder: boolean) => void | Promise<void>;
};
type FileTreeProps = {
entries: FileTreeEntry[]; selectedPath?: string | null; onSelect?: (entry: FileTreeEntry) => void;
expandedPaths?: Set<string>; onExpandedChange?: (expanded: Set<string>) => void;
contextMenu?: (entry: FileTreeEntry) => DropdownItem[]; actions?: FileTreeActions; label?: string;
class?: string;
};
type FileViewFile = {
path: string; mediaType?: string; size?: number;
};
type FileViewPreviewKind = "markdown" | "image" | "pdf" | "json" | "delimited-text" | "audio" | "video" | "text";
type FileViewRendererProps = {
previewLines?: number; onExpandPreview?: () => void; headingScale?: "compact" | "normal" | "large";
file: FileViewFile; content: FileViewContent; previewHref: string | null; downloadHref: string | null;
editor: {
draft: () => string;
setDraft: (value: string) => void;
dirty: () => boolean;
saving: () => boolean;
save: () => Promise<void>;
} | null;
};
type FileViewRenderer = {
id: string; match: (file: FileViewFile, content: FileViewContent) => boolean;
component: Component<FileViewRendererProps>; editable?: boolean;
};
type FileViewProps = {
variant?: "default" | "plain"; onDocumentTitle?: (title: string | null) => void;
previewLines?: number; onExpandPreview?: () => void; headingScale?: "compact" | "normal" | "large";
file: FileViewFile; load: () => Promise<FileViewContent>; revision?: unknown;
registerRefresh?: (refresh: () => Promise<void>) => void | (() => void);
save?: (content: string) => Promise<void>; previewHref?: string | null; downloadHref?: string | null;
renderers?: readonly FileViewRenderer[]; onDirtyChange?: (dirty: boolean) => void; class?: string;
};
type FileBrowserPanelProps = {
source: FileSource; readOnly?: boolean; refreshKey?: unknown; initialPath?: string;
onSelectedPathChange?: (path: string | null) => void; renderers?: readonly FileViewRenderer[];
confirmDiscard?: (path: string, nextPath: string | null) => boolean | Promise<boolean>; class?: string;
registerRefresh?: (refresh: () => Promise<void>) => void | (() => void);
};FileSource and FileViewContent are defined above. Component<P> is a Solid component taking P and returning JSX. Renderer order matters: the first matching custom renderer wins before built-ins. Its editable capability and the supplied save determine editing availability. registerRefresh receives a refresh function and may return cleanup; the host should release stored references on cleanup. onSelectedPathChange reports selection; initialPath initializes it rather than controlling it. refreshKey/revision may be any changing identity.
contextMenu returns DropdownItem entries. FileTreeActions.rename receives a path and new name; FileSource.rename receives full from/to paths. Size fields are bytes. getFileViewPreviewKind(file: FileViewFile): FileViewPreviewKind | null infers the format; canPreviewFile(file: FileViewFile): boolean applies format and size support; formatFileViewSize(bytes: number): string formats bytes.
Accessibility
FileTree exposes tree and tree-item roles with arrow-key navigation.
Selection, expansion, context actions, preview actions, and text editing
remain keyboard reachable.
Runtime
The static tree and preview shells render on the server. Resource loading, selection, editing, drag and drop, media controls, prompts, and dialogs require browser code.
Example
const entries = (): FileTreeEntry[] =>
Object.entries(contentByPath).map(([path, content]) => ({
path,
mediaType: path.endsWith(".md") ? "text/markdown" : "text/plain",
size: new TextEncoder().encode(content).byteLength,
}));
const source: FileSource = {
list: async () => entries(),
read: async (path) => ({
encoding: "utf8",
content: contentByPath[path] ?? "",
mediaType: path.endsWith(".md") ? "text/markdown" : "text/plain",
}),
write: async (path, content) => {
contentByPath[path] = content;
},
rename: async (from, to) => {
contentByPath[to] = contentByPath[from] ?? "";
delete contentByPath[from];
},
remove: async (path) => {
delete contentByPath[path];
},
};
<FileBrowserPanel
source={source}
initialPath="/README.md"
renderers={applicationRenderers}
confirmDiscard={(path) => confirm(`Discard changes to ${path}?`)}
/>;Direct composition
<FileTree
entries={entries()}
selectedPath={selectedPath()}
onSelect={(entry) => setSelectedPath(entry.path)}
/>
<FileView
file={{ path: selectedPath(), mediaType: "text/plain" }}
load={() => source.read(selectedPath())}
save={(content) => source.write!(selectedPath(), content)}
renderers={applicationRenderers}
/>Paginated list and grid views
Use FileGrid with createCollectionSelection when the host owns a paginated
collection rather than an entire tree. Pass the same controller to
DataTable.selection and FileGrid.selection to preserve selection when changing
views. Both require stable getRowId values. The controller's ids accessor is
the selectable collection in display order; removed IDs are pruned.
const selection = createCollectionSelection({ ids: () => entries().map(file => file.path) });
<FileGrid
rows={entries()}
getRowId={file => file.path}
selection={selection}
label="Files"
size="md"
renderPreview={file => <FileTypeIcon name={file.name} folder={file.directory} size="lg" />}
renderLabel={file => file.name}
renderMeta={file => file.sizeLabel}
onOpen={openFile}
/>Click selects, Ctrl/Cmd-click toggles, and Shift-click selects a range. Arrow keys,
Home, and End navigate; Space toggles, Ctrl/Cmd-A selects the supplied collection,
and Escape clears it. Enter and double-click call onOpen (or
DataTable.onRowDoubleClick). Nested controls retain their own behavior.
renderActions accepts host-owned buttons or checkboxes; itemProps adds
host attributes such as drag-and-drop handlers to each tile. The host owns data,
context menus, authorization, previews, and navigation; onContextMenu can align
selection before opening a menu. Use sm, md, or lg for tile size. FileGrid
has no scroll owner; place it in ScrollArea. A DataTable already owns scrolling.
layout="list" shows one compact row per item instead of a tile: a 2 rem
preview slot, the name on one line, and the meta below it, with actions at the
trailing edge. Rows have no lines between them; hover and selection use the same
fills as tiles. Use it for choosing among files that have no thumbnail, for
example in a picker dialog. Keep DataTable for lists that need columns,
sorting, or row structure.
A click that replaces the rows, for example by opening a folder, does not turn a double-click into a second action: the rest of the gesture is ignored by the item that moved under the pointer.
Three options of createCollectionSelection serve pickers:
isDisabled(id)marks an item that cannot be selected or opened, for example a file a picker cannot accept. It stays inidsand in focus order, so arrow keys and screen readers still reach it.FileGridgives itaria-disabledand muted text, and clicks, Space, and Enter do nothing; ranges and select-all leave it out. Say why inrenderMeta.multiple: falsekeeps at most one item selected. A click or arrow key selects only that item; ranges and Ctrl/Cmd-A do nothing. The grid reportsaria-multiselectable="false".checklist: truemakes a plain click or tap toggle one item, so touch users can choose several. Arrow keys then only move focus, Space toggles, and Shift-click still selects a range.
Escape clears a selection. With nothing selected, it is left to the host, so Escape inside a dialog closes the dialog.
For signed native media URLs, FileView.crossOrigin="anonymous" omits credentials
on cross-origin media requests. onPreviewError reports native image and audio
load errors, and a video that falls back, so the host can request a fresh URL
and offer retry. These options
leave existing media behavior unchanged when omitted. PDF and text content can
instead be fetched by the host with an abort signal and explicit byte limit.
Compact previews
Pass previewLines={5} and onExpandPreview to FileView for a detail-panel
excerpt. Text and Markdown show five source lines; CSV/TSV show five data rows
plus the header. The remaining-line button calls onExpandPreview. Omit
previewLines in the expanded view. CSV/TSV then paginate through all loaded
records, 200 per page, retaining the header. The 50-column limit is displayed
when columns are omitted; existing file-size limits still apply.
headingScale="compact" uses smaller Markdown headings and tighter paragraph spacing in a small surface.
Use headingScale="normal" for a full-document view. The default remains
compact. These options also reach custom renderers through
FileViewRendererProps.
Use variant="plain" when the host already frames the preview, such as a
dialog, a detail panel group, or a page section. The default keeps the preview
frame for a preview that stands alone.
A plain preview has no frame, background, or document padding. It keeps its natural height, so the host is the one element that scrolls; a host that used to rely on a plain preview scrolling inside a bounded box now scrolls it itself. Wide content, such as a CSV table, scrolls sideways in place. Markdown keeps a reading measure of 72 characters in wider hosts; its tables and code blocks scroll within that column. Around a document, the preview reaches 0.5rem into its host on both sides and pads the text back into place, so a Markdown table's lines can run past the text. A host that clips leaves that much room around the preview. Text and source files show their line numbers and soft-wrapped lines directly on the host surface, without a code box or its copy header, so the host offers copying where it needs it. JSON shows its keys and values on the host surface without their own card. An excerpt's "more lines" action lines up with the content.
A host that bounds its height can stretch a plain preview to fill it, for
example with flex: 1 1 auto; min-height: 0. Images and video then fit that
height, and a table scrolls inside the preview, so its header row and the
preview actions stay in view. Without a bounded height, a video keeps a 16:9
frame.
Document title
Pass onDocumentTitle when the host shows its own title, for example a preview
dialog. When the Markdown preview starts with a level-one heading, FileView
reports the heading's plain text and leaves that heading out of the preview,
so the title is not shown twice. It reports null for Markdown without a
leading heading, for every other file type, and when the file fails to load;
the host then titles the preview with the file name. Nothing is reported while
the file loads. A leading YAML front matter block is skipped first. The
heading stays in the Markdown editor, and the reported title follows edits.
The heading stays in the document, and null is reported, when it contains a
link or an image, which a plain-text title would lose, and when nothing follows
it, so the preview is never empty. The title reaches the host from an effect,
so only in the browser; a server render shows the document without its leading
heading before mount.
Pass the file name as a constant subtitle, as below, so the header does not gain a line when the title arrives. A title that may wrap onto more lines than the file name needs its own plan, for example showing the lines below it only together with the title.
const [title, setTitle] = createSignal<string | null | undefined>(undefined);
<PanelDialog.Header title={title() ?? fileName} subtitle={fileName} close={close} />
<PanelDialog.Body>
<FileView variant="plain" headingScale="normal" file={file} load={load} onDocumentTitle={setTitle} />
</PanelDialog.Body>