書類行DocumentRowExperimental
書類の選択、プレビュー、ダウンロードを独立した操作として並べるファイル行です。
プレビュー
すべて選択1件を選択中です。
2026年
2025年
チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。
状態とバリエーション
独立した操作対象
チェックボックス、プレビュー、ダウンロードは互いに入れ子にならない独立操作です。
すべて選択1件を選択中です。
2026年
2025年
チェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。
無効化された操作
無効なダウンロード操作は、ホバーまたはフォーカスで理由を説明します。
処理中
無効化理由つきチェックボックスで対象書類を選択できます。書類名を押すとプレビューを開きます。
読むだけの一覧
選択もプレビューも要らないときは control と onOpen を省きます。長いファイル名は末尾が省略されるので、形式・大きさ・日付は meta に分けて置きます。
見積書_群青交通様_車両整備一式.pdfPDF・124KB・2026/06/25
整備仕様書(第3版・車両12台ぶん・別紙の写真と部品表を含む・2026年度上期).pdfPDF・4.2MB・2026/06/24
現車写真.zipZIP・38MB・2026/06/24
プロパティ
表は横にスクロールできます
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| title | ReactNode | - | 書類名です。 |
| description | ReactNode | - | タイトル下の補足行です。 |
| meta | ReactNode | - | 形式、サイズ、発行日などのメタ情報です。 |
| icon | ReactNode | - | ファイル種別アイコンです。 |
| control | ReactNode | - | 複数選択など、先頭に置く独立した操作です。 |
| status | ReactNode | - | 新着、発行済みなどの状態ピルです。 |
| actions | ReactNode | - | ダウンロードなど、末尾に置く独立した操作です。 |
| 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>
);
}