書類行DocumentRowExperimental

書類の選択、プレビュー、ダウンロードを独立した操作として並べるファイル行です。

プレビュー

すべて選択1件を選択中です。

2026年

3件

2025年

1件

チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。

状態とバリエーション

独立した操作対象

チェックボックス、プレビュー、ダウンロードは互いに入れ子にならない独立操作です。

すべて選択1件を選択中です。

2026年

3件

2025年

1件

チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。

無効化された操作

無効なダウンロード操作は、ホバーまたはフォーカスで理由を説明します。

処理中

無効化理由つき
1件

チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。

読むだけの一覧

選択もプレビューも要らないときは control と onOpen を省きます。長いファイル名は末尾が省略されるので、形式・大きさ・日付は meta に分けて置きます。

見積書_群青交通様_車両整備一式.pdfPDF・124KB・2026/06/25
整備仕様書(第3版・車両12台ぶん・別紙の写真と部品表を含む・2026年度上期).pdfPDF・4.2MB・2026/06/24
現車写真.zipZIP・38MB・2026/06/24

プロパティ

表は横にスクロールできます
プロパティ初期値説明
titleReactNode-書類名です。
descriptionReactNode-タイトル下の補足行です。
metaReactNode-形式、サイズ、発行日などのメタ情報です。
iconReactNode-ファイル種別アイコンです。
controlReactNode-複数選択など、先頭に置く独立した操作です。
statusReactNode-新着、発行済みなどの状態ピルです。
actionsReactNode-ダウンロードなど、末尾に置く独立した操作です。
onOpen() => void-ファイル本体部分をプレビューボタンにします。control / actions とは別の操作対象です。

使い方

import * as React from "react";
import {
  Badge,
  Button,
  Checkbox,
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DocumentRow,
  SectionList,
  Toast,
  Tooltip,
  TooltipButton,
  TooltipContent,
  TooltipTrigger,
  type SectionListSection,
} from "@gunjo/ui";
import { IconDownload, IconFileTypePdf, IconFileTypeZip } from "@tabler/icons-react";

const documents = [
  {
    id: "payroll-2026-06",
    group: "current",
    title: "2026年6月分 給与明細",
    description: "人事部・月次発行",
    meta: "PDF・124KB・発行日 2026/06/25",
    status: "新着",
  },
  {
    id: "tax-2026",
    group: "current",
    title: "令和8年 源泉徴収票",
    description: "年末調整・確定版",
    meta: "PDF・88KB・発行日 2026/01/12",
  },
  {
    id: "expense-2026",
    group: "current",
    title: "経費精算 添付書類",
    description: "監査チーム確認中",
    meta: "ZIP・2.4MB・更新日 2026/06/20",
    locked: true,
  },
];

export function PayrollDocuments() {
  const portalRef = React.useRef<HTMLDivElement>(null);
  const [selectedIds, setSelectedIds] = React.useState(() => new Set(["payroll-2026-06"]));
  const [status, setStatus] = React.useState("チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。");
  const [previewDocument, setPreviewDocument] = React.useState<(typeof documents)[number] | null>(null);
  const [toastMessage, setToastMessage] = React.useState<string | null>(null);
  const selectableDocs = documents.filter((doc) => !doc.locked);
  const selectedCount = selectableDocs.filter((doc) => selectedIds.has(doc.id)).length;
  const allSelected = selectableDocs.length > 0 && selectedCount === selectableDocs.length;

  const toggleSelected = (id: string, checked: boolean) => {
    const doc = documents.find((item) => item.id === id);
    if (doc?.locked) return;
    setSelectedIds((current) => {
      const next = new Set(current);
      if (checked) next.add(id);
      else next.delete(id);
      setStatus(next.size === 0 ? "チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。" : `${next.size}件を選択中です。`);
      return next;
    });
  };

  const toggleAllSelected = (checked: boolean) => {
    setSelectedIds((current) => {
      const next = new Set(current);
      for (const doc of selectableDocs) {
        if (checked) next.add(doc.id);
        else next.delete(doc.id);
      }
      const nextCount = selectableDocs.filter((doc) => next.has(doc.id)).length;
      setStatus(nextCount === 0 ? "チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。" : `${nextCount}件を選択中です。`);
      return next;
    });
  };

  const clearSelection = () => toggleAllSelected(false);
  const downloadSelected = () => {
    if (selectedCount === 0) return;
    setToastMessage(`選択した${selectedCount}件のダウンロードを開始しました。`);
  };

  const rows = documents.map((doc) => (
    <DocumentRow
      key={doc.id}
      icon={doc.id.includes("expense") ? <IconFileTypeZip className="h-5 w-5" /> : <IconFileTypePdf className="h-5 w-5" />}
      title={doc.title}
      description={doc.description}
      meta={doc.meta}
      control={
        doc.locked ? (
          <Tooltip>
            <TooltipTrigger asChild>
              <span tabIndex={0} className="inline-flex rounded-[4px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                <Checkbox checked={false} disabled aria-label={`選択: ${doc.title}`} />
              </span>
            </TooltipTrigger>
            <TooltipContent portalContainer={portalRef.current}>
              確定処理中の書類は選択対象にできません。
            </TooltipContent>
          </Tooltip>
        ) : (
          <Checkbox
            checked={selectedIds.has(doc.id)}
            onCheckedChange={(checked) => toggleSelected(doc.id, checked === true)}
            aria-label={`選択: ${doc.title}`}
          />
        )
      }
      status={doc.status ? <Badge variant="secondary">{doc.status}</Badge> : undefined}
      onOpen={() => setPreviewDocument(doc)}
      actions={
        <TooltipButton
          size="icon"
          variant="ghost"
          aria-label={`ダウンロード: ${doc.title}`}
          tooltip={`ダウンロード: ${doc.title}`}
          onClick={() => setToastMessage(`${doc.title} のダウンロードを開始しました。`)}
        >
          <IconDownload className="h-4 w-4" />
        </TooltipButton>
      }
    />
  ));

  const sections: SectionListSection[] = [
    { key: "current", title: "2026年", meta: "3件", content: rows },
  ];

  return (
    <div
      ref={portalRef}
      data-document-row-preview-frame
      className="relative flex w-full max-w-2xl flex-col gap-4"
    >
      {toastMessage ? (
        <div className="pointer-events-none absolute bottom-4 right-6 z-[100] w-[min(360px,calc(100%-3rem))]">
          <Toast
            message={toastMessage}
            type="success"
            isVisible
            onClose={() => setToastMessage(null)}
            placement="inline"
            closeLabel="通知を閉じる"
            tooltipPortalContainer={portalRef.current}
          />
        </div>
      ) : null}
      <div className="flex flex-col gap-4">
        <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-3 py-2">
          <Checkbox
            checked={allSelected}
            onCheckedChange={(checked) => toggleAllSelected(checked === true)}
            label="すべて選択"
            description={`${selectedCount}件を選択中です。`}
          />
          <div className="flex flex-wrap items-center gap-2">
            {selectedCount === 0 ? (
              <Tooltip>
                <TooltipTrigger asChild>
                  <span tabIndex={0} className="inline-flex rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                    <Button type="button" variant="ghost" size="sm" disabled>
                      選択を解除
                    </Button>
                  </span>
                </TooltipTrigger>
                <TooltipContent portalContainer={portalRef.current}>
                  書類を選択すると一括ダウンロードできます。
                </TooltipContent>
              </Tooltip>
            ) : (
              <Button type="button" variant="ghost" size="sm" onClick={clearSelection}>
                選択を解除
              </Button>
            )}
            {selectedCount === 0 ? (
              <Tooltip>
                <TooltipTrigger asChild>
                  <span tabIndex={0} className="inline-flex rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                    <Button type="button" variant="outline" size="sm" disabled>
                      <IconDownload className="h-4 w-4" />
                      選択した書類をダウンロード
                    </Button>
                  </span>
                </TooltipTrigger>
                <TooltipContent portalContainer={portalRef.current}>
                  書類を選択すると一括ダウンロードできます。
                </TooltipContent>
              </Tooltip>
            ) : (
              <Button type="button" variant="outline" size="sm" onClick={downloadSelected}>
                <IconDownload className="h-4 w-4" />
                選択した書類をダウンロード
              </Button>
            )}
          </div>
        </div>
        <SectionList sections={sections} label="書類一覧" />
        <p className="rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground" aria-live="polite">
          {status}
        </p>
      </div>
      <Dialog open={previewDocument != null} onOpenChange={(open) => !open && setPreviewDocument(null)}>
        <DialogContent
          portalContainer={portalRef.current}
          overlayClassName="rounded-xl"
          closeLabel="閉じる"
          className="max-w-md"
        >
          <DialogHeader>
            <DialogTitle>書類プレビュー</DialogTitle>
            <DialogDescription>選択した書類の内容をプレビューします。</DialogDescription>
          </DialogHeader>
          {previewDocument ? (
            <div className="rounded-md border bg-muted/30 p-4 text-sm leading-6">
              <p className="font-medium">{previewDocument.title}</p>
              <p className="mt-1 text-muted-foreground">{previewDocument.meta}</p>
              <p className="mt-3 text-muted-foreground">このプレビューではサンプル本文を表示しています。</p>
            </div>
          ) : null}
          <DialogFooter>
            <Button type="button" variant="outline" onClick={() => setPreviewDocument(null)}>
              閉じる
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}

使用コンポーネント