// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useTableSortable.tsx * @input React, types, Icon, theme tokens, i18n (useTranslator) * @output Exports useTableSortable hook and sort-related types * @position Sortable plugin; consumed by Table via plugins prop * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Table/Table.doc.mjs (sort documentation) * - /packages/core/src/Table/index.ts (exports) */ import { useRef, useMemo, type ReactNode } from 'react'; import * as stylex from "../../../style-runtime.js"; import "../../../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars } from "../../../theme/tokens.stylex.js"; import { focusOutlineProps } from "../../../utils/focusOutline.stylex.js"; import { Icon } from "../../../Icon/index.js"; import { resolveContextActions } from "../../tableContextMenu.js"; import { useTranslator, type TranslatorFn } from "../../../i18n/index.js"; import type { TablePlugin, HeaderCellRenderProps, TableColumn, TableContextAction } from "../../types.js"; // ============================================================================= // Sort Types // ============================================================================= /** * Sort direction for a single column. */ export type TableSortDirection = 'ascending' | 'descending'; /** * A single sort entry in the sort state array. */ export interface TableSortEntry { /** The sort key identifying which column (or derived value) to sort by. */ sortKey: TSortKey; /** The sort direction. */ direction: TableSortDirection; } /** * Complete sort state — an ordered array of sort entries. * The first entry is the primary sort; subsequent entries are tiebreakers. * Empty array = no sort applied. * * @example * ``` * const sort: TableSortState = [ * { sortKey: 'name', direction: 'ascending' }, * { sortKey: 'age', direction: 'descending' }, * ]; * ``` */ export type TableSortState = TableSortEntry[]; // ============================================================================= // Hook Config // ============================================================================= /** * Configuration for useTableSortable. * * Follows Astryx headless plugin conventions: the consumer owns all state * and provides callbacks. The plugin never holds internal sort state. * * @template TSortKey - Union of valid sort key strings * * @example * ``` * const [sort, setSort] = useState([ * { sortKey: 'name', direction: 'ascending' }, * ]); * const sortPlugin = useTableSortable({ sort, onSortChange: setSort }); * * ``` */ export interface UseTableSortableConfig { /** Current sort state — ordered array of active sort entries. */ sort: TableSortState; /** * Called when the user changes sort via header click. * Receives the complete new sort state array. */ onSortChange: (sort: TableSortState) => void; /** * Allow returning to unsorted state. * When true, clicking a sorted column cycles: asc → desc → unsorted. * When false, clicking cycles: asc → desc → asc. * * @default true */ allowUnsortedState?: boolean; /** * Enable multi-sort via modifier key (Shift+click). * When true, Shift+click adds/toggles a column as a secondary sort. * Regular click still replaces the entire sort state (single-sort behavior). * * @default false */ isMultiSortEnabled?: boolean; } // ============================================================================= // Styles // ============================================================================= const sortStyles = { button: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "xzye2dw", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kkrTdU: "x1ypdohk x16khyan", kVVagm: "xln7xf2", kMwMTN: "x1heor9g", kzqmXN: "xh8yej3", kZKoxP: "x5yr21d", k9WMMc: "x16tdsg8", kaIpWk: "xx3sua9", $$css: true as const }, buttonAlignCenter: { kjj79g: "xl56j7k", $$css: true as const }, buttonAlignEnd: { kjj79g: "x13a6bvl", $$css: true as const } }; // ============================================================================= // Helpers // ============================================================================= function resolveSortKey>(column: TableColumn): string | null { const { sortable } = column; if (!sortable) { return null; } if (sortable === true) { return column.key; } return sortable.sortKey ?? column.key; } function getHeaderLabel>(column: TableColumn): string { const { header } = column; if (typeof header === 'string') { return header; } return column.key; } function buildAriaLabel>(t: TranslatorFn, column: TableColumn, direction: TableSortDirection | null, rank: number | null, total: number): string { const label = getHeaderLabel(column); if (direction == null) { return t('@astryx.table.sort.sortBy', { label }); } const directionLabel = direction === 'ascending' ? t('@astryx.table.sort.direction.ascending') : t('@astryx.table.sort.direction.descending'); if (rank != null && total > 1) { return t('@astryx.table.sort.sortedByWithPriority', { label, direction: directionLabel, rank, total }); } return t('@astryx.table.sort.sortedBy', { label, direction: directionLabel }); } function getNextDirection(current: TableSortDirection | null, allowUnsortedState: boolean): TableSortDirection | null { if (current == null) { return 'ascending'; } if (current === 'ascending') { return 'descending'; } // current === 'descending' return allowUnsortedState ? null : 'ascending'; } // ============================================================================= // Sort Header Button Component // ============================================================================= function SortHeaderButton>({ column, children, configRef }: { column: TableColumn; children: ReactNode; configRef: React.RefObject; }) { const t = useTranslator(); const config = configRef.current; const sortKey = resolveSortKey(column) ?? ''; const entryIndex = config.sort.findIndex(e => e.sortKey === sortKey); const entry = entryIndex >= 0 ? config.sort[entryIndex] : null; const direction = entry?.direction ?? null; const isMultiSort = config.isMultiSortEnabled === true && config.sort.length > 1; const rank = isMultiSort && entryIndex >= 0 ? entryIndex + 1 : null; const iconName = direction === 'ascending' ? 'arrowUp' : direction === 'descending' ? 'arrowDown' : 'arrowsUpDown'; const ariaLabel = buildAriaLabel(t, column, direction, rank, config.sort.length); const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => { const cfg = configRef.current; const isShift = e.shiftKey && cfg.isMultiSortEnabled; const allowUnsorted = cfg.allowUnsortedState ?? true; if (isShift) { // Multi-sort: toggle in place or append const idx = cfg.sort.findIndex(s => s.sortKey === sortKey); if (idx >= 0) { const nextDir = getNextDirection(cfg.sort[idx].direction, allowUnsorted); if (nextDir == null) { // Remove from array const newSort = [...cfg.sort]; newSort.splice(idx, 1); cfg.onSortChange(newSort); } else { const newSort = [...cfg.sort]; newSort[idx] = { ...newSort[idx], direction: nextDir }; cfg.onSortChange(newSort); } } else { // Append new entry cfg.onSortChange([...cfg.sort, { sortKey, direction: 'ascending' }]); } } else { // Single-sort: replace entire array const currentEntry = cfg.sort.find(s => s.sortKey === sortKey); const nextDir = getNextDirection(currentEntry?.direction ?? null, allowUnsorted); if (nextDir == null) { cfg.onSortChange([]); } else { cfg.onSortChange([{ sortKey, direction: nextDir }]); } } }; return ; } // ============================================================================= // Hook // ============================================================================= /** * useTableSortable — table plugin for column sorting. * * Returns a stable TablePlugin that transforms header cells to add * clickable sort indicators. Follows the headless pattern: consumer owns * sort state, plugin provides UI and interaction. * * @example * ``` * const [sort, setSort] = useState([]); * const sortPlugin = useTableSortable({ sort, onSortChange: setSort }); *
* ``` */ export function useTableSortable, TSortKey extends string = string>(config: UseTableSortableConfig): TablePlugin { const t = useTranslator(); const configRef = useRef(config); configRef.current = config; return useMemo((): TablePlugin => ({ transformHeaderCell(props: HeaderCellRenderProps, column: TableColumn): HeaderCellRenderProps { const sortKey = resolveSortKey(column); if (sortKey == null) { return props; } const cfg = configRef.current; const entry = cfg.sort.find(e => e.sortKey === sortKey); // Context-menu actions are built lazily (only when the menu opens) via // a getter, reading the current sort state at call time — so we don't // build an action array for every header on every render, and the // checked/clear state always reflects the latest sort. const getSortActions = (): TableContextAction[] => { const c = configRef.current; const dir = c.sort.find(e => e.sortKey === sortKey)?.direction ?? null; const actions: TableContextAction[] = [{ id: 'sort-asc', group: 'sort', label: t('@astryx.table.sort.ascending'), icon: , checked: dir === 'ascending', onSelect: () => c.onSortChange([{ sortKey: sortKey as TSortKey, direction: 'ascending' }]) }, { id: 'sort-desc', group: 'sort', label: t('@astryx.table.sort.descending'), icon: , checked: dir === 'descending', onSelect: () => c.onSortChange([{ sortKey: sortKey as TSortKey, direction: 'descending' }]) }]; if (dir != null) { actions.push({ id: 'sort-clear', group: 'sort-clear', label: t('@astryx.table.sort.clear'), icon: , onSelect: () => c.onSortChange(c.sort.filter(e => e.sortKey !== sortKey)) }); } return actions; }; // Merge with any actions a prior plugin contributed (array or getter), // resolving both lazily at open time. const priorActions = props.contextMenuActions; return { ...props, htmlProps: { ...props.htmlProps, ...(entry != null ? { 'aria-sort': entry.direction } : {}) }, content: }> {props.content} , contextMenuActions: () => [...resolveContextActions(priorActions), ...getSortActions()] }; } }), [t]); }