2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0
ComponentsFiles

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" />}
    />
  );
}
PropAdds
selectionCheckboxes, the selection bar, Shift ranges, Ctrl+A / Cmd+A, Escape, Space
uploadDrop and paste into the open folder, progress under the header
downloadDownload in the file menu. useDownload or useFetchDownload
shareCopy link through a second useDownload, such as disposition: "inline"
deleteDelete in the file menu with a confirmation, and bulk Delete on the selection
toastsToasts 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>;
PartRenders
ObjectListHeaderSelect-all, the path, a reload spinner, New folder, and children. The selection bar while selecting
ObjectListBreadcrumbThe path. It shortens to fit, and a menu lists the folders above the open one
ObjectListSearchDebounced search for list.search. In the header, a button that opens a field across the bar
ObjectListSortMenuName, size, and date order for list.sort. Pass options for your fields
ObjectListViewToggleList and grid buttons
ObjectListSelectAllThe select-all checkbox
ObjectListSelectionBarCount, actions, bulk Delete, and Clear
ObjectListBulkDeleteBulk Delete for a bar of your own
NewFolderControlNew folder, when the source has createFolder. In the header it opens across the bar. ObjectListHeader already has one
ObjectListUsageBytes and files from list.usage, with a meter for its caps
ObjectListUploadStatusUpload progress and errors
ObjectListContentRows or tiles, loading, error, and empty states, and the next page
ObjectListEmptyThe empty state, with your own text or a call to action
ObjectListItemMenuDownload, Copy link, your actions, and Delete for a file. Upload to this folder for a folder

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, then Files › … › 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 UploadTrigger hide, leaving square icon buttons with the same names.
  • ObjectListSearch is 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. Pass collapsible={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
  }
/>
  • onOpenItem runs when a file is clicked or activated with Enter. Folders always open in place.
  • renderActions replaces the trailing controls. Without it, files get ObjectListItemMenu when the list has download, share, or delete, and folders get it when files can be uploaded into them. Return null to hide it.
  • renderDescription replaces the second line. renderMeta sits beside the name.
  • renderEntry replaces the whole row or tile. Return ObjectListRow or ObjectListTile with your own className, media, or actions to 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

KeyAction
Arrow keys, Home, EndMove between entries (by row in the grid)
EnterOpen the folder, or call onOpenItem
SpaceToggle the focused entry. Shift: the range
Ctrl+A / Cmd+ASelect every loaded entry
EscapeClear the selection
Backspace / Alt+ArrowUpOpen 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;
  • UploadTrigger and UploadDropzone inside the list use the open folder. Their own call merges 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.

  • memoryObjectSource builds them from a /-separated path with getFolder.
  • dbObjectSource lists the folders stored by db(), 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 createFolder to 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";

Prop

Type

On this page