feat(tables): implement sortable columns across various table components

- Added TableSortProvider to manage sorting state in AdminsTable, CmsTable, CompaniesTable, CustomersTable, TendersTable, and CompanyCategoriesTable.
- Enhanced TableHead component to support sortable columns with visual indicators for sort direction.
- Introduced columnSortKeys and columnDefaultOrder mappings in respective table presenters to define sortable fields and default sorting behavior.
- Updated useCompanyListPresenter, useCmsTablePresenter, useCustomerListPresenter, useTenderListPresenter, and useCompanyCategoriesPresenter to handle sorting logic and state management.
- Improved user experience by allowing dynamic sorting of table data based on user interactions.
This commit is contained in:
AmirReza Jamali
2026-05-31 10:48:08 +03:30
parent 5ef0298840
commit 46ddc018a6
16 changed files with 971 additions and 54 deletions
+27 -9
View File
@@ -22,6 +22,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { AdminStatus } from "@/constants/enums";
@@ -41,6 +42,11 @@ const AdminsTable = () => {
isChangeStatusModalOpen,
setIsChangeStatusModalOpen,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
changeStatus,
currentUser,
setStatus,
@@ -86,18 +92,29 @@ const AdminsTable = () => {
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<TableSortProvider
sortKey={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table data-cy="admins-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column, index) => (
<TableHead
key={column}
colSpan={100}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
))}
{columns.map((column, index) => {
const sortKey = columnSortKeys[column];
return (
<TableHead
key={column}
colSpan={100}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[column]}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
);
})}
</TableRow>
</TableHeader>
@@ -202,6 +219,7 @@ const AdminsTable = () => {
</TableBody>
)}
</Table>
</TableSortProvider>
</div>
<Modal
isOpen={isChangeStatusModalOpen}
@@ -1,5 +1,6 @@
"use client";
import { SortDirection } from "@/components/ui/tableSortContext";
import { apiDefaultParams } from "@/constants/Api_Params";
import { AdminStatus } from "@/constants/enums";
import {
@@ -10,7 +11,11 @@ import {
} from "@/hooks/queries";
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
import { useHybridPagination } from "@/hooks/useHybridPagination";
import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared";
import {
buildQueryString,
deleteEmptyKeys,
getPaginatedRowNumber,
} from "@/utils/shared";
import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -225,12 +230,49 @@ export const useAdminsPresenter = () => {
"actions",
];
/** Column label → BSON `sort_by` field. */
const columnSortKeys: Record<string, string> = {
"full name": "full_name",
email: "email",
username: "username",
status: "status",
};
/** First-click direction per column. */
const columnDefaultOrder: Record<string, SortDirection> = {
"full name": "asc",
email: "asc",
username: "asc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const newParams = buildFirstPageParams({
...params,
sort_by: key,
sort_order: order,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
},
[buildFirstPageParams, params, pathName, router],
);
return {
allUsers: usersList,
usersList,
setFilterValue,
metadata,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
isPending,
onConfirmDelete,
isModalOpen,
+26 -5
View File
@@ -11,6 +11,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
@@ -22,6 +23,11 @@ import useCmsTablePresenter from "./useCmsTablePresenter";
const CmsTable = () => {
const {
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
data,
isPending,
isModalOpen,
@@ -55,14 +61,28 @@ const CmsTable = () => {
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<TableSortProvider
sortKey={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
{columns.map((column) => {
const sortKey = columnSortKeys[column];
return (
<TableHead
key={column}
colSpan={100}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[column]}
>
{column}
</TableHead>
);
})}
</TableRow>
</TableHeader>
{isPending ? (
@@ -130,6 +150,7 @@ const CmsTable = () => {
</TableBody>
)}
</Table>
</TableSortProvider>
</div>
{isModalOpen && (
<ConfirmationModal
@@ -1,8 +1,9 @@
"use client";
import { SortDirection } from "@/components/ui/tableSortContext";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
import { TCms } from "@/lib/api/types/TCms";
import { deleteEmptyKeys } from "@/utils/shared";
import { buildQueryString, deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -67,8 +68,45 @@ const useCmsTablePresenter = () => {
if (!currentCms?.id) return;
deleteCms(currentCms.id);
};
/** Column label → BSON `sort_by` field. */
const columnSortKeys: Record<string, string> = {
key: "key",
"created at": "created_at",
};
/** First-click direction per column. */
const columnDefaultOrder: Record<string, SortDirection> = {
key: "asc",
"created at": "desc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const next: Record<string, any> = {
...params,
sort_by: key,
sort_order: order,
offset: 0,
};
delete next.cursor;
const cleanedParams = deleteEmptyKeys(next);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathname}?${queryString}`);
},
[params, pathname, router],
);
return {
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
isPending,
data,
isModalOpen,
@@ -14,6 +14,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
@@ -42,6 +43,11 @@ const CompanyCategoriesTable = () => {
watch,
setFilterValue,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
isMutating,
metadata,
shouldShowPagination,
@@ -70,15 +76,28 @@ const CompanyCategoriesTable = () => {
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<TableSortProvider
sortKey={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table data-cy="company-categories-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100}>Row</TableHead>
<TableHead colSpan={100}>Title</TableHead>
<TableHead colSpan={100}>Description</TableHead>
<TableHead colSpan={100}>Created at</TableHead>
<TableHead colSpan={100}>Published</TableHead>
<TableHead colSpan={100}>Actions</TableHead>
{columns.map((column) => {
const sortKey = columnSortKeys[column];
return (
<TableHead
key={column}
colSpan={100}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[column]}
>
{column}
</TableHead>
);
})}
</TableRow>
</TableHeader>
{isLoading ? (
@@ -172,6 +191,7 @@ const CompanyCategoriesTable = () => {
</TableBody>
)}
</Table>
</TableSortProvider>
</div>
{isModalOpen && (
<ConfirmationModal
@@ -1,4 +1,5 @@
"use client";
import { SortDirection } from "@/components/ui/tableSortContext";
import { apiDefaultParams } from "@/constants/Api_Params";
import {
useCompanyCategoriesQuery,
@@ -8,7 +9,7 @@ import {
import { useHybridPagination } from "@/hooks/useHybridPagination";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompanyCategory } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { buildQueryString, deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
@@ -108,6 +109,40 @@ const useCompanyCategoriesPresenter = () => {
"Published",
"Actions",
];
/** Column label → BSON `sort_by` field. */
const columnSortKeys: Record<string, string> = {
Title: "name",
Description: "description",
"Created at": "created_at",
Published: "published",
};
/** First-click direction per column. */
const columnDefaultOrder: Record<string, SortDirection> = {
Title: "asc",
Description: "asc",
"Created at": "desc",
Published: "desc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const newParams = buildFirstPageParams({
...params,
sort_by: key,
sort_order: order,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
},
[buildFirstPageParams, params, pathName, router],
);
return {
categories,
optimisticCategories,
@@ -119,6 +154,11 @@ const useCompanyCategoriesPresenter = () => {
pathName,
router,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
params,
setParams,
currentCategory,
+27 -9
View File
@@ -18,6 +18,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
@@ -55,6 +56,11 @@ const CompaniesTable = () => {
isPending,
pathName,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
allCompanies,
setIsModalOpen,
router,
@@ -93,18 +99,29 @@ const CompaniesTable = () => {
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<TableSortProvider
sortKey={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={col}
>
{col}
</TableHead>
))}
{columns.map((col) => {
const sortKey = columnSortKeys[col];
return (
<TableHead
colSpan={100}
className="text-start uppercase"
key={col}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[col]}
>
{col}
</TableHead>
);
})}
</TableRow>
</TableHeader>
{isPending ? (
@@ -267,6 +284,7 @@ const CompaniesTable = () => {
</TableBody>
)}
</Table>
</TableSortProvider>
</div>
{isModalOpen && (
<ConfirmationModal
@@ -1,5 +1,6 @@
"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import { SortDirection } from "@/components/ui/tableSortContext";
import { apiDefaultParams } from "@/constants/Api_Params";
import {
useCompaniesQuery,
@@ -8,7 +9,10 @@ import {
import { useHybridPagination } from "@/hooks/useHybridPagination";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompany } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import {
buildQueryString,
deleteEmptyKeys,
} from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -104,6 +108,48 @@ export const useCompanyListPresenter = () => {
deleteCompany(currentCompany.id);
}
};
/** Column label → BSON `sort_by` field. */
const columnSortKeys: Record<string, string> = {
name: "name",
email: "email",
phone: "phone",
country: "address.country",
state: "address.state",
language: "language",
currency: "currency",
"employee count": "employee_count",
};
/** First-click direction per column. */
const columnDefaultOrder: Record<string, SortDirection> = {
name: "asc",
email: "asc",
phone: "asc",
country: "asc",
state: "asc",
language: "asc",
currency: "asc",
"employee count": "desc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const newParams = buildFirstPageParams({
...params,
sort_by: key,
sort_order: order,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
},
[buildFirstPageParams, params, pathName, router],
);
const allCompanies = data?.data.companies ?? [];
const metadata = data?.meta;
return {
@@ -111,6 +157,11 @@ export const useCompanyListPresenter = () => {
onConfirmDelete,
metadata,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
pathName,
currentCompany,
modalConfig,
+27 -9
View File
@@ -22,6 +22,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
@@ -38,6 +39,11 @@ const CustomersTable = () => {
pathName,
router,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
allCustomers,
metadata,
isModalOpen,
@@ -84,18 +90,29 @@ const CustomersTable = () => {
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<TableSortProvider
sortKey={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
colSpan={100}
className="text-start font-extrabold uppercase"
key={col}
>
{col}
</TableHead>
))}
{columns.map((col) => {
const sortKey = columnSortKeys[col];
return (
<TableHead
colSpan={100}
className="text-start font-extrabold uppercase"
key={col}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[col]}
>
{col}
</TableHead>
);
})}
</TableRow>
</TableHeader>
{isPending ? (
@@ -266,6 +283,7 @@ const CustomersTable = () => {
</TableBody>
)}
</Table>
</TableSortProvider>
</div>
<IsVisible condition={shouldShowPagination}>
<Pagination
@@ -1,5 +1,6 @@
"use client";
import { SortDirection } from "@/components/ui/tableSortContext";
import {
useAssignCompany,
useCompanyFullList,
@@ -11,7 +12,7 @@ import { useHybridPagination } from "@/hooks/useHybridPagination";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
import { apiDefaultParams } from "@/constants/Api_Params";
import { deleteEmptyKeys } from "@/utils/shared";
import { buildQueryString, deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -144,6 +145,42 @@ const useCustomerListPresenter = () => {
}
};
/** Column label → BSON `sort_by` field. */
const columnSortKeys: Record<string, string> = {
"full name": "full_name",
email: "email",
"created at": "created_at",
type: "type",
status: "status",
role: "role",
};
/** First-click direction per column. */
const columnDefaultOrder: Record<string, SortDirection> = {
"full name": "asc",
email: "asc",
type: "asc",
role: "asc",
"created at": "desc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const newParams = buildFirstPageParams({
...params,
sort_by: key,
sort_order: order,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
},
[buildFirstPageParams, params, pathName, router],
);
const customers = data?.data?.customers ?? [];
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
@@ -153,6 +190,11 @@ const useCustomerListPresenter = () => {
metadata: data?.meta,
currentCustomer,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
isPending,
setCurrentCustomer,
selectedCompanies,
+26 -5
View File
@@ -10,6 +10,7 @@ import {
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "@/components/ui/table";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
@@ -25,6 +26,11 @@ import useTenderListPresenter from "./useTenderListPresenter";
const TendersTable = () => {
const {
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
filterRegister,
handleFilterSubmit,
search,
@@ -67,14 +73,28 @@ const TendersTable = () => {
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<TableSortProvider
sortKey={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
{columns.map((column) => {
const sortKey = columnSortKeys[column];
return (
<TableHead
key={column}
colSpan={100}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[column]}
>
{column}
</TableHead>
);
})}
</TableRow>
</TableHeader>
@@ -153,6 +173,7 @@ const TendersTable = () => {
</TableBody>
)}
</Table>
</TableSortProvider>
</div>
<IsVisible condition={shouldShowPagination}>
<Pagination
@@ -1,5 +1,6 @@
"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import { SortDirection } from "@/components/ui/tableSortContext";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useTendersQuery } from "@/hooks/queries";
import { useHybridPagination } from "@/hooks/useHybridPagination";
@@ -506,9 +507,50 @@ const useTenderListPresenter = () => {
"status",
"actions",
];
/** Column label → BSON `sort_by` field (see bp-panel-list-sorting.md). */
const columnSortKeys: Record<string, string> = {
title: "title",
"country code": "country_code",
"publication date": "publication_date",
"submission deadline": "submission_deadline",
"tender deadline": "tender_deadline",
"created at": "created_at",
status: "status",
};
/** First-click direction per column, following the BP sorting UX guide. */
const columnDefaultOrder: Record<string, SortDirection> = {
title: "asc",
"submission deadline": "asc",
"tender deadline": "asc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const newParams = buildFirstPageParams({
...params,
sort_by: key,
sort_order: order,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
},
[buildFirstPageParams, params, pathName, router],
);
return {
currentTender,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
setCurrentTender,
router,
allTenders: data,
+154 -6
View File
@@ -1,5 +1,13 @@
import { cn } from "@/lib/utils";
import * as React from "react";
import {
SortDirection,
TableSortContext,
TableSortContextValue,
} from "./tableSortContext";
import { useSortableHeadPresenter } from "./useSortableHeadPresenter";
export type { SortDirection };
export function Table({
className,
@@ -15,6 +23,31 @@ export function Table({
);
}
/**
* Provides the active `sort_by` / `sort_order` state to any `TableHead`
* rendered with `sortable`. Wrap a `<Table>` with this to enable server-driven
* column sorting (see `bp-panel-list-sorting.md`).
*/
export function TableSortProvider({
children,
...value
}: TableSortContextValue & { children: React.ReactNode }) {
const memoizedValue = React.useMemo<TableSortContextValue>(
() => ({
sortKey: value.sortKey,
sortOrder: value.sortOrder,
onSortChange: value.onSortChange,
}),
[value.sortKey, value.sortOrder, value.onSortChange],
);
return (
<TableSortContext.Provider value={memoizedValue}>
{children}
</TableSortContext.Provider>
);
}
export function TableHeader({
className,
...props
@@ -74,18 +107,133 @@ export const TableRow = React.forwardRef<
});
TableRow.displayName = "TableRow";
const tableHeadBaseClasses =
"h-13 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.08em] text-dark-5 dark:text-dark-6 [&:has([role=checkbox])]:pr-0";
interface TableHeadProps
extends React.ThHTMLAttributes<HTMLTableCellElement> {
/** When set, renders an interactive, animated sort control. */
sortable?: boolean;
/** BSON field name sent as `sort_by` (e.g. `created_at`). */
sortKey?: string;
/** Direction requested on the first click of an inactive column. */
defaultSortOrder?: SortDirection;
}
export function TableHead({
className,
sortable,
sortKey,
defaultSortOrder,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>) {
}: TableHeadProps) {
if (sortable && sortKey) {
return (
<SortableTableHead
className={className}
sortKey={sortKey}
defaultSortOrder={defaultSortOrder}
{...props}
/>
);
}
return <th className={cn(tableHeadBaseClasses, className)} {...props} />;
}
function Chevron({
direction,
className,
}: {
direction: "up" | "down";
className?: string;
}) {
return (
<svg
width="11"
height="7"
viewBox="0 0 12 8"
fill="none"
aria-hidden="true"
className={cn("block", className)}
>
<path
d={direction === "up" ? "M1.5 6.5L6 2L10.5 6.5" : "M1.5 1.5L6 6L10.5 1.5"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SortableTableHead({
className,
sortKey,
defaultSortOrder = "desc",
children,
...props
}: Omit<TableHeadProps, "sortable"> & { sortKey: string }) {
const {
rootRef,
pillRef,
upRef,
downRef,
sweepRef,
isActive,
currentOrder,
handleClick,
} = useSortableHeadPresenter(sortKey, defaultSortOrder);
return (
<th
className={cn(
"h-13 px-4 text-left align-middle text-[11px] font-semibold uppercase tracking-[0.08em] text-dark-5 dark:text-dark-6 [&:has([role=checkbox])]:pr-0",
className,
)}
className={cn(tableHeadBaseClasses, "select-none", className)}
aria-sort={
isActive
? currentOrder === "asc"
? "ascending"
: "descending"
: "none"
}
{...props}
/>
>
<button
ref={rootRef}
type="button"
onClick={handleClick}
className={cn(
"group/sort -mx-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 align-middle uppercase tracking-[0.08em] outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-primary/40",
isActive
? "text-primary"
: "text-dark-5 hover:text-dark dark:text-dark-6 dark:hover:text-white",
)}
>
<span className="whitespace-nowrap">{children}</span>
<span
ref={pillRef}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-md transition-colors duration-200",
isActive
? "bg-primary/10 dark:bg-primary/15"
: "bg-transparent group-hover/sort:bg-gray-2 dark:group-hover/sort:bg-dark-3",
)}
>
<span
ref={sweepRef}
className="pointer-events-none absolute inset-y-0 -inset-x-2 bg-gradient-to-r from-transparent via-primary/50 to-transparent opacity-0"
/>
<span className="relative flex flex-col items-center justify-center gap-[1px] leading-none">
<span ref={upRef} className="block">
<Chevron direction="up" />
</span>
<span ref={downRef} className="block">
<Chevron direction="down" />
</span>
</span>
</span>
</button>
</th>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { createContext } from "react";
export type SortDirection = "asc" | "desc";
export interface TableSortContextValue {
/** BSON field name (snake_case) currently used for `sort_by`. */
sortKey?: string;
/** Active `sort_order` value. */
sortOrder?: SortDirection;
/** Fired when a sortable header is toggled. */
onSortChange?: (key: string, order: SortDirection) => void;
}
export const TableSortContext = createContext<TableSortContextValue>({});
@@ -0,0 +1,143 @@
"use client";
import gsap from "gsap";
import { useCallback, useContext, useLayoutEffect, useRef } from "react";
import { SortDirection, TableSortContext } from "./tableSortContext";
const prefersReducedMotion = () =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/**
* Presenter for a single sortable column header.
*
* Owns the interaction logic (which direction to request next) and the GSAP
* choreography for the animated sort indicator, keeping the `table.tsx` view
* free of side effects per the architecture guidelines.
*/
export const useSortableHeadPresenter = (
sortKey: string,
defaultSortOrder: SortDirection = "desc",
) => {
const {
sortKey: activeKey,
sortOrder,
onSortChange,
} = useContext(TableSortContext);
const isActive = activeKey === sortKey;
const currentOrder: SortDirection = isActive
? sortOrder ?? defaultSortOrder
: defaultSortOrder;
const rootRef = useRef<HTMLButtonElement>(null);
const pillRef = useRef<HTMLSpanElement>(null);
const upRef = useRef<HTMLSpanElement>(null);
const downRef = useRef<HTMLSpanElement>(null);
const sweepRef = useRef<HTMLSpanElement>(null);
const didMount = useRef(false);
const handleClick = useCallback(() => {
if (!onSortChange) return;
const nextOrder: SortDirection = isActive
? currentOrder === "asc"
? "desc"
: "asc"
: defaultSortOrder;
onSortChange(sortKey, nextOrder);
}, [onSortChange, isActive, currentOrder, defaultSortOrder, sortKey]);
useLayoutEffect(() => {
const reduced = prefersReducedMotion();
const ctx = gsap.context(() => {
const up = upRef.current;
const down = downRef.current;
const pill = pillRef.current;
const sweep = sweepRef.current;
if (!up || !down || !pill) return;
const activeArrow = currentOrder === "asc" ? up : down;
const idleArrow = currentOrder === "asc" ? down : up;
const activeOpacity = isActive ? 1 : 0.45;
const idleOpacity = isActive ? 0.2 : 0.45;
if (reduced) {
gsap.set(idleArrow, { opacity: idleOpacity, y: 0, scale: 1 });
gsap.set(activeArrow, { opacity: activeOpacity, y: 0, scale: 1 });
return;
}
// Resting state for whichever chevron is not the requested direction.
gsap.to(idleArrow, {
opacity: idleOpacity,
scale: 0.85,
y: 0,
duration: 0.3,
ease: "power2.out",
});
// First paint: settle without the celebratory burst.
if (!didMount.current) {
didMount.current = true;
gsap.set(activeArrow, {
opacity: activeOpacity,
scale: isActive ? 1 : 0.85,
y: 0,
});
return;
}
gsap.to(activeArrow, {
opacity: activeOpacity,
scale: isActive ? 1 : 0.85,
y: 0,
duration: 0.35,
ease: "power2.out",
});
if (!isActive) return;
// Pop the pill and slide the chosen chevron in from its direction.
gsap.fromTo(
pill,
{ scale: 0.78 },
{ scale: 1, duration: 0.55, ease: "back.out(3.2)" },
);
gsap.fromTo(
activeArrow,
{ y: currentOrder === "asc" ? 7 : -7, scale: 0.4, opacity: 0 },
{
y: 0,
scale: 1,
opacity: 1,
duration: 0.55,
ease: "back.out(2.6)",
},
);
// A light sweep races across the indicator on every change.
if (sweep) {
gsap.fromTo(
sweep,
{ xPercent: -160, opacity: 0.9 },
{ xPercent: 160, opacity: 0, duration: 0.7, ease: "power2.inOut" },
);
}
}, rootRef);
return () => ctx.revert();
}, [isActive, currentOrder]);
return {
rootRef,
pillRef,
upRef,
downRef,
sweepRef,
isActive,
currentOrder,
handleClick,
};
};