Context Menu
Compose consumer-owned right-click actions with Files selection semantics.
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.
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>
);
}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>
);
}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>
);
}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>
);
}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.