Context Menu

Compose consumer-owned right-click actions with Files selection semantics.

Files
4
Discovery
Depositions
Q4 Financial Report.pdf
Board Minutes.docx

This example keeps Cubitt responsible only for file visuals and selection semantics. The app owns the ContextMenu components, action labels, permissions, file/folder branching, destructive states, and persistence.

useFilesContextMenu and useFilesContext must be called inside Files. The context menu hook resolves the menu target from the current selection and returns prop getters for the menu root, the collection trigger, and each Files.Item. The public Files context lets consumer-owned menu actions reuse Cubitt interactions such as inline rename.

The same action renderer can be used from a right-click menu and from a toolbar overflow menu. Applicability is handled by rendering different menu items for single files, single folders, multiple files, and mixed selections.

app/components/files-context-menu-example.tsx
import { useState } from "react";
import {
  Files,
  type FilesItem,
  type FilesViewMode,
} from "@tilt-legal/cubitt-components/files";
import { toast } from "@tilt-legal/cubitt-components/toast";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@tilt-legal/cubitt-components/toggle-group";
import { Toolbar } from "@tilt-legal/cubitt-components/toolbar";
import { Grid, Menu as MenuIcon } from "@tilt-legal/cubitt-icons/ui/outline";
import { FilesContextMenuSurface } from "@/components/files-context-menu-surface";
import { FilesSelectionActionsMenu } from "@/components/files-selection-actions-menu";
import { type ExampleFileSource, contextMenuItems } from "@/lib/files";

export function FilesContextMenuExample() {
  const [items, setItems] = useState(contextMenuItems);
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [view, setView] = useState<FilesViewMode>("grid");

  return (
    <div className="flex flex-col gap-4">
      <Files
        onRename={(item: FilesItem<ExampleFileSource>, name) => {
          setItems((currentItems) =>
            currentItems.map((currentItem) =>
              currentItem.id === item.id
                ? { ...currentItem, name }
                : currentItem
            )
          );
          toast.success(`Renamed ${name}`);
        }}
        onSelectedIdsChange={setSelectedIds}
        selectedIds={selectedIds}
        selectionMode="multiple"
      >
        <Toolbar>
          <Toolbar.Heading>
            <Toolbar.Title>Files</Toolbar.Title>
            <Toolbar.Count>{items.length}</Toolbar.Count>
          </Toolbar.Heading>
          <Toolbar.Actions>
            <FilesSelectionActionsMenu
              items={items}
              onAction={(message) => toast.success(message)}
            />
            <ToggleGroup
              aria-label="View"
              groupVariant="segmented"
              multiple={false}
              onValueChange={(nextValue) => {
                if (nextValue === "grid" || nextValue === "list") {
                  setView(nextValue);
                }
              }}
              value={view}
            >
              <ToggleGroupItem aria-label="Grid view" mode="icon" value="grid">
                <Grid />
              </ToggleGroupItem>
              <ToggleGroupItem aria-label="List view" mode="icon" value="list">
                <MenuIcon />
              </ToggleGroupItem>
            </ToggleGroup>
          </Toolbar.Actions>
        </Toolbar>
        <FilesContextMenuSurface
          items={items}
          onAction={(message) => toast.success(message)}
          view={view}
        />
      </Files>
    </div>
  );
}
app/components/files-action-menu-items.tsx
import type { ReactNode } from "react";
import {
  type FilesItem,
  useFilesContext,
} from "@tilt-legal/cubitt-components/files";
import {
  ContextMenuItem,
  ContextMenuSeparator,
} from "@tilt-legal/cubitt-components/context-menu";
import {
  MenuItem,
  MenuSeparator,
} from "@tilt-legal/cubitt-components/menu";
import {
  Archive,
  Download,
  FolderOpen,
  Pencil,
} from "@tilt-legal/cubitt-icons/ui/outline";
import type { ExampleFileSource } from "@/lib/files";

type ActionMenuKind = "context" | "toolbar";

function getPluralTargetLabel(
  items: FilesItem<ExampleFileSource>[],
  noun: "file" | "folder" | "item"
): string {
  return `${items.length} ${noun}${items.length === 1 ? "" : "s"}`;
}

