Object List
Folder and file list that renders useObjectList or any matching state.
ObjectList renders a folder catalog. Pass the object returned by
useObjectList as list. The component
does not fetch and does not call the hook. A server payload or your own
store works when it has the same fields.
Preview
The preview groups an in-memory list with readObjectListLevel. Four files
load at a time. Scroll to the end of the frame and the next page loads. Swap
query for your API, or for dbObjectQuery, without changing the component.
Install
Complete UI Setup first — stylesheet and toaster. Then add this component:
import { ObjectList } from "@dimah-s3/ui";Usage
"use client";
import {
useDelete,
useDownload,
useObjectList,
useUpload,
type ObjectListQuery,
} from "@dimah-s3/react";
import { ObjectList, ObjectListMenu, UploadButton } from "@dimah-s3/ui";
type LibraryFile = {
id: string;
name: string;
key: string;
sizeLabel: string;
type: string;
};
export function Library({ query }: { query: ObjectListQuery<LibraryFile> }) {
const list = useObjectList({ query });
const upload = useUpload({
route: "docs",
maxFiles: 8,
noClick: true,
noKeyboard: true,
getUploadOptions: () => ({
metadata: { folderId: list.parent ?? "" },
}),
});
const download = useDownload({ route: "docs" });
const share = useDownload({ route: "docs", disposition: "inline" });
const remove = useDelete({ route: "docs" });
return (
<ObjectList
list={list}
getKey={(file) => file.id}
getLabel={(file) => file.name}
getDescription={(file) => file.sizeLabel}
getFileType={(file) => file.type}
upload={upload}
header={
<UploadButton upload={upload} size="sm" status={false} toast={false} />
}
delete={remove}
getObjectKey={(file) => file.key}
onOpenItem={(file) => {
void share.presign(file.key, file.name).then((signed) => {
if (signed) window.open(signed.url, "_blank", "noopener,noreferrer");
});
}}
renderActions={(file) => (
<ObjectListMenu
objectKey={file.key}
fileName={file.name}
download={download}
share={share}
delete={remove}
onDeleted={() => void list.refresh()}
/>
)}
/>
);
}getKey and getLabel are the only required accessors. Pass upload to drop or paste files onto the open folder. Pass delete, selectable, selectionActions, or onSelectionChange to select the loaded file rows. The checkbox sits inside the row. Shift-click a checkbox to extend from the previous row. Escape clears the selection. With focus inside the list, Ctrl+A or Cmd+A selects the same rows as the header checkbox. A selected row sets data-selected. Files set data-kind="file" and folders set data-kind="folder". getObjectKey is the stored object key when it differs from getKey. Pass resolveFolderKeys to give a folder a checkbox; bulk delete includes the keys it returns. Without it, folders stay navigation-only. onOpenItem runs when a file row is clicked. renderMeta sits beside the file name. renderActions is where the row menu goes. renderItem replaces the whole row, including the checkbox and renderMeta.
The header checkbox selects rows loaded so far. When list.hasMore is set, its name is "Select this page" and the bar says the count is on this page. Rows on the next page stay unchecked. onSelectionChange receives the checked files and folders from the loaded page. It also runs when the open folder changes and the selection clears. selectionActions renders on the selection bar beside Delete. Those controls are inert while a bulk delete is running.
ObjectListMenu downloads, deletes, and copies a link. Pass onDeleted to reload after that row is deleted. Leave useDelete's onSuccess for your own work: a bulk delete calls onSuccess once per key, and the selection bar already reloads once. Copy link signs through share when you pass it, otherwise through download, then writes the URL to the clipboard. Pass share as a second useDownload with disposition: "inline" when the link should open in a browser while Download stays an attachment. Pass expiresIn to ask for a shorter TTL than the route cap. The server signs the shorter of that value and download.expiresIn, and the toast shows the TTL that was actually signed. Signing a link does not start a download. Pass actions for extra rows, such as rename. They render after Copy link and before Delete.
Frame
Pass height to keep the header in place and scroll the rows inside a fixed frame. A number is pixels. A string is a CSS length, such as "18rem" or "100%". Pass maxHeight when the frame should grow with the folder until that cap, then scroll the same way. Upload progress stays with the header.
When list.hasMore is set, the next page loads as the end of the rows comes into view. A frame that is not full keeps loading until it is, or until there is no next page. Scrolling to the end loads the page after that.
Filter
Keep the search box outside ObjectList. Put the text in query, and in queryKey so the first page loads again. When the filter matches nothing, pass emptyTitle and emptyDescription. Otherwise the empty state says the folder is empty. Pass empty to replace that state.
const [search, setSearch] = useState("");
const list = useObjectList({
query: ({ parent, cursor, signal }) =>
loadFiles({ folder: parent, search, cursor, signal }),
queryKey: [search],
});Upload into the open folder
Pass the same useUpload to upload and, for the file picker, to UploadButton in header. Set noClick and noKeyboard on the hook. The list surface accepts a drop and a paste, and it does not open the picker when a row is clicked. The list renders the hook's file input itself, so upload.open() from any trigger — a menu item, a split button, or an empty-state call to action — opens the picker.
getUploadOptions reads list.parent and sends it as metadata.folderId. It runs as each file starts, so it sees the folder that is open at that moment. Read clientMetadata.folderId in upload.object or upload.guard and store it on your file row. The library does not write that id into the object key, and it does not retarget a drop onto the folder under the pointer.
When the batch settles and at least one file succeeded, ObjectList calls list.refresh() once. A batch where some files fail still reloads, then the status strip stays on the failure until upload.reset(). Leave onSuccess for your own work. A second list.refresh() there loads the page twice. Set toast={false} and status={false} on the header button so the list owns progress and toasts.
const upload = useUpload({
route: "docs",
maxFiles: 8,
noClick: true,
noKeyboard: true,
getUploadOptions: () => ({
metadata: { folderId: list.parent ?? "" },
}),
});
<ObjectList
list={list}
getKey={(file) => file.id}
getLabel={(file) => file.name}
upload={upload}
header={
<UploadButton upload={upload} size="sm" status={false} toast={false} />
}
/>;trail drives the breadcrumb. useObjectList fills it when you open folders with openFolder and navigate. For a controlled URL, keep the same path in defaultTrail on the first render, or pass trail on the list object. A path longer than the root plus two folders collapses the earlier folders into a menu.
Storage
Return usage from the query when you know how full the catalog is. useObjectList keeps it on list.usage. A later page can omit it. A refresh or a new folder that omits it clears the line.
dbObjectQuery forwards the usage already on listObjects: bytes and file count for the whole scope, not the open folder. Pass your own caps as usageLimits. Those are the same numbers as db({ quota }) when that plugin enforces the limit. The component does not read the server config. Without usageLimits, the line is the totals only. Without usage, nothing is shown.
<ObjectList
list={list}
getKey={(file) => file.id}
getLabel={(file) => file.name}
usageLimits={{ maxBytes: 100 * 1024 * 1024, maxFiles: 50 }}
/>A query you write yourself uses the same shape:
return {
items: page.files,
nextCursor: page.nextCursor,
usage: { totalBytes: page.totalBytes, objectCount: page.fileCount },
};The meter follows whichever cap is closer to full. At the cap, the line and the meter use the destructive color.
Folders
ObjectList does not invent folders. The first page of query returns them. dbObjectQuery lists objects and sends parent as a key prefix. Generated keys contain a uuid segment, so that prefix is not a folder tree. Use it for a flat catalog.
For a tree, keep folders in your own table and return them from query. Return folders only on the first page. Later pages append items.
const list = useObjectList({
query: async ({ parent, cursor, signal }) => {
const page = await loadLibrary({ folderId: parent, cursor, signal });
return {
folders: cursor ? undefined : page.folders,
items: page.files,
nextCursor: page.nextCursor,
};
},
});readObjectListLevel groups a complete in-memory list. Pass the full set for that listing. One page of a database query cannot be grouped after the fact. Upload into the open folder from the section above.
Menu
import type { ObjectListMenuProps } from "@dimah-s3/ui";import type { ObjectListMenuAction } from "@dimah-s3/ui";Props
import type { ObjectListProps } from "@dimah-s3/ui";Prop
Type