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
@@ -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,
};
};