Skip to main content
Glama
Table.stories.tsx31.2 kB
import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; import { useState } from 'react'; import { Table } from './Table'; /** * ## Table Component * * The Table component provides an enhanced table experience with modal expansion * and collapsible content capabilities. Perfect for displaying tabular data with * responsive behavior and enhanced user experience. * * ### Key Features * - **Modal Expansion**: Click the diagonal arrow to view tables in full-screen modal * - **Collapsible Content**: Use `isRollable` prop for space-saving collapsed tables * - **Responsive Design**: Handles large tables gracefully with proper overflow * - **Accessibility**: Maintains proper table semantics and keyboard navigation * - **Flexible Styling**: Supports all HTML table attributes and custom CSS classes * * ### Accessibility Features * - Proper table semantics with thead, tbody structure * - Supports scope attributes for column headers * - Keyboard navigation for modal controls * - Screen reader compatibility with table structure * * ### Best Practices * - Use semantic HTML table structure (thead, tbody, tfoot) * - Provide clear column headers with scope attributes * - Consider pagination for very large datasets * - Use consistent styling patterns across similar tables * - Enable `isRollable` for tables that might need space management */ const meta: Meta<typeof Table> = { title: 'Components/Table', component: Table, parameters: { layout: 'padded', docs: { description: { component: 'A comprehensive table component with modal expansion and collapsible content capabilities for displaying tabular data effectively.', }, }, }, tags: ['autodocs'], argTypes: { isRollable: { control: 'boolean', description: 'Whether the table content can be collapsed/expanded', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, }, }, className: { control: 'text', description: 'Additional CSS classes for custom styling', table: { type: { summary: 'string' }, }, }, children: { control: false, description: 'Table content including thead, tbody, tfoot elements', table: { type: { summary: 'ReactNode' }, }, }, }, } satisfies Meta<typeof Table>; export default meta; type Story = StoryObj<typeof Table>; /** * ## Basic Examples * * These stories demonstrate the core functionality and common usage patterns. */ /** * ### Simple Data Table * * A basic table with user data demonstrating standard table structure and styling. */ export const Default: Story = { render: () => ( <div className="max-w-4xl"> <Table> <thead> <tr className="border-gray-200 border-b bg-gray-50"> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Name </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Email </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Role </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Status </th> </tr> </thead> <tbody className="divide-y divide-gray-200 bg-white"> <tr className="transition-colors hover:bg-gray-50"> <td className="whitespace-nowrap px-6 py-4 font-medium text-gray-900 text-sm"> John Doe </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> john@example.com </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> Administrator </td> <td className="whitespace-nowrap px-6 py-4"> <span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 font-medium text-green-800 text-xs"> Active </span> </td> </tr> <tr className="transition-colors hover:bg-gray-50"> <td className="whitespace-nowrap px-6 py-4 font-medium text-gray-900 text-sm"> Jane Smith </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> jane@example.com </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> Editor </td> <td className="whitespace-nowrap px-6 py-4"> <span className="inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 font-medium text-xs text-yellow-800"> Pending </span> </td> </tr> <tr className="transition-colors hover:bg-gray-50"> <td className="whitespace-nowrap px-6 py-4 font-medium text-gray-900 text-sm"> Bob Johnson </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> bob@example.com </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> Viewer </td> <td className="whitespace-nowrap px-6 py-4"> <span className="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 font-medium text-red-800 text-xs"> Inactive </span> </td> </tr> </tbody> </Table> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const table = canvas.getByRole('table'); // Verify table structure await expect(table).toBeInTheDocument(); await expect(canvas.getAllByRole('columnheader')).toHaveLength(4); await expect(canvas.getAllByRole('row')).toHaveLength(4); // 1 header + 3 data rows // Test modal expansion button const modalButton = canvas.getByLabelText('Move'); await expect(modalButton).toBeInTheDocument(); await userEvent.click(modalButton); // Modal should be open (though we can't easily test modal content in this context) }, }; /** * ## Advanced Features * * Stories demonstrating advanced functionality and real-world usage scenarios. */ /** * ### Product Inventory Table * * A comprehensive product table with sorting, filtering, and interactive elements. */ export const ProductInventory: Story = { render: () => { const [sortColumn, setSortColumn] = useState<string>('name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [filterCategory, setFilterCategory] = useState<string>('all'); const products = [ { id: 1, name: 'Laptop Pro', category: 'Electronics', price: 1299, stock: 45, rating: 4.8, }, { id: 2, name: 'Wireless Mouse', category: 'Electronics', price: 29, stock: 120, rating: 4.5, }, { id: 3, name: 'Standing Desk', category: 'Furniture', price: 399, stock: 15, rating: 4.6, }, { id: 4, name: 'Coffee Maker', category: 'Appliances', price: 89, stock: 67, rating: 4.2, }, { id: 5, name: 'Ergonomic Chair', category: 'Furniture', price: 249, stock: 8, rating: 4.7, }, { id: 6, name: 'Bluetooth Speaker', category: 'Electronics', price: 79, stock: 34, rating: 4.4, }, ]; const categories = ['all', ...new Set(products.map((p) => p.category))]; const filteredProducts = filterCategory === 'all' ? products : products.filter((p) => p.category === filterCategory); const sortedProducts = [...filteredProducts].sort((a, b) => { const aVal = a[sortColumn as keyof typeof a]; const bVal = b[sortColumn as keyof typeof b]; const multiplier = sortDirection === 'asc' ? 1 : -1; return aVal > bVal ? multiplier : -multiplier; }); const handleSort = (column: string) => { if (sortColumn === column) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); } else { setSortColumn(column); setSortDirection('asc'); } }; const getStockStatus = (stock: number) => { if (stock <= 10) return { text: 'Low Stock', color: 'bg-red-100 text-red-800' }; if (stock <= 50) return { text: 'Medium', color: 'bg-yellow-100 text-yellow-800' }; return { text: 'In Stock', color: 'bg-green-100 text-green-800' }; }; return ( <div className="max-w-6xl space-y-4"> <div className="flex items-center justify-between"> <h3 className="font-semibold text-lg">Product Inventory</h3> <div className="flex items-center gap-4"> <div className="flex items-center gap-2"> <label htmlFor="category-filter" className="font-medium text-sm"> Filter: </label> <select id="category-filter" value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="rounded-lg border border-gray-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" > {categories.map((category) => ( <option key={category} value={category}> {category === 'all' ? 'All Categories' : category} </option> ))} </select> </div> <div className="text-gray-500 text-sm"> {sortedProducts.length} products </div> </div> </div> <Table className="overflow-hidden rounded-lg border border-gray-200"> <thead className="bg-gray-50"> <tr> <th scope="col" className="cursor-pointer px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider hover:bg-gray-100" onClick={() => handleSort('name')} > <div className="flex items-center gap-1"> Product Name {sortColumn === 'name' && ( <span className="text-gray-400"> {sortDirection === 'asc' ? '↑' : '↓'} </span> )} </div> </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Category </th> <th scope="col" className="cursor-pointer px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider hover:bg-gray-100" onClick={() => handleSort('price')} > <div className="flex items-center gap-1"> Price {sortColumn === 'price' && ( <span className="text-gray-400"> {sortDirection === 'asc' ? '↑' : '↓'} </span> )} </div> </th> <th scope="col" className="cursor-pointer px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider hover:bg-gray-100" onClick={() => handleSort('stock')} > <div className="flex items-center gap-1"> Stock {sortColumn === 'stock' && ( <span className="text-gray-400"> {sortDirection === 'asc' ? '↑' : '↓'} </span> )} </div> </th> <th scope="col" className="cursor-pointer px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider hover:bg-gray-100" onClick={() => handleSort('rating')} > <div className="flex items-center gap-1"> Rating {sortColumn === 'rating' && ( <span className="text-gray-400"> {sortDirection === 'asc' ? '↑' : '↓'} </span> )} </div> </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Actions </th> </tr> </thead> <tbody className="divide-y divide-gray-200 bg-white"> {sortedProducts.map((product) => { const stockStatus = getStockStatus(product.stock); return ( <tr key={product.id} className="transition-colors hover:bg-gray-50" > <td className="whitespace-nowrap px-6 py-4"> <div className="font-medium text-gray-900 text-sm"> {product.name} </div> <div className="text-gray-500 text-sm"> ID: #{product.id} </div> </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> {product.category} </td> <td className="whitespace-nowrap px-6 py-4 font-medium text-gray-900 text-sm"> ${product.price.toLocaleString()} </td> <td className="whitespace-nowrap px-6 py-4"> <div className="flex items-center gap-2"> <span className="text-gray-900 text-sm"> {product.stock} </span> <span className={`inline-flex items-center rounded-full px-2 py-1 font-medium text-xs ${stockStatus.color}`} > {stockStatus.text} </span> </div> </td> <td className="whitespace-nowrap px-6 py-4 text-gray-900 text-sm"> <div className="flex items-center gap-1"> <span>{product.rating}</span> <span className="text-yellow-400"> {'★'.repeat(Math.floor(product.rating))} </span> </div> </td> <td className="space-x-2 whitespace-nowrap px-6 py-4 font-medium text-sm"> <button className="text-blue-600 hover:text-blue-900"> Edit </button> <button className="text-red-600 hover:text-red-900"> Delete </button> </td> </tr> ); })} </tbody> </Table> </div> ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Test sorting functionality const nameHeader = canvas.getByText('Product Name'); await userEvent.click(nameHeader); // Test filtering const filterSelect = canvas.getByLabelText('Filter:'); await userEvent.selectOptions(filterSelect, 'Electronics'); // Verify filtered results await expect(canvas.getByText('Laptop Pro')).toBeInTheDocument(); }, }; /** * ### Financial Report Table * * A financial data table with formatted numbers and trend indicators. */ export const FinancialReport: Story = { render: () => { const financialData = [ { period: 'Q1 2024', revenue: 2450000, expenses: 1980000, profit: 470000, growth: 12.5, }, { period: 'Q2 2024', revenue: 2780000, expenses: 2100000, profit: 680000, growth: 13.5, }, { period: 'Q3 2024', revenue: 3120000, expenses: 2350000, profit: 770000, growth: 18.2, }, { period: 'Q4 2024', revenue: 3450000, expenses: 2680000, profit: 770000, growth: 15.8, }, ]; const formatCurrency = (amount: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(amount); }; const getGrowthColor = (growth: number) => { if (growth > 15) return 'text-green-600'; if (growth > 10) return 'text-blue-600'; if (growth > 5) return 'text-yellow-600'; return 'text-red-600'; }; return ( <div className="max-w-5xl space-y-4"> <div className="flex items-center justify-between"> <h3 className="font-semibold text-lg">Quarterly Financial Report</h3> <div className="text-gray-500 text-sm">Fiscal Year 2024</div> </div> <Table className="overflow-hidden rounded-lg border border-gray-300 shadow-sm"> <thead> <tr className="bg-gradient-to-r from-gray-50 to-gray-100"> <th scope="col" className="px-8 py-4 text-left font-bold text-gray-700 text-sm uppercase tracking-wider" > Period </th> <th scope="col" className="px-8 py-4 text-right font-bold text-gray-700 text-sm uppercase tracking-wider" > Revenue </th> <th scope="col" className="px-8 py-4 text-right font-bold text-gray-700 text-sm uppercase tracking-wider" > Expenses </th> <th scope="col" className="px-8 py-4 text-right font-bold text-gray-700 text-sm uppercase tracking-wider" > Net Profit </th> <th scope="col" className="px-8 py-4 text-right font-bold text-gray-700 text-sm uppercase tracking-wider" > Growth Rate </th> </tr> </thead> <tbody className="bg-white"> {financialData.map((quarter, index) => ( <tr key={quarter.period} className={`${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'} border-gray-200 border-b transition-colors hover:bg-blue-50`} > <td className="whitespace-nowrap px-8 py-6"> <div className="font-bold text-gray-900 text-sm"> {quarter.period} </div> <div className="text-gray-500 text-xs"> Quarter {index + 1} </div> </td> <td className="whitespace-nowrap px-8 py-6 text-right"> <div className="font-bold text-gray-900 text-lg"> {formatCurrency(quarter.revenue)} </div> <div className="text-gray-500 text-xs">Total Revenue</div> </td> <td className="whitespace-nowrap px-8 py-6 text-right"> <div className="font-medium text-lg text-red-600"> {formatCurrency(quarter.expenses)} </div> <div className="text-gray-500 text-xs">Operating Costs</div> </td> <td className="whitespace-nowrap px-8 py-6 text-right"> <div className="font-bold text-green-600 text-lg"> {formatCurrency(quarter.profit)} </div> <div className="text-gray-500 text-xs"> Margin:{' '} {((quarter.profit / quarter.revenue) * 100).toFixed(1)}% </div> </td> <td className="whitespace-nowrap px-8 py-6 text-right"> <div className={`font-bold text-lg ${getGrowthColor(quarter.growth)}`} > +{quarter.growth}% </div> <div className="flex items-center justify-end gap-1"> <span className={`text-lg ${getGrowthColor(quarter.growth)}`} > {quarter.growth > 10 ? '↗️' : quarter.growth > 5 ? '→' : '↘️'} </span> <span className="text-gray-500 text-xs"> vs prev quarter </span> </div> </td> </tr> ))} </tbody> <tfoot className="bg-gray-800 text-white"> <tr> <td className="px-8 py-4 font-bold text-sm">TOTAL</td> <td className="px-8 py-4 text-right font-bold text-lg"> {formatCurrency( financialData.reduce((sum, q) => sum + q.revenue, 0) )} </td> <td className="px-8 py-4 text-right font-bold text-lg"> {formatCurrency( financialData.reduce((sum, q) => sum + q.expenses, 0) )} </td> <td className="px-8 py-4 text-right font-bold text-green-400 text-lg"> {formatCurrency( financialData.reduce((sum, q) => sum + q.profit, 0) )} </td> <td className="px-8 py-4 text-right font-bold text-lg"> {( financialData.reduce((sum, q) => sum + q.growth, 0) / financialData.length ).toFixed(1)} % </td> </tr> </tfoot> </Table> <div className="flex items-center gap-6 text-gray-600 text-sm"> <div className="flex items-center gap-2"> <div className="h-3 w-3 rounded bg-green-600"></div> <span>High Growth ({'>'}15%)</span> </div> <div className="flex items-center gap-2"> <div className="h-3 w-3 rounded bg-blue-600"></div> <span>Good Growth (10-15%)</span> </div> <div className="flex items-center gap-2"> <div className="h-3 w-3 rounded bg-yellow-600"></div> <span>Moderate Growth (5-10%)</span> </div> <div className="flex items-center gap-2"> <div className="h-3 w-3 rounded bg-red-600"></div> <span>Low Growth ({'<'}5%)</span> </div> </div> </div> ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Verify financial data is displayed await expect(canvas.getByText('Q1 2024')).toBeInTheDocument(); await expect(canvas.getByText('$2,450,000')).toBeInTheDocument(); // Check total row await expect(canvas.getByText('TOTAL')).toBeInTheDocument(); }, }; /** * ## Special Cases * * Stories demonstrating edge cases and special scenarios. */ /** * ### Collapsible Large Table * * A large table with collapsible content to save space. */ export const CollapsibleLargeTable: Story = { render: () => { const employees = Array.from({ length: 25 }, (_, i) => ({ id: i + 1, name: `Employee ${i + 1}`, email: `employee${i + 1}@company.com`, department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5], position: ['Manager', 'Senior', 'Junior', 'Lead', 'Director'][i % 5], salary: 50000 + i * 2000, startDate: new Date(2020 + (i % 5), i % 12, (i % 28) + 1), status: ['Active', 'On Leave', 'Inactive'][i % 3], })); return ( <div className="max-w-7xl space-y-4"> <div className="flex items-center justify-between"> <h3 className="font-semibold text-lg">Employee Directory</h3> <div className="text-gray-500 text-sm"> {employees.length} employees • Click to expand/collapse </div> </div> <Table isRollable className="rounded-lg border border-gray-200"> <thead className="bg-gray-50"> <tr> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > ID </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > Employee </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > Department </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > Position </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > Salary </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > Start Date </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase" > Status </th> </tr> </thead> <tbody className="divide-y divide-gray-200 bg-white"> {employees.map((employee) => ( <tr key={employee.id} className="hover:bg-gray-50"> <td className="whitespace-nowrap px-6 py-4 text-gray-900 text-sm"> #{employee.id.toString().padStart(3, '0')} </td> <td className="whitespace-nowrap px-6 py-4"> <div className="font-medium text-gray-900 text-sm"> {employee.name} </div> <div className="text-gray-500 text-sm">{employee.email}</div> </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> {employee.department} </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> {employee.position} </td> <td className="whitespace-nowrap px-6 py-4 font-medium text-gray-900 text-sm"> ${employee.salary.toLocaleString()} </td> <td className="whitespace-nowrap px-6 py-4 text-gray-500 text-sm"> {employee.startDate.toLocaleDateString()} </td> <td className="whitespace-nowrap px-6 py-4"> <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 font-medium text-xs ${ employee.status === 'Active' ? 'bg-green-100 text-green-800' : employee.status === 'On Leave' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' }`} > {employee.status} </span> </td> </tr> ))} </tbody> </Table> </div> ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Verify large table is present const rows = canvas.getAllByRole('row'); await expect(rows.length).toBeGreaterThan(20); // Header + data rows // Test that modal expansion button works const modalButton = canvas.getByLabelText('Move'); await expect(modalButton).toBeInTheDocument(); }, }; /** * ### Empty State Table * * A table showing empty state with appropriate messaging. */ export const EmptyState: Story = { render: () => ( <div className="max-w-4xl"> <Table className="rounded-lg border border-gray-200"> <thead className="bg-gray-50"> <tr> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Name </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Email </th> <th scope="col" className="px-6 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider" > Status </th> </tr> </thead> <tbody className="bg-white"> <tr> <td colSpan={3} className="px-6 py-16 text-center"> <div className="space-y-4"> <div className="text-6xl text-gray-300">📋</div> <div> <h3 className="mb-2 font-medium text-gray-900 text-lg"> No data available </h3> <p className="mb-6 text-gray-500 text-sm"> There are currently no items to display in this table. <br /> Try adjusting your filters or adding some data. </p> <button className="inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 font-medium text-sm text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"> Add New Item </button> </div> </div> </td> </tr> </tbody> </Table> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Verify empty state message await expect(canvas.getByText('No data available')).toBeInTheDocument(); await expect(canvas.getByText('Add New Item')).toBeInTheDocument(); }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server