shadimage
retab
uiattachment-sidebarsource: ok · hit

Attachment Sidebar

File attachment navigator built from SidebarList primitives and FileThumbnail.

npx shadcn add "https://ui.retab.com/r/attachment-sidebar.json"
free

Where it goes

sidebardashboard-shell

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

@retab/utils@retab/sidebar-list@retab/file-size-format@retab/file-thumbnail

Files1 · source cached 10/11/2026, 7:42:48 AM

registry/new-york-v4/ui/attachment-sidebar.tsx
"use client";

import * as React from "react";
import { Paperclip } from "lucide-react";

import { cn } from "@/lib/utils";
import { createViewerResource } from "@/lib/viewer-resource";
import type { ViewerSource } from "@/lib/viewer-source";

import { formatFileSize } from "./file-size-format";
import { FileThumbnail } from "./file-thumbnail";
import {
  SidebarListButton,
  SidebarListContent,
  SidebarListGroup,
  SidebarListGroupContent,
  SidebarListGroupLabel,
  SidebarListHeader,
  SidebarListMenu,
  SidebarListMenuItem,
  SidebarListRoot,
  SidebarListSeparator,
} from "./sidebar-list";

export interface AttachmentSidebarItem {
  id: string;
  source: ViewerSource;
  label?: string;
  description?: string;
  size?: number | null;
  isDisabled?: boolean;
}

export interface AttachmentSidebarProps {
  items: readonly AttachmentSidebarItem[];
  selectedId?: string | null;
  onSelect?: (id: string) => void;
  header?: React.ReactNode;
  emptyLabel?: React.ReactNode;
  children?: React.ReactNode;
  side?: "left" | "right";
  width?: string;
  className?: string;
}

/**
 * File attachment navigator built from providerless SidebarList primitives. It
 * owns attachment/file row semantics, while callers own the selected id and any
 * domain-specific rows they pass as children.
 */
export function AttachmentSidebar({
  items,
  selectedId,
  onSelect,
  header,
  emptyLabel = "No attachments.",
  children,
  side = "right",
  width = "18rem",
  className,
}: AttachmentSidebarProps) {
  return (
    <SidebarListRoot
      width={width}
      data-slot="attachment-sidebar"
      className={cn(
        "border-sidebar-border bg-sidebar",
        side === "right" ? "md:border-l" : "md:border-r",
        className,
      )}
    >
      <SidebarListHeader className="border-b px-3 py-2">
        {header ?? <DefaultAttachmentSidebarHeader count={items.length} />}
      </SidebarListHeader>
      <SidebarListContent>
        {children ? (
          <>
            {children}
            <SidebarListSeparator />
          </>
        ) : null}
        <SidebarListGroup className="min-h-0 flex-1">
          <SidebarListGroupLabel>Attachments</SidebarListGroupLabel>
          <SidebarListGroupContent>
            {items.length === 0 ? (
              <p className="text-sidebar-foreground/70 px-2 py-3 text-xs">
                {emptyLabel}
              </p>
            ) : (
              <SidebarListMenu className="gap-2">
                {items.map((item) => (
                  <AttachmentSidebarMenuItem
                    key={item.id}
                    item={item}
                    isSelected={selectedId === item.id}
                    onSelect={onSelect}
                  />
                ))}
              </SidebarListMenu>
            )}
          </SidebarListGroupContent>
        </SidebarListGroup>
      </SidebarListContent>
    </SidebarListRoot>
  );
}

function DefaultAttachmentSidebarHeader({ count }: { count: number }) {
  return (
    <div className="text-sidebar-foreground flex h-6 items-center gap-2 text-xs font-medium">
      <Paperclip className="text-sidebar-accent-foreground size-3.5" />
      <span>
        {count} attachment{count === 1 ? "" : "s"}
      </span>
    </div>
  );
}

function AttachmentSidebarMenuItem({
  item,
  isSelected,
  onSelect,
}: {
  item: AttachmentSidebarItem;
  isSelected: boolean;
  onSelect: ((id: string) => void) | undefined;
}) {
  const resource = createViewerResource(item.source);
  const label = item.label?.trim() || resource.fileName;
  const meta =
    item.description?.trim() ||
    (item.size != null
      ? formatFileSize(item.size)
      : (resource.mimeType ?? resource.descriptor.category));

  return (
    <SidebarListMenuItem>
      <SidebarListButton
        aria-current={isSelected ? "page" : undefined}
        aria-label={`${label} ${meta}`}
        className="data-[active=true]:border-sidebar-border h-auto items-start gap-2 rounded-lg border border-transparent p-2"
        disabled={item.isDisabled}
        isActive={isSelected}
        onClick={() => onSelect?.(item.id)}
      >
        <FileThumbnail
          source={item.source}
          presentation="decorative"
          thumbnailShape="document"
          thumbnailSize="md"
          className="flex-shrink-0"
        />
        <span className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
          <span className="truncate text-sm font-medium">{label}</span>
          <span className="text-sidebar-foreground/70 truncate text-xs">
            {meta}
          </span>
        </span>
      </SidebarListButton>
    </SidebarListMenuItem>
  );
}

More from retab

Browse all components in this library →