Object List
Composable file browser for useObjectList — list and grid views, search, sort, selection, folders, upload, and a file menu.
ObjectList renders a folder catalog. Pass the object returned by
useObjectList as list. The component
does not fetch and does not call the hooks you give it.
Preview
The preview lists records in memory with memoryObjectSource. Six files
load at a time. Search, sort, switch to the grid, select rows with
Shift-click or Ctrl+A, or drop a file onto the frame. Swap the source for
your API, or for dbObjectSource, without changing the component.
Install
Complete UI Setup first: the stylesheet and one
<Toaster />. Then add this component:
import { ObjectList } from "@dimah-s3/ui";Usage
Pass list, then the hooks you already have. Each one switches a feature on.
"use client";
import {
useDelete,
useDownload,
useObjectList,
useObjectListSelection,
useUpload,
type ObjectListSource,
} from "@dimah-s3/react";
import { ObjectList, UploadTrigger } from "@dimah-s3/ui";
type LibraryFile = { id: string; key: string; name: string; size: number };
export function Library({ source }: { source: ObjectListSource<LibraryFile> }) {
const list = useObjectList(source);
const selection = useObjectListSelection(list);
const upload = useUpload({ route: "docs", maxFiles: 8, queue: true });
const download = useDownload({ route: "docs" });
const remove = useDelete({ route: "docs" });
return (
<ObjectList
list={list}
selection={selection}
upload={upload}
download={download}
delete={remove}
toolbar={<UploadTrigger size="sm" />}
/>
);
}| Prop | Adds |
|---|---|
selection | Checkboxes, the selection bar, Shift ranges, Ctrl+A / Cmd+A, Escape, Space |
upload | Drop and paste into the open folder, progress under the header |
download | Download in the file menu. useDownload or useFetchDownload |
share | Copy link through a second useDownload, such as disposition: "inline" |
delete | Delete in the file menu with a confirmation, and bulk Delete on the selection |
toasts | Toasts for those hooks. On by default; see Toasts |
Rows read list.getFile(item): the name, object key, size, type, date, and
thumbnail you return from getFile on the source. The second line shows the
size and the date. A file with previewUrl shows the image; the name stays
the accessible label.
The list is the Upload,
Download, and
Delete root for its hooks. So
UploadTrigger, UploadDropzone, DownloadTrigger, and DeleteTrigger
work inside it without their own root.
Compose
children replaces the default layout. Every part reads the nearest
ObjectList, so you only arrange them. Props on ObjectList configure the
data and the entries; children arrange the frame.
import {
ObjectList,
ObjectListContent,
ObjectListHeader,
ObjectListSearch,
ObjectListSortMenu,
ObjectListUploadStatus,
ObjectListUsage,
ObjectListViewToggle,
UploadTrigger,
} from "@dimah-s3/ui";
<ObjectList
list={list}
selection={selection}
upload={upload}
delete={remove}
height="28rem"
>
<ObjectListHeader>
<ObjectListSearch />
<ObjectListSortMenu />
<ObjectListViewToggle />
<UploadTrigger size="sm" />
</ObjectListHeader>
<ObjectListUsage />
<ObjectListUploadStatus />
<ObjectListContent />
</ObjectList>;| Part | Renders |
|---|---|
ObjectListHeader | Select-all, the path, a reload spinner, New folder, and children. The selection bar while selecting |
ObjectListBreadcrumb | The path. It shortens to fit, and a menu lists the folders above the open one |
ObjectListSearch | Debounced search for list.search. In the header, a button that opens a field across the bar |
ObjectListSortMenu | Name, size, and date order for list.sort. Pass options for your fields |
ObjectListViewToggle | List and grid buttons |
ObjectListSelectAll | The select-all checkbox |
ObjectListSelectionBar | Count, actions, bulk Delete, and Clear |
ObjectListBulkDelete | Bulk Delete for a bar of your own |
NewFolderControl | New folder, when the source has createFolder. In the header it opens across the bar. ObjectListHeader already has one |
ObjectListUsage | Bytes and files from list.usage, with a meter for its caps |
ObjectListUploadStatus | Upload progress and errors |
ObjectListContent | Rows or tiles, loading, error, and empty states, and the next page |
ObjectListEmpty | The empty state, with your own text or a call to action |
ObjectListItemMenu | Download, Copy link, your actions, and Delete for a file. Upload to this folder for a folder |
Header
ObjectListHeader lays out by its own width, so it fits a sidebar as well as
a page:
- The path keeps room for itself. When the controls do not fit beside it, they move to a second row, aligned to the end.
- The path shortens to fit:
Files › … › Parent › Current, thenFiles › … › Current, then… › Current. The … menu lists every folder above the open one. - Under 36rem, the labels of New folder, Sort, bulk Delete, and the default
UploadTriggerhide, leaving square icon buttons with the same names. ObjectListSearchis a search button. A click opens a field across the whole bar; it stays open while it has text. Escape clears the text, then closes it, and Close search does both. Passcollapsible={false}to keep the field in the bar.- New folder opens the same way. A click replaces the bar with the name field, the create button, and Cancel. Enter creates the folder; Escape or Cancel closes the field and brings the path and the other controls back.
Render the controls as direct children of the header, so an open search or new folder can take the bar from them.
useObjectListContext() returns the list, selection, hooks, and view, for
parts of your own. Inside an entry, useObjectListEntry() returns it.
Entries
Change part of every entry with a render* prop. Each receives the entry:
entry.kind is "file" or "folder", entry.item is your record, and
entry.file is what getFile returned.
<ObjectList
list={list}
onOpenItem={(file) => openPreview(file)}
renderMeta={(entry) =>
entry.kind === "file" && entry.item.shared ? <Badge>Shared</Badge> : null
}
renderDescription={(entry) =>
entry.kind === "file"
? `${entry.item.owner} · ${formatSize(entry.item.size)}`
: null
}
renderActions={(entry) =>
entry.kind === "file" ? (
<ObjectListItemMenu
actions={[{ label: "Rename", onClick: () => rename(entry.item) }]}
/>
) : null
}
/>onOpenItemruns when a file is clicked or activated with Enter. Folders always open in place.renderActionsreplaces the trailing controls. Without it, files getObjectListItemMenuwhen the list hasdownload,share, ordelete, and folders get it when files can be uploaded into them. Returnnullto hide it.renderDescriptionreplaces the second line.renderMetasits beside the name.renderEntryreplaces the whole row or tile. ReturnObjectListRoworObjectListTilewith your ownclassName,media, oractionsto keep selection, keyboard, and opening.
Each entry sets data-slot="object-list-entry", data-kind, data-view, and data-selected for styling.
Views
defaultView="grid" starts in the grid. Pass view and onViewChange to
keep it in your own state, for example in the URL. ObjectListViewToggle
switches it. The grid shows previewUrl thumbnails at full width and a
larger glyph for other files.
Search and sort
list.setSearch and list.setSort reload the first page. The query receives
search (trimmed) and sort; the source decides what they mean.
memoryObjectSource matches names and sorts by name, size, date, or
sortValue. A query of your own passes them to your API. While the new page
loads, the current rows stay on screen.
<ObjectListSortMenu
options={[
{ field: "name", label: "Name" },
{ field: "owner", label: "Owner" },
{ field: "updatedAt", label: "Modified", direction: "desc" },
]}
/>Opening another folder clears the search. With a search and no match, the empty state says nothing matches.
Selection
Pass useObjectListSelection(list) as selection. By default only files can
be selected. Click a checkbox, Shift-click to extend from the last one, or
Ctrl-click / Cmd-click a row. With focus inside the list, Space toggles the
focused entry, Ctrl+A or Cmd+A selects every loaded entry, and Escape
clears. With more pages to load, select-all takes the rows on screen and
says so.
The selection bar shows the count, selectionActions, bulk Delete when the
list has delete, and Clear. Controls in selectionActions read the
selection you already hold:
const selection = useObjectListSelection(list);
<ObjectList
list={list}
selection={selection}
selectionActions={
<Button size="sm" onClick={() => share(selection.selectedItems)}>
Share
</Button>
}
/>;To delete folders, let them be selected and say which keys they hold.
Without resolveFolderKeys, selected folders stay out of the delete.
const selection = useObjectListSelection(list, { isSelectable: () => true });
<ObjectList
list={list}
selection={selection}
delete={remove}
resolveFolderKeys={(folder) => api.keysUnder(folder.id)}
/>;Delete
The file menus and the selection bar share one confirmation. removeMany
sends the keys in batches of 100. Deleted files leave the rows and the
selection at once, then the list reloads the pages on screen. Selected
folders stay selected while they still exist.
onDeleted receives the DeleteResult of each confirmed delete: the keys
that were deleted, those that failed, and those beforeDelete blocked. Leave
useDelete's onSuccess for your own work.
After a delete, focus moves to the entry that took the place of the first deleted one, else the one before it, else the frame.
Keyboard
| Key | Action |
|---|---|
| Arrow keys, Home, End | Move between entries (by row in the grid) |
| Enter | Open the folder, or call onOpenItem |
| Space | Toggle the focused entry. Shift: the range |
| Ctrl+A / Cmd+A | Select every loaded entry |
| Escape | Clear the selection |
| Backspace / Alt+ArrowUp | Open the parent folder |
Fields, menus, and dialogs inside the list keep their own keys.
Upload into folders
Pass a useUpload as upload. The frame takes drops and pastes into the
open folder. It is a drop target, not a dropzone: clicking it does not open
the picker, so the hook needs no noClick or noKeyboard. For the picker,
put an UploadTrigger in the header, the toolbar, or the empty state. It
uses the list's hook without its own Upload.
<ObjectList
list={list}
upload={upload}
empty={
<ObjectListEmpty>
<UploadTrigger size="sm" />
</ObjectListEmpty>
}
/>Where a file goes comes from the source. When it has uploadOptions(parent),
every file uploaded into the list sends the options for its folder:
- drops and pastes on the frame use the open folder;
- a folder row takes drops for that folder and announces
Drop to upload into {name}while a drag is over it, and its menu has "Upload to this folder" as the keyboard path; UploadTriggerandUploadDropzoneinside the list use the open folder. Their owncallmerges over it.
dbObjectSource sets uploadOptions for its folders. With your own source,
return the folder as metadata, read it as clientMetadata in the server's
upload hook, and store it on your file row:
const list = useObjectList({
...source,
uploadOptions: (parent) => ({ metadata: { folderId: parent ?? "" } }),
});Without uploadOptions, files use the hook's own options, and folder rows
take no drops. Each time the queue settles with new uploads, the list
reloads once. While the hook is disabled, the frame and folder rows ignore
drops and pastes, and "Upload to this folder" is disabled.
Toasts
With toasts on (the default), the list shows toasts for upload,
download, and delete. They step aside for an UploadToasts,
DownloadToasts, or DeleteToasts you mount for the same hook, so a hook
never reports twice. Mount one <Toaster /> either way.
Copy link and the bulk delete report their own result. With
toasts={false}, they, and every delete result, speak through a polite live
region instead. The drop overlay follows only drags over the list itself.
File menu
ObjectListItemMenu downloads, copies a link, and deletes one file. Inside a
list every prop is optional: the object key, name, and size come from the
entry, and the hooks come from the list. Delete asks the list's confirmation.
Copy link signs one URL through share when the list has it, otherwise
through a navigate-mode download, then writes it to the clipboard. Signing
does not start a download. Pass expiresIn to ask for a shorter TTL than
the route cap; the message shows the TTL that was signed. Pass actions, or
DropdownMenuItem children, for rows such as rename. They render after Copy
link and before Delete.
Outside a list the menu has Copy link, with share, and your own rows:
<ObjectListItemMenu
objectKey={file.key}
fileName={file.name}
share={share}
actions={[{ label: "Rename", onClick: () => rename(file) }]}
/>Frame
Pass height to keep the header in place and scroll the entries 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 up
to that cap.
With loadMore="auto" (the default), the next page loads as the end of the
entries comes into view, and a frame that is not full keeps loading until it
is. loadMore="button" shows Load more instead. A failed page shows Retry,
which repeats that page.
Storage
Return usage from the query and useObjectList keeps it on list.usage.
ObjectListUsage shows the bytes and the file count. When the usage carries
maxBytes or maxFiles, it adds a meter that follows whichever cap is
closer to full and turns destructive at the cap. Without usage, nothing is
shown.
The caps come from the source: memoryObjectSource({ limits }), or the
db({ quota }) caps through dbObjectSource. dbObjectSource sends the
scope total, not the open folder's.
Folders
ObjectList does not invent folders. The first page of the query returns
them.
memoryObjectSourcebuilds them from a/-separated path withgetFolder.dbObjectSourcelists the folders stored bydb(), uploads into the open folder, and creates folders with New folder.- With your own source, return folders from your query on the first page, and
add
createFolderto enable New folder.
New folder, in the header, opens a name field across the bar. Enter creates the folder through the source, and Escape or Cancel closes the field. An invalid or taken name shows a message under the field that describes it.
Props
import type { ObjectListProps } from "@dimah-s3/ui";Prop
Type
File menu
import type { ObjectListItemMenuProps } from "@dimah-s3/ui";