function ActionMenuItem({
  children,
  disabled,
  kind,
  onClick,
  variant,
}: {
  children: ReactNode;
  disabled?: boolean;
  kind: ActionMenuKind;
  onClick?: () => void;
  variant?: "destructive";
}) {
  if (kind === "context") {
    return (
      <ContextMenuItem disabled={disabled} onClick={onClick} variant={variant}>
        {children}
      </ContextMenuItem>
    );
  }

  return (
    <MenuItem disabled={disabled} onClick={onClick} variant={variant}>
      {children}
    </MenuItem>
  );
}

function ActionMenuSeparator({ kind }: { kind: ActionMenuKind }) {
  return kind === "context" ? <ContextMenuSeparator /> : <MenuSeparator />;
}

export function FilesActionMenuItems({
  kind,
  onAction,
  targetItems,
}: {
  kind: ActionMenuKind;
  onAction: (message: string) => void;
  targetItems: FilesItem<ExampleFileSource>[];
}) {
  const filesContext = useFilesContext();

  if (targetItems.length === 0) {
    return (
      <ActionMenuItem disabled kind={kind}>
        No file selected
      </ActionMenuItem>
    );
  }

  const singleTarget = targetItems.length === 1 ? targetItems[0] : null;
  const fileTargets = targetItems.filter((item) => item.kind === "file");
  const folderTargets = targetItems.filter((item) => item.kind === "folder");
  const isAllFiles = fileTargets.length === targetItems.length;
  const isAllFolders = folderTargets.length === targetItems.length;

  if (singleTarget) {
    const isFolder = singleTarget.kind === "folder";

    return (
      <>
        <ActionMenuItem
          kind={kind}
          onClick={() => onAction(`Opened ${singleTarget.name}`)}
        >
          <FolderOpen />
          {isFolder ? "Open folder" : "Open"}
        </ActionMenuItem>
        {isFolder ? null : (
          <ActionMenuItem
            kind={kind}
            onClick={() => onAction(`Downloaded ${singleTarget.name}`)}
          >
            <Download />
            Download
          </ActionMenuItem>
        )}
        <ActionMenuItem
          kind={kind}
          onClick={() => filesContext.startRename(singleTarget)}
        >
          <Pencil />
          Rename
        </ActionMenuItem>
        <ActionMenuSeparator kind={kind} />
        <ActionMenuItem
          kind={kind}
          onClick={() => onAction(`Archived ${singleTarget.name}`)}
          variant="destructive"
        >
          <Archive />
          {isFolder ? "Archive folder" : "Archive file"}
        </ActionMenuItem>
      </>
    );
  }

  if (isAllFiles) {
    const fileLabel = getPluralTargetLabel(targetItems, "file");

    return (
      <>
        <ActionMenuItem
          kind={kind}
          onClick={() => onAction(`Downloaded ${fileLabel}`)}
        >
          <Download />
          Download {fileLabel}
        </ActionMenuItem>
        <ActionMenuSeparator kind={kind} />
        <ActionMenuItem
          kind={kind}
          onClick={() => onAction(`Archived ${fileLabel}`)}
          variant="destructive"
        >
          <Archive />
          Archive {fileLabel}
        </ActionMenuItem>
      </>
    );
  }

  const targetLabel = getPluralTargetLabel(
    targetItems,
    isAllFolders ? "folder" : "item"
  );

  return (
    <ActionMenuItem
      kind={kind}
      onClick={() => onAction(`Archived ${targetLabel}`)}
      variant="destructive"
    >
      <Archive />
      Archive {targetLabel}
    </ActionMenuItem>
  );
}
app/components/files-selection-actions-menu.tsx
import {
  type FilesItem,
  useFilesContext,
} from "@tilt-legal/cubitt-components/files";
import { Button } from "@tilt-legal/cubitt-components/button";
import {
  Menu,
  MenuContent,
  MenuTrigger,
} from "@tilt-legal/cubitt-components/menu";
import { Dots } from "@tilt-legal/cubitt-icons/ui/outline";
import { FilesActionMenuItems } from "@/components/files-action-menu-items";
import type { ExampleFileSource } from "@/lib/files";

function getSelectionItems(
  items: FilesItem<ExampleFileSource>[],
  selectedIds: string[]
): FilesItem<ExampleFileSource>[] {
  const itemsById = new Map(items.map((item) => [item.id, item]));

  return selectedIds
    .map((id) => itemsById.get(id))
    .filter((item): item is FilesItem<ExampleFileSource> => Boolean(item));
}

export function FilesSelectionActionsMenu({
  items,
  onAction,
}: {
  items: FilesItem<ExampleFileSource>[];
  onAction: (message: string) => void;
}) {
  const filesContext = useFilesContext();
  const targetItems = getSelectionItems(items, filesContext.selectedIds);

  return (
    <Menu>
      <Button
        aria-label="File actions"
        mode="icon"
        render={<MenuTrigger />}
        variant="secondary"
      >
        <Dots />
      </Button>
      <MenuContent align="end">
        <FilesActionMenuItems
          kind="toolbar"
          onAction={onAction}
          targetItems={targetItems}
        />
      </MenuContent>
    </Menu>
  );
}
app/components/files-context-menu-surface.tsx
import {
  Files,
  type FilesItem,
  type FilesViewMode,
  useFilesContextMenu,
} from "@tilt-legal/cubitt-components/files";
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuTrigger,
} from "@tilt-legal/cubitt-components/context-menu";
import { FilesActionMenuItems } from "@/components/files-action-menu-items";
import type { ExampleFileSource } from "@/lib/files";

type Props = {
  items: FilesItem<ExampleFileSource>[];
  onAction: (message: string) => void;
  view: FilesViewMode;
};

export function FilesContextMenuSurface({ items, onAction, view }: Props) {
  const contextMenu = useFilesContextMenu(items);

  return (
    <ContextMenu {...contextMenu.getRootProps()}>
      <ContextMenuTrigger
        render={(triggerProps) =>
          view === "grid" ? (
            <Files.Grid {...contextMenu.getTriggerProps(triggerProps)}>
              {items.map((item) => (
                <Files.Item
                  item={item}
                  key={item.id}
                  {...contextMenu.getItemProps(item)}
                />
              ))}
            </Files.Grid>
          ) : (
            <Files.List {...contextMenu.getTriggerProps(triggerProps)}>
              {items.map((item) => (
                <Files.Item
                  item={item}
                  key={item.id}
                  {...contextMenu.getItemProps(item)}
                />
              ))}
            </Files.List>
          )
        }
      />
      <ContextMenuContent>
        <FilesActionMenuItems
          kind="context"
          onAction={onAction}
          targetItems={contextMenu.targetItems}
        />
      </ContextMenuContent>
    </ContextMenu>
  );
}
app/lib/files.ts
import type { FilesItem } from "@tilt-legal/cubitt-components/files";

export type ExampleFileSource = {
  owner: string;
};

export const contextMenuItems: FilesItem<ExampleFileSource>[] = [
  {
    id: "folder-discovery",
    kind: "folder",
    name: "Discovery",
    description: "24 items",
    source: { owner: "Sarah Chen" },
  },
  {
    id: "folder-depositions",
    kind: "folder",
    name: "Depositions",
    description: "8 items",
    source: { owner: "Ava Johnson" },
  },
  {
    id: "financial-report",
    kind: "file",
    name: "Q4 Financial Report.pdf",
    mediaType: "application/pdf",
    sizeLabel: "2.4 MB",
    description: "Sarah Chen",
    source: { owner: "Sarah Chen" },
  },
  {
    id: "board-minutes",
    kind: "file",
    name: "Board Minutes.docx",
    mediaType:
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    sizeLabel: "848 KB",
    description: "Ava Johnson",
    source: { owner: "Ava Johnson" },
  },
];

Right-clicking an unselected item targets only that item and updates selection. Right-clicking an already selected item keeps the selected group as the target set. Use targetItems for mixed file/folder rules and targetIds when calling app-owned commands.