Components: GenericTable - manishgupta248/Project_Mishika_1 GitHub Wiki

GenericTable Component

This GenericTable component is a reusable table solution for Next.js applications, providing features like sorting, searching, action buttons, and resizable columns. It's designed to efficiently display and manage data with a user-friendly interface.

Features

1. Dynamic Data Display:

  • Accepts an array of data (data) to display in the table.
  • Handles loading states and empty data scenarios.

2. Configurable Columns:

  • Accepts an array of column configurations (columns) to define table headers and data rendering.
  • Supports custom rendering of cell data using the render function.
  • Allows setting initial column widths.

3. Sorting:

  • Enables sorting of columns by clicking on the header.
  • Indicates sort direction (ascending/descending) with arrow icons.
  • Supports disabling sorting per column.

4. Searching:

  • Provides a search input to filter table data.
  • Performs case-insensitive search across all columns.

5. Resizable Columns:

  • Allows users to resize columns by dragging the column dividers.
  • Stores and applies dynamic column widths.

6. Action Buttons:

  • Supports optional edit and delete action buttons.
  • Passes item data to onEdit and onDelete callbacks.
  • Disables actions when loading or when the action callback is not provided.

7. Memoized Data:

  • Uses useMemo to memoize filtered and sorted data for performance optimization.
  • Reduces unnecessary re-renders.

8. Accessibility:

  • Includes accessibility attributes (e.g., aria-sort, aria-label).
  • Provides clear labels and descriptions.

9 Tailwind CSS Styling:

  • Styled with Tailwind CSS for consistent and responsive design.

Usage

import GenericTable from './components/common/GenericTable';

const MyTable = ({ data, loading, onEdit, onDelete }) => {
  const columns = [
    { key: 'name', label: 'Name', sortable: true },
    { key: 'email', label: 'Email', sortable: true },
    { key: 'age', label: 'Age', sortable: true, width: '100px' },
    { key: 'status', label: 'Status', render: (item) => (
      <span className={item.status === 'Active' ? 'text-green-600' : 'text-red-600'}>
        {item.status}
      </span>
    )},
  ];

  return (
    <GenericTable
      data={data}
      columns={columns}
      loading={loading}
      onEdit={onEdit}
      onDelete={onDelete}
    />
  );
};
export default MyTable;

Codes (src/components/common/GenericTable.js)

'use client';
import { useState, useMemo, useRef, useEffect } from 'react';

/**
 * A reusable table component with sorting, searching, action buttons, and resizable columns.
 * @param {Object} props
 * @param {Array<Object>} props.data - The data to display in the table.
 * @param {Array<{key: string, label: string, sortable?: boolean, render?: (item: Object) => React.ReactNode, width?: string}>} props.columns - Column configurations with optional initial width.
 * @param {boolean} [props.loading] - Whether the table is in a loading state.
 * @param {(item: Object) => void} [props.onEdit] - Callback for editing an item.
 * @param {(item: Object) => void} [props.onDelete] - Callback for deleting an item.
 */
export default function GenericTable({
  data = [],
  columns,
  loading = false,
  onEdit,
  onDelete,
}) {
  const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
  const [searchTerm, setSearchTerm] = useState('');
  const [columnWidths, setColumnWidths] = useState(() =>
    columns.reduce((acc, col) => ({
      ...acc,
      [col.key]: col.width || 'auto', // Default to 'auto' if no width specified
    }), {})
  );
  const tableRef = useRef(null); // Ref for the table element
  const resizingColumn = useRef(null); // Track the column being resized
  const startX = useRef(0); // Starting X position for resize

  // Handle column sorting
  const handleSort = (key) => {
    if (!columns.find((col) => col.key === key)?.sortable) return;
    setSortConfig((prev) => ({
      key,
      direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc',
    }));
  };

  // Handle column resize start
  const handleResizeStart = (e, columnKey) => {
    resizingColumn.current = columnKey;
    startX.current = e.clientX;
    document.addEventListener('mousemove', handleResizeMove);
    document.addEventListener('mouseup', handleResizeEnd);
  };

  // Handle column resize movement
  const handleResizeMove = (e) => {
    if (!resizingColumn.current) return;
    const th = tableRef.current.querySelector(`th[data-key="${resizingColumn.current}"]`);
    const currentWidth = th.getBoundingClientRect().width;
    const delta = e.clientX - startX.current;
    const newWidth = Math.max(50, currentWidth + delta); // Minimum width of 50px
    setColumnWidths((prev) => ({
      ...prev,
      [resizingColumn.current]: `${newWidth}px`,
    }));
    startX.current = e.clientX; // Update startX for smooth dragging
  };

  // Handle column resize end
  const handleResizeEnd = () => {
    resizingColumn.current = null;
    document.removeEventListener('mousemove', handleResizeMove);
    document.removeEventListener('mouseup', handleResizeEnd);
  };

  // Memoize filtered and sorted data for performance
  const filteredAndSortedData = useMemo(() => {
    let result = [...(Array.isArray(data) ? data : [])];

    // Apply search filter
    if (searchTerm) {
      const lowerSearch = searchTerm.toLowerCase();
      result = result.filter((item) =>
        columns.some((col) =>
          String(item[col.key] ?? '').toLowerCase().includes(lowerSearch)
        )
      );
    }

    // Apply sorting
    if (sortConfig.key) {
      result.sort((a, b) => {
        const aValue = a[sortConfig.key] ?? '';
        const bValue = b[sortConfig.key] ?? '';
        const compare = String(aValue).localeCompare(String(bValue));
        return sortConfig.direction === 'asc' ? compare : -compare;
      });
    }

    return result;
  }, [data, columns, searchTerm, sortConfig]);

  // Clean up event listeners on unmount
  useEffect(() => {
    return () => {
      document.removeEventListener('mousemove', handleResizeMove);
      document.removeEventListener('mouseup', handleResizeEnd);
    };
  }, []);

  return (
    <div className="bg-blue-50 border border-blue-200 p-6 rounded-lg shadow-md">
      {loading && !data.length ? (
        <p className="text-gray-500 text-center">Loading...</p>
      ) : filteredAndSortedData.length === 0 ? (
        <p className="text-gray-500 text-center">No items found.</p>
      ) : (
        <>
          <div className="mb-4">
            <input
              type="text"
              value={searchTerm}
              onChange={(e) => setSearchTerm(e.target.value)}
              placeholder="Search..."
              disabled={loading}
              className="w-full p-2 border border-green-400 rounded-md bg-white shadow-sm focus:ring-2 focus:ring-blue-300 focus:border-green-600 disabled:bg-gray-200 disabled:cursor-not-allowed transition-all duration-200"
              aria-label="Search table data"
            />
          </div>
          <div className="overflow-x-auto">
            <table
              ref={tableRef}
              className="min-w-full border-collapse border border-green-400"
            >
              <thead>
                <tr className="bg-[#800000] text-white">
                  {columns.map((col) => (
                    <th
                      key={col.key}
                      data-key={col.key} // For referencing in resize
                      onClick={() => col.sortable && handleSort(col.key)}
                      className={`border border-green-400 p-3 text-center text-sm font-semibold relative ${
                        col.sortable
                          ? 'cursor-pointer hover:bg-[#600000] transition-colors'
                          : ''
                      }`}
                      style={{ width: columnWidths[col.key] }} // Apply dynamic width
                      aria-sort={
                        col.sortable && sortConfig.key === col.key
                          ? sortConfig.direction
                          : 'none'
                      }
                    >
                      {col.label}
                      {col.sortable && sortConfig.key === col.key && (
                        <span aria-hidden="true">
                          {sortConfig.direction === 'asc' ? ' ↑' : ' ↓'}
                        </span>
                      )}
                      {/* Resize handle */}
                      <span
                        onMouseDown={(e) => handleResizeStart(e, col.key)}
                        className="absolute right-0 top-0 h-full w-2 cursor-col-resize bg-gray-300 opacity-0 hover:opacity-100 transition-opacity"
                        aria-label={`Resize ${col.label} column`}
                      />
                    </th>
                  ))}
                  <th
                    className="border border-green-400 p-3 text-center text-sm font-semibold"
                    style={{ width: columnWidths['actions'] || 'auto' }}
                  >
                    Actions
                    <span
                      onMouseDown={(e) => handleResizeStart(e, 'actions')}
                      className="absolute right-0 top-0 h-full w-2 cursor-col-resize bg-gray-300 opacity-0 hover:opacity-100 transition-opacity"
                      aria-label="Resize Actions column"
                    />
                  </th>
                </tr>
              </thead>
              <tbody>
                {filteredAndSortedData.map((item) => (
                  <tr
                    key={item.id || JSON.stringify(item)}
                    className="hover:bg-gray-50 transition-colors duration-150"
                  >
                    {columns.map((col) => (
                      <td
                        key={col.key}
                        className="border border-green-400 p-3 text-center"
                        style={{ width: columnWidths[col.key] }} // Apply dynamic width
                      >
                        {col.render ? col.render(item) : item[col.key] ?? '-'}
                      </td>
                    ))}
                    <td
                      className="border border-green-400 p-2 text-center"
                      style={{ width: columnWidths['actions'] || 'auto' }}
                    >
                      <button
                        onClick={() => onEdit?.(item)}
                        disabled={loading || !onEdit}
                        className="text-blue-600 hover:text-blue-800 disabled:text-gray-400 mr-4 px-1 transition-colors"
                        aria-label={`Edit ${item.name || 'item'}`}
                      >
                        Edit
                      </button>
                      <span className="text-gray-400">|</span>
                      <button
                        onClick={() => onDelete?.(item)}
                        disabled={loading || !onDelete}
                        className="text-red-600 hover:text-red-800 disabled:text-gray-400 px-2 transition-colors"
                        aria-label={`Delete ${item.name || 'item'}`}
                      >
                        Delete
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </>
      )}
    </div>
  );
}

⚠️ **GitHub.com Fallback** ⚠️