From 3f71b8379566840f9666b6072a3723d3813ef19b Mon Sep 17 00:00:00 2001 From: fyui001 Date: Fri, 10 Jul 2026 13:38:01 +0900 Subject: [PATCH 1/2] Migrate the shared table to TanStack Table Rewrite the shared DataTable on @tanstack/react-table v8 with a columns/data API, keeping PaginatedTable as the card and pagination wrapper. Column meta carries minWidthPx and sticky hints, the scroll area is an opt-in scrollAreaClassName forwarded to the table container, and clickable rows guard against interactive descendants. Drop the striped rows and the colgroup width handling, and convert the drug, medication history, and dashboard tables to column defs. --- components/common/DataTable.tsx | 170 ++++++++++-------- components/common/PaginatedTable.tsx | 34 ++-- components/medication/DrugListTable.tsx | 71 ++++---- .../medication/MedicationHistoryTable.tsx | 53 +++--- components/page-component/DashboardPage.tsx | 56 +++--- components/ui/table.tsx | 8 +- package.json | 1 + stories/common/DataTable.stories.tsx | 102 +++++++++++ types/reactTable.d.ts | 12 ++ yarn.lock | 12 ++ 10 files changed, 345 insertions(+), 174 deletions(-) create mode 100644 stories/common/DataTable.stories.tsx create mode 100644 types/reactTable.d.ts diff --git a/components/common/DataTable.tsx b/components/common/DataTable.tsx index de51070..39b7275 100644 --- a/components/common/DataTable.tsx +++ b/components/common/DataTable.tsx @@ -1,6 +1,13 @@ 'use client' -import { type ReactNode } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { + type ColumnDef, + useReactTable, + getCoreRowModel, + flexRender, +} from '@tanstack/react-table' +import { cn } from '@/lib/utils' import { Table, TableBody, @@ -10,119 +17,140 @@ import { TableRow, } from 'components/ui/table' -export interface ColumnDefinition { - id: string - header: ReactNode - cell: (item: T) => ReactNode - width?: string -} - -interface DataTableProps { - columnDefinitions: ColumnDefinition[] - items: T[] - trackBy?: keyof T | ((item: T) => string) +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + trackBy?: keyof TData | ((row: TData) => string) loading?: boolean loadingText?: string - empty?: ReactNode - striped?: boolean - onRowClick?: (item: T) => void - maxHeight?: string + emptyText?: ReactNode + onRowClick?: (row: TData) => void + scrollAreaClassName?: string } -export default function DataTable({ - columnDefinitions, - items, +export default function DataTable({ + columns, + data, trackBy, loading, loadingText = '読み込み中...', - empty, - striped = false, + emptyText, onRowClick, - maxHeight, -}: DataTableProps) { - const getKey = (item: T, index: number): string => { + scrollAreaClassName = 'h-full overflow-auto', +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }) + + const getRowKey = (row: TData, index: number): string => { if (!trackBy) return String(index) - if (typeof trackBy === 'function') return trackBy(item) - return String(item[trackBy]) + if (typeof trackBy === 'function') return trackBy(row) + return String(row[trackBy]) } - const table = ( - - {columnDefinitions.some((col) => col.width) && ( - - {columnDefinitions.map((col) => ( - - ))} - - )} + const colSpan = table.getAllLeafColumns().length + + return ( +
- - {columnDefinitions.map((col) => ( - {col.header} - ))} - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const minWidthPx = header.column.columnDef.meta?.minWidthPx + const isSticky = header.column.columnDef.meta?.sticky === 'left' + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) + })} + + ))} {loading ? ( {loadingText} - ) : items.length === 0 ? ( + ) : table.getRowModel().rows.length === 0 ? ( - {empty} + {emptyText} ) : ( - items.map((item, index) => ( + table.getRowModel().rows.map((row, index) => ( onRowClick(item) : undefined} + role={onRowClick ? 'button' : undefined} + onClick={ + onRowClick + ? (e) => { + const target = e.target as HTMLElement + if ( + target.closest( + 'a, button, input, select, textarea, [data-slot="checkbox"]', + ) + ) + return + onRowClick(row.original) + } + : undefined + } onKeyDown={ onRowClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - onRowClick(item) + onRowClick(row.original) } } : undefined } > - {columnDefinitions.map((col) => ( - {col.cell(item)} - ))} + {row.getVisibleCells().map((cell) => { + const minWidthPx = cell.column.columnDef.meta?.minWidthPx + const cellStyle: CSSProperties | undefined = minWidthPx + ? { minWidth: `${minWidthPx}px` } + : undefined + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} )) )}
) - - if (maxHeight) { - return ( -
- {table} -
- ) - } - - return table } diff --git a/components/common/PaginatedTable.tsx b/components/common/PaginatedTable.tsx index 0a86b05..3dc2813 100644 --- a/components/common/PaginatedTable.tsx +++ b/components/common/PaginatedTable.tsx @@ -10,7 +10,9 @@ import { } from 'components/ui/select' import { Card, CardContent, CardHeader, CardTitle } from 'components/ui/card' import { ChevronLeft, ChevronRight } from 'lucide-react' -import DataTable, { type ColumnDefinition } from 'components/common/DataTable' +import { type ReactNode } from 'react' +import { type ColumnDef } from '@tanstack/react-table' +import DataTable from 'components/common/DataTable' const PAGE_SIZE_OPTIONS = [ { value: '25', label: '25件' }, @@ -21,12 +23,12 @@ const PAGE_SIZE_OPTIONS = [ interface PaginatedTableProps { title: string - columnDefinitions: ColumnDefinition[] + columns: ColumnDef[] items: T[] trackBy?: keyof T | ((item: T) => string) loading?: boolean loadingText?: string - empty?: string + emptyText?: ReactNode currentPage: number lastPage: number perPage: number @@ -38,12 +40,12 @@ interface PaginatedTableProps { export default function PaginatedTable({ title, - columnDefinitions, + columns, items, trackBy, loading, loadingText, - empty, + emptyText, currentPage, lastPage, perPage, @@ -100,18 +102,16 @@ export default function PaginatedTable({ -
- -
+
) diff --git a/components/medication/DrugListTable.tsx b/components/medication/DrugListTable.tsx index 30769d9..08ba0fc 100644 --- a/components/medication/DrugListTable.tsx +++ b/components/medication/DrugListTable.tsx @@ -1,6 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' +import { type ColumnDef } from '@tanstack/react-table' import { FileText, ExternalLink } from 'lucide-react' import PaginatedTable from 'components/common/PaginatedTable' @@ -11,6 +12,40 @@ interface Drug { hasNote?: boolean } +const columns: ColumnDef[] = [ + { + id: 'name', + header: '薬名', + cell: ({ row }) => ( + + {row.original.name || '-'} + {row.original.hasNote && ( + + )} + + ), + }, + { + id: 'url', + header: 'リンク', + cell: ({ row }) => + row.original.url ? ( + e.stopPropagation()} + > + 詳細 + + + ) : ( + '-' + ), + }, +] + interface DrugListTableProps { items: Drug[] currentPage: number @@ -35,42 +70,10 @@ export default function DrugListTable({ return ( ( - - {item.name || '-'} - {item.hasNote && ( - - )} - - ), - }, - { - id: 'url', - header: 'リンク', - cell: (item) => - item.url ? ( - e.stopPropagation()} - > - 詳細 - - - ) : ( - '-' - ), - }, - ]} + columns={columns} items={items} trackBy="id" - empty="薬が登録されていません" + emptyText="薬が登録されていません" currentPage={currentPage} lastPage={lastPage} perPage={perPage} diff --git a/components/medication/MedicationHistoryTable.tsx b/components/medication/MedicationHistoryTable.tsx index ddffa63..a0c8df3 100644 --- a/components/medication/MedicationHistoryTable.tsx +++ b/components/medication/MedicationHistoryTable.tsx @@ -1,6 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' +import { type ColumnDef } from '@tanstack/react-table' import { FileText } from 'lucide-react' import PaginatedTable from 'components/common/PaginatedTable' @@ -12,6 +13,31 @@ interface MedicationRecord { hasNote?: boolean } +const columns: ColumnDef[] = [ + { + id: 'name', + header: '薬名', + cell: ({ row }) => ( + + {row.original.name || '-'} + {row.original.hasNote && ( + + )} + + ), + }, + { + id: 'amount', + header: '服薬量(mg)', + cell: ({ row }) => `${row.original.amount}mg`, + }, + { + id: 'takenAt', + header: '服薬日時', + cell: ({ row }) => row.original.takenAt, + }, +] + interface MedicationHistoryTableProps { items: MedicationRecord[] loading?: boolean @@ -38,34 +64,11 @@ export default function MedicationHistoryTable({ return ( ( - - {item.name || '-'} - {item.hasNote && ( - - )} - - ), - }, - { - id: 'amount', - header: '服薬量(mg)', - cell: (item) => `${item.amount}mg`, - }, - { - id: 'takenAt', - header: '服薬日時', - cell: (item) => item.takenAt, - }, - ]} + columns={columns} items={items} trackBy="id" loading={loading} - empty="服薬履歴がありません" + emptyText="服薬履歴がありません" currentPage={currentPage} lastPage={lastPage} perPage={perPage} diff --git a/components/page-component/DashboardPage.tsx b/components/page-component/DashboardPage.tsx index e49575c..ae08ca7 100644 --- a/components/page-component/DashboardPage.tsx +++ b/components/page-component/DashboardPage.tsx @@ -11,6 +11,7 @@ import { SelectValue, } from 'components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/ui/tabs' +import { type ColumnDef } from '@tanstack/react-table' import DataTable from 'components/common/DataTable' import DiscordLinkPrompt from 'components/common/DiscordLinkPrompt' import { @@ -315,9 +316,34 @@ function WeeklyCountChart({ histories }: { histories: MedicationHistory[] }) { ) } +interface RecentHistoryItem { + id: string + name: string + amount: number + takenAt: string +} + +const recentHistoryColumns: ColumnDef[] = [ + { + id: 'name', + header: '薬名', + cell: ({ row }) => row.original.name, + }, + { + id: 'amount', + header: '服薬量(mg)', + cell: ({ row }) => `${row.original.amount}mg`, + }, + { + id: 'takenAt', + header: '服薬日時', + cell: ({ row }) => row.original.takenAt, + }, +] + function RecentHistoryTable({ histories }: { histories: MedicationHistory[] }) { const router = useRouter() - const recentItems = histories.map((h) => ({ + const recentItems: RecentHistoryItem[] = histories.map((h) => ({ id: String(h.id), name: h.drugName, amount: h.amount, @@ -331,32 +357,12 @@ function RecentHistoryTable({ histories }: { histories: MedicationHistory[] }) { item.name, - width: '40%', - }, - { - id: 'amount', - header: '服薬量(mg)', - cell: (item) => `${item.amount}mg`, - width: '25%', - }, - { - id: 'takenAt', - header: '服薬日時', - cell: (item) => item.takenAt, - width: '35%', - }, - ]} - items={recentItems} + columns={recentHistoryColumns} + data={recentItems} trackBy="id" - striped - empty="服薬履歴はありません" + emptyText="服薬履歴はありません" onRowClick={(item) => router.push(`/medication/history/${item.id}`)} - maxHeight="500px" + scrollAreaClassName="max-h-[500px] overflow-auto" /> diff --git a/components/ui/table.tsx b/components/ui/table.tsx index b9593e1..3eddda4 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -4,11 +4,15 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function Table({ className, ...props }: React.ComponentProps<'table'>) { +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<'table'> & { containerClassName?: string }) { return (
[] = [ + { id: 'name', header: '薬名', cell: ({ row }) => row.original.name }, + { + id: 'amount', + header: '服薬量(mg)', + cell: ({ row }) => `${row.original.amount}mg`, + }, +] + +const linkColumns: ColumnDef[] = [ + { id: 'name', header: '薬名', cell: ({ row }) => row.original.name }, + { + id: 'url', + header: 'リンク', + cell: ({ row }) => + row.original.url ? ( + e.stopPropagation()} + > + 詳細 + + + ) : ( + '-' + ), + }, +] + +const rows: Row[] = [ + { id: '1', name: 'レボチロキシン', amount: 1 }, + { id: '2', name: 'ロキソプロフェン', amount: 2 }, + { id: '3', name: 'アムロジピン', amount: 5 }, +] + +const meta = { + title: 'Components/DataTable', + component: DataTable, + parameters: { + layout: 'padded', + }, +} satisfies Meta> + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + columns, + data: rows, + trackBy: 'id', + onRowClick: (row) => alert(row.name), + }, +} + +export const Empty: Story = { + args: { + columns, + data: [], + emptyText: 'データがありません', + }, +} + +export const Loading: Story = { + args: { + columns, + data: [], + loading: true, + }, +} + +export const ClickableWithLink: Story = { + args: { + columns: linkColumns, + data: [ + { + id: '1', + name: 'レボチロキシン', + amount: 1, + url: 'https://example.com', + }, + { id: '2', name: 'ロキソプロフェン', amount: 2, url: '' }, + ], + trackBy: 'id', + onRowClick: (row) => alert(row.name), + }, +} diff --git a/types/reactTable.d.ts b/types/reactTable.d.ts new file mode 100644 index 0000000..e7eb37f --- /dev/null +++ b/types/reactTable.d.ts @@ -0,0 +1,12 @@ +import type { RowData } from '@tanstack/react-table' + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + sortingField?: string + truncate?: boolean + sticky?: 'left' + minWidthPx?: number + stickyOffsetPx?: number + } +} diff --git a/yarn.lock b/yarn.lock index a5d8265..ade279e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2842,6 +2842,18 @@ postcss "^8.5.15" tailwindcss "4.3.2" +"@tanstack/react-table@^8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b" + integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== + dependencies: + "@tanstack/table-core" "8.21.3" + +"@tanstack/table-core@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c" + integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== + "@testing-library/jest-dom@^6.9.1": version "6.9.1" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" From 0e09b27eb3714db6ba263748c79694e9ea770947 Mon Sep 17 00:00:00 2001 From: fyui001 Date: Fri, 10 Jul 2026 13:43:54 +0900 Subject: [PATCH 2/2] Guard keyboard row activation against interactive descendants Apply the same interactive-descendant guard to Enter and Space that row clicks already use, so activating an in-cell link with the keyboard no longer also triggers the row navigation. --- components/common/DataTable.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/components/common/DataTable.tsx b/components/common/DataTable.tsx index 39b7275..c6d307d 100644 --- a/components/common/DataTable.tsx +++ b/components/common/DataTable.tsx @@ -128,10 +128,16 @@ export default function DataTable({ onKeyDown={ onRowClick ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onRowClick(row.original) - } + if (e.key !== 'Enter' && e.key !== ' ') return + const target = e.target as HTMLElement + if ( + target.closest( + 'a, button, input, select, textarea, [data-slot="checkbox"]', + ) + ) + return + e.preventDefault() + onRowClick(row.original) } : undefined